도메인 주도 개발 시작하기: 3. 애그리거트

ParkIsComing·2023년 3월 20일
0

3.1 애그리거트

도메인 요소 간의 관계를 잘 파악하여야 코드의 변경과 확장이 쉬워진다.

그렇다면 도메인 요소 간의 관계는 어떻게 잘 파악할 수 있을까?
복잡한 도메인을 이해하기 쉬운 단계로 만들려면 상위 수준에서 모델을 바라보아야 하는데, 이때 애그리거트 개념을 적용할 수 있다.

애그리거트의 특징은 다음과 같다.

  • 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
  • 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. 즉, 애그리거트는 경계를 갖는다.
  • 함께 변경되는 빈도가 높은 객체는 한 애그리거트에 속할 가능성이 높다.
  • 'A'가 'B'를 갖는다고 해석되는 요구사항이 있어도 A와 B가 하 애그리거트에 속한다는 것은 아니다. 예를 들어, Product와 Review는 한 애그리거트에 속하지 않는다. Review의 변경이 Product에 영향을 주는 것도 아니고, Product의 변경이 Review에 영향을 주는 것도 아니다.

3.2 애그리거트 루트

애그리거트에 속한 모든 객체는 일관된(정상) 상태를 유지해야 하는데, 이를 관리하는 것이 애그리거트 루트이다. 결과적으로 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직/간접적으로 속하게 된다.

  • 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 제공한다. 이때 애그리거트는 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.
public class Order{
  
  //애그리거트 루트인 Order에서 도메인 규칙을 구현한 기능을 제공한다.
  public void changeShippingInfo(ShippingInfo newShippingInfo){
    verifyNotYetShipped();
    this.shippingInfo = newShippingInfo;
  }

  private void verifyNotYetShipped(){
    if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING){
      throw new IllegalStateException("already shipped");
    }
  }
  
}
  • 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하면 안 된다.
ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
  • 도메인 모델에서 공개 set 메소드는 가급적 피하자. 같은 맥락에서 도메인에 @Setter를 무분별하게 사용하는 것도 지양해야 한다.

  • 밸류는 불변 타입으로 구현한다. 밸류 타입의 내부 상태를 변경하려면 애그리거트 루트를 통해서만 가능하다.

public class Order{
  private ShippingInfo shippingInfo;
  
  //애그리거트 루트인 Order에서 도메인 규칙을 구현한 기능을 제공한다.
  public void changeShippingInfo(ShippingInfo newShippingInfo){
    verifyNotYetShipped();
    setShippingInfo(newShippingInfo)
  }

  private void setShippingInfo(ShippingInfo newShippingInfo){
    //밸류가 불변이면 새로운 객체를 할당하는 방법으로 값을 변경한다.
    //따라서 this.ShippingInfo.setAddress(newShippingInfo.getAddress()) 같은 방법은 불가능
    this.shippingInfo = newShippingInfo;
  }

  private void verifyNotYetShipped(){
    if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING){
      throw new IllegalStateException("already shipped");
    }
  }
  
}
  • 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합하여 기능을 완성한다.
public class Order{
  private Money totalAmounts;
  private List<OrderLine> orderLines;

  private void calculateTotalAmounts(){
    int sum = orderLines.stream()
      .mapToInt(ol->ol.getPrice()*ol.getQuantity())
      .sum();
    this.totalAmounts = new Money(sum);
  }
 }
public class Member {
  private Password password;

  public void changePassword(String currentPassword, String newPassword){
    if(!password.match(currentPassword)){
      throw new PasswordNotMatchException();
    }
    this.password = new Password(password);
  }
}

트랜잭션 범위

  • 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
  • 즉, 애그리거트에서 다른 애그리거트를 변경하지 않는다.
  • 애그리거트 간 결합도가 높아지면 향후에 수정비용이 증가하기 때문이다.

예를 들어,

public class Order {
	private Orderer orderer;
    
    public void shipTo(ShippingInfo newShippingInfo, boolean useNewShippingAddrAsMemberAddr) {
    	verifyNotYetShipped();
        setShippingInfo(newShippingInfo);
        if(useNewShippingAddrAsMemberAddr){
        	orderer.getMember().changeAddress(newShippingInfo.getAddress()); //Order 애그리거트에서 Member 애그리거트의 상태를 변경하려는 시도
            //이런식으로 다른 애그리거트의 상태를 변경하면 안 된다.
        }
	}
}
  • 부득이하게 한 트랜잭션으로 여러개의 애그리거트를 수정해야 한다면 응용서비스에서 두 애그리거트를 수정하도록 구현하자.
public class ChangeOrderService{
	@Transactional
    public void changeShippingInfo(OrderId id, ShippingInfo shippingInfo, boolean useNewShippingAddrAsMemberAddr){
      Order order = orderReppsitory.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, Orderer 등 모든 구성 요소를 포함해야하는 것이다.
  • 리포지터리를 구현하는 db의 성격에 따라서 구현 방식도 조금씩 달라진다.
    • RDBMS를 사용하면 트랜잭션을 이용해서 애그리거트의 변경이 저장소에 반영되는 것을 보장한다.
    • 몽고db를 사용하면 한 개의 애그리거트를 한 개의 문서에 저장하는 방식을 선택한다.

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

애그리거트는 다른 애그리거트를 참조할 수 있다.애그리거트 관리의 주체는 애그리거트 루트이므로 결국 애그리거트를 참조한다는 것은 애그리거트 루트를 참조한다는 것이다.

ORM 기술을 이용해 애그리거트 루트에 대한 참조를 쉽게 구현할 수 있고, 필드(or get메소드)를 이용한 애그리거트 참조를 사용하면 다른 애그리거트를 쉽게 조회할 수 있다. 하지만, 이러한 특성의 단점도 있다.

애그리거트를 직접 참조하면 발생하는 문제

  1. 한 애그리거트 내부에서 다른 애그리거트를 접근할 수 있으면 다른 애거리거트의 상태를 쉽게 변화시킬 수 있다. 이는 애그리거트의 간의 결합도를 높여 애그리거트의 변경을 어렵게 만든다.

  2. 성능에 대한 고민이 필요하다.
    : 지연로딩/ 즉시로딩 등에 대한 고민 필요

  3. 확장의 문제

  • 트래픽이 증가하면 분산을 위해 하위도메인별로 시스템을 분리하곤 한다.
  • 이때 하위 도메인마다 다른 db를 채택하기도 한다.
  • 그렇게 되면 다른 애그리거트 루트를 참조하기 위해 JPA 같은 단일 기술을 사용할 수 없다.

🥰해결책 : ID를 이용한 애그리거트 참조

ID를 이용해 다른 애그리거트를 참조한다!

  • ID 참조를 사용하면 모든 객체가 통으로 참조로 연결되지 않고, 한 애그리거트에 속한 객체들만 참조로 연결된다.

  • 다른 애그리거트를 참조하지 않기 때문에 애그리거트 간 참조를 지연로딩으로 할지, 즉시로딩으로 할지 고민할 필요도 없다.

public class ChangeOrderService{
  @Transactional
  public void changeShippingInfo(OrderId orderId, 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());
    }
  }
}

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

  1. 한 애그리거트에서 다른 애그리거트를 수정하는 문제 해결
  2. 애그리거트별로 다른 구현 기술(mysql, 몽고db) 사용 가능

ID를 이용한 참조와 조회의 성능

ID를 이용한 애그리거트 참조는 N+1 문제를 낳을 수 있다.

이를 해결하기 위해서는 조인을 이용하는 조회 전용 쿼리를 사용하면 된다.
예를 들어 데이터 조회를 위한 별도의 DAO를 만들고 DAO의 조회 메소드에 조인을 이용해 하나의 쿼리를 원하는 데이터를 불러 오자.


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

애그리거트마다 서로 다른 db를 채택하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없을 것이다. 그렇다면

  1. 캐시를 적용하거나
  2. 조회 전용 저장소를 따로 구성한다.

3.5 애그리거트 간 집합 연관

개념적으로 존재하는 애그리거트 간의 연관을 항상 실제 구현에 반영할 필요는 없다. 요구사항에 맞춰 필요한 부분만 구현하면 된다.

아래 코드의 경우 Category에 속한 모든 Product를 조회하게 때문에 성능상의 이슈가 발생한다. (1:N)


public class Category {
   private Set<Product> products;    
   public List<Product> getProducts(int page, int size) {

만약 카테고리 안의 상품을 구하고 싶다면 상품 입장에 자신이 속한 카테고리를 N-1로 연관지어 구하자.


M:N 연관도 요구사항에 맞춰 구현할 부분을 결정하면 된다.

이번에는 하나의 카테고리를 여러 상품을 가질 수 있고 하나의 상품은 여러 카테고리를 가질 수 있다고 해보자.

@Entity
@Table(name = "product")
public class Product {
    @EmbeddedId
    private ProductId id;
    
    @ElementCollection
    @CollectionTable(name = "product_category", joinColumns = @JoinColumn(name = "product_id"))
    private Set<CategoryId> categoryIds;
    //생략
}

3.6 애그리거트를 팩토리로 사용하기

상품 등록 기능은 상점 계정이 차단 상태가 아닐 때만 실행할 수 있다고 가정해보자. 그러면 Store의 isBlocked() 메소드를 사용해 상점 계정 상태를 체크한 뒤에 Product를 생성해야 한다.

만약 RegisterProductService라는 응용 서비스에서 store의 상태를 확인하고 product를 추가하는 로직을 넣으면 어떨까? 🤔
작동에는 문제가 없지만 도메인 로직 처리가 응용 서비스단에 노출되었다는 점에서 바람직하지는 않다.

그렇다면 Product를 생성하는 기능을 Store 애그리거트에 넣으면 어떨까?
해당 기능을 통해서 도메인 내에서 Store의 상태를 확인하고 Product를 생성하는 기능을 모두 수행한다.
이로써 Store 애그리거트는 Product의 팩토리 역할을 하면서 도메인 로직을 구현하게 된다.
그렇게 되면 응용 서비스단에서는 팩토리 기능을 통해 Product를 생성하면 될 것이다.
이를 아래처럼 구현할 수 있다.

public class Store {
   public Product createProduct(ProductId newProductId{
      if(isBlocked()){
          throw new StoreBlockedException(); //Product를 생성하는 로직을 Store 애그리거트에 넣었다.
      }
       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;
   }
}

결론: 애그리거트가 갖고 있는 데이터를 활용하여 다른 애그리거트를 만들어야 한다면?
--> 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보자!!

0개의 댓글