도메인 주도 개발 시작하기 책 정리
애그리거트
✅ 애그리거트
복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면,
상위 수준에서 도메인 모델을 조망할 수 있는 방법이 필요한데,
이 방법이 바로 애그리거트다.
-> 관련 객체를 하나의 군으로 묶어 준다.
-> 단순한 구조: 도메인 기능을 확장하고 변경하는데 필요한 노력이 줄어든다.
✅ 어떻게 나눠야 할까?
1. 동일한 라이프 사이클
ex) 주문 애그리거트를 만드려면, Order, Orderer, ShippingInfo와 같은 객체를 함께 생성해야 한다.
2. 변경 주체
보통 포함 관계를 애그리거트로 묶는다고 생각하는 경우도 많은데, 이보다는 변경 주체가 더 중요하다.
ex) Review는 Product에 포함되기 때문에 같은 애그리거트라고 생각할 수 있지만,
Product는 변경 주체가 상품 담당자이고, Review는 변경 주체가 고객이다.
-> 서로의 변경이 서로에게 영향을 주지 않기 때문에, 다른 애그리거트다.
+) 보통 애그리거트는 한 개의 엔티티 객체와 관련된 값 객체들로 구성된 경우가 많다.
애그리거트 루트
✅ 애그리거트 루트
애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해 애그리거트 전체를 관리할 주체. (루트 엔티티)
도메인 규칙과 일관성
애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
이를 위해, 애그리거트 루트는 메서드를 통해 도메인 기능을 구현한다.
애그리거트 루트를 통해서만, 엔티티, 밸류 타입의 상태를 변경할 수 있기 때문에, 다음 두가지를 습관적으로 적용해야 한다.
- 단순히 필드를 변경하는 public setter를 만들지 않는다.
- 밸류 타입은 불변으로 구현한다.
애그리거트 루트의 기능 구현
1. 애그리거트 루트는 애그리거트 내부의 다른 객체를 참조해서 기능을 완성한다.
2. 혹은, 애그리거트 내부 다른 객체에 기능 실행을 위임하기도 한다.
-> 이 경우 조심해야 할 게, 외부에서 애그리거트 내부 객체를 참조해서 직접 기능을 실행하는 경우, 애그리거트의 일관성이 깨질 수 있다.
-> 따라서, 내부 객체를 불변으로 구현하거나, 변경 기능을 패키지나 protected로 한정해서 외부에서 실행할 수 없도록 제한할 수 있다.
트랜잭션 범위
트랜잭션이 수정하는 테이블의 양(잠금 대상)이 많아질수록 전체적인 처리량을 떨어뜨린다.
-> 한 트랜잭션은 한 개의 애그리거트만 수정해야 한다.
+) 부득이하게, 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면,
애그리거트에서 다른 애그리거트를 직접 수정하지 말고, 응용 서비스에서 두 애그리거트를 수정하도록 구현한다.
리포지터리와 애그리거트
✅ 리포지터리의 범위
애그리거트의 일관성을 유지하면서 저장, 조회를 하려면,
리포지터리는 애그리거트 단위로 존재한다.
저장할 때는 애그리거트 전체 객체를 저장해야 하고,
조회할 때는 애그리거트 전체 객체를 조회해야 한다.
ID를 이용한 애그리거트 참조
✅ 필드 참조
애그리거트 관리 주체는 애그리거트 루트이므로,
애그리거트가 다른 애그리거트를 참조한다는 것은 다른 애그리거트의 루트를 참조한다는 것과 같다.
ex) 주문 애그리거트에 속한 Orderer는 주문한 회원을 참조하기 위해, 회원 애그리거트 루트인 Member를 참조할 수 있다.
@Embeddable
public class Orderer {
private Member member;
private String name;
...
}
✅ 필드 참조 장점
이렇게 필드로 애그리거트 루트를 참조했을 때의 장점은 구현의 편리함이다.
ex) 주문 애그리거트를 통해 쉽게 회원 애그리거트에 접근할 수 있다.
order.getOrderer().getMember().getId()
또한, JPA는 @ManyToOne, @OneToOne 등, 연관된 객체를 로딩하는 기능을 제공하고 있으므로,
필드를 이용해 다른 애그리거트를 쉽게 참조할 수 있다.
✅ 필드 참조 단점
애그리거트 필드 참조는 다음과 같은 문제를 야기할 수 있다.
<편한 탐색 오용>
애그리거트를 직접 참조한다는 편리함을 오용한다면,
어떤 애그리거트 내부에서 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다.
이는 앞서 얘기한, 한 트랜잭션은 하나의 애그리거트만 변경해야 한다는 것에도 위배된다.
<성능에 대한 고민>
JPA를 사용하면 참조한 다른 애그리거트 객체를 지연로딩 할 것인지, 즉시로딩 할 것인지에 대한 성능 관점에서의 고민이 필요하다.
<확장 어려움>
초기에는 단일 서버에 단일 DBMS로 서비스를 제공하지만,
나중에 도메인 별로 시스템을 분리한다면?
이 과정에서 도메인마다 서로 다른 DBMS를 사용해야 한다면?
-> 이미 여기서부터 JPA의 의미가 사라진다.
✅ 애그리거트 ID 참조
위와 같은 세 가지 문제를 완화할 때 사용할 수 있는 것이 ID 참조이다.
@Embeddable
public class Orderer {
private MemberId memberId;
private String name;
...
}
ID를 참조하면, 애그리거트의 경계를 명확히 하고, 모델의 복잡도를 낮춰주며, 애그리거트의 응집도를 높여준다.
<편한 탐색 오용>
한 애그리거트에서 다른 애그리거트를 직접 참조하지 않기 때문에, 다른 애그리거트를 수정하는 문제를 근원적으로 방지할 수 있다.
<성능에 대한 고민>
다른 애그리거트를 직접 참조하지 않기 때문에, 참조를 지연로딩으로 할지, 즉시로딩으로 할지 고민하지 않아도 된다.
<확장 어려움>
애그리거트 간에 경계를 명확히 했기 때문에, 애그리거트 별로 다른 구현 기술(RDBMS, NoSQL, ...)을 사용하는 것이 가능해진다.
ID를 이용한 참조와 조회 성능
✅ N + 1 문제
애그리거트를 ID로 참조한다는 것은, 연관된 애그리거트를 조회할 때, 지연 로딩과 같은 효과를 얻을 수 있다.
-> 지연 로딩에서 항상 따라오는 문제가 N + 1 문제인데,
이를 해결하기 위해, 특정 데이터 조회를 위한 별도의 DAO를 만들고,
DAO 조회 메서드에 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩하면 된다.
애그리거트 간 집합 연관
✅ 1 - N 연관
ex) 카테고리, 상품이 연관되어 있을 때,
한 카테고리는 여러 상품이 속할 수 있고,
한 상품은 하나의 카테고리에만 속할 수 있다면,
카테고리 - 상품은 1 - N 관계다.
먼저 카테고리가 상품 리스트를 갖고 있고,
이를 페이징해서 조회하는 보편적인 상황을 보자.
public class Category {
private Set<Product> products;
public List<Product> getProducts(int page, int size) {
List<Product> sortedProducts = sortById(products);
return sortedProducts.subList((page - 1) * size, page * size);
}
}
이 코드의 단점은,
특정 카테고리에 속한 모든 상품을 조회한다는 것이다.
이는, 상품의 개수가 많아질수록 실행 속도가 급격히 느려져 성능에 심각한 문제를 일으킬 수 있다.
따라서, 이런 성능 문제 때문에 애그리거트 간의 1 - N 연관을 실제 구현에 반영하지 않는다.
오히려 상품 입장에서 N - 1로 연관을 지어, 쿼리를 통해 상품 목록을 구한다.
public class ProductListService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(CategoryId.of(categoryId)).get();
checkCategory(category);
List<Product> products = productRepository.findByCategoryId(category.getId(), page, size);
int totalCount = productRepository.countsByCategoryId(category.getId());
return new Page(page, size, totalCount, products);
}
}
✅ M - N 연관
ex) 카테고리, 상품이 연관되어 있을 때,
한 카테고리는 여러 상품이 속할 수 있고,
한 상품은 여러 카테고리에 속할 수 있다면,
카테고리 - 상품은 M - N 관계다.
만약, 상품 상세에서 모든 카테고리를 보여줘야 하는 요구사항이 있다면,
상품 -> 카테고리에 대한 연관관계만 있으면 충분하다.
@Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "product_category",
joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
}
RDBMS로 M - N 연관을 구현하려면,
조인 테이블을 사용하기에 위와 같이 JPA 매핑 설정을 이용해 코드를 짤 수 있다.
+) 특정 카테고리에 속한 상품 리스트도 상품 -> 카테고리 연관만 있어도 쿼리 조건을 통해 조회할 수 있다.
애그리거트를 팩토리로 사용하기
ex) 만약 상점이 차단됐다면, 특정 상품을 등록 못하게 하자.
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store = storeRepository.findById(req.getStoreId());
checkNull(store);
if (store.isBlocked()) {
throw new StoreBlockedException();
}
ProductId id = productRepository.nextId();
Product product = new Product(id, store.getId(), ...);
productRepository.save(product);
return id;
}
}
이 코드는 상점이 상품을 생성할 수 있는지 판단하고, 상품을 생성하는 것은 논리적으로 하나의 도메인 기능인데,
이 도메인 기능을 응용 서비스에서 구현하고 있다.
이 도메인 기능을 넣기 위한, 별도의 도메인 서비스나 팩토리 클래스를 만들 수도 있지만,
이 기능은 상점 애그리거트에 구현하기 좋은 조건이다.
- 상품은 상점의 식별자를 필요로 한다.
- 상품을 생성하는 조건을 판단할 때, 상점의 상태를 이용한다.
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에서만 구현하기 때문에,
도메인의 응집도를 높일 수 있다.
'book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
[DDD Start] 응용 서비스와 표현 영역 (0) | 2023.08.12 |
---|---|
[DDD Start] 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.08.12 |
[DDD Start] 리포지터리와 모델 구현 (0) | 2023.07.28 |
[DDD Start] 아키텍처 개요 (0) | 2023.07.12 |
[DDD Start] 도메인 모델 시작하기 (0) | 2023.07.06 |