이번 시간에는 지난 포스팅에서 다룬 설계를 토대로하여, Entity 클래스를 작성해보도록 하겠습니다.
우선 Entity를 개발하기 앞서 패키지 구조를 살펴보도록 하겠습니다.
x
- common : 어플리케이션에서 전체적으로 사용되는 기능들이 위치함
- config : Spring 설정파일들이 위치함
- value : entity들에서 공통적으로 사용되는 value타입 클래스들이 위치함
- domains : 도메인들이 위치함
- user : User 도메인 패키지
- presentation : 사용자의 endpoint로 사용자와 소통하는 UI로직(Controller)들이 위치함
- service : application(service) 계층에 해당하는 로직들이 위치함
- domain : 도메인 계층에 해당하는 로직들이 위치함
- infra : 위의 3계층이 공통적으로 사용할 기능과 외부 모듈등이 위치함
위와같은 구조로 프로젝트를 진행해본결과 장점은 아래와 같습니다.
- 첫째로,
common
과domains
를 분리함으로써, 도메인로직들은domains
디렉토리 하위에 존재함을 직관적으로 볼수 있기때문에, 도메인로직에 좀더 집중할 수 있습니다. - 둘째로,
domains
디렉토리에서 다시 어플리케이션에서 사용되는domain
들이 나뉘고, 그 안에layerd architecture
의 형태로 디렉토리 구조를 잡음으로써, ui 계층로직, service 계층로직, domain 계층 로직, infra 계층 로직을 다시 분리하여, 각각의 domain들의 도메인로직에 집중할 수 있기 때문에, 매우 효율적인 구조로 느껴졌습니다.
BaseEntity 작성 (DB 추적을위한 : JpaAudit)
도메인 설계시 존재하지 않았던, BaseEntity 작성이라는 것을 보고 의아하셨을 것입니다.
개발을 하다보면 각각의 Entity들에서 공통적으로 필요한 필드들이 존재하는데요, 대표적으로 id, 생성일자, 수정일자...
등이 존재합니다. 특히 서버를 운영중에, DB의 각 row들의 생성일자, 수정일자
는 매우 중요한 역할을 합니다.
이를 위해 JPA에서는 JpaAudit
이란것을 제공합니다. JpaAudit은 각각의 Entity의 생성시간과, 수정시간을 자동으로 생성 및 갱신해줍니다.
xxxxxxxxxx
AuditingEntityListener.class) (
public class BaseEntity {
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
}
@MappedSuperclass
- 이 엔티티를 상속받는 클래스에게 단순히 매핑정보만을 상속합니다.
@EntityListeners
- db의 특정 동작을 하기 전 또는 후에 커스텀 콜백을 요청할수 있도록하는 어노테이션입니다. AuditinEntityListener.class 를 속성으로 전달하면, 엔티티의 생성 또는 수정시간을, 자동으로 업데이트를 할 수 있습니다.
이제 BaseEntity
를 상속받는 Entity들은 createdDate, lastModifiedDate가 자동으로 기록됩니다.
Entity
이제 지난 포스팅에서 설계한 것을 토대로 각 Entity들을 생성하도록 하겠습니다. 설계한 것이 거의 그대로 코드로 옮겨지기 때문에, 특별한 내용이 아니라면 생략하도록 하겠습니다.
https://gitlab.com/galid1/jpa-commerce
완성된 코드는 gitlab을 참조해주세요.
공통적 어노테이션 설명
우선 모든 Entity에서 거의 공통적으로 들어가는 어노테이션들에 대한 설명 및 팁을 알려드리겠습니다.
@Table
@Table(name = "member)
어노테이션의 경우, MemberEntity 클래스가, jpa Auto ddl 옵션에 의해 생성될때, member_entity
가 아닌 members
라는 이름으로 생성되도록 하기 위해 부여했습니다. 애초에 클래스 이름을 Members
로 한다면, @Table 어노테이션이 필요하지 않지만, 해당 클래스가 Entity임을 직관적으로 알아볼 수 있도록 하기 위해 저는 이런식으로 코딩을 합니다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor
어노테이션은 Lombok의 어노테이션인데요, 기본 생성자를 생성해주는 어노테이션입니다. Jpa의 경우, Repository에서 Entity를 조회하는 경우, Entity를 생성할때, 기본생성자를 이용하기 때문에, 꼭! 기본생성자를 Entity클래스에 포함해야합니다. (Intellij에서는 위 그림과 같이 Entity 클래스에 기본생성자가 없는 경우 경고메시지를 보여주기도 합니다.)
Java에서는 별도의 생성자가 존재하지 않는경우 기본 생성자를 자동으로 생성해주는데요, 그런데도 왜 @NoArgsConstructor
를 별도로 부여할까요? 저희는 @Builder
어노테이션이 부여되어 있으며, 도메인 개념을 포함하는 생성자를 별도로 생성할 것이기 때문입니다.
*하지만 @NoArgsConstructor
를 그대로 사용하면 안됩니다. 도메인 개념을 포함하는 별도의 생성자가 존재 하더라도, @NoArgsConstructor로 인해 생성되는 기본생성자를 그대로 둔다면, 별도의 생성자를 사용해야하는지를 망각한 개발자가 기본생성자를 이용해 도메인 개념에 해를 가하는 객체를 생성할 수도 있기 때문입니다. 따라서, JPA에서 허용하는 수준의 access = AccessLevel.PROTECTED
속성을 이용해, 최대한 기본 생성자를 감추도록 합니다. (PROTECTED 수준의 기본생성자까지는 jpa에서 찾을수 있다고합니다.)
@Builder
생성자위에 @Builder
어노테이션이 보이는데요, Lombok에서 제공하는 어노테이션입니다. 생성자에 부여된 파라미터들을 포함하는 Builder를 만들어주는 어노테이션입니다. 생성자의 파라미터가 많은 경우, 실수로 파라미터를 잘못전달할 수도 있는데요, Builder를 이용해 생성하면 이런 실수의 우려를 덜어줍니다. 또한 Builder를 이용해 객체를 생성하는 경우, 생성자의 파라미터를 위치를 변경하더라도 객체를 생성하는 코드를 변경하지 않아도 됩니다.
@Getter
Lombok의 어노테이션으로, FieldLevel에 부여한다면, 해당 필드의 Getter 메소드를 자동으로 생성하며, ClassLevel에 부여하면, 모든 필드의 getter 메소드를 생성합니다.
xxxxxxxxxx
*@Setter를 부여하지 않은 이유는 Entity의 도메인 개념을 해하기 때문입니다. 따라서, Entity에 포함된 Field를 변경하는 등의 행동은 핵심 도메인 로직이기 때문에, 단순히 Setter메소드를 추가하기 보다는, 해당 도메인 로직을 잘 설명할 수 있는 이름의, 메소드를 생성하는 것이 좋습니다.
또한, Setter가 부여되어 있다면, 연관관계를 통해 다른 엔티티를 참조하여, 엔티티의 내용을 마음대로 변경할 수 있기때문에, 유지보수가 어려워지기도 합니다.
*@Getter를 부여한 이유는, 데이터를 조회하는 일로는 어떠한 일도 발생하지 않기 때문입니다.
@Id, @GeneratedValue
@Id
는 엔티티의 식별자로 지정하는 어노테이션입니다. @GeneratedValue
는 식별자의 생성 전략을 지정하는 어노테이션으로, 기본값은 AUTO
이며, Database의 auto_increment
전략과 거의 동일한 기능을 합니다.
*@XToOne(fetch = FetchType.LAZY)
이 어노테이션은 X 대 1
관계를 나타내기 위한 어노테이션입니다. 하지만 중요히 봐야할 것은 (fetch = FetchType.LAZY)
속성입니다.
기본적으로 JPA로 프로그래밍시, 모든 연관관계의 fetch
는 LAZY
로 진행합니다.
x모든 연관관계를 LAZY로 하는 이유는, 크게 두가지 이유입니다.
1. 자동으로 실행될 SQL 추측이 어렵습니다. 즉, 유지보수가 어렵게 됩니다.
- 위의 OrderEntity를 가져올때, 나머지 모든 연관관계의 fetch type이 eager인 경우, 모든 연관된 데이터를 가져오기 위한 쿼리가 자동으로 생성되기 때문에 생성될 쿼리를 추측하기가 어렵게 됩니다.
2. N+1 문제가 발생하기도 합니다.
- jpql을 이용해 order를 조회하는 경우, "select o from Orders o"라는 쿼리가 실행되는데요, 이때, 내부의 연관관계들의 fetch type이 eager인 경우 이들을 조회해오기 위한 쿼리들이 다시 또 자동 생성되어 호출되기 때문에, N+1 문제가 발생합니다.
xToOne 관계는 기본값이 EAGER
이기 때문에, 별도로 FetchType.LAZY
를 지정해야하며, xToMany 관계는 기본값이 LAZY이기 때문에 생략합니다.
1. MemberEntity
xxxxxxxxxx
name = "members") (
public class MemberEntity extends BaseEntity {
private Long id;
private String name;
private Address address;
private MemberEntity(String name, Address address) {
this.name = name;
this.address = address;
}
}
2 OrderEntity
xxxxxxxxxx
name = "orders") (
public class OrderEntity extends BaseEntity {
strategy = GenerationType.AUTO) (
private Long id;
private int totalAmount;
value = EnumType.STRING) (
private OrderStatus status;
fetch = FetchType.LAZY) (
name = "member_id") (
private MemberEntity orderer;
fetch = FetchType.LAZY) (
name = "delivery_id") (
private DeliveryEntity deliveryInformation;
name = "order_item_id") (
private List<OrderItemEntity> orderItemList;
public OrderEntity(MemberEntity orderer, DeliveryEntity deliveryInformation, OrderItemEntity... orderItemEntityList) {
this.orderer = orderer;
this.deliveryInformation = deliveryInformation;
this.setOrderItemList(orderItemEntityList);
this.status = OrderStatus.ORDERED_STATUS;
}
private void setOrderItemList(OrderItemEntity... orderItemEntityList) {
Arrays.stream(orderItemEntityList)
.forEach(orderItemEntity -> this.orderItemList.add(orderItemEntity));
this.calculateTotalAmount();
}
private void calculateTotalAmount() {
this.totalAmount = this.orderItemList.stream()
.mapToInt(orderItem -> orderItem.getOrderItemAmount())
.sum();
}
// ==== 비즈니스 로직 ====
public void cancel() {
if(this.deliveryInformation.getStatus() == DeliveryStatus.COMPLETE_STATUS
|| this.deliveryInformation.getStatus() == DeliveryStatus.SHIPPING_STATUS)
throw new IllegalStateException("이미 배송중이거나 배송이 완료된 주문은 취소가 불가능합니다.");
this.orderItemList.stream()
.forEach(orderItem -> orderItem.cancel());
this.status = OrderStatus.CANCEL_STATUS;
}
}
setter를 사용하지 말라고했었는데요? (setOrderItemList(OrderItemEntity... orderItemEntityList) )
- setter를 사용할때에 도메인 개념을해하기 때문에 set보다는 조금 더 해당메소드가 하는 기능을 잘 설명할수 있는 이름으로 메소드를 작명하라고 했었습니다. 하지만, 이 메소드의 공개범위는 private
으로 해당 Entity안에서만 사용되는 메소드입니다. 따라서, 해당 setter가 외부에서 호출될 일이 없기 때문에 상관이 없습니다.
- OrderItemEntity들을 추가할 때 setOrderItemList() 메소드를 이용함으로써, 자동으로 OrderItemEntityList에 따라 주문한 상품 가격(각각의 상품 가격 * 수량)이 계산되도록합니다.
OrderDate는 어디에?
설계할 때에 존재한 orderDate는 JPA Auditing 기능을 통해 database에 자동으로 추가되는 created_date를 orderDate로 사용할 예정이기 때문에 제외하였습니다.
도메인 로직?
cancel() 은 도메인로직입니다. 도메인 로직을 entity에 두는 이유는, 도메인의 제약사항에 해당하는 데이터들이 entity에 모여있기 때문입니다. (OOP에서는 메소드 호출에 필요한 데이터가 존재하는 곳에 메소드가 위치하는것이 가장 응집력있는 프로그래밍이라고 합니다.) 또한, cancel()의 메소드는 어떠한 상황에서 cancel이 가능 또는 불가능한지를 표현할 수 있어야합니다.
마지막으로, cancel()시 연결된 item의 재고량을 복구하기 위해, orderItemList에 존재하는 모든 orderItemEntity의 cancel()을 호출함으로써, 재고량을 복구합니다.
2.1 OrderStatus
xxxxxxxxxx
public enum OrderStatus {
ORDERED_STATUS, SHIPPING_STATUS, CANCEL_STATUS
}
3. DeliveryEntity
xxxxxxxxxx
name = "delivery") (
access = AccessLevel.PROTECTED) (
public class DeliveryEntity extends BaseEntity {
private Long id;
private Address address;
value = EnumType.STRING) (
private DeliveryStatus status;
public DeliveryEntity(Address address) {
this.address = address;
this.status = DeliveryStatus.READY_STATUS;
}
}
3.1 DeliveryStatus
public enum DeliveryStatus {
READY_STATUS, SHIPPING_STATUS, COMPLETE_STATUS
}
4. OrderItemEntity
xxxxxxxxxx
name = "order_item") (
access = AccessLevel.PROTECTED) (
public class OrderItemEntity extends BaseEntity {
private Long id;
private int orderQuantity;
private int orderItemAmount;
fetch = FetchType.LAZY) (
name = "item_id") (
private ItemEntity item;
public OrderItemEntity(ItemEntity item, int orderQuantity) {
this.order(item, orderQuantity);
this.orderQuantity = orderQuantity;
this.calculateOrderItemTotalAmount();
}
private void order(ItemEntity item, int orderQuantity) {
item.removeStockQuantity(orderQuantity);
this.item = item;
}
private void calculateOrderItemTotalAmount() {
this.orderItemAmount = this.item.getPrice() * orderQuantity;
}
// ==== 비즈니스 로직 ====
public void cancel() {
this.item.addStockQuantity(this.orderQuantity);
}
}
order(ItemEntity item, int orderQuantity)
OrderEntity 생성시 OrderItemEntity가 필요하며, 이는 결국 주문이 발생
했다고 볼 수있습니다. 또한, 주문이 발생한다면, item의 재고량이 주문 수량만큼 감소하는 로직이 필요합니다. 따라서, 전달받은 itemEntity와 연관관계를 맺음과 동시에, item의 재고량을 감소하도록 했습니다.
cancel()
OrderEntity에서 주문 취소시, 각 OrderItemEntity의 cancel()
를 호출했는데요, 이는 앞서 설명드렸던 것처럼, 각각의 주문 아이템의 재고량을 복구시키기 위함이었습니다. 따라서, cancel()에서는 주문수량 만큼 다시 재고량을 복구시키는 로직을 구현했습니다.
5. ItemEntity
xxxxxxxxxx
name = "items") (
strategy = InheritanceType.SINGLE_TABLE) (
access = AccessLevel.PROTECTED) (
public class ItemEntity extends BaseEntity {
private Long id;
private String name;
private int price;
private int stockQuantity;
name = "category_id") (
private List<CategoryEntity> categoryList;
public ItemEntity(String name, int price, int stockQuantity) {
this.name = name;
this.price = price;
this.stockQuantity = stockQuantity;
}
// ==== 비즈니스 로직 ====
public void removeStockQuantity(int orderQuantity) {
int restStockQuantity = this.stockQuantity - orderQuantity;
if(restStockQuantity < 0)
throw new NotEnoughStockQuantityException();
this.stockQuantity = restStockQuantity;
}
public void addStockQuantity(int quantity) {
this.stockQuantity += quantity;
}
}
5.1 Album
xxxxxxxxxx
public class Album extends ItemEntity{
private String artist;
private String etc;
}
5.2 Book
x
public class Book extends ItemEntity{
private String author;
private String isbn;
}
5.3 Movie
xxxxxxxxxx
public class Movie extends ItemEntity{
private String director;
private String actor;
}
6. CategoryEntity
x
name = "categories") (
access = AccessLevel.PROTECTED) (
public class CategoryEntity extends BaseEntity {
private Long id;
private String categoryName;
public CategoryEntity(String categoryName) {
this.categoryName = categoryName;
}
}
다음 편에서는, 서비스 및 리포지토리 개발에대해서 살펴보도록 하겠습니다.