관리 메뉴

csct3434

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

개발 서적/도메인 주도 개발 시작하기

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

csct3434 2024. 6. 14. 10:08

애그리거트

  • 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다. 주요 도메인 요소 간의 관계를 파악하기 어렵다는 것은 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다. 
  • 이때 상위 수준에서 모델을 정리하면 도메인 모델의 복잡한 관계를 이해하는데 도움이 된다. 상위 수준에서 모델이 어떻게 엮여 있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있다.
  • 상위 수준에서 모델을 조망할 수 있는 방법이 바로 애그리거트다.
    • 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다.
    • 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다.
    • 애그리거트는 독립된 객체 군이며 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
    • 애그리거트의 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다. 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.
    • 'A가 B를 갖는다'로 설계할 수 있는 요구사항이 있다고 해서 A와 B가 같은 애그리거트에 속한다고 할 수 없다.
      • 좋은 예가 상품과 리뷰다.
      • 상품과 리뷰는 같이 생성되지 않으며 함께 변경되지도 않는다.
      • 상품을 변경하는 주체가 상품 담당자라면 리뷰를 생성하고 변경하는 주체는 고객이다.
      • 리뷰의 변경이 상품에 영향을 주지 않고 반대로 상품의 변경이 리뷰에 영향을 주지 않는다.
    • 작가의 경험을 비추어 봤을 때, 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많았으며 두 개 이상의 엔티티로 구성되는 애그리거트는 드물었다고 한다.

애그리거트 루트

  • 도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야 한다.
  • 애그리거트에 속한 모든 객체가 일관된 상태를 유지하도록 관리하는 주체가 바로 애그리거트의 루트 엔티티이다.
  • 애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접 또는 간접적으로 속하게 된다.
  • 애그리거트 루트의 핵심 역할은 애그리거트가 제공해야 할 도메인 기능을 애그리거트의 일관성이 깨지지 않도록 구현하는 것이다.
    • 배송이 시작되기 전까지만 배송지 정보를 변경할 수 있다는 규칙이 있다면, 애그리거트 루트인 Order의 changeShippingInfo() 메서드는 배송 시작 여부를 확인하고 규칙을 충족할 때만 배송지 정보를 변경해야 한다.
  • 애그리거트 외부에서 애그리거트에 속한 객체를 직접 변경하지 못하도록 막아야 애그리거트 루트가 강제하는 규칙이 항상 적용될 수 있다.
  • 상태 확인 로직을 애그리거트 루트가 아닌 응용 서비스에서 구현한다면 동일한 로직이 여러 응용 서비스에서 중복될 가능성이 높아져 유지 보수가 어려워진다.
  • 불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들려면 도메인 모델에 대해 다음의 두 가지를 습관적으로 적용해야 한다.
    • 단순히 필드를 변경하는 set 메서드를 public으로 선언하지 않는다.
    • 밸류 타입은 불변으로 구현한다 (밸류 타입의 상태를 변경하려면 애그리거트 루트를 통해서만 가능)
  • 팀 표준이나 구현 기술의 제약으로 밸류 타입을 불변으로 구현할 수 없다면 변경 기능을 protected로 한정하여 외부에서 실행할 수 없도록 제한하는 방법도 가능하다.

트랜잭션 범위

  • 트랜잭션의 범위가 클수록 잠금 대상이 많아지고 이에 따라 동시성이 저하되므로 전체적인 성능(처리량)을 떨어뜨리게 된다.
  • 한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다. 이는 곧 한 애그리거트에서 다른 애그리거트를 변경하지 않는 것을 의미한다.
  • 만약 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면
    • 한 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현한다.
    • 도메인 이벤트를 사용하여 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경하도록 구현한다.
  • 한 트랜잭션에서 한 개의 애그리거트를 변경하는 것을 권장하지만, 팀 표준, 기술 제약, UI 구현의 편리성에 따라 한 트랜잭션에서 두 개 이상의 애그리거트를 변경하는 것을 고려할 수 있다.

리포지터리

  • 애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다.
  • 애그리거트의 상태가 변경되면 모든 객체의 변경을 원자적으로 저장소에 반영해야 한다.
  • 애그리거트에서 두 개의 객체를 변경했는데 저장소에는 한 객체에 대한 변경만 반영되면 데이터 일관성이 깨지므로 문제가 된다.

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

  • 애그리거트 간의 참조는 필드를 통해 쉽게 구현할 수 있다. 하지만 필드를 이용한 애그리거트 참조는 다음의 문제를 야기할 수 있다.
    • 한 애그리거트에서 다른 애그리거트를 변경하고자 하는 유혹에 빠지기 쉽다.
    • 성능과 관련된 고민을 해야 한다 : JPA의 경우 즉시 로딩과 지연 로딩 중 무엇이 유리한 지
    • 확장성이 낮아진다 : 도메인별로 시스템을 분리하여 서로 다른 DBMS를 사용할 경우, 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없다.

  • ID 참조를 사용하면 이러한 문제들을 완화할 수 있다.
    • 모든 객체가 참조로 연결되지 않고 한 애그리거트에 속한 객체들만 참조로 연결된다.
    • 애그리거트 간의 물리적인 연결을 제거함으로써 경계가 명확해지고 응집도가 높아지며 복잡도는 낮아진다.
    • 외부 애그리거트를 직접 참조하지 않기 때문에 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지할 수 있다.
    • 애그리거트별로 다른 구현 기술을 사용하는 것이 가능해지며 각 도메인을 별도의 프로세스로 서비스할 수 있게 된다.
  • ID 참조를 사용하면 응용 서비스에서 필요한 애그리거트를 로딩하므로 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다. 따라서 N+1 조회가 발생하여 전체 조회 속도가 느려지는 원인이 된다.
  • ID 참조 방식을 사용하면서 N+1 조회와 같은 문제가 발생하지 않도록 하려면
    • 조회 전용 쿼리 : 데이터 조회를 위한 별도 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩한다.
    • 애그리거트마다 서로 다른 저장소를 사용할 경우 : 캐시를 적용하거나 조회 전용 저장소를 따로 구성한다.

애그리거트 간 집합 연관

  • 성능 문제로 인해 애그리거트 간의 1-N 연관은 실제 구현에 반영하지 않는다. 대신 N-1 연관을 구현한다.
  • M-N 연관의 경우 요구사항을 고려하여 양방향 M-N 연관 대신 단방향 M-N 연관만 구현한다.

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

public class RegisterProductService {

    public long registerNewProduct(NewProductRequest req) {
        Store store = storeRepository.findById(req.getStoreId());
        checkNull(store);
        if(store.isBlocked()) {
            throw new StoreBlockedException();
        }
        long productId = productRepository.nextId();
        Product product = new Product(productId, store.getId(), ...);
        productRepository.save(product);
        return productId;
    }

}
  • Store가 Product를 생성할 수 있는지를 판단하고 Product를 생성하는 것은 논리적으로 하나의 도메인 기능인데, 위 코드는 이러한 도메인 기능을 응용 서비스에서 구현하고 있다.
public class Store {

    public Product createProduct(long productId, ...) {
        if(isBlocked()) throw new StoreBlockedException();
        return new Product(newProductId, getId(), ...);
    }
    
}
  • 이 도메인 기능을 넣기 위한 별도의 도메인 서비스나 팩토리 클래스를 만들 수도 있지만 이 기능을 Store 애그리거트에 팩토리 메서드로 구현할 수 있다.
  • 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 것을 고려해 보자.

public class RegisterProductService {

    public long registerNewProduct(NewProductRequest req) {
        Store store = storeRepository.findById(req.getStoreId());
        checkNull(store);
        long productId = productRepository.nextId();
        Product product = new Product(productId, store.getId(), store.isBlocked(), ...);
        productRepository.save(product);
        return productId;
    }

}
public class Product {

    private id;
    private storeId;
    ...
    
	public Product(long id, long storeId, boolean storeBlocked, ...) {
        if(storeBlocked) throw new StoreBlockedException();
        this.id = id;
        this.storeId = storeId;
        ...
    }
}
  • 개인적으로 이렇게 되면 Store에서 Product의 객체를 생성하므로 애그리거트의 책임을 벗어난다고 생각한다.
  • 또한 Product를 생성할 때 Store외의 다른 애그리거트의 정보를 사용할 경우 이러한 팩토리 메서드는 더이상 사용할 수 없다.
  • Store에 등록되지 않는 Product는 존재하지 않으므로 Product에서 Store에 대한 참조는 필연적이다.
  • 따라서 차라리 Product의 생성자에 Store의 차단 상태를 함께 전달하는 방식이 Store의 응집도를 높일 수 있으므로 더 낫다고 생각한다.

느낀점

  • 도메인을 설계할 때 상위 수준인 애그리거트를 먼저 설계하자
  • 응용 서비스가 아닌 애그리거트 루트에 도메인 규칙을 구현함으로써 규칙을 강제하고 이를 캡슐화하자.
  • 한 애그리거트에서 다른 애그리거트를 변경하지 않도록 애그리거트의 경계를 잘 지키도록 하자.
  • 한 객체를 통해 다른 객체를 참조할 일이 없는 경우 ID 참조로 설계하자.