함께 변경되어야 하는 객체들의 묶음과 그 대표(애그리거트 루트). DDD 전술 설계의 핵심 단위다.
왜 묶는가 — 일관성 경계
주문(Order) ─┬─ 주문항목(OrderItem)
└─ 배송지(Address)
"주문 총액 = 항목 합계" 라는 규칙을 지키려면
→ 주문과 항목이 '함께' 바뀌어야 한다
→ 이 묶음이 하나의 트랜잭션 단위가 된다
세 가지 규칙
① 바깥에서는 루트를 통해서만 접근한다
orderRepository.findById(id).addItem(...) ✅
orderItemRepository.save(item) ❌ 규칙을 우회한다
-
② 애그리거트 하나가 트랜잭션 하나
- 여러 애그리거트를 한 트랜잭션에서 바꾸지 않는다
-
③ 다른 애그리거트는 id 로 참조한다
- 객체 참조가 아니라 식별자만 들고 있는다
@Entity
class Order {
@Id private Long id;
@OneToMany(cascade = ALL, orphanRemoval = true) // 같은 애그리거트
private List<OrderItem> items;
private Long memberId; // ✅ 다른 애그리거트는 id 로
// @ManyToOne private Member member; ❌ 경계를 흐린다
}
③이 왜 중요한가
객체 참조로 묶으면
- 지연 로딩 문제가 애그리거트 경계를 넘어 번진다
- 한 트랜잭션에서 두 애그리거트를 함께 바꾸기 쉬워진다
- 나중에 서비스를 분리할 때 참조를 전부 끊어야 한다
id 참조로 두면 경계가 코드에 드러나고, 분리가 쉬워진다
여러 애그리거트를 바꿔야 하면
// 도메인 이벤트로 넘긴다 — 트랜잭션을 나눈다
class Order {
void complete() {
this.status = COMPLETED;
registerEvent(new OrderCompleted(this.id)); // 커밋 후 다른 애그리거트가 처리
}
}
@TransactionalEventListener(phase = AFTER_COMMIT)
void on(OrderCompleted e) { pointService.grant(e.orderId()); }
최종 일관성을 받아들이는 대신 트랜잭션을 짧게 유지한다.
바운디드 컨텍스트 — 전략 설계
같은 "상품" 이라는 말이 부서마다 다른 것을 가리킨다
주문 컨텍스트 상품 = 가격·재고
검색 컨텍스트 상품 = 제목·태그·설명
- 하나의 거대한 Product 클래스로 합치면 아무도 못 고치는 코드가 된다
- 컨텍스트마다 자기 모델을 갖고, 경계에서 번역한다
면접 함정
- ❌ "애그리거트는 크게 잡을수록 안전하다" → 크면 락 범위와 트랜잭션이 커져 동시성이 죽는다.
- ❌ "DDD는 폴더 구조" → 도메인 언어와 경계를 정하는 일이 본질이다.
경계를 코드로 강제하기
// 패키지 구조로 경계를 드러낸다
com.example.order
├ domain Order · OrderItem · OrderRepository(인터페이스)
├ application OrderService
└ adapter JpaOrderRepository · OrderController
// ArchUnit 으로 다른 애그리거트의 내부 접근을 막는다
@Test void 애그리거트_내부는_외부에서_직접_접근하지_않는다() {
noClasses().that().resideOutsideOfPackage("..order..")
.should().accessClassesThat().haveSimpleName("OrderItem")
.check(new ClassFileImporter().importPackages("com.example"));
}
크기를 정하는 기준
"이 규칙을 지키려면 무엇과 무엇이 같은 트랜잭션에서 바뀌어야 하나"
- 주문 총액 = 항목 합계 — → 주문 + 항목이 한 애그리거트
- 회원 등급 = 누적 구매액 — → 즉시 일관성이 필요한가? 아니면 이벤트로 충분한가?
작게 잡을수록 동시성이 좋고, 크게 잡을수록 규칙 지키기가 쉽다 망설여지면 작게 잡고 이벤트로 잇는다