반응형
3. 애그리거트
3.1 애그리거트
애그리거트의 필요성
- 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고, 그렇게 되면 도메인 간의 관계를 파악하기 어려워진다.
- 복잡한 도메인을 이해하고 쉽게 관리하려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요하다.
- 애그리거트는 복잡한 도메인을 상위 수준에서 조망할 수 있도록 해준다.
애그리거트의 장점
- 애그리거트는 도메인의 일관성을 유지하는데 도움을 준다.
- 복잡한 도메인을 단순한 구조로 표현할 수 있다.
- 도메인을 변경하는데 필요한 노력이 줄어든다.
- 애그리거트에 속한 객체는 동일한 라이프 사이클을 갖는다.
애그리거트의 규칙
- 애그리거트는 자신에게 속한 객체만을 관리할 뿐 다른 애그리거트는 관리하지 않는다.
- 도메인 규칙에 따라 함께 생성되는 구성 요소는 한 애그리거트에 속할 가능성이 높다.
- 도메인 규칙을 제대로 이해할수록 애그리거트의 실제 크기는 줄어든다.
3.2 애그리거트 루트
루트 엔티티
- 애그리거트에 속한 객체들의 일관성을 위해 애그리거트 전체를 관리하는 객체가 바로 루트 엔티티다.
3.2.1 도메인 규칙과 일관성
- 애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
- 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현한다.
public class Order {
public void changeShippingInfo(ShippingInfo newShipping){
verifyNotYetShipped();
setShippingInfo(newShipping);
}
private void verifyNotYetShipped() {
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shipped");
}
}
- 해당 코드처럼 애그리거트 루트인 Order는 주문 애그리거트에서 제공하는 기능을 도메인 규칙에 맞게 구현해야한다.
ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
- 위의 코드는 Order객체에서 ShippingInfo를 가져와 직접 정보를 변경하고 있다.
- 이는 업무 규칙 로직을 통과하지 않고 직접적인 값을 DB에 추가한 것이다.
- 해당 코드는 결과적으로 논리적인 데이터 일관성이 깨지게 된다.
- 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만드려면 도메인 모델에 다음 두가지 규칙을 적용해야한다.
- 단순히 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
- 밸류 타입은 불변으로 구현한다.
- 공개 set 메서드는 도메인의 의미와 의도를 표현하지 못한다.
- Value type 객체의 값을 변경할 수 없으면 애그리거트 루트에서 해당 객체의 값을 참조해도 외부에서는 객체의 상태를 변경할 수 없다.
- 따라서 밸류 객체가 변경되려면 새로운 밸류 객체를 할당해야한다.
3.2.2 애그리거트 루트의 기능 구현
public class Order {
private Money totalAmounts;
private List<OrderLine> orderLines;
private void calculateTotalAmounts(){
int sum = orderLines.stream().mapToInt(x -> x.getAmounts()).sum();
this.totalAmounts = new Money(sum);
}
}
- Order객체에서는 총 주문량을 계산하기 위해 OrderLine객체를 사용해 기능을 구현한다.
public class Member {
private Password password;
public void changePassword(String currentPassword, String newPassword){
if(!password.match(currentPassword)){
throw new PasswordNotMatchException();
}
this.password = newPassword;
}
}
- Member객체는 password검증 로직을 내부 메서드에서 구현하고, 새로운 password를 할당한다.
public class Order {
private Money totalAmounts;
private List<OrderLine> orderLines;
public void changeOrderLines(List<OrderLine> newLines){
orderLines.changeOrderLines(newLines);
this.totalAmounts = orderLines.getTotalAmounts();
}
}
- Order의 changeOrderLines() 메서도는 내부 orderLines의 상태 변경을 OrderLines 객체에 위임한다.
3.2.3 트랜잭션 범위
- 트랜잭션 범위는 성능차이를 이유로 작을수록 좋다.
- 한 개의 테이블을 수정하면 트랜잭션 충돌을 막기 위해 잠그는 대상이 한 개 테이블의 한 행으로 한정되지만, 세 개의 테이블을 수정하면 잠금 대상이 더 많아진다.
- 잠금 대상이 많아 진다는 것은 그만큼 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 의미하고 이는 성능을 떨어뜨린다.
- 한 트랜잭션에서는 한 개의 애그티리거만 수정해야한다.
- 이는 한 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미한다.
public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMemberAddr){
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
if(useNewShippingAddrAsMemberAddr){
//다른 애그리거트의 상태를 변경하면 안 됨!
orderer.getMember().changeAddress(newShippingInfo.getAddress);
}
}
}
- 위의 코드는 한 애그리거트가 자신의 책임임 범위를 넘어 다른 애그리거트의 상태까지 관리하는 예시이다.
- 배송지 정보를 변경하면서 동시에 배송지 정보를 회원의 주소로 설정하는 기능이다. > 주문 애그리거트가 회원 애그리거트를 침범하게 된다.
public class ChangeOrderService {
//두 개 이상의 애그리거트를 변경해야 하면,
//응용 서비스에서 각 애그리거트의 상태를 변경한다.
@Transactional
public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
Order order = orderRepository.findbyId(id);
if(order == null) throw new OrderNotFoundException();
order.shipTo(newShippingInfo);
if(useNewShippingAddrAsMemberAddr){
Member member = findMember(order.getOrderer());
member.changeAddress(newShippingInfo.getAddress());
}
}
}
- 하나의 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현한다.
3.3 리포지터리와 애그리거트
- 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.
- Order와 OrderLine을 물리적으로 각각 별도의 DB Order가 애그리거트 루트고, OrderLine은 애그리거트에 속하는 구성요소이므로 Order를 위한 리포지터리만 존재한다.
- 애그리거트는 개념적으로 하나이므로 리포지터리는 애그리거트 전체를 저장송 영속화해야한다.
- Order 애그리거트와 관련된 테이블이 세 개라면 Order 애그리거트를 저장할 때 애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야한다.
- 동일하게 애그리거트를 구하는 리포지터리 메서드는 완전한 애그리거트를 제공해야 한다. 즉, Order를 조회하는 레포지터리는 OrderLine, Orderer 등 모든 구성요소를 포함하고 있어야 한다.
3.4 ID를 이용한 애그리거트 참조
- 애그리거트도 다른 애그리거트를 참조한다.
- 이는 애그리거트에서 다른 애그리거트 루트를 참조한다는 것을 뜻한다.
public class Order {
private Orderer orderer;
public void changeShippingInfo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
if(useNewShippingAddrAsMemberAddr){
//한 애그리거트 내부에서 다른 애그리거트에 접근할 수 있으면,
//구현이 쉬워진다는 것 때문에 다른 애그리거트의 상태를 변경하는
//유혹에 빠지기 쉽다.
orderer.getMember().changeAddress(newShippingInfo.getAddress);
}
this.shippingInfo = newShippingInfo;
}
}
- 위의 코드처럼 애그리거트 내부에서 다른 애그리거트 객체에 접근하는 오류를 발생하기 쉽다.
- 이런 코드는 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.
- 애그리거트를 직접 참조하면 성능과 관련된 여러 가지 고민을 해야한다.
- 사용자가 늘고 트래픽이 증가하면 자연스럽게 부하를 분산하기 위해 하위 도메인별로 시스템을 분리한다.
- 해당 과정에서 하위 모메인마다 서로 다른 DBMS를 사용할 때가 있 다.
- 이는 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다.
- 위의 문제를 해결하기 위해 ID를 사용해 애그리거트를 참조한다.
public class ChangeOrderService {
@Transactional
public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr){
Order order = orderRepository.findbyId(id);
if(order == null) throw new OrderNotFoundException();
order.changeShippingInfo(newShippingInfo);
if(useNewShippingAddrAsMemberAddr){
//ID를 이용해서 참조하는 애그리거트를 구한다.
Member member = memberRepository.findById(order.getOrderer().getMemberId());
member.changeAddress(newShippingInfo.getAddress());
}
}
}
- ID를 이용한 참조 방식을 사용하면 복잡도를 낮추는 것과 함께 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지할 수 있다.
- 애그리거트별로 다른 구현 기술을 사용하는 것도 가능해진다. 중요한 데이터인 주문 애그리거트는 RDBMS에 저장하고 조회 성능이 중요한 상품 애그리거트는 NoSQL에 저장할 수 있다.
3.4.1 ID를 이용한 참조와 조회 성능
- 다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽을 때 조회 속도가 문제 될 수 있다.
Member member = memberRepository.findById(orderId);
List<Order> orders = orderRepository.findByOrderer(orderId);
List<OrderView> dtos = orders.stream().map(order ->{
ProductId prodiId = order.getOrderLines().get().getProductId();
Product product = productRepository.findById(prodId);
return new OrderView(order, member, product);
}).collect(toList());
- 위의 코드는 N + 1 조회 문제의 대표적인 예이다.
- N + 1문제는 조회 대상이 N개일 때 N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행한다는 뜻이다.
- N + 1 조회 문제는 더 많은 쿼리를 실행하기 때문에 전체 조회 속도가 느려지는 원인이 된다.
- 해당 문제를 해결하는 방법은 데이터 조회를 위한 별도 DAO 를 만들고 DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩하는 것이다.
3.5 애그리거트 간 집합 연관
애그리거트 간 1-N, M-N 연관
- 카테고리와 상품의 예시
- 카테고리 입장에서 한 케테고리에 한 개 이상의 상품이 속할 수 있으니 카테고리와 상품은 1-N관계이다.
- 한 상품이 한 카테고리에만 속할 수 있 다면 상품과 카테고리는 N-1 관계이다.
- 애그리거트간 1-N관계는 Set과 같은 컬렉션을 이용하여 표현할 수 있다.
- 그러나 개념적으로 애그리거트 간에 1-N 연관이 있더라도 성능 문제 때문에 애그리거트 간의 1-N 연관을 실제 구현에 반영하지 않는다.
- 카테고리에 속한 상품을 구할 필요가 있다면 상품 입장에서 자신이 속한 카테고리를 N-1로 연관 지어 구하게 된다.
public class ProductListService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size){
Category category = categoryRepository.findById(categoryId);
List<Product> products = productRepository.findByCategory(category.getId(), page, size);
}
}
- 위의 코드처럼, 카테고리에 속한 상품 목록을 제공하는 응용 서비스는 다음과 같이 ProductRepository를 이용해서 categoryId가 지정한 카테고리 식별자인 Product목록을 구한다.
- 카테고리의 양방향 M-N 연관이 존재해도, 실제로는 단방향 연관만을 적용한다.
3.6애그리거트를 팩토리로 사용하기
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req){
Store store = storeRepository.findBVyId(req.getStoreId());
checKNull(store);
ProductId id = productRepository.nextId();
Product product = store.createProduct(id, ...);
productRepository.save(product);
return id;
}
}
- 위처럼 Store 애그리거트 안에서 Product 애그리거트를 생성하면서 펙토리 기능을 구현할 수 있다.
반응형
'programming > DDD' 카테고리의 다른 글
[DDD] chapter 6 (0) | 2024.02.10 |
---|---|
[DDD] chapter 5 (1) | 2024.02.07 |
[DDD] chapter 4 (0) | 2024.02.03 |
[DDD] chapter2 (1) | 2024.01.13 |
[DDD] chapter 1 (0) | 2024.01.13 |