최범균 님의 DDD Start를 읽고 정리한 내용입니다.
DDD 애그리거트(Aggregate)
테이블이 100개 이상 있는 ERD를 보고 있다고 생각해보자.
하나 하나 따라가보면 개별 테이블의 연관 관계는 알 수는 있지만, 한 눈에 전체의 구조를 파악하기는 굉장히 어렵다.
도메인 모델도 마찬가지이다. 그렇게 되면 수정사항이 생겼을 때 코드를 변경하고 확장하는 일이 매우 힘들어질 것이다.
애그리거트를 활용하면 이러한 어려움을 해결할 수 있다.
🔗애그리거트
애그리거트는 관련 도메인을 하나의 군집으로 묶은 것
애그리거트를 사용하면 연관 도메인을 묶어서 이해하기 때문에 모델 관계를 파악하기가 더 쉽다.
또한 더 잘 이해할 수 있고 애그리거트 단위로 일관성을 관리하면 코드도 일목조연하게 작성할 수 있다.
코드의 복잡도가 낮아지기 때문에 유지보수 및 확장, 변경에 들이는 노력이 줄어든다.
🚥애그리거트 루트
애그리거트에 속한 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요하다.
루트 엔티티는 애그리거트의 대표 엔티티로, 애그리거트에 속한 엔티티는 루트 엔티티에 직접 혹은 간접적으로 속한다.
- 애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 조율하는 것이다.
- 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 제공한다.
- 주문 애그리거트의 루트 엔티티 Order는 관련 기능을 구현한 메소드를 제공
- 배송지 변경
- 상품 변경 등.
- 주문 애그리거트의 루트 엔티티 Order는 관련 기능을 구현한 메소드를 제공
🪓일관성이 깨지는 경우
주문 애그리거트의 루트인 Order
는 배송 정보 ShippingInfo
를 갖는다.
배송지 주소는 배송 중이거나 배송 완료인 경우 변경이 불가능하다.
하지만 아래 코드는 개발자가 어디든 집어 넣기만 한다면 규칙에 상관없이 배송지를 변경할 수 있다.
ShippingInfo info = order.getShippingInfo();
info.setAddress(newAddress);
검사 로직을 추가하여 함부로 변경하지 못하도록 제한할 수도 있다.
그런 경우 다른 위치에서 중복된 코드를 작성할 가능성이 매우 높아지게 된다.
🩹일관성을 지키려면?
일관성을 지키는 가장 좋은 방법은 일관성을 지키도록 강제하는 것이다.
두 가지 습관을 들여 두면 일관성을 지키는데 큰 도움이 된다.
- public setter를 만들지 않는다.
- 밸류 타입은 불변으로 만든다.
public Setter 만들지 않기
public setter는 일반적으로 필드에 값을 할당하기 위해 사용한다.
습관처럼 작성하지만 public setter는 도메인의 의미를 적절하게 표현할 수 없다.
또한 public setter가 포함된 객체는 단순히 특정 필드를 묶은 것처럼 이해될 수 있으며 그렇게 개발할 가능성이 높아진다.
그렇게 개발된 로직은 보통 한 곳에 응집되지 않고 응용 영역/표현 영역으로 분산되어 유지보수 시 분석 및 수정에 더 많은 시간을 들여야 한다.
setter를 사용하지 않는다면 의미가 드러나는 메소드 명을 고민하게 되고, 이에 걸맞는 로직을 응집시켜 구현하게 된다.
밸류 타입 불변으로 만들기
public setter 만들지 않기의 연장 선상이다.
애그리거트 루트에서 밸류 객체를 구한다 해도 값이 변경되지 않는다면, 애그리거트의 외부에서 밸류 객체 상태를 함부로 바꿀 수 없다.
ShippingInfo info = order.getShippingInfo();
info.setAddress(newAddress); // address가 불변이면 외부에서 이 로직을 사용할 수 없다.
대신 루트를 통해 애그리거트 정보를 바꾸어주자.
public class Order {
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
}
🛠애그리거트 기능 구현
애그리거트 루트는 애그리거트 내부의 다른 객체를 조합하여 기능을 완성해야 한다.
Order
는 총 주문 금액을 구하기 위해 OrderLine
목록을 사용한다.
public class Order {
private Money totalAmounts;
private List<OrderLine> orderLines;
private void calculateTotalAmounts() {
int sum = orderLines.stream()
.mapToInt(orderLine -> orderLine.getPrice() * orderLine.quantity())
.sum();
this.totalAmounts = new Money(sum);
}
}
🗜트랜잭션의 범위
한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
- 성능 문제
- 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 높아진다.
- 또한 트랜잭션이 잠궈둬야 하는 행이 많아진다.
- 애그리거트가 하나인 경우 한 행을 잠그고, 여러 개인 경우 그만큼 늘어나게 된다.
- 당연히 성능에 문제가 생길 가능성이 높아진다.
- 독립성이 깨진다
- 다른 애그리거트에 의존하면 애그리거트 간 결합도가 매우 높아진다(⛓⛓⛓).
- 결합도가 높아지면 수정이 매우 어려워진다.
아래 코드는 Order
에서 Customer
의 주소를 변경하고 있다.
public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo shippingInfo, boolean useNewShippingAddrAsMemberAddr) {
verifyNotYetShipped();
setShippingInfo(newShipingInfo);
if (useNewShippingAddrAsMemberAddr) {
// 다른 애그리거트 Customer를 수정하고 있다.
orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
}
}
}
위의 기능을 수행할 별도의 서비스 객체를 생성하는 것이 바람직하다.
public class ChangeOrderService {
@Transactional
public void changeShippingInfo(...) {
Order order = orderRepository.findById(id);
if (order == null) {
throw new OrderNotFoundException();
}
order.shipTo(newShippingInfo);
if (useNewShippingAddrAsMemberAddr) {
order.getOrderer().getCustomer().changeAddress(newShippingInfo.getAddress());
}
}
}
한 트랜잭션에서 1개의 애그리거트를 변경하는 것은 필수가 아닌 권장사항이다.
상황에 맞추어 두 개 이상의 애그리거트를 수정하는 것을 고려할 수 있다.
🗃레포지토리
애그리거트는 한 개의 완전한 도메인 모델을 표현하므로, 레포지토리는 애그리거트 단위로 존재한다.
Order
와 OrderLine
을 별도의 테이블에 저장해도 애그리거트에 포함된 모든 도메인 모델은 루트 엔티티를 위해 존재하고 루트 엔티티에 속하기 때문에 레포지토리는 하나를 사용하는 것이 바람직하다.
// 애그리거트 전체를 영속화하여 저장한다.
orderRepository.save(order);
// 레포지터리는 완전한 order를 제공해야 한다.
Order order = orderRepository.findById(orderId);
🔌애그리거트 참조
JPA를 사용하면 필드에 포함하기만 해도 다른 애그리거트를 참조할 수 있다.
public class Orderer {
private Member member;
...
}
편리한 방법이지만 몇 가지 문제를 안고 있다.
- 편한 탐색 오용
- 성능 문제
- 확장의 어려움
편한 탐색 오용
편리함을 오용하면 구현의 편리함 때문에 다른 애그리거트를 수정하려는 시도를 할 수 있다.
다른 애그리거트를 수정하면 의존 결합도가 높아지기 때문에 유지보수 비용이 높아지는 것을 항상 염두에 두도록 하자.
성능 문제
JPA를 사용하면 lazy loading, eager loading 옵션을 사용할 수 있다.
연관 객체 조회 즉시 View에 넘겨주어야 한다면 eager loading이 더 유리하겠지만,
애그리거트의 상태를 변경하는 중에는 불필요 정보는 가져오지 않는 lazy loading이 유리하다.
경우의 수를 고려하여 로딩 전략을 결정하자.
확장의 어려움
사용자가 많아지면 단일 DBMS로 시작했던 서비스에 변화가 생기기 시작한다.
부하 분산을 위해 하위 도메인 별로 시스템을 분리하는 마이크로 서비스 아키텍처를 적용하고,
각 아키텍처마다 다른 DBMS를 사용할 수도 있다.
이렇게 되면 JPA 단일 기술로 다른 애그리거트를 참조할 수 없다.
해결책
필드에 대상 애그리거트를 두지 않고 ID를 두어 간접 참조를 활용한다.
public class Orderer {
private MemberId memberId;
...
}
이렇게 되면 모든 객체가 참조로 연결되지 않으며 다른 부과 효과를 불러온다.
- 애그리거트의 경계가 더 명확해진다.
- 애그리거트 간 물리적인 연결이 제거되어 모델 복잡도가 낮아진다.
- 애그리거트 간 의존성이 제거되어 응집도가 높아진다.
- 구현 복잡도가 낮아지고 성능에 대한 고민을 할 필요가 없다. (로딩 전략이 고정되기 때문)
- 애그리거트 별로 다른 기술을 사용할 수 있다. (RDBMS, NoSQL 등)
이 해결책에도 문제점은 존재한다.
모든 로딩 전략이 lazy loading으로 고정되어 버리는데, 이 전략은 N+1 문제라는 대표적인 문제를 갖고 있다.
Customer customer = customerRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordererId);
List<OrderView> dtos = orders.stream()
.map(order -> {
ProductId id = order.getOrderLines.get(0).getProductId();
Product product = productRepository.findById(id);
return new OrderView(order, customer, product);
}).collect(toList());
위의 코드는 각 주문마다 첫 번째 주문 상품 정보를 불러온다.
N번 조회하는 각 정보에 1번씩 쿼리를 추가로 실행하는 것이다.
만약 조인을 사용한다면 이 문제는 사라진다.
ID 참조 방식을 사용하면서 N+1 문제도 방지하고 싶다면 전용 조회 쿼리를 사용할 수 있다.
별도의 Dao 객체를 만들고 JPQL/MyBatis 등 쿼리를 직접 활용하는 기술로 두 테이블을 조인하여 한 번에 불러오는 것이 성능 낭비를 막는 좋은 방법이다.
애그리거트 간 집합 연관
1:N, N:M 연관 관계에서도 성능에 더 유리한 관계가 있다.
다음의 두 가지를 고려해보자.
-
Category
에 N개의Product
를 포함public class Category { private Set<Product> products; ... }
- 이 경우
Category
에 속한Product
를 보여주려면 모든Product
를 로딩한다. Product
의 개수가 많으면 성능에 문제를 일으킬 가능성이 있다.
- 이 경우
-
Product
에 1개의Category
를 포함public class Product { private CategoryId categoryId; ... }
- 상품 입장에서 자신이 속한
Category
를 로딩한다.
- 상품 입장에서 자신이 속한
애그리거트를 팩토리로 활용
일부 도메인 로직이 응용 서비스에 노출되는 경우가 있다.
팩토리 애그리거트를 활용하여 결합도를 낮출 수 있다.
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest request) {
Store account = accountRepository.findByStoreId(request.getStoreId());
if (account.isBannedStore()) {
throw new BannedStoreException();
}
ProductId id = productRepository.nextId();
Product product = new Product(id, ...);
...
}
}
일부 기능을 Store
로 옮겨보자.
public class Store {
public Product createProduct(ProductId newProductId, ...) {
if (isBannedStore) {
throw new BannedStoreException();
}
return new Product(newProductId, ...);
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest request) {
Store account = accountRepository.findByStoreId(request.getStoreId());
ProductId id = productRepository.nextId();
// 차단 여부가 응용 서비스에 노출되지 않는다.
Product product = account.createProduct(id, ...);
...
}
}
서비스 로직의 방향성은 그대로지만 일부 도메인 로직이 노출되지 않도록 변경되었다.
'Java > Domain Driven Design' 카테고리의 다른 글
DDD 아키텍처 (0) | 2020.03.18 |
---|---|
간단한 예제로 DDD 입문하기 (Spring boot) (0) | 2020.03.11 |