Software Architecture/DDD & Patterns
DDD Patterns - 03. 애그리거트 설계
계란판 하나가 곧 트랜잭션 하나 — 애그리거트 설계
계란판을 옮긴다고 하자. 판 위의 계란들은 함께 옮겨진다 — 하나를 빼면 판이 기울어 다른 계란도 움직인다. 두 판은 따로 옮긴다. 한 판의 계란이 다른 판에 영향을 주지 않는다. 판의 크기가 중요하다 — 너무 크면 무거워서 들기 어렵고(경합), 너무 작으면 관리할 판이 많아진다.
애그리거트(aggregate)는 이 계란판에 해당한다. 하나의 트랜잭션에서 함께 변경되는 객체 묶음이고, 다른 애그리거트와는 독립적으로 다뤄진다. 애그리거트 설계는 "어디까지를 한 판에 담을 것인가"를 정하는 일이다. Vernon은
일관성 경계 — 한 트랜잭션의 범위
주문 애그리거트를 예로 보자. Order(루트 엔터티)와 OrderLine(주문 항목)은 함께 변경된다 — 주문에 항목을 추가하면 Order의 총액도 바뀐다. 이 둘은 한 트랜잭션에 묶인다. 반면 Order와 Customer는 다른 애그리거트다 — 주문을 변경하면서 고객의 정보(이름, 등급)까지 바꿀 필요는 없다.
flowchart LR
subgraph OrderAgg["주문 애그리거트 (한 트랜잭션)"]
Order["Order (루트)"]
OL1["OrderLine: 상품A × 2"]
OL2["OrderLine: 상품B × 1"]
Order --- OL1
Order --- OL2
end
subgraph CustomerAgg["고객 애그리거트 (별개 트랜잭션)"]
Customer["Customer (루트)"]
end
Order -. "ID 참조 (customerId)" .-> Customer
점선은 ID 참조다 — Order가 Customer 객체를 직접 들고 있지 않고 customerId만 갖는다. 두 애그리거트가 다른 트랜잭션에 있으므로, 한쪽을 잠그더라도 다른 쪽이 막히지 않는다.
객체 참조 vs ID 참조 — 경계를 넘는 법
애그리거트 안에서는 객체 참조를 쓴다 — Order가 OrderLine을 직접 들고, 루트가 하위 객체의 일관성을 책임진다. 경계 밖으로는 ID 참조를 쓴다 — Order가 customerId만 갖고, 필요할 때 조회한다. 이 규칙을 어기면 두 애그리거트가 사실상 하나로 엉킨다.
// 애그리거트 안 — 객체 참조 (Order → OrderLine)
public class Order {
private final OrderId id;
private List<OrderLine> lines; // 직접 참조, 함께 변경
public void addLine(ProductId product, int qty) {
lines.add(new OrderLine(product, qty));
recalculateTotal(); // 루트가 일관성 유지
}
}
// 애그리거트 밖 — ID 참조 (Order → Customer)
public class Order {
private final CustomerId customerId; // ID만, Customer 객체 아님
// Customer 정보가 필요하면 별도 조회
}
객체 참조를 경계 밖에까지 끌고 가면, Order를 저장할 때 Customer까지 함께 저장해야 하고, Customer를 변경할 때 Order도 잠긴다. 경계가 무너진다. ID 참조는 두 애그리거트의 독립성을 지킨다.
작은 애그리거트 — Vernon의 권고
애그리거트는 작게 만들라 — Vernon의 반복적 권고다. 큰 애그리거트는 트랜잭션 범위가 넓어져 동시성(경합)이 잦아진다. 한 주문 애그리거트에 고객·재고·결제까지 다 넣으면, 한 사용자가 주문을 변경하는 동안 다른 사용자는 고객 정보도, 재고도 못 건드린다. 큰 판을 여러 사람이 함께 들려 하면 서로 기다려야 하는 것과 같다.
작은 애그리거트의 기준 — 루트 엔터티와 그 직접적 하위 객체만. 고객은 별도 애그리거트, 재고는 별도 애그리거트, 결제는 별도 애그리거트. 주문은 주문 항목만을 하위 객체로 갖는다. 이렇게 자르면 각 애그리거트의 트랜잭션이 좁아지고 동시성이 좋아진다.
최종 일관성 — 경계 밖의 타협
애그리거트 안은 한 트랜잭션으로 강한 일관성을 보장한다. 하지만 경계를 넘으면 강한 일관성을 포기해야 한다 — 주문이 접수되고 재고가 차감되는 것은, 한 트랜잭션이 아니라 "주문 접수" 이벤트 → "재고 차감"의 최종 일관성 흐름으로 이어진다. 두 애그리거트를 한 트랜잭션에 묶으려 하면 분산 트랜잭션(2PC)이 필요해지고, 그 비용은 크다.
애그리거트 설계의 본질은 "어디까지를 한 트랜잭션에 담아 강한 일관성을 보장할 것인가, 어디서부터 최종 일관성으로 타협할 것인가"를 긋는 일이다. 계란판을 너무 크게 만들면 들 수 없고, 너무 작게 만들면 옮길 횟수만 늘어난다 — 비즈니스 규칙(함께 변경되어야 하는 것)과 동시성 요구(독립적으로 변경되어야 하는 것) 사이에서 판의 크기를 정하는 것이 애그리거트 설계다.
설계 사례 — 큰 애그리거트의 동시성 경합과 분리
처음 설계에서 Order 애그리거트가 고객 정보와 재고를 품고 있었다고 하자. 한 트랜잭션에서 Order, Customer, Inventory를 모두 변경한다. 이 구조는 "주문 시 고객 등급을 확인하고 재고를 차감한다"는 규칙에는 자연스럽다. 하지만 운영하다 보니 문제가 드러난다.
// 큰 애그리거트 — 동시성 경합 발생
public class Order {
private Customer customer; // 고객을 객체로 직접 참조 (경계 위반)
private List<InventoryItem> items; // 재고도 직접 참조
private OrderStatus status;
@Transactional
public void place() {
if (customer.getGrade() == Grade.BANNED) throw new IllegalStateException();
items.forEach(InventoryItem::decrease);
status = OrderStatus.PLACED;
}
}
사용자 A가 주문을 생성하는 동안(트랜잭션이 Customer와 Inventory를 잠근다), 사용자 B가 고객의 배송지를 변경하려 하면 대기해야 한다. 사용자 C가 다른 상품의 재고를 확인하려 해도 대기한다. 한 사람이 주문하는 동안 여러 사람이 기다린다 — 계란판이 너무 커서 한 사람이 들고 있으면 아무도 다른 판에 손을 댈 수 없다.
분리하면 각 애그리거트가 독립적으로 동작한다:
// 분리 후 — 각 애그리거트가 자기 트랜잭션에서 독립
public class Order {
private final CustomerId customerId; // ID 참조 (객체 아님)
private final List<OrderLine> lines; // 주문 항목만 하위 객체
private OrderStatus status;
@Transactional
public OrderPlaced place(CustomerSnapshot customer) { // 읽기 전용 스냅샷으로 검증
if (customer.grade() == Grade.BANNED) throw new IllegalStateException();
status = OrderStatus.PLACED;
return new OrderPlaced(id, customerId, total(), Instant.now());
}
}
// 재고는 별도 애그리거트 — OrderPlaced 이벤트를 구독해 차감
public class InventoryItem {
@Transactional
public void decrease(ProductId product, int qty, OrderId orderId) {
if (this.quantity < qty) throw new OutOfStockException();
this.quantity -= qty;
}
}
분리 후 사용자 A가 주문해도 Customer와 Inventory 애그리거트는 잠기지 않는다. 사용자 B가 배송지를 변경하고, 사용자 C가 재고를 확인하는 것이 동시에 일어난다. 재고 차감은 OrderPlaced 이벤트를 받아 별도 트랜잭션에서 일어난다 — 주문과 재고가 시간적으로 분리되지만, 최종적으로는 일관성이 맞춰진다.
이 분리가 가져오는 trade-off가 있다. 주문 시점에 재고 부족을 즉시 알 수 없다 — 주문은 성공하지만, 재고 차감 단계에서 실패할 수 있다. 이 경우 보상 트랜잭션(주문 취소 이벤트)으로 복구해야 한다. 강한 일관성(한 트랜잭션에서 전부)을 포기하고 동시성과 가용성을 얻는 것이다 — 큰 계란판 하나 대신 작은 판 여러 개로 옮기는 것이다.
참고
- Vernon, Vaughn —
(Addison-Wesley, 2013), Ch.5 (애그리거트 설계 원칙) — 접근 2026-07-15 - Vernon, Vaughn —
(Addison-Wesley, 2016), Ch.3 (작은 애그리거트 권고) — 접근 2026-07-15 - Evans, Eric —
(Addison-Wesley, 2003), Ch.6 (애그리거트) — 접근 2026-07-15
'Software Architecture > DDD & Patterns' 카테고리의 다른 글
| DDD Patterns - 06. 헥사고날 실천 (0) | 2026.07.21 |
|---|---|
| DDD Patterns - 05. 빈약한 도메인 모델 (0) | 2026.07.21 |
| DDD Patterns - 04. 앤터프라이즈 패턴 (0) | 2026.07.21 |
| DDD Patterns - 02. DDD 전술적 설계 (0) | 2026.07.20 |
| DDD Patterns - 01. DDD 전략적 설계 (0) | 2026.07.20 |