[DDD] : 애그리거트 : Aggregate

애그리거트란?

하나의 시스템을 개발할 때, 상위 수준 개념을 이용해서 전체 모델을 정리하면 전반적인 관계를 이해하는 데 도움이 됩니다.

쇼핑몰을 예시로 들면, 아래와 같이 표현할 수 있습니다.

이를 개별 객체 단위로 다시 그려보면 아래와 같습니다.

여기서 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고, 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워지게 됩니다. 결국, 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요한데, 이 방법이 애그리거트 입니다.

 

애그리거트를 한 마디로 표현한다면,

관련된 객체들의 집합을 하나의 단위로 취급하는 패턴입니다.

즉, 도메인 모델에서 밀접하게 관련된 객체들의 집합을 의미합니다. 이는 복잡한 도메인을 더 작고 관리하기 쉬운 단위로 나누는 데 도움을 줍니다.

 

아래는 위의 객체 모델을 애그리거트 단위로 묶어 다시 표현한 것입니다.

애그리거트는 다음과 같은 특성을 가지고 있습니다.

  • 일관성을 관리하는 기준을 제공한다.
  • 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 가지고 있다.
  • 애그리거트에 속한 구성요소는 대부분 함께 생성되고 함께 제거한다.
  • 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다.
    • 애그리거트는 독립된 객체 군이다.
    • 각 애그리거트는 자기 자신을 관리할 뿐, 다른 애그리거트를 관리하지 않는다.

애그리거트의 경계를 설정할 때 기본이 되는 것은 도메인 규칙요구사항입니다.

도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높습니다. 또한, 함께 변경되는 빈도가 높은 객체도 한 애그리거트에 속할 가능성이 높습니다.

 

애그리거트 루트

주문 애그리거트는 다음을 포함하고 있습니다.

  • 총 금액인 `totalAmounts`를 갖고 있는 `Order` 엔티티
  • 개별 구매 상품의 개수인 `quantity`와 금액인 `price`를 갖고 있는 `OrderLine` 밸류

만약, 구매할 상품의 개수를 변경하게 되면 `OrderLine`의 `quantity`를 변경하고 `Order`의 `totalAmounts`도 함께 변경되어야 합니다. 즉, 도메인 규칙을 지키기 위해서는 애그리거트에 속한 모든 객체가 정상 상태를 가져야함을 알 수 있습니다.

따라서, 애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해 애그리거트 전체를 관리할 주체가 필요한데, 이 책임을 가지는 것이 애그리거트의 루트 엔티티입니다. 애그리거트에 속한 객체는 애그리거트 루트에 직/간접적으로 속하게 됩니다.

주문 애그리거트를 루트를 포함해 표현하면 다음과 같습니다.

도메인 규칙과 일관성

애그리거트 루트의 핵심 역할은

애그리거트의 일관성이 깨지지 않도록 하는 것입니다.

 

이를 위해, 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현합니다. 예를 들어, 배송지 변경, 삼품 변경과 같은 기능을 제공하고 애그리거트 루트인 `Order`가 이 기능을 구현한 메서드를 제공합니다.

 

주의할 점은 

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

이는 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 될 수 있습니다.

 

따라서, 불필요한 중복을 피하애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들기 위해 다음 두 가지를 적용해야 합니다.

1. 단순히 필드를 변경하는 `set` 메서드를 public 범위로 만들지 않는다.

public으로 설정된 `set` 메서드는 다음과 같은 문제점이 있습니다.

  • 단순히 값을 설정하지, 도메인의 의미나 의도를 표현하지는 못한다.
  • 도메인 로직을 도메인 객체가 아닌 응용 영역이나 표현 영역으로 분산시킨다.

`set` 형식의 이름을 갖는 public 메서드를 사용하지 않으면 `cancel...`이나 `change...`과 같이 더 의미가 잘 들어나는 이름을 사용하는 빈도가 높아집니다.

2. 밸류 타입은 불변으로 구현한다.

밸류 객체의 값을 변경할 수 없으면 애그리거트 루트에서 밸류 객체를 구해도 외부에서 밸류 객체의 상태를 변경할 수 없습니다. 결국 애그리거트의 일관성이 깨질 가능성이 줄어들게 되는 것입니다.

이때, 밸류 객체의 값을 변경하는 방법새로운 밸류 객체를 할당하는 것뿐입니다.

 

트랜잭션 범위

트랜잭션의 범위는 작을수록 좋습니다. 동일하게

한 트랜잭션에서는 한 개의 애그리거트만 수정해야 합니다.

 

한 트랜잭션에서 여러 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 높아지게 되고 전체 처리량이 떨어지게 됩니다. 애그리거트는 최대한 서로 독립적이어야 하는데, 의존하기 시작하면 애그리거트 간 결합도가 높아지게 됩니다. 

부득이하게 한 트랜잭션에서 두 개 이상의 애그리거트를 수정해야 한다면 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 합니다. 

 

리포지터리와 애그리거트

애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하기 때문에

객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재합니다.

`Order`와 `OrderLine`이 각각의 테이블에 존재한다해서 각각의 리포지터리를 만들지 않습니다.

 

결국 리포지터리는 애그리거트 전체를 저장소에 영속화해야 합니다. 따라서, 만약 `Order` 애그리거트와 관련된 테이블이 3개라면 `Order` 애그리거트를 저장할 때 

애그리거트 루트오 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소에 매핑된 테이블을 함께 저장해야 합니다.

 

변경 또한 마찬가지입니다. 애그리거트에서 두 개의 객체를 변경했는데 저장소에는 한 객체에 대한 변경만 반영되면 데이터 일관성이 깨지게 되어 문제가 발생합니다.

만약, RDBMS를 사용한다면 트랜잭션을 이용해 이를 보장할 수 있습니다.

 

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

애그리거트 또한 다른 애그리거트를 참조할 수 있는데, 이때 다른 애그리거트의 루트를 참조하게 됩니다.

 

이때 다음과 같이 애그리거트 루트인 필드를 통해 참조할 수 있습니다.

하지만, 필드를 이용한 애그리거트 참조는 다음과 같은 문제를 야기할 수 있습니다.

편한 탐색 오용

필드 참조의 가장 큰 문제는 편리함을 오용할 수 있다는 점입니다.

한 애그리거트 내부에서 다른 애그리거트를 직접 접근할 수 있게되면 상태를 쉽게 변경할 수 있게 됩니다. 앞서 말한 것처럼

한 애그리거트가 관리하는 범위는 자기 자신으로 한정해야 합니다.

결국 애그리거트 간의 의존 결합도가 높아져 변경을 어렵게 만들게 되는 원인이 됩니다. 

성능에 대한 고민

필드를 직접 참조하면 성능과 관련된 여러 가지 고민을 해야 합니다. 

물론 JPA를 사용하면 참조한 객체를 `Lazy`나 `Eager`을 통해 로딩할 수 있지만, 이 과정에서 다양한 경우의 수를 고려해야 하고 쿼리의 로딩 전략을 결정하는 데 많은 노력이 들게 됩니다.

확장의 어려움

제가 생각하기에 가장 문제가 되는 부분입니다. 사용자가 몰리기 시작하면서 부하를 분산하기 위해 도메인별로 시스템을 분리하는 경우를 생각해봅시다. 이 과정에서 도메인마다 서로 다른 DBMS를 사용할 수 있고, 심지어 하위 도메인마다 다른 종류의 데이터 저장소를 사용하기도 합니다. 

이 경우에는 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없게 됩니다.

 

이런 세 가지 문제를 완화할 수 있는 방법이 ID를 이용해 다른 애그리거트를 참조하는 방식입니다.

DB에서 외래키로 참조하는 것과 비슷하게 ID를 통해 다른 애그리거트를 참조할 수 있습니다.

ID를 참조하는 경우 다음과 같은 이점이 있습니다.

  • 모든 객체가 참조로 연결되지 않고, 한 애그리거트에 속한 객체들만 참조로 연결된다.
  • 애그리거트 간의 물리적인 연결을 제거해 모델의 복잡도를 낮춰준다.
  • 애그리거트 간의 의존을 제거해 응집도를 높여준다.
  • 참조 시, `Lazy`와 `Eager` 로딩에 대한 고민이 사라져 구현 복잡도도 낮아진다.
  • 애그리거트별로 다른 구현 기술을 사용할 수 있다.

ID를 통한 참조와 조회 성능

ID를 참조하는 방식을 사용하면 여러 애그리거트를 읽을 때 조회 속도가 문제가 될 수 있습니다.

이 경우, "조회 대상이 N개일 때, N개를 읽어오는 한 번의 쿼리와 연관된 데이터를 읽어오는 쿼리를 N번 실행한다."라는 N+1 조회 문제가 발생할 수 있습니다.

 

ID 참조 방식을 사용하면서 N+1 조회와 같은 문제를 해결하기 위해서는 조회 전용 쿼리를 사용하면 됩니다. 예를 들어, 데이터 조회를 위한 별도의 DAO를 만들고 DAO의 조회 메서드에서 조인을 이용해 한 번의 쿼리로 필요한 데이터를 로딩하면 됩니다. 

만약, 애그리거트마다 서로 다른 저장소를 사용하면 한 번의 쿼리로 관련 애그리거트를 조회할 수 없습니다. 이때는 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성해야 하는데, 이 내용은 추후에 알아보도록 하겠습니다.