본 포스팅은 DDD 를 공부하면서 정리하기 위한 포스팅입니다.
출처 : 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지 (저자. 최범균)
1. 애그리거트(Aggregate)
애그리거트는 도메인 영역을 구성하는 요소 중 하나로, 관련된 도메인 객체들의 집합을 뜻합니다.
어떠한 도메인 모델을 이해하기 위해, 상위 수준의 도메인 모델과 하위 수준의 도메인 모델을 이해하는데 아래와 같은 예시로 설명할 수 있습니다.
상위 수준 도메인 모델의 경우, 비교적 큰 개념 간의 관계를 나타내는 반면 하위 수준 도메인 모델의 경우, 주로 객체 단위의 관계를 표현합니다. (클래스 다이어그램의 간략화 버전 정도로 이해하셔도 될 듯 합니다.)
문제는 상위 수준 도메인 모델을 기반으로 하위 수준 도메인 모델을 도출해냈을 때, 객체 간의 관계에 초점이 맞춰져 있어 하위 수준 도메인 모델을 보고서는 거대한 도메인의 전반적인 흐름이나 그 속에서의 주요 관계를 파악하는데 다소 힘들다는 점 입니다.
이런 문제를 해결하기 위해 하위 수준 도메인 모델에서도 상위 수준 도메인 모델에서와 같이 보다 큰 개념들 간의 관계를 파악하기 위해 도입된 개념이 바로 애그리거트(Aggregate) 입니다.
그렇다면 애그리거트의 "관련된 객체들의 집합" 에서 "관련된" 은 정확히 어떠한 관련을 말하는 것일까요.
애그리거트를 묶는 기준으로 두기 가장 좋은 기준은 "도메인의 요구사항" 입니다. 특정 도메인 영역을 개발하기 위해 필요한 기능을 정리하면서 도출되는 규칙을 정리했을 때, 그에 필요한 객체들은 한 애그리거트에 묶일 수 있습니다.
하지만, 이는 충분한 경험이 뒷받침되어야 보이는 부분이므로 와닿지 않을 수 있습니다. 이럴 때 세울 수 있는 또 하나의 기준은 "생명주기가 같은 객체들을 묶는 것" 입니다.
객체들이 생성되고 소멸되는 시점이 동일한 사이클을 가지는 객체들은 하나의 애그리거트에 속하도록 설계할 수 있습니다.
2. 애그리거트 루트과 도메인 기능 구현
모든 애그리거트에는 해당 도메인을 대표하는 루트 엔티티가 존재합니다. 그것을 애그리거트 루트 라고 부릅니다.
위에 제시됐던 하위 수준 도메인 모델을 먼저 애그리거트로 묶어본다면, 아래 처럼 묶을 수 있을 것 입니다.
음영으로 표시된 부분을 하나의 애그리거트로 부를 수 있습니다. Order 객체를 중심으로 OrderItem, OrderStatus, OrderDeliveryInfo, Address, Receiver 객체는 모두 주문 애그리거트에 속한다고 볼 수 있고, 이 경우 주문 애그리거트의 루트는 Order 가 됩니다.
애그리거트 루트 엔티티(이하. 애그리거트 루트)는 애그리거트에 속하는 객체들의 상태를 정상적인 상태로 유지하고, 도메인 로직으로 인한 변경을 책임집니다. 즉, A 애그리거트에서 B 애그리거트에 속하는 객체의 상태를 변경하는 경우, 해당 객체에 직접 접근하여 상태를 변경할 수 없으며, 반드시 B 애그리거트 루트 객체를 통해서 간접적으로 변경이 이루어져야 합니다.
예를 들어 아래 요건대로 주문 취소 기능을 개발한다고 가정해보겠습니다.
- 주문 상태가 준비중이면, 주문을 취소할 수 있다.
- 주문 취소 시, 주문 취소일자와 취소사유를 입력해야 한다.
위 두 기능을 간략하게 코드로 개발하면 아래와 같을 것 입니다.
public class OrderService {
@Transactional
public void cancel(Long orderId, LocalDateTime cancelDate, String cancelReason) {
Order order = findById(orderId);
if(order.getOrderStatus() != OrderStatus.READY) {
throw new CannotCancelOrderException("준비 중인 주문만 취소가 가능합니다.");
}
order.setOrderStatus(OrderStatus.CANCEL);
order.setCancelDate(cancelDate);
order.setCancelReason(cancelReason);
}
}
위 코드는 주문을 취소하는 간단한 로직을 구현한 것입니다. 위 코드의 문제점을 몇가지 얘기해보면 아래와 같습니다.
- Order 도메인 객체에 setter 메서드가 public 으로 열려있다.
- 응용 서비스 계층에 도메인 로직이 작성되어 있다.
앞에서 애그리거트를 설명하면서 "애그리거트에 속한 객체들이 정상적인 상태를 유지하는 것은 애그리거트 루트의 책임" 이라고 언급한 바 있습니다. 이러한 관점에서 setter 메서드를 public 으로 열어두는 것은 좋지 않습니다.
만약 위 코드에서 개발자가 실수로 setter 를 빼먹으면 아래와 같은 문제점이 발생할 수 있습니다.
- 주문 상태가 READY 인데, 취소일자와 취소사유가 입력된다.
- 주문 상태가 CANCEL 로 됐는데, 취소일자 혹은 취소사유가 입력되지 않았다.
위와 같은 문제점들을 해소하기 위해 응용 서비스 계층에 작성된 도메인 로직을 Order 라는 도메인 객체로 옮기면 아래처럼 변경될 수 있습니다.
public class OrderService {
@Transactional
public void cancel(Long orderId, LocalDateTime cancelDate, String cancelReason) {
Order order = findById(orderId);
order.cancel(cancelDate, cancelReason);
}
}
public class Order {
private OrderStatus orderStatus;
public void cancel(LocalDateTime cancelDate, String cancelReason) {
if(this.orderStatus != OrderStatus.READY) {
throw new CannotCancelOrderException("준비 중인 주문만 취소가 가능합니다.");
}
this.orderStatus = OrderStatus.CANCEL;
this.cancelDate = cancelDate;
this.cancelReason = cancelReason;
}
}
위 처럼 도메인의 비지니스 로직은 도메인 내에서 구현하고, 응용 서비스 에선 도메인을 조회하고 해당 도메인의 기능을 호출하여 기능 수행자체는 도메인 계층에 위임하는 것이 포인트입니다.
3. 애그리거트 참조
애그리거트에서 다른 애그리거트를 사용해야 하는 경우가 있을 수 있습니다. 대표적인 예가 "주문 상품" 입니다.
주문 애그리거트를 설계할 때, 한 번에 여러 가지 상품을 주문 할 수 있다는 요구사항이 있다면, 주문 상품은 주문 객체 내에 컬렉션 형태로 존재할 것 입니다.
public class Order {
private List<OrderItem> items;
/* -- 중량 -- */
}
public class OrderItem {
Product product;
int quantity;
/* -- 중략 -- */
}
위 코드와 같이 맨 앞에서 다뤘던 애그리거트를 구분해놓은 도메인 모델을 봤을 때, Order / OrderItem 객체와 Product 객체는 다른 애그리거트임을 알 수 있습니다. 하지만 위 코드는 Order 애그리거트에 Product 애그리거트를 참조하고 있는 형태입니다.
객체지향 기법으로 작성된 코드라는 관점에선 그리 잘못된 코드로 보이지는 않습니다. 하지만, 도메인 모델에 비지니스 로직을 구현하고, 관련 도메인 객체들끼리 애그리거트로 묶어놓은 현 시점에선 애그리거트 내 객체가 다른 애그리거트 내 객체를 품고 있는 형태는 여러 고민이 필요합니다.
- 성능 : Order 애그리거트 루트를 조회하면, 자연스럽게 OrderItem 객체가 조회되고, 그러면 다시 자연스럽게 Product 객체까지 조회되어, 하나의 Order 를 조회하기 위한 작업이 너무 커질 수 있습니다. 또한 OrderItem 이 N개 인 경우 성능은 점점 더 느려질 것 입니다.
- 확장성 : 애그리거트 참조 형태를 사용한다면 같은 저장소 여야만 가능하다. 추후에 시스템이 커지고 서비스가 분할되는 경우가 생기면 각 애그리거트를 서비스 단위로 분리해서 운영할 여지가 존재하는데, 이러한 경우 다른 저장소를 가지는 각각의 서비스 단위로 분리할 수 없습니다.
- 애그리거트의 본질 무력화 : 애그리거트에서 다른 애그리거트의 상태를 변경하기 위해선, 응용 서비스 계층에서 각 애그리거트를 모두 조회하여 기능을 조합해서 처리해야 하는데, 애그리거트 도메인 계층에서 다른 애그리거트의 상태를 변경할 수 있으면 안됩니다.
이런 경우, 애그리거트로 나눠 놓은 장점을 살리는 방법은 객체를 참조하기 보단, 애그리거트 루트의 ID를 참조하는 것입니다.
코드로 본다면 아래와 같아집니다.
public class Order {
private List<OrderItem> items;
/* -- 중량 -- */
}
public class OrderItem {
Long productId;
int quantity;
/* -- 중략 -- */
}
Product 객체를 참조하지 않고, Product 객체의 ID 를 참조한 모습입니다.
위 처럼 애그리거트를 명확하게 분리해놓고, 응용 서비스 계층에서 다른 애그리거트의 ID 를 가지고 직접 조회해서 사용하는 방식이 있습니다. 아래 코드는 그 예시입니다.
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public OrderDto findOrder(Long orderId) {
Order order = orderRepository.findById(orderId);
List<OrderItemDto> itemDtos = new ArrayList<>();
List<OrderItem> items = order.getItems();
for(OrderItem item : items) {
Product product = productRepository.findById(item.getProductId());
items.add(
new OrderItemDto(product.getId(), product.getName(), item.getQuantity())
);
}
return new OrderDto(order, itemDtos);
}
}
위 코드는 주문 내역을 조회하기 위해 Order 정보와 Product 정보를 조회하여 하나의 DTO 로 return 하는 로직입니다.
주문 애그리거트와 상품 애그리거트에서 정보를 조회해와야 하기 때문에 Order 객체를 먼저 구하고, 구해진 orderItems 를 순회하면서 productRepository 에서 상품 정보를 조회하여 원하는 정보를 가져옵니다.
애그리거트 참조를 피하기 위해 ID 참조를 선택했다면, 위와 같은 현상은 피하기 어렵고, 아쉽게도 Order 1개를 구하기 위해 몇 개 일지 모르는 OrderItem 의 쿼리가 추가적으로 발생하고 있음을 알 수 있습니다.
이 처럼 1번의 조회(Order 를 조회)를 시작으로 그 안에 N번의 조회(OrderItem 을 통해 Product 조회)가 이루어지는 현상을 N+1 문제 라고 부릅니다. (JPA 를 배울 때 한 번 쯤 들어봤을 N+1과 동일한 문제라고 보셔도 무방합니다. - 100% 동일하진 않습니다.)
이것은 앞에 애그리거트 참조를 ID 참조로 변경하면서 발생한 문제로 성능에 치명적인 영향을 끼칠 수 도 있습니다.
하지만 보통 이러한 조회 로직에선 위와 같은 N+1 문제를 방지하고자 여러 최적화 작업을 거치게 됩니다. 그 부분은 이번 포스팅에서 자세히 다룰 내용은 아니라 일단 넘어가도록 하겠습니다.(나중에 CQRS 정도 공부하게 되면 다루겠죠...?)
4. 애그리거트 팩토리
도메인 모델을 구현하다보면, 애그리거트에서 다른 애그리거트 루트를 생성해야 하는 경우가 발생할 수 있습니다.
대표적인 예가 바로 온라인 쇼핑몰의 상품등록 입니다. 상품등록은 주로 온라인 쇼핑몰의 운영자 혹은 온라인 쇼핑몰에 입점한 판매자가 수행합니다. 하지만 상품을 구매하는 것은 사용자 이므로 라이프사이클이 달라 판매자 애그리거트에서 수행할지, 상품 애그리거트에서 수행할 지 헷갈릴 수 있습니다.
예를 들어, 온라인 쇼핑몰에 입점한 판매자가 부정상품의 다수 등록으로 인해 쇼핑몰로 부터 상품등록 자격정지 처분을 받은 경우라면 상품을 등록할 수 없어야 합니다. 이 경우 아래와 같이 코드를 구현할 수 있을 것 입니다.
public class PartnersService{
public Long createProduct(Long partnersId, ProductCreateRequest request) {
Partners partners = partnersRepository.findById(partnersId);
if(partners.isBlocked()) {
throw new CannotCreateProductException("상품을 등록할 수 없습니다.");
}
Product savedProduct = productRepository.save(
new Product(partnersId, request)
);
return savedProduct.getId();
}
}
먼저, 판매자(Partners) 의 상태가 판매자격정지 처분 상태인지(isBlocked) 먼저 확인하고, 그렇지 않다면 상품을 생성하여 그 ID 를 return 하도록 응용 서비스를 구현한 형태입니다.
얼핏 봤을 때는 판매자 애그리거트와 상품 애그리거트를 적절히 사용한 것 처럼 보이지만, "판매자의 판매자격이 정지 상태인지 확인한다." 라는 도메인 규칙이 서비스 계층에 노출되어 있으므로 도메인 로직을 도메인 계층에 구현했다고 보기 어렵습니다.
이를 해결한 형태로 코드를 변경하면 아래 처럼 변경 할 수 있을 것 입니다.
public class PartnersService{
public Long createProduct(Long partnersId, ProductCreateRequest request) {
Partners partners = partnersRepository.findById(partnersId);
Product savedProduct = partners.createProduct(request);
return savedProduct.getId();
}
}
public class Partners{
private Long id;
private boolean isBlocked;
public Product createProduct(ProductCreateRequest request) {
if(isBlocked) {
throw new CannotCreateProductException("상품을 등록 할 수 없습니다.");
}
return new Product(id, request);
}
}
응용 서비스 계층에선 애그리거트 조회, 도메인 로직 호출, 결과 return 3가지만 수행하고 실질적인 도메인 로직은 도메인 내에서 실행하도록 위임하는 형태입니다. 상품을 생성하는 주체는 판매자(Partners)이므로 도메인 계층에서 비지니스 로직을 처리한다는 관점에선 위와 같은 코드 형태가 더 적합할 것 입니다.
단, 현재 Partners 라는 도메인 객체 내에서 createProduct 메서드의 파라미터로 ProductCreateRequest 라는 DTO 를 의존하고 있는데, 이는 좋은 구조가 아닙니다. 그 이유는 DTO 는 표현하고자 하는 값이 수시로 변경될 가능성이 비교적 높은 객체인데, 해당 변경이 도메인 내에 영향을 주기 때문입니다. 저렇게 DTO 자체를 받아서 처리하는 식의 개발은 지양하지만 예시의 간결함을 위해 거기까진 고려하지 않았음을 알려드립니다.
하지만, 여기서도 한 가지 고려해 볼 수 있는 점은 Product 의 생성 부분 입니다. Product 내부에 항목이 추가되는 등의 이유로 생성자의 형태가 변경된다면 Product 애그리거트의 변경이 Partners 애그리거트까지 영향을 주게 됩니다.
이를 조금이나마 보완할 수 있는 방법으론 Factory 기법을 도입하는 것 입니다.
애그리거트 루트를 생성하는 Factory 를 선언하여 사용하므로써, Factory 의 내용을 수정함으로써 Factory 를 통해 애그리거트를 생성하는 다른 애그리거트에서의 변경을 최소화 하는 방법입니다.
public class PartnersService{
public Long createProduct(Long partnersId, ProductCreateRequest request) {
Partners partners = partnersRepository.findById(partnersId);
Product savedProduct = partners.createProduct(request);
return savedProduct.getId();
}
}
public class Partners{
private Long id;
private boolean isBlocked;
public Product createProduct(ProductCreateRequest request) {
if(isBlocked) {
throw new CannotCreateProductException("상품을 등록 할 수 없습니다.");
}
return ProductFactory.create(partnersId, request);
}
}
public class ProductFactory{
public Product create(Long partnersId, ProductCreateRequest request) {
return new Product(partnersId, request);
}
}
'DDD&MSA' 카테고리의 다른 글
[DDD] 도메인 서비스(Domain Service) (0) | 2023.06.14 |
---|---|
[DDD] 응용 서비스 구현과 표현 계층 (0) | 2023.06.07 |
[DDD] 주문 애그리거트 구현하기(with JPA) (0) | 2023.06.03 |
[DDD] Domain 영역의 구성 요소 맛보기(with 애플리케이션 아키텍처) (0) | 2023.05.29 |
[DDD] DDD - 엔티티(Entity)와 밸류(Value) (feat. Java) (0) | 2023.05.28 |
댓글