도메인 모델 설계 시, 관련된 엔티티들을 묶어 애그리거트(Aggregate)로 정의하면 데이터 변경의 일관성을 유지하고 모델의 복잡성을 줄일 수 있다. 애그리거트는 도메인 주도 설계(DDD)의 핵심 구성 요소 중 하나로 여러 객체를 하나의 논리적인 단위로 취급하여 변경 시 전체 데이터의 일관성을 보장하는 데 목적이 있다
애그리거트의 개념
도메인 모델에서 ‘Member’와 ‘MemberDetail’이라는 두 엔티티를 각각 독립적으로 관리하고 개별 Repository를 통해 접근하는 방식은 여러 작업(생성, 변경, 삭제) 시 여러 단계를 거처야 하는 복잡성을 야기한다. 예를 들어, MemberModifyService에서 [Member – MemberRepository]와 [MemberDetail – MemberDetailRepository]를 각각 생성, 연결, 저장하는 5단계의 작업이 필요했다.
이러한 복잡성을 해결하기 위해 Member와 MemberDetail을 하나의 거대한 개념적 단위인 Member Aggregate로 묶는다. 이 묶음의 대표 엔티티를 애그리거트 루트(Aggregate Root)라고 하며, 외부에서는 이 애그리거트 루트를 통해서만 애그리거트 내부의 접근하고 변경할 수 있다. 애그리거트 루트는 애그리거트의 생명주기를 관리하며, 애그리거트 내부의 모든 엔티티와 값 객체(Value Object)의 일관성을 유지한다
애그리거트의 특징 및 목적
- 데이터 변경의 일관성 유지: 애그리거트 내부의 모든 변경은 하나의 단위로 처리되어 데이터 일관성을 보장한다
- 루트를 통한 접근: 애그리거트 내부의 다른 엔티티나 값 객체는 외부에서 직접 접근할 수 없으며, 반드시 애그리거트 루트를 통해서만 접근해야 한다. 이는 데이터 일관성 유지를 위한 핵심 원칙이다
- 불변식 (Invariant) 충족: 애그리거트 내부의 모든 변경이 완료된 후에는 미리 정의된 불변식(항상 참이어야 하는 조건)이 향상 충족되어야 한다. 예를 들어, 구매 주문 (Purchase Order) 애그리거트에서 모든 라인 아이템 금액의 합계가 총 주문 금액과 일치해야 한다는 불변식이 있을 수 있다
- 트랜잭션 및 동시성 관리 경계: 애그리거트 내의 모든 변경은 하나의 트랜잭션 안에서 처리되어야 한다. 이는 트랜잭션의 경계를 명확히 하고 동시성 문제를 관리하는 데 도움이 된다
- 모델 구조 단순화: 애그리거트를 사용하면 엔티티 간의 복잡한 연관 관계를 줄여 모델을 더 단순하게 이해하고 관리할 수 있다
애그리거트 적용 방법
- JPA cascade 활용: JPA의 cascade 옵션(persist, all 등)을 활용하여 애그리거트 루트의 변경이 내부 엔티티에 자동으로 전파되도록 설정하면 편리하다
- 애그리거트 단위 리포지토리: 리포지토리는 애그리거트 루트 엔티티 단위로 생성한다. 스프링 데이터 JPA에서는 Repository<T, ID>에서 T가 애그리거트 루트 엔티티를 의미한다. 즉, 애그리커트 내부의 다른 엔티티를 위한 별도의 리포지토리는 만들지 않는다
- 단일 트랜잭션 내 단일 애그리거트 변경 권장: 애그리거트 내부의 데이터 일관성을 위해 단일 애그리거트 변경은 반드시 단일 트랜잭션 안에서 이루어져야 한다. 이는 타협할 수 없는 핵심 원칙이다. 반면, 여러 애그리거트를 동시에 변경해야 하는 경우는 비즈니스 요구사항이나 성능상의 이유로 빈번하게 발생하며, 이때는 하나의 트랜잭션에서 여러 애그리거트를 유연하게 처리할 수 있다. 즉, “단일 애그리거트의 일관성은 단일 트랜잭션으로, 여러 애그리거트는 필요시 유연하게”접근하는 것이 실용적이다
- 다른 애그리거트 참조 시 ID 사용: 다른 애그리거트를 참조할 때는 직접적인 객체 레퍼런스보다는 애그리거트 루트의 ID 값을 사용하는 것을 권장한다. 이는 특히 서비스 간 통신이나 영속성 컨텍스트를 벗어난 참조에서 유용하다. JPA의 지연 로딩(Lazy Loading)과 캐싱 메커니즘을 활용하여 성능 문제를 완화할 수 있다
애그리거트 설계 및 적용의 어려움과 유연성
- 적절한 애그리거트 경계 설정: 어디서부터 어디까지를 하나의 애그리거트로 묶을 것인지 결정하는 것이 어렵다. 처음에는 애그리거트의 범위가 넓게 느껴질 수 있지만, 개발 과정에서 필요에 따라 범위가 작아지는 경향이 있다
- 성능 문제: 애그리거트 전체를 한 번에 로딩하고 모든 불변식을 체크하는 방식이 특정 상황에서는 성능 저하를 야기할 수 있다
- 내부 엔티티 직접 참조에 대한 유연한 접근: 애그리거트 내부 엔티티는 원칙적으로 애그리거트 루트를 통해서만 접근해야 하지만, 실무에서는 조회 성능 및 비즈니스 요구사항에 따라 유연한 접근이 필요하다. 특히 조회(Query) 작업에서는 CQRS(Command Query Responsibility Segregation) 패턴을 적용하여, 명령(Command) 작업에서는 애그리거트 규칙을 엄격히 따르되, 조회 작업에서는 필요한 데이터를 효율적으로 가져오기 위해 내부 엔티티에 직접 접근할 수 있다. 복잡한 검색이나 리포팅 등에서 이러한 유연한 접근은 필수적이다. 중요한 것은 이러한 예외적 접근이 도메인의 핵심 불변식을 해치지 않도록 주의해야 한다는 점이다
- 애그리거트 설계는 완벽할 필요는 없으며, 문맥에 따라 유연하게 접근하는 것이 중요하다
헥사고날 아키텍처와 애그리거트
헥사고날 아키텍처는 애플리케이션의 핵심 도메인 로직을 외부 기술로부터 분리하여 유연하게 테스트하기 쉽게 만든다. 애그리거트와 헥사고날 아키텍처는 상호 보완적으로 사용될 수 있다
- 애그리거트별 응용 서비스 구성: 헥사고날 아키텍처 내에서 각 애그리거트별로 전용 응용 서비스를 구성하여 외부와의 진입점 역할을 담당하도록 설계할 수 있다. 이들 응용 서비스는 포트(Port) 인터페이스를 통해 외부 어댑터와 통신하며, 해당 애그리거트의 책임 범위를 명확히 분리한다
- 애그리거트 간 통신: 다른 애그리거트의 기능이 필요할 때는 해당 헥사고날의 포트 인터페이스를 통해 요청을 보낸다. 이때 전달하는 정보는 애그리거트 루트의 ID를 사용하는 것을 권장한다. JPA는 영속성 컨텍스트 캐싱을 통해 ID 기반 조회 시 데이터베이스 접근 없이 캐시된 엔티티를 활용할 수 있도록 지원한다
- 도메인 이벤트 활용: 도메인 이벤트를 사용하여 애그리거트 간의 변화를 비동기적으로 전달하고 처리할 수 있다. 이는 시스템의 결합도를 낮추고 최종적 일관성(Eventually Consistency)을 유지하는 데 효과적이다
애그리거트 크기의 실용적 접근
- 너무 큰 애그리거트: 성능 문제, 동시성 충돌 증가
- 너무 작은 애그리거트: 트랜잭션 복잡성, 일관성 관리 어려움
- 적절한 크기: 비즈니스 불변식을 만족하는 최소 단위
JPA와의 실제 결합 예시
// 실무에서 자주 사용하는 패턴
@Entity
public class Order { // 애그리거트 루트
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
// 도메인 로직 및 불변식 검증
public void addItem(OrderItem item) {
// 비즈니스 규칙 검증
this.items.add(item);
}
}