「도메인 주도 개발 시작하기」3장 애그리거트, 4장 리포지터리와 모델 구현 정리
3장 애그리거트
🎈 3-1. 애그리거트
애그리거트: 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들기 위해 상위 수준에서 모델을 조망할 수 있는 방법
애그리거트 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항.
'A가 B를 갖는다'로 설계할 수 있다고 해서 A와 B가 한 애그리거트에 속한다는 것을 의미하지 않는다.
예시) 상품과 리뷰. 상품에 리뷰를 갖는 것으로 생각할 수 있지만 함께 생성, 변경되지 않고 변경 주체도 다르기 때문에 서로 다른 애그리거트에 속한다.
🎈 3-2. 애그리거트 루트와 역할
애그리거트의 루트 엔티티: 애그리거트 전체를 관리하는 엔티티
3.2.1. 도메인 규칙과 일관성
애그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.
애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안 된다.
애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음 두 가지를 습관적으로 적용해야 함!!
- 단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
public void setName(String name){
this.name = name;
}
공개 set 메서드는 도메인의 의미나 의도를 표현하지 못하고 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다. 도메인 로직이 한 곳에 응집되지 않아 코드 유지보수분석수정에 더 많은 시간이 필요.
- 밸류 타입은 불변으로 구현한다.
밸류 객체가 불변이면 밸류 객체의 값을 변경하는 방법은 새로운 밸류 객체를 할당하는 것뿐이다.
3.2.2. 애그리거트 루트의 기능 구현
애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다. (Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용함.)
애그리거트 루트가 기능 실행을 위임하기도 함. 이 때 루트가 getOrderLines()와 같이 OrderLines를 구할 수 있는 메서드를 제공하면 애그리거트 외부에서 OrderLines의 기능을 실행할 수 있게 되고 버그가 생김. -> 애초에 애그리거트 외부에서 OrderLine 목록을 변경할 수 없도록 OrderLines를 불변으로 구현하면 된다.
3.2.3. 트랜잭션 범위
트랜잭션 범위는 작을수록 좋다. 잠금 대상이 많다진다는 것은 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 의미하고 이것은 전체적인 성능을 떨어뜨린다.
한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다(=한 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것). 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌 발생 가능성이 높아져 전체 처리량이 떨어지게 된다.
(도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하는 코드를 작성할 수 있다. )
🎈 3-3. 애그리거트와 리포지터리
애그리거트는 개념상 완전히 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.
애그리거트는 개념적으로 하나이므로 리포지터리는 애그리거트 전체를 저장소에 영속화해야 한다. Order 애그리거트와 관련된 테이블이 세 개라면 Order 애그리거트를 저장할 때 애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다.
애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다.
🎈 3-4. ID를 이용한 애그리거트 참조
애그리거트에서 다른 애그리거트를 참조한다는 것은 다른 애그리거트의 루트를 참조한다는 것과 같다.
ORM 기술을 통한 필드를 이용한 애그리거트 참조의 문제점
- 편한 탐색 오용
한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여 결과적으로 애그리거트의 변경을 어렵게 만든다.
- 성능에 대한 고민
지연 로딩과 즉시 로딩의 두 가지 방식으로 로딩.
- 확장 어려움
하위 도메인마다 서로 다른 DBMS를 사용할 때도 있다. JPA와 같은 단일 기술을 사용할 수 없을 수 있음.
-> 이 세가지를 완하하는 것이 ID를 이용해서 다른 애그리거트를 참조하는 것. 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결됨 -> 애그리거트의 경계를 명확히 하고 물리적 연결을 제거해 모델의 복잡도를 낮춤. 애그리거트 간의 의존을 제거해 응집도를 높여줌. 다른 애그리거트 직접 참조하지 않으므로 구현 복잡도 낮아짐.
외부 애그리거트 직접 참조하지 않기 때문에 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지. 애그리거트별로 다른 구현 기술을 사용하는 것도 가능해짐.
3.4.1. ID를 이용한 참조와 조회 성능
Member member = memberRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordererId);
List<OrderView> dtos = orders.stream()
.map(order -> {
ProductId prodId = order.getOrderLines().get(0).getProductId();
//각 주문마다 첫 번째 주문 상품 정보 로딩 위한 쿼리 실행
Product product = productRepository.findById(prodId);
return new OrderView(order, member, product);
}).collect(toList());
조회 대상이 N개일 때 N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행하고 있음. ID를 이용한 애그리거트 참조는 지연 로딩과 같은 효과를 만드는 데 지연 로딩과 관련된 대표적인 문제가 N+1 조회 문제이다.
이같은 문제가 발생하지 않도록 하려면 조회 전용 쿼리를 사용하면 됨. 예를 들어 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩함.
@Repository
public class JpaOrderViewDao implements OrderViewDao{
@PersistenceContext
private EntityManager em;
@Override
public List<OrderView> selectByOrderer(String ordererId){
String selectQuery =
"select new com.myshop.order.application.dto.OrderView(o, m, p) "+
"from Order o join o.orderLines ol, Member m, Product p "+
"and o.orderer.memberId = m.id "+
"and o.orderer.memberId = m.id "+
"and index(ol) = 0 "+
"and ol.productId = p.id "+
"order by o.number.number desc";
TypedQuery<OrderView> query =
em.createQuery(selectQuery, OrderView.class);
query.setParameter("ordererId", ordererId);
return query.getResultList();
}
}
위 코드는 JPQL을 이용해 Order, Member, Product 애그리거트를 조인으로 조회하여 한 번의 쿼리로 로딩함. 즉시 로딩이나 지연 로딩과 같은 로딩 전략을 고민할 필요 없이 조회 화면에서 필요한 애그리거트 데이터를 한 번의 쿼리로 로딩할 수 있음. 쿼리가 복잡하거나 SQL에 특화된 기능을 사용해야 한다면 조회를 위한 부분만 마이바티스와 같은 기술을 이요할 수도 있음.
🎈 3-5. 애그리거트 간 집합 연관
RDBMS를 이용해서 M-N 연관을 구현하려면 조인 테이블을 사용한다.
JPA를 이용하면 다음과 같은 매핑 설정을 사용해서 ID 참조를 이용한 M-N 단방향 연관을 구현할 수 있다.
@Entity
@Table(name = "product")
public class Product{
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category",
joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
}
🎈 3-6. 애그리거트를 팩토리로 사용하기
public class RegisterProductService{
public ProductId registerNewProduct(NewProductRequest req){
Store store = storeRepository.findById(req.getStoreId());
checkNull(store);
if(account.isBlocked()){
throw new StoreBlockedException();
}
ProductId id = productRepository.nextId();
Product product = new Product(id, store.getId(), ...생략.);
productRepository.save(product);
return id;
}
...
}
Store가 Product를 생성할 수 있는지를 판단하고 Product 생성하는 것은 논리적으로 하나의 도메인 기능인데 이를 응용 서비스에서 구현하고 있다는 문제.
public class Store{
public Product createProduct(ProductId newProductId, ...생략){
if (isBlocked()) throw new StoreBlockedException();
return new Product(newProductId, getId(), ...생략);
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req){
Store store = storeRepository.findById(req.getStoreId());
checkNull(store);
ProductId id = productRepository.nextId();
Product product = store.createProduct(id, ...생략);
productRepository.save(product);
return id;
}
...
}
Store가 Product를 생성할 수 있는지를 확인하는 도메인 로직은 Store에서 구현함. product 생성 가능 여부 확인 로직은 변경해도 응용 서비스는 영향을 받지 않음. 도메인의 응집도도 높아짐.
public class Store {
public Product createProduct(ProductId newProductId, ProductInfo pi){
if (isBlocked()) throw new StoreBlockedException();
return ProductFactory.create(newProductId, getId(), pi);
}
다른 productFactory에 위임하는 방법. (차단 상태의 상점은 상품을 만들 수 없다는 도메인 로직은 한곳에 계속 위치함.)
4장 레포지터리와 모델 구현
🎈 4-1. JPA를 이용한 리포지터리 구현
4.1.1. 모듈 위치
리포지터리 인터페이스는 애그리거트와 같이 도메인 여영ㄱ에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭쳐 영역에 속한다.
4.1.2. 리포지터리 기본 기능 구현
인터페이스는 애그리거트 루트를 기준으로 작성한다.
애그리거트 수정 결과를 저장소에 반영하는 메서드를 추가할 필요 없다. JPA를 사용하면 트랜잭션 범위에서 변경한 데이터를 자동으로 DB에 반영하기 때문이다.
🎈 4-2. 스프링 데이터 JPA를 이용한 리포지터리 구현
스프링 데이터 JPA는 지정한 규칙에 맞레 리포지터리 인터페이스를 정의하면 리포지터리를 구현한 객체를 알아서 만들어 스프링 빈으로 등록해 준다. 리포지터리 인터페이스를 직접 구현하지 않아도 되기 때문에 개발자는 리포지터리를 쉽게 정의할 수 있다.
OrderRepository를 기준으로 엔티티를 저장하는 메서드.
- Order save(Order entity)
- void save(Order entity)
식별자 이용해서 엔티티 조회할 때.
- Order findById(OrderNo id)
- Optional<Order> findById(OrderNo id)
특정 프로퍼티를 이용해 조회할 때
- List<Order> findByOrderer(Orderer orderer)
중첩 프로퍼티. Orderer 객체의 memberId 프로퍼티가 파라미터와 같은 Order 목록을 조회
- List<Order> findByOrdererMemberId(MemberId memberId)
삭제
- void delete(Order order)
- void deleteById(OrderNo id)
🎈 4-3. 매핑 구현
4.3.1. 엔티티와 밸류 기본 매핑 구현
애그리거트와 JPA 매핑을 위한 기본 규칙
- 애그리거트 루트는 엔티티이므로 @Entity로 매핑 설정한다.
한 테이블에 엔티티와 밸류 데이터가 같이 있다면
- 밸류는 @Embeddable로 매핑 설정한다.
- 밸류 타입 프로퍼티는 @Embedded로 매핑 설정한다.
루트 엔티티와 루트 엔티티에 속한 밸류는 한 테이블에 매핑할 때가 많다.
루트 엔티티인 Order는 JPA의 @Entity로 매핑하고, Order에 속하는 Orderer는 밸류이므로 @Embeddable로 매핑한다.
import javax.persistence.Entity;
@Entity
@Table(name = "purchase_order")
public class Order{
...
}
import javax.persistence.Embeddable;
@Embeddable
public class Orderer{
//MemberId에 정의된 칼럼 이름을 변경하기 위해
//@AttributeOverride 애너테이션 사용
@Embedded
@AttributeOverride(
@AttributeOverride(name = "id", column = @Column(name = "orderer_id"))
)
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
}
@Embeddable
public class MemberId implements Serializable {
@column(name="member_id") //id프로퍼티 매핑되는 칼럼 이름으로 "member_id"를 지정
private String id;
}
4.3.2. 기본 생성자
엔티티와 밸류의 생성자는 객체를 생성할 때 필요한 것을 전달받는다. 밸류가 불변 타입이면 생성 시점에 필요한 값을 모두 전달받으므로 값을 변경하는 set 메서드를 제공하지 않는다. 이는 Receiver 클래스에 기본 생성자를 추가할 필요가 없다는 것을 의미함.
하지만 JPA에서 @Entity와 @Embeddable로 클래스 매핑하려면 기본 생성자 제공해야 함.
기본 생성자는 JPA 프로바이더가 객체를 생성할 때만 사용함. 다른 코드에서 사용하면 값이 없는 온전하지 못한 객체를 만들게 됨. 이런 이유로 다른 코드에서 기본 생성자 사용 못하도록 protected로 선언함.
4.3.3. 필드 접근 방식 사용
엔티티에 프로퍼티를 위한 공개 get/set 메서드를 추가하면 도메인의 의도가 사라지고 객체가 아닌 데이터 기반으로 엔티티를 구현할 가능성이 높아짐.
상태 변경을 위한 setState() 메서드보다 주문 취소를 위한 cancel() 메서드가 도메인을 더 잘 표현하고, setShippingInfo() 메서드보다 배송지 변경의 의미를 갖는 changeShippingInfo()가 도메인을 더 잘 표현한다.
객체가 제공할 기능 중심으로 엔티티를 구현하게끔 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set/ 메서드를 구현하지 말아야 한다.
@Entity
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Column(name = "state")
@Enumerated(EnumType.STRING)
private OrderState state;
...//cancel(), changeShippingMethod() 등 도메인 기능 구현
...//필요한 get 메서드 제공
4.3.4. AttributeConverter를 이용한 밸류 매핑 처리
두 개 이상의 프로퍼티를 한 개의 DB 칼럼에 매핑해야할 때.
package javax.persistence;
public interface AttributeConverter<X, Y> {
public Y convertToDatabaseColumn (X attribute);
public X convertToEntityAttribute (Y dbData);
}
package com.myshop.common.jpa;
import com.myshop.common.model.Money;
import javax.persistence.AttributeConverter;
import javax.persistence.Converter;
@Converter(autoApply = true) //default = false
public class MoneyConverter implements AttributeConverter<Money, Integer> {
@Override
public Integer convertToDatabaseColumn(Money money){
return money == null ? null : money.getValue();
}
@Override
public Money convertToEntityAttribute (Integer value){
return value == null ? null : new Money(value);
}
}
4.3.5. 밸류 컬렉션: 별도 테이블 매핑
public class Order {
private List<OrderLine> orderLines;
...
밸류 컬렉션을 별도 테이블로 매핑할 때는 @ElementCollection과 @CollectionTable을 함께 사용한다.
@Entity
@Table(name = "purchase_order")
public class Order {
@EmbeddedId
private OrderNo number;
...
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "order_line",
joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;
...
}
@Embeddable
public class OrderLine {
@Embedded
private ProductId productId;
@column(name = "price")
private Money price;
@Column(name = "quantity")
...
}
4.3.6. 밸류 컬렌션: 한 개 칼럼 매핑
밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때. (도메인 모델에는 이메일 주소 목록을 Set으로 보관하고 DB에는 한 개 칼럼에 콤마로 구분해서 저장해야 할 때) -> AttributeConverter 사용
4.3.7. 밸류를 이용한 ID 매핑
식별자라는 의미를 부각시키기 위해 식별자 자체를 밸류 타입으로 만들 수 있음(OrderNo, MemberId 같은 것이 식별자를 표현하기 위한 밸류 타입). 밸류 타입을 식별자로 매핑하면 @Id 대신 @EmbeddedId 애너테이션 사용.
@Entity
@Table(name = "purchase_order")
public class Order {
@EmbeddedId
private OrderNo number;
...
}
@Embeddable
public class OrderNo implements Serializable{
@Column(name = "order_number")
private String number;
...
}
4.3.8. 별도 테이블에 저장하는 밸류 매핑
애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 밸류가 아니라 엔티티가 확실하다면 해당 엔티티가 다른 애그리거트는 아닌지 확인해야 한다. 특히 자신만의 독자적인 라이프 사이클은 갖는다면 구분되는 애그리거트일 가능성이 높다. 예) 상품과 리뷰
애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지를 확인하는 것.
Article, ArticleContent 테이블이 있을 때 ArticleContent 테이블에 ID 칼럼이 있다고 해서 ArticleContent를 엔티티로 생각하면 안됨. ID 칼럼이 식별자이지만 article 테이블과 연결하기 위함이지 별도 식별자가 필요한 것은 아니기 때문에.
밸류를 매핑한 테이블을 지정하기 위해 @SecondaryTable과 @AttributeOverride를 사용한다.
@Entity
@Table(name = "article")
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@AttributeOverrides({
@AttributeOverride(
name = "content",
column = @Column(table = "article_content", name = "content")),
@AttributeOverride(
name = "contentType",
column = @Column(table = "article_content", name = "content_type"))
})
@Embedded
private ArticleContent content;
4.3.9. 밸류 컬렉션을 @Entity로 매핑하기
JPA는 @Embeddable 타입의 클래스 상속 매핑을 지원하지 않음. 상속 구조를 갖는 밸류 타입을 사용하려면 @Embeddable 대신 @Entity를 이용해서 상속 매핑으로 처리해야 함.
한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image 클래스에 다음 설정을 사용함.
- @Inheritance 애너테이션 적용
- strategy 값으로 SINGLE_TABLE 사용
- @DiscriminatorColumn 애너테이션을 이용하여 타입 구분용으로 사용할 칼럼 지정
4.3.10. ID 참조와 조인 테이블을 이용한 단방향 M-N 매핑
애그리거트 간 집합 연관은 성능 상의 이유로 피해야 하지만, 그럼에도 써야한다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다.
🎈 4-4. 애그리거트 로딩 전략
다음과 같이 애그리거트 루트를 로딩하면 루트에 속한 모든 객체가 완전한 상태여야 한다.
//product는 완전한 하나여야 한다.
Product product = productRepository.findById(id);
조회 시점에서 애그리거트를 완전한 상태가 되도록 하려면 애그리거트 루트에서 연관 매핑의 조회 방식을 즉시 로딩으로 설정하면 된다.
컬렉션에 대해 로딩 전략을 FetchType.EAGER로 설정 시 문제 생길 수 있음. Product 애그리거트 루트가 @Entity로 구현한 Image와 @Embeddable로 구현한 Option 목록을 갖고 있을 때, 하이버네이트가 Product를 위한 테이블, Image를 위한 테이블, Option을 위한 테이블을 조인한 쿼리를 실행함. -> 쿼리는 카타시안 조인을 사용하고 이는 쿼리 결과에 중복을 발생시킴. 따라서 즉시 로딩 방식을 사용할 때는 성능을 검토해야 함.
애그리거트가 완전해야 하는 이유
1. 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하기 때문에.
2. 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문에.
🎈 4-5. 애그리거트의 영속성 전파
애그리거트는 조회 뿐만 아니라 저장하고 삭제할 때도 하나로 처리해야 함을 의미함.
- 저장 메서드는 애그리거트 루트만 저장하면 안 되고 애그리거트에 속한 모든 객체를 저장해야 한다.
- 삭제 메서드는 애그리거트 루트뿐만 아니라 애그리거트에 속한 모든 객체를 삭제해야 한다.
@Embeddable 매핑 타입은 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 됨. 애그리거트에 속한 @Entity 타입에 대한 매핑은 cascade 속성을 사용해 저장과 삭제 시에 함께 처리되도록 설정해야 함.
🎈 4-6. 식별자 생성 기능
대표적인 식별자 생성 방식 세 가지
- 사용자가 직접 생성
- 도메인 로직으로 생성
- DB를 이용한 일련번호 사용
이메일 주소처럼 사용자가 직접 식별자를 입력하는 경우는 식별자 생성 주체가 사용자이기 때문에 도메인 영역에 식별자 생성 기능 구현할 필요 없음.
식별자 생성 규칙이 있다면 이는 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 함.
특정 값의 조합으로 식별자 생성되는 것도 규칙이므로 도메인 서비스 이용해서 식별자 생성.
DB 자동 증가 칼럼을 식별자로 사용하면 식별자 매핑에서 @GeneratedValue를 사용함(저장 시점에 식별자 생성).
🎈 4-7. 도메인 구현과 DIP
DIP에 따르면 @Entity, @Table은 구현 기술에 속하므로 도메인 모델은 구현 기술인 JPA에 의존하지 말아야 하는데 도메인 모델은 영속성 구현 기술인 JPA에 의존하고 있음. 리포지터리 인터페이스는 도메인 패키지에 위치하는데 구현 기술인 스프링 데이터 JPA의 Repository 인터페이스를 상속함(즉 도메인이 인프라에 의존).
구현 기술에 대한 의존을 없애려면 스프링 데이터 JPA의 Repository 인터페이스를 상속받지 않도록 수정하고 ArticleRepository 인터페이스를 구현한 클래스를 인프라에 위치시켜야 함. @Entity, @Table과 같은 JPA 특화 애너테이션을 지우고 인프라에 JPA를 연동하기 위한 클래스를 추가해야 함.
구현 기술이 거의 변경이 없는 상황에서는 DIP와 타협해도 괜찮다고 봄.