코딩은 마라톤

[도메인 주도 개발 시작하기] Ch3. 애그리거트 본문

Backend

[도메인 주도 개발 시작하기] Ch3. 애그리거트

anxi 2024. 9. 22. 19:16

애그리거트

개발할 때 상위 수준 개념을 이용해서 전체 모델을 정리하면 전반적인 관계를 이해하는 데 도움이 된다.

↔ 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고, 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다.

→ 코드 유지보수성 및 확장성이 어려워진다.

→ 복잡한 도메인을 이애하고 관리하기 쉬운 단위로 만들기 위해 상위 수준에서 모델을 볼 수 있어야 하는데 이 방법이 바로 "애그리거트" 다.

개별 구성요소로 모델을 볼 때
애그리거트로 모델을 볼 때

 

  • 애그리거트는 모델 이해 뿐만 아니라 일관성을 관리하는 기준이 된다.
  • 애그리거트는 관련된 모델을 하나로 모았기 때문에 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
    • 주문 애그리거트 생성 시 Order만 생성하면 안되고 관련 객체를 함께 생성해야 한다.
  • 애그리거트 경계 설정
    • 도메인 규칙에 따라 함께 생성되는 구성요소일 경우 한 애그리거트에 속할 가능성이 높다.
      • 주문할 상품 개수, 배송자 정보, 주문자 정보는 주문 시점에 함께 생성되기 때문에 한 애그리거트에 속한다.
    • 함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속한다.
      • ex) 상품 수 변경 시 주문 가격이 변경되는 것
    • 주의할 점
      • A가 B를 갖는다 의 요구사항일 경우, A와 B를 한 애그리거트로 묶어 생각할 수 있다.
      • 주문이 주문자, 배송 정보를 가지므로 어느 정도 맞는 말이긴 하다.
      • 하지만 상품 상세 페이지에서 상품 정보와 리뷰 내용을 보여줄 때 상품이 리뷰를 가지므로 상품 애그리거트에 리뷰가 포함되는 것은 틀린 말이다.
        • 상품과 리뷰는 함께 생성되지 않고, 함께 변경되지도 않는다.
    • Tip
      • 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많다. (엔티티 1 + 밸류 N)

애그리거트 루트

  • 주문 애그리거트
    • 총 금액을 갖고 있는 Order 엔티티
    • 개별 구매 상품 개수, 금액을 갖고 있는 OrderLine 밸류

애그리거트는 여러 객체로 구성되는데, 한 객체만 상태가 정상이면 안된다.

만약 Order의 총 금액은 변경되지만 OrderLine의 금액, 상품 개수가 변경되지 않으면 일관성이 깨진다.

→ 이를 방지하기 위해 애그리거트 전체를 관리할 주체가 필요하고 이 책임은 "애그리거트의 루트 엔티티"가 가진다.

도메인 규칙과 일관성

  • 루트 엔티티의 핵심 역할 : 애그리거트의 일관성이 깨지지 않도록 하는 것
  • 핵심 역할 수행 방법
    • 애그리거트가 제공해야 할 도메인 기능을 구현한다.
    • 주문 애그리거트는 배송지 변경, 상품 변경과 같은 기능을 제공하고 루트인 Order가 이 기능을 구현한 메서드를 제공한다.
public class Order {
  
  public void changeShippingInfo(ShippingInfo newShippingInfo) {
	  verifyNotYetShipped();
	  setShiipingInfo(newShippingInfo);
	}
	
	private void verifyNotYetShipped() {
	  if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING) {
	    throw new IllegalStateException("already shipped");
	  }
	}
	...
}

 

 

Problem) 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안된다.

ShippingInfo si = new ShippingInfo();
si.setAddress(newAddress);

 

Solve)

  1. 상태 확인 로직을 응용 서비스에 구현한다. (동일한 검사 로직을 여러 응용서비스에서 사용할 수 있어 유지보수 도움 X)
  2. 필드를 변경하는 set 메서드를 공개(public) 범위로 만들지 않는다.
  3. 밸류 타입은 불변으로 구현한다. (밸류 타입의 내부 상태 변경은 애그리거트 루트를 통해서만 가능하다.)

애그리거트 루트의 기능 구현

애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다.

public class Order {
  
  private Money totalAmounts;
  private List<OrderLine> orderLines;
  
  private void calculateTotalAmounts() {
    int sum = orderLines.stream()
             .mapToInt(o1 -> o1.getPrice() * o1.getQuantity())
             .sum();
             
    this.totalAmounts = new Money(sum);
  }
  ...
}

Order의 totalAmounts를 구하기 위해서 Order의 애그리거트인 OrderLine 밸류를 가져와서 계산한다.

상태만 참조하는 것이 아닌 기능 실행을 위임하기도 한다.

public class OrderLines {
  private List<OrderLine> lines;
  
  public Money getTotalAmounts() { ...구현; }
  public void changeOrderLines(List<OrderLine> newLines) {
    this.lines = newLines;
  }
}

 

Order(루트)에서 orderLine을 변경하는 것이 아닌 OrderLines 목록을 별도 클래스로 만들고,

public class Order {
  
  private Money totalAmounts;
  private OrderLines orderLines;
  
  public void changeOrderLines(List<OrderLine> newLines) {
    this.orderLines.changeOrderLines(newLines);
    this.totalAmounts = orderLines.getTotalAmounts();
  }
  ...
}

Order(루트)에서 OrderLines의 기능 실행을 한다.


트랜잭션 범위

  • 트랜잭션 범위는 작을수록 좋다.
  • 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
    • 두 개 이상 수정하면 트랜잭션 충돌 가능성이 높아지기 때문!
    • 즉, 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 의미
    • 만약 부득이하게 한 트랜잭션에서 두 개 이상의 애그리거트를 수정해야 한다면 한 애그리거트에서 직접 수정하는 것이 아닌 응용 계층에서 두 애그리거트를 수정하도록 구현한다.
public class ChangeOrderService {
  
  @Transactional
  public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo,
         boolean useNewShippingAddrAsMemberAddr) {
      Order Order = orderRepository.findById(id);
      if (order == null) throw ~;
      
      // Order 애그리거트 변경
      order.shipTo(newShippingInfo);
      if (useNewShippingAddrAsMemberAddr) {
        Member member = findMember(order.getOrderer());
        member.changeAddress(newShippingInfo.getAddress());
      }
  }
}

리포지터리와 애그리거트

애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.

Order와 OrderLine을 만약 별도 테이블에 저장한다고 해서 리포지터리는 각각 만들지 않는다.

리포지터리는 저장소에서 애그리거트를 영속화 및 읽어야 하므로 다음 두 메서드를 기본으로 제공한다.

  • save : 애그리거트 저장
  • findById : ID로 애그리거트 구함

만약 애그리거트와 관련된 테이블이 여러 개일 때, Order 애그리거트에 속한 모든 구성요소에 매핑된 테이블에 데이터를 저장해야 한다.

마찬가지로, 애그리거트를 구하는 리포지터리 메서드는 완전한 애그리거트를 제공해야 한다.


ID를 이용한 애그리거트 참조

한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다.

→ 애그리거트 관리 주체는 애그리거트 루트이므로 → 다른 애그리거트를 참조한다는 것 == 다른 애그리거트의 루트를 참조한다는 것

public class Order {
  private Orderer orderer;
  ..
}

public class Orderer {
  private Member member;
  ..
}
public class Member {
  ..
}

위처럼 필드를 참조할 경우 세 가지 문제가 발생한다.

  1. 편리함을 오용할 수 있다. (다른 애그리거트의 객체를 쉽게 접근 가능)
  2. 직접 참조 시 성능과 관련된 문제 고민
  3. 확장성
  • 이러한 문제를 해결하기 위해, ID를 이용해서 다른 애그리거트를 참조하는 방식을 사용한다. (== 테이블에서 외래키 참조하는 것과 유사)
    • 이 경우, 필드 참조와 달리 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다.
public class ChangeOrderService {
  
  @Transactional
  public void changeShippingInfo(OrderId id, ShippingInfo newShippingInfo,
         boolean useNewShippingAddrAsMemberAddr) {
      Order Order = orderRepository.findById(id);
      if (order == null) throw ~;
      order.shipTo(newShippingInfo);
      if (useNewShippingAddrAsMemberAddr) {
        Member member = memberRepository.findById(
				      order.getOrderer().getMemberId());
        member.changeAddress(newShippingInfo.getAddress());
      }
  }
}

ID 참조 방식에 따른 조회 성능

만약 주문 목록을 보여줄 때, 주문, 상품, 회원 애그리거트를 ID 참조 방식으로 가져올 경우 주문마다 상품 정보를 읽어오는 쿼리를 실행한다.

주문 개수가 10개이면 주문을 읽어오기 위한 1번의 쿼리 + 주문별로 각 상품을 읽어오기 위한 10번의 쿼리를 실행한다. → N+1 문제 발생

따라서 데이터 조회를 위한 별도 DAO 생성 및 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩한다.

@Repository
public class JpaOrderViewDao implements OrderViewDao {
  @PersistenceContext
  private EntityManager em;
  
  @Override
  public List<OrderView> selectByOrderer(String ordererId) {
    String selectQuery = 
      "select new OrderView(o, m, p) " +
      "from Order o join o.orderLines o1, Member m, Product p " +
      "where o.orderer.memberId.id = :ordererId " +
      "and o.orderer.memberId = m.id " +
      "and index(o1) = 0 " +
      "and o1.productId = p.id " +
      "order by o.number.number desc";
    TypedQuery<OrderView> query = 
            em.createQuery(selectQuery, OrderView.class);
    query.setParameter("ordererId", ordererId);
    return query.getResultList();
  }
}