[ddd] 애그리거트

애그리거트란?

도메인 모델이 복잡해졌을 때, 개별 객체 수준에서 모델을 바라보면 전반적인 구조나 큰 수준에서 도메인간의 관계를 이해하기 어려워진다
주요 도메인 개념간의 관계를 파악하기 어렵다는 것은 코드를 변경하고 확장하는 것이 어려워진다는 것을 의미한다
상위 수준에서 모델이 어떻게 엮여있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있는데,
세부적인 모델만 이해한 상태로는 코드를 수정하기가 두렵기 때문에 코드 변경을 최대한 회피하는 쪽으로 요구사항을 협의하게 된다
즉, 이러한 문제점을 없애려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요하고, 그것이 바로 애그리거트 이다

애그리거트 예시

  • 애그리거트는 관련된 모델을 하나로 모은 것이기 때문에 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 갖는다
  • 애그리거트는 경계를 갖는다
    • 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다
    • 각 애그리거트는 자기 자신만을 관리할 뿐 다른 애그리거트를 관리하지 않는다
    • 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다
      • 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다
    • A가 B를 갖는다는 대부분 한 애그리거트이지만, 무조건 한 애그리거트인 것은 아니다
      • 상품과 리뷰가 좋은 예시이다
  • 대부분의 애그리거트는 한 개의 엔티티 객체만 갖는 경우가 많으며 두 개 이상의 엔티티로 구성되는 애그리거트는 드물게 존재한다

애그리거트 루트

애그리거트는 여러 객체로 구성되기 때문에 한 객체만 상태가 정상이어서는 안된다
도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야한다

애그리거트에 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요한데, 이 책임을 지는 것이 바로 애그리거트의 루트 엔티티이다
애그리거트에 속한 모든 엔티티는 루트 엔티티에 직접 또는 간접적으로 속한다

  • 애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다

  • 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현한다

    • 애그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다
  • 애그리거트 루트가 아닌 다른 객체가 애그리거트에 속한 객체를 직접 변경하면 안된다

    • 이러면 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다
    1
    2
    ShippingInfo si = order.getShippingInfo();
    si.setAddress(newAddress);

    이는 도메인 규칙을 무시하고 DB 테이블에서 직접 데이터를 수정하는 것과 같다
    그렇다고 이를 막는 로직을 서비스 레이어에서 구현하게 되면, 해당 로직이 여러 서비스 레이어에서 중복될 가능성이 높아진다

  • 습관적으로 작성하는 setter 메서드를 피해야한다

    • 도메인 로직을 도메인 객체가 아닌 응용이나 표현 영역으로 분산되게 만드는 원인이 된다
    • 이렇게 되면 도메인 로직이 한곳에 응집되어 있지 않으므로 코드를 유지보수할때도 시간이 훨씬 많이 들게된다
    • setter만 넣지 않아도 이런 상황을 대부분 방지할 수 있다
  • 밸류 객체는 불변 타입으로 구현한다

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

  • 애그리거트 루트는 다른 객체들을 조합해서 기능을 완성한다

  • 기능 실행을 위임하기도 한다

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class Order {
    private OrderLines orderLines;
    private Long totalAmounts;

    public void changeOrderLines(List<OrderLine> newOrderLines) {
    orderLines.changeOrderLines(newOrderLines); // delegate
    this.totalAmounts = orderLines.getTotalAmounts();
    }
    }

    class OrderLines { // first-level collection
    private List<OrderLine> orderLines;

    public void changeOrderLines(List<OrderLine> newOrderLines) {
    this.orderLines = newOrderLines;
    }
    }

    OrdergetOrderLines() 같은 메서드가 제공될 경우, 외부에서 OrderLines의 메서드를 직접 호출하면 도메인 로직이 깨질 우려가 있으므로(totalAmounts가 반영안됨), 아래와 같이 변경해줘야 한다

    • OrderLines를 불변으로 선언한다(OrderLines의 changeOrderLines에서 새로운 OrderLines 객체 반환)
    • OrderLines내 changeOrderLines의 접근 제한자를 패키지나 protected 범위를 사용한다(같은 애그리거트라 같은 패키지에 속하므로)

트랜잭션 범위

  • 트랜잭션의 범위는 작을수록 좋다
  • 한 트랜잭션에서 한 애그리거트만 수정하는 것을 권장한다
  • 아래와 같은 경우에는 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하는 것을 고려해볼 수 있다
    • 도메인 이벤트와 비동기를 사용할 수 없을 경우(보통 두 애그리거트 수정이 필요하면 이 방식을 이용한다)
    • UI 구현의 편리(한 화면에서 여러 상태를 변경하고 싶을 때)
  • 한 트랜잭션에서 두 개 이상의 애그리거트를 수정해야 한다면 애그리거트에서 다른 애그리거트를 직접 수정하지 말고 응용 서비스에서 두 애그리거트를 수정하도록 구현해야한다
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // bad
    @Transactional
    public void doSomething(boolean someFlag) { // 응용 계층
    Order order = orderRepository.findOne(id);
    order.doSomething();
    }

    public void doSomething(boolean someFlag) { // 도메인 계층
    if(someFlag) {
    order.getProduct().doSomething(); // 다른 애그리거트의 도메인 로직을 수행함
    }
    }

    // good
    @Transactional
    public void doSomething(OrderId id, boolean someFlag) {
    Order order = orderRepository.findOne(id);
    order.doSomething();

    if(someFlag) {
    order.getProduct().doSomething(); // 응용 계층에서 다른 도메인 로직을 수행함
    }
    }

리포지터리와 애그리거트

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

  • 별도 DB 테이블에 저장한다고 해서 리포지터리를 따로 만들지 않는다
  • Order가 애그리거트 루트이고, OrderLine은 애그리거트 구성요소이므로 리포지터리는 Order만 존재한다

애그리거트는 개념적으로 하나이므로 리포지터리는 애그리거트 전체를 저장소에 영속화해야 한다

1
2
3
4
5
// 애그리거트 전체 영속화
orderRepository.save(order);

// 완전한 애그리거트 제공
Order order = orderRepository.findById(orderId);
  • 애그리거트 전체(OrderLine 등 포함)를 영속화해야 한다
  • 완전한 order 애그리거트를 제공해야 한다 == OrderLine 등을 전부 제공해야 한다
    • 완전한 애그리거트가 아니라면 NullPointerException 같은 문제가 발생한다(OrderLine이 비어있는둥)
  • 애그리거트를 영속화하는 저장소로 무엇을 사용하든지간에 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야한다

애그리거트간 참조

애그리거트의 관리 주체는 애그리거트 루트이므로, 애그리거트를 참조한다는 것은 애그리거트 루트를 참조한다는 것과 같다

1
2
3
4
5
6
7
8
class Orderer { // Order 애그리거트 구성요소
private Member member;
private String name;
}

class Member { // 다른 애그리거트의 루트

}

이런식으로 직접 다른 애그리거트 루트를 참조하는 것이 편리하긴하나, 다음과 같은 문제점을 야기한다

  1. 편한 탐색의 오용

    한 애그리거트 내부에서 다른 애그리거트에 접근할 수 있는 편리함 때문에 다른 애그리거트를 수정하고자 하는 유혹에 빠지기 쉽다

  2. 성능에 대한 고민

    JPA를 사용할 경우 즉시로딩을 사용해야할지 지연로딩을 사용해야할지 고민해야 한다

  3. 확장 어려움

    트래픽이 증가하여 도메인별로 시스템을 분리했을때(다른 저장소 사용, 다른 기술 사용 등) 문제가 된다
    분리된 시스템에서 몽고DB를 사용할 경우 JPA 같은 단일 기술을 사용할 수 없다

ID를 이용하여 다른 애그리거트를 참조하면 위의 문제점을 완화할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
class Order {
private Orderer orderer; // 객체 참조
}

class Orderer {
private MemberId memberId; // ID 참조
private String name;
}

class Member {
private MemberId memberId;
}
  • 애그리거트끼리는 ID 참조로 연결되고, 애그리거트에 속한 객체들끼리는 객체 참조로 연결된다
  • 애그리거트간 경계를 명확히 하고 애그리거트간 물리적 연결을 제거하기 때문에 모델의 복잡도를 낮춰준다
  • 애그리거트간 의존이 제거되므로 응집도가 높아지는 효과도 있다

이제 다른 애그리거트가 필요하면 응용 서비스에서 아이디를 이용해서 로딩하면 된다

1
2
3
4
5
6
7
8
9
10
@Transactional
public void doSomething(OrderId id, boolean someFlag) {
Order order = orderRepository.findOne(id);
order.doSomething();

if(someFlag) {
Member member = memberRepository.findById(order.getOrderer().getMemberId());
member.doSomething();
}
}

이렇게 됨으로써

  • 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 원천적으로 방지할 수 있고
  • 즉시 로딩을 할지 지연 로딩을 할 지 걱정하지 않아도 되며
  • 애그리거트별로 다른 구현 기술을 사용하는 것도 가능해진다

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

// TODO
이 부분은 보완이 필요하다
동기는 이해했으나 구현을 이해하지 못하겠다
View는 어느 영역이라고 봐야하는가?

애그리거트간 집합 연관

1:N 연관

개념적으로는 한쪽 애그리거트에 컬렉션으로 연관을 만든다

1
2
3
class Category {
private Set<Product> products;
}

근데 이런식으로 1:N을 구현에 반영하는 것이 요구사항을 충족하는 것과 상관없는 경우가 종종 있다
위의 경우만 해도, 카테고리내의 상품들을 보여주는 경우 보통 페이징이 들어가게 된다

즉, 아래와 같이 구현된다는 것을 의미한다

1
2
3
4
5
6
7
8
class Category {
private Set<Product> products;

public List<Product> getProducts(int page, int size) {
List<Product> sortedProducts = sortById(products);
return sortedProducts.subList((page - 1) * size, page * size);
}
}

이렇게 구현할 경우 Category 내의 모든 Product를 들고와서 필터하는 것이 되므로, 성능상 심각한 문제가 발생한다
그러므로 보통 이렇게 구현하는 경우는 드물고, 상품 입장에서 자신이 속한 카테고리를 N:1로 연관지어 구한다

1
2
3
class Product {
private CategoryId categoryId;
}

그리고 응용서비스에서 이를 이용하여 Product 목록을 구한다

1
2
3
4
5
6
7
class ProductService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(new CategoryId(categoryId));

return productRepository.findByCategoryId(category.getId(), PageRequest.of(page, size)); // spring data jpa 기능 사용
}
}

연관은 필요하지만 성능때문에 리포지터리에서 구현했다고 생각하면 될까?

N:M 연관

개념적으로는 양쪽 애그리거트에 컬렉션으로 연관을 만든다
하지만 이것도 1:N 관계처럼 요구사항을 고려한 뒤 이 구현을 포함시킬지 말지를 결정해야 한다

보통 특정 카테고리에 속한 상품 목록을 보여줄 때

  • 목록 화면에서 각 상품이 속한 모든 카테고리를 표시하지 않고 (==카테고리에서 상품으로의 집합 연관은 필요없다)
  • 상품 상세에서 제품이 속한 모든 카테고리를 보여준다 (==상품에서 카테고리로의 집합 연관은 필요하다)

이부분 또한 성능때문에 카테고리에서 상품으로의 연관은 고려하지 않는다

즉, 카테고리로의 단방향 N:M 연관만 적용하면 된다

1
2
3
4
5
6
7
8
9
10
11
class Product {
@EmbeddedId
private ProductId id;

@ElementCollection
@CollectionTable(
name = "product_category",
joinColumns = @JoinColumn(name = "product_id")
)
private Set<CategoryId> categoryIds; // ID 참조
}

근데 이렇게하면 Product에서 Category를 조회하는(상품 상세) 부분에서는 N+1이 일어나지 않는가?

Product 목록을 구해오는 리포지터리 부분만 N:M에 맞게 변경해주면 된다
나는 Spring Data Jpa를 썼다고 가정하므로 메서드명만 수정하였다

1
2
3
4
5
6
7
class ProductService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(new CategoryId(categoryId));

return productRepository.findByCategoryIdsIn(category.getId(), PageRequest.of(page, size)); // spring data jpa 기능 사용
}
}

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

정지된 상점이 아닐 경우 상품을 추가해주는 로직이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store = storeRepository.findById(req.getStoreId());

if(store.isBlocked()) {
throw new IllegalStateException();
}

Product product = new Product(store.getId(), /** 속성들 **/);
productRepository.save(product);

return product.getId();
}
}

다시보면 도메인 기능이 응용 서비스에 노출되어 있음을 알 수 있다
상점이 상품을 생성할 수 있는지 여부를 판단하고 상품을 생성하는 것은 논리적으로 하나의 도메인 기능이기 때문이다

이 기능을 구현하기에 더 좋은 장소는 Store 애그리거트이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Store {
public Product createProduct(/** 속성들 **/) {
if(isBlocked()) {
throw new IllegalStateException();
}

return new Product(/** 속성들 **/);
}
}

class ProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store = storeRepository.findById(req.getStoreId());

Product product = store.createProduct(/** 속성들 **/);
productRepository.save(product);

return product.getId();
}
}

이로써 Product 생성 가능 여부를 확인하는 도메인 로직을 변경해도 도메인 영역의 Store만 변경하면 되고 응용 서비스는 영향을 받지 않게 된다 == 도메인의 응집도가 높아졌다

애그리거트를 팩토리로 사용할 때 얻을 수 있는 장점이다

이처럼 애그리거트가 갖고 있는 데이터를 이용해서 다른 애그리거트를 생성해야 한다면 애그리거트에 팩토리 메서드를 구현하는 것을 고려해보는 것이 좋다

Product의 경우 Store의 식별자와 속성들을 필요로 하는데, Store에서 이를 팩토리 메서드로 구현함으로써 필요한 데이터의 일부를 직접 제공하면서 중요한 도메인 로직을 구현할 수 있었다

참고 : 최범균, 『DDD Start!』, 지앤선(2016)