DDD 이론 7: Aggregate 설계 심화
일관성 경계를 정의하는 Aggregate 패턴의 본질, 올바른 크기 결정, Aggregate 간 통신, 영속성 전략을 심층적으로 학습합니다.
- • Aggregate의 본질과 필요성을 깊이 이해한다
- • Aggregate Root의 역할과 책임을 완전히 파악한다
- • 불변식(Invariant)과 일관성 경계를 명확히 정의할 수 있다
- • 올바른 Aggregate 크기를 결정하는 원칙을 적용할 수 있다
- • Aggregate 간 참조와 통신 패턴을 구현할 수 있다
- • Repository를 통한 Aggregate 영속성을 구현할 수 있다
- • 실제 도메인에서 Aggregate를 설계할 수 있다
1. Aggregate의 본질과 필요성
"Aggregate는 데이터 변경의 단위로 취급되는 연관된 객체들의 클러스터다. 각 Aggregate는 루트와 경계를 가진다."
— Eric Evans, Domain-Driven Design, Chapter 6
1.1 왜 Aggregate가 필요한가?
🔥 Aggregate 없이 발생하는 문제들
문제 1: 일관성 깨짐
// 주문과 주문항목이 별도로 수정되면? orderItemRepository.delete(item); // 주문항목 삭제 // 이 시점에 order.totalAmount는 여전히 이전 값 // 다른 트랜잭션이 order를 읽으면 불일치 발생!
문제 2: 불변식 위반
// 비즈니스 규칙: 주문 총액은 항목 합계와 일치해야 함 order.items.push(newItem); // 항목 추가 // order.recalculateTotal()을 호출하지 않으면? // 불변식 위반: totalAmount ≠ sum(items.price)
문제 3: 동시성 충돌
// 두 사용자가 동시에 같은 주문을 수정 User A: order.addItem(itemA); // 트랜잭션 1 User B: order.addItem(itemB); // 트랜잭션 2 // 누구의 변경이 반영될까? Lost Update 발생!
✅ Aggregate가 해결하는 것
일관성 경계 정의
Aggregate 내부는 항상 일관된 상태를 유지합니다. 트랜잭션은 하나의 Aggregate 단위로 처리됩니다.
불변식 보호
Aggregate Root를 통해서만 내부 객체에 접근하므로 비즈니스 규칙이 항상 지켜집니다.
동시성 제어
Aggregate 단위로 락을 걸어 동시성 충돌을 방지합니다. Optimistic Locking의 단위가 됩니다.
트랜잭션 범위 명확화
하나의 트랜잭션에서 하나의 Aggregate만 수정하는 것이 원칙입니다.
1.2 Aggregate의 구성 요소
┌─────────────────────────────────────────────────────────────────┐ │ Aggregate │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ │ │ │ │ ┌─────────────────┐ │ │ │ │ │ Aggregate Root │ ◄── 유일한 진입점 │ │ │ │ │ (Entity) │ │ │ │ │ └────────┬────────┘ │ │ │ │ │ │ │ │ │ ┌─────────────┼─────────────┐ │ │ │ │ │ │ │ │ │ │ │ ▼ ▼ ▼ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ Entity │ │ Value │ │ Entity │ │ │ │ │ │ │ │ Object │ │ │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ │ │ │ │ ◄─────────── Aggregate Boundary ───────────► │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ • 외부에서는 Root를 통해서만 접근 │ │ • 내부 객체는 외부에 직접 노출되지 않음 │ │ • 트랜잭션 = 하나의 Aggregate │ └─────────────────────────────────────────────────────────────────┘
1.3 핵심 용어 정리
Aggregate Root (루트)
Aggregate의 진입점이 되는 Entity. 외부에서 Aggregate에 접근하는 유일한 통로입니다. 전역 식별자를 가지며, Repository는 Root만 저장/조회합니다.
Aggregate Boundary (경계)
일관성이 보장되어야 하는 범위. 경계 내부의 모든 객체는 하나의 트랜잭션에서 함께 변경됩니다.
Invariant (불변식)
항상 참이어야 하는 비즈니스 규칙. 예: "주문 총액 = 항목 합계 - 할인", "재고는 0 이상이어야 함"
Local Identity (지역 식별자)
Aggregate 내부 Entity의 식별자. Aggregate 내에서만 유일하면 됩니다. 외부에서는 Root ID + Local ID로 참조합니다.
1.4 Aggregate 예시: 주문
┌─────────────────────────────────────────────────────────────────┐ │ Order Aggregate │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Order (Root) │ │ │ │ - id: OrderId │ │ │ │ - customerId: CustomerId (외부 참조) │ │ │ │ - status: OrderStatus │ │ │ │ - totalAmount: Money │ │ │ │ - shippingAddress: Address │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ │ │ contains │ │ ▼ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ OrderLineItem (내부 Entity) │ │ │ │ - lineNumber: number (지역 식별자) │ │ │ │ - productId: ProductId (외부 참조) │ │ │ │ - quantity: Quantity │ │ │ │ - unitPrice: Money │ │ │ │ - lineTotal: Money │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ 불변식: │ │ • totalAmount = sum(lineItems.lineTotal) - discount │ │ • 확정된 주문은 항목 수정 불가 │ │ • 최소 1개 이상의 항목 필요 │ │ │ └─────────────────────────────────────────────────────────────────┘
2. Aggregate Root의 역할과 책임
2.1 Aggregate Root의 핵심 책임
1. 유일한 진입점 (Single Entry Point)
외부에서 Aggregate 내부 객체에 직접 접근할 수 없습니다. 모든 조작은 Root를 통해서만 가능합니다.
// ❌ 잘못된 접근: 내부 객체 직접 조작 order.lineItems[0].quantity = 5; // ✅ 올바른 접근: Root를 통한 조작 order.updateItemQuantity(lineNumber: 1, quantity: Quantity.of(5));
2. 불변식 보호 (Invariant Protection)
모든 상태 변경 시 비즈니스 규칙을 검증합니다. 불변식이 깨지는 변경은 거부합니다.
class Order {
addItem(item: OrderItem): void {
// 불변식 검증
if (this.status !== OrderStatus.DRAFT) {
throw new OrderNotModifiableError();
}
this._items.push(item);
this.recalculateTotal(); // 불변식 유지
}
}3. 전역 식별자 제공 (Global Identity)
Root만 전역적으로 유일한 식별자를 가집니다. 내부 Entity는 Aggregate 내에서만 유일한 지역 식별자를 가집니다.
4. 트랜잭션 경계 (Transaction Boundary)
하나의 트랜잭션에서 하나의 Aggregate만 수정합니다. 여러 Aggregate 수정이 필요하면 도메인 이벤트를 사용합니다.
2.2 Aggregate Root 구현 패턴
class Order extends AggregateRoot<OrderId> {
private _customerId: CustomerId;
private _items: OrderLineItem[];
private _status: OrderStatus;
private _shippingAddress: Address;
private _discount: Discount | null;
private _totalAmount: Money;
// Private 생성자 - 팩토리 메서드 사용 강제
private constructor(id: OrderId) {
super(id);
this._items = [];
this._status = OrderStatus.DRAFT;
this._totalAmount = Money.zero();
this._discount = null;
}
// 팩토리 메서드: 새 Aggregate 생성
static create(customerId: CustomerId, shippingAddress: Address): Order {
const order = new Order(OrderId.generate());
order._customerId = customerId;
order._shippingAddress = shippingAddress;
// 도메인 이벤트 발행
order.addDomainEvent(new OrderCreatedEvent(order.id, customerId));
return order;
}
// 재구성: Repository에서 로드
static reconstitute(props: OrderProps): Order {
const order = new Order(props.id);
order._customerId = props.customerId;
order._items = props.items;
order._status = props.status;
order._shippingAddress = props.shippingAddress;
order._discount = props.discount;
order._totalAmount = props.totalAmount;
order._version = props.version;
return order;
}
// ========== 명령 메서드 (Command Methods) ==========
addItem(productId: ProductId, productName: string,
quantity: Quantity, unitPrice: Money): void {
this.ensureModifiable();
// 기존 항목 확인
const existingItem = this._items.find(
item => item.productId.equals(productId)
);
if (existingItem) {
// 기존 항목 수량 증가
const newQuantity = existingItem.quantity.add(quantity);
this.updateItemQuantity(existingItem.lineNumber, newQuantity);
} else {
// 새 항목 추가
const lineNumber = this.nextLineNumber();
const item = OrderLineItem.create(
lineNumber, productId, productName, quantity, unitPrice
);
this._items.push(item);
this.recalculateTotal();
this.addDomainEvent(new OrderItemAddedEvent(this.id, item));
}
}
removeItem(lineNumber: number): void {
this.ensureModifiable();
const index = this._items.findIndex(
item => item.lineNumber === lineNumber
);
if (index === -1) {
throw new OrderItemNotFoundError(lineNumber);
}
const removed = this._items.splice(index, 1)[0];
this.recalculateTotal();
this.addDomainEvent(new OrderItemRemovedEvent(this.id, removed));
}
updateItemQuantity(lineNumber: number, quantity: Quantity): void {
this.ensureModifiable();
const item = this._items.find(i => i.lineNumber === lineNumber);
if (!item) {
throw new OrderItemNotFoundError(lineNumber);
}
// 내부 Entity 수정도 Root를 통해
const updatedItem = item.withQuantity(quantity);
const index = this._items.indexOf(item);
this._items[index] = updatedItem;
this.recalculateTotal();
}
applyDiscount(discount: Discount): void {
this.ensureModifiable();
if (this._discount) {
throw new DiscountAlreadyAppliedError();
}
this._discount = discount;
this.recalculateTotal();
this.addDomainEvent(new DiscountAppliedEvent(this.id, discount));
}
confirm(): void {
this.ensureModifiable();
// 불변식 검증
if (this._items.length === 0) {
throw new EmptyOrderError();
}
if (this._totalAmount.isLessThan(Order.MINIMUM_ORDER_AMOUNT)) {
throw new MinimumOrderAmountError(Order.MINIMUM_ORDER_AMOUNT);
}
this._status = OrderStatus.CONFIRMED;
this.addDomainEvent(new OrderConfirmedEvent(this.id, this._totalAmount));
}
ship(trackingNumber: TrackingNumber): void {
if (this._status !== OrderStatus.CONFIRMED) {
throw new InvalidOrderStateError('Only confirmed orders can be shipped');
}
this._status = OrderStatus.SHIPPED;
this.addDomainEvent(new OrderShippedEvent(this.id, trackingNumber));
}
cancel(reason: CancellationReason): void {
if (!this._status.canBeCancelled()) {
throw new OrderCannotBeCancelledError(this._status);
}
this._status = OrderStatus.CANCELLED;
this.addDomainEvent(new OrderCancelledEvent(this.id, reason));
}
// ========== 쿼리 메서드 (Query Methods) ==========
get customerId(): CustomerId { return this._customerId; }
get status(): OrderStatus { return this._status; }
get totalAmount(): Money { return this._totalAmount; }
get shippingAddress(): Address { return this._shippingAddress; }
get discount(): Discount | null { return this._discount; }
// 내부 컬렉션은 복사본 반환 (불변성 보호)
get items(): readonly OrderLineItem[] {
return [...this._items];
}
get itemCount(): number {
return this._items.length;
}
get subtotal(): Money {
return this._items.reduce(
(sum, item) => sum.add(item.lineTotal),
Money.zero()
);
}
// ========== Private 메서드 ==========
private ensureModifiable(): void {
if (this._status !== OrderStatus.DRAFT) {
throw new OrderNotModifiableError(this._status);
}
}
private recalculateTotal(): void {
const subtotal = this.subtotal;
const discountAmount = this._discount?.applyTo(subtotal) ?? Money.zero();
this._totalAmount = subtotal.subtract(discountAmount);
}
private nextLineNumber(): number {
if (this._items.length === 0) return 1;
return Math.max(...this._items.map(i => i.lineNumber)) + 1;
}
// 상수
private static readonly MINIMUM_ORDER_AMOUNT = Money.won(10000);
}2.3 내부 Entity 구현
// 내부 Entity: Aggregate 내에서만 유효한 지역 식별자
class OrderLineItem {
private constructor(
private readonly _lineNumber: number, // 지역 식별자
private readonly _productId: ProductId,
private readonly _productName: string,
private readonly _quantity: Quantity,
private readonly _unitPrice: Money
) {}
static create(
lineNumber: number,
productId: ProductId,
productName: string,
quantity: Quantity,
unitPrice: Money
): OrderLineItem {
return new OrderLineItem(
lineNumber, productId, productName, quantity, unitPrice
);
}
// 불변 객체처럼 동작 - 변경 시 새 인스턴스 반환
withQuantity(quantity: Quantity): OrderLineItem {
return new OrderLineItem(
this._lineNumber,
this._productId,
this._productName,
quantity,
this._unitPrice
);
}
get lineNumber(): number { return this._lineNumber; }
get productId(): ProductId { return this._productId; }
get productName(): string { return this._productName; }
get quantity(): Quantity { return this._quantity; }
get unitPrice(): Money { return this._unitPrice; }
get lineTotal(): Money {
return this._unitPrice.multiply(this._quantity.value);
}
}2.4 Aggregate Root 규칙 요약
✅ DO
- • Root를 통해서만 내부 객체 수정
- • 모든 변경에서 불변식 검증
- • 내부 컬렉션은 복사본 반환
- • 의미 있는 비즈니스 메서드 제공
- • 도메인 이벤트로 변경 알림
❌ DON'T
- • 내부 객체 직접 노출 (getter로 참조 반환)
- • 무분별한 setter 제공
- • 불변식 검증 없이 상태 변경
- • 외부에서 내부 Entity 직접 생성
- • 다른 Aggregate 직접 참조
3. 불변식(Invariant)과 일관성 경계
3.1 불변식이란?
"불변식(Invariant)은 데이터가 변경될 때마다 유지되어야 하는 일관성 규칙이다."
— Eric Evans
불변식의 종류
1. 단일 Entity 불변식
하나의 Entity 내에서 유지되어야 하는 규칙
// 예: 계좌 잔액은 0 이상이어야 함
class Account {
withdraw(amount: Money): void {
if (this.balance.isLessThan(amount)) {
throw new InsufficientFundsError();
}
this._balance = this._balance.subtract(amount);
}
}2. Aggregate 내 불변식
Aggregate 내 여러 객체 간의 일관성 규칙
// 예: 주문 총액 = 항목 합계 - 할인
class Order {
private recalculateTotal(): void {
const subtotal = this._items.reduce(
(sum, item) => sum.add(item.lineTotal),
Money.zero()
);
const discount = this._discount?.applyTo(subtotal) ?? Money.zero();
this._totalAmount = subtotal.subtract(discount);
// 불변식: totalAmount는 항상 계산된 값과 일치
}
}3. 상태 전이 불변식
상태 변경 시 허용되는 전이 규칙
// 예: 주문 상태 전이 규칙
enum OrderStatus {
DRAFT, // → CONFIRMED, CANCELLED
CONFIRMED, // → SHIPPED, CANCELLED
SHIPPED, // → DELIVERED
DELIVERED, // (최종 상태)
CANCELLED // (최종 상태)
}
class Order {
confirm(): void {
if (this._status !== OrderStatus.DRAFT) {
throw new InvalidStateTransitionError(
this._status, OrderStatus.CONFIRMED
);
}
this._status = OrderStatus.CONFIRMED;
}
}3.2 강한 일관성 vs 최종 일관성
🔒 강한 일관성 (Strong Consistency)
Aggregate 내부에서 적용. 트랜잭션 완료 시점에 모든 불변식이 만족되어야 함.
- • 단일 트랜잭션 내에서 보장
- • 즉시 일관성 확인 가능
- • Aggregate 경계 = 일관성 경계
// 하나의 트랜잭션
@Transactional
void confirmOrder(OrderId id) {
Order order = orderRepo.findById(id);
order.confirm(); // 불변식 검증
orderRepo.save(order);
} // 커밋 시점에 일관성 보장⏳ 최종 일관성 (Eventual Consistency)
Aggregate 간에 적용. 시간이 지나면 결국 일관성이 맞춰짐.
- • 도메인 이벤트로 전파
- • 비동기 처리
- • 일시적 불일치 허용
// 주문 확정 → 재고 차감 (다른 Aggregate)
order.confirm();
// OrderConfirmedEvent 발행
// 이벤트 핸들러 (비동기)
@EventHandler
void on(OrderConfirmedEvent e) {
inventory.reserve(e.items);
} // 최종적으로 일관성 달성3.3 일관성 경계 결정 기준
질문 1: 이 규칙이 즉시 만족되어야 하는가?
예: 같은 Aggregate에 포함 (강한 일관성)
아니오: 별도 Aggregate, 이벤트로 연결 (최종 일관성)
질문 2: 함께 변경되어야 하는가?
주문 항목이 변경되면 주문 총액도 즉시 변경되어야 함 → 같은 Aggregate
주문이 확정되면 재고가 차감되어야 함 → 다른 Aggregate (이벤트)
질문 3: 동시성 충돌이 발생하면?
같은 Aggregate 내 충돌: 하나만 성공 (Optimistic Lock)
다른 Aggregate 간: 각각 독립적으로 처리 가능
3.4 실제 사례: 불변식 설계
| 불변식 | 일관성 유형 | 구현 방식 |
|---|---|---|
| 주문 총액 = 항목 합계 - 할인 | 강한 일관성 | Order Aggregate 내부 |
| 확정된 주문은 수정 불가 | 강한 일관성 | 상태 검증 메서드 |
| 주문 시 재고 차감 | 최종 일관성 | OrderConfirmedEvent → Inventory |
| 결제 완료 시 포인트 적립 | 최종 일관성 | PaymentCompletedEvent → Points |
| 주문 취소 시 재고 복구 | 최종 일관성 | OrderCancelledEvent → Inventory |
class Order {
// 불변식 1: 총액 계산
private recalculateTotal(): void {
const subtotal = this._items.reduce(
(sum, item) => sum.add(item.lineTotal),
Money.zero()
);
const discountAmount = this._discount?.applyTo(subtotal) ?? Money.zero();
const shippingFee = this.calculateShippingFee(subtotal.subtract(discountAmount));
this._totalAmount = subtotal.subtract(discountAmount).add(shippingFee);
}
// 불변식 2: 상태 전이 규칙
confirm(): void {
// 전제조건 검증
this.ensureCanConfirm();
this._status = OrderStatus.CONFIRMED;
// 도메인 이벤트 발행 (최종 일관성 트리거)
this.addDomainEvent(new OrderConfirmedEvent(
this.id,
this._items.map(item => ({
productId: item.productId,
quantity: item.quantity
}))
));
}
private ensureCanConfirm(): void {
// 불변식: DRAFT 상태에서만 확정 가능
if (this._status !== OrderStatus.DRAFT) {
throw new InvalidStateTransitionError(
`Cannot confirm order in ${this._status} status`
);
}
// 불변식: 최소 1개 항목 필요
if (this._items.length === 0) {
throw new EmptyOrderError();
}
// 불변식: 최소 주문 금액
if (this._totalAmount.isLessThan(Order.MINIMUM_ORDER_AMOUNT)) {
throw new MinimumOrderAmountError(Order.MINIMUM_ORDER_AMOUNT);
}
}
}4. 올바른 Aggregate 크기 결정
"작은 Aggregate를 설계하라. 대부분의 Aggregate는 Root와 Value Object만으로 구성될 수 있다."
— Vaughn Vernon, Implementing Domain-Driven Design
4.1 큰 Aggregate의 문제점
🔒 동시성 충돌 증가
Aggregate가 크면 여러 사용자가 동시에 같은 Aggregate를 수정할 확률이 높아집니다. Optimistic Lock 실패가 빈번해집니다.
// 큰 Aggregate: 모든 주문 항목이 하나의 Aggregate User A: order.addItem(itemA); // version 1 → 2 User B: order.updateAddress(...); // version 1 → 실패! // 주소 변경이 항목 추가와 충돌
🐌 성능 저하
Aggregate 전체를 로드해야 하므로 메모리 사용량과 쿼리 시간이 증가합니다.
🔗 불필요한 결합
관련 없는 개념들이 하나의 Aggregate에 묶여 변경의 영향 범위가 커집니다.
4.2 Aggregate 크기 결정 원칙
원칙 1: 진정한 불변식만 포함
"반드시 함께 변경되어야 하는가?"를 기준으로 판단합니다. 비즈니스 규칙상 즉시 일관성이 필요한 것만 포함합니다.
원칙 2: ID로 다른 Aggregate 참조
다른 Aggregate를 직접 참조하지 않고 ID만 저장합니다. 필요시 Repository를 통해 조회합니다.
// ❌ 직접 참조
class Order {
customer: Customer; // Customer Aggregate 직접 참조
}
// ✅ ID로 참조
class Order {
customerId: CustomerId; // ID만 저장
}
// 필요시 조회
const customer = await customerRepository.findById(order.customerId);원칙 3: 최종 일관성 수용
도메인 전문가와 대화하여 "몇 초/분 후에 일관성이 맞춰져도 되는가?"를 확인합니다. 대부분의 경우 최종 일관성으로 충분합니다.
4.3 Aggregate 분리 사례
┌─────────────────────────────────────────────────────────────────┐ │ Order Aggregate (너무 큼!) │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Order (Root) │ │ │ │ │ ├── OrderLineItem[] ← 주문 항목 │ │ ├── Payment ← 결제 정보 (별도 생명주기) │ │ ├── Shipment ← 배송 정보 (별도 생명주기) │ │ ├── Review[] ← 리뷰 (별도 생명주기) │ │ └── Customer ← 고객 (완전히 다른 Aggregate!) │ │ │ │ 문제점: │ │ • 결제 상태 변경 시 전체 Order 락 │ │ • 배송 추적 업데이트마다 Order 버전 충돌 │ │ • 리뷰 작성이 주문과 충돌 │ │ │ └─────────────────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Order Aggregate │ │ Payment Aggregate│ │Shipment Aggregate│
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ │ │ │ │ │
│ Order (Root) │ │ Payment (Root) │ │ Shipment (Root) │
│ │ │ │ │ │ │ │ │
│ └─ LineItem[] │ │ └─ orderId │ │ └─ orderId │
│ └─ customerId │ │ └─ amount │ │ └─ tracking │
│ └─ status │ │ └─ status │ │ └─ status │
│ │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│ │ │
│ Domain Events │ │
└────────────────────┴────────────────────┘
OrderConfirmed → Payment 생성
PaymentCompleted → Shipment 생성
ShipmentDelivered → Order 완료 처리
┌──────────────────┐ ┌──────────────────┐
│ Customer Aggregate│ │ Review Aggregate │
├──────────────────┤ ├──────────────────┤
│ │ │ │
│ Customer (Root) │ │ Review (Root) │
│ │ │ │ │ │
│ └─ profile │ │ └─ orderId │
│ └─ addresses │ │ └─ productId │
│ │ │ └─ rating │
└──────────────────┘ └──────────────────┘4.4 Aggregate 크기 결정 체크리스트
이 객체들이 반드시 함께 변경되어야 하는가?
아니라면 별도 Aggregate로 분리
동시성 충돌이 자주 발생하는가?
그렇다면 Aggregate를 더 작게 분리
Aggregate 로딩 시 성능 문제가 있는가?
그렇다면 분리하거나 Lazy Loading 고려
최종 일관성으로 충분한가?
도메인 전문가와 확인 후 이벤트 기반으로 전환
다른 Aggregate를 직접 참조하고 있는가?
ID 참조로 변경
4.5 경험적 가이드라인
✅ 좋은 신호
- • Root + Value Objects로 구성
- • 내부 Entity가 1-2개 이하
- • 컬렉션 크기가 제한적 (10개 이하)
- • 동시성 충돌이 드묾
- • 로딩 시간이 빠름
⚠️ 경고 신호
- • 내부 Entity가 3개 이상
- • 컬렉션이 무한히 증가
- • 다른 Aggregate 직접 참조
- • 잦은 Optimistic Lock 실패
- • 로딩에 수 초 이상 소요
5. Aggregate 간 참조와 통신
5.1 ID를 통한 참조
❌ 직접 참조 (안티패턴)
class Order {
// 다른 Aggregate 직접 참조
customer: Customer;
products: Product[];
// 문제점:
// 1. Order 로드 시 Customer, Product도 로드
// 2. 트랜잭션 경계 모호
// 3. 순환 참조 위험
}✅ ID 참조 (권장)
class Order {
// ID만 저장
customerId: CustomerId;
// 내부 Entity에서도 외부 Aggregate는 ID로
items: OrderLineItem[];
// OrderLineItem.productId: ProductId
// 장점:
// 1. 느슨한 결합
// 2. 명확한 트랜잭션 경계
// 3. 독립적 로딩/캐싱
}5.2 도메인 이벤트를 통한 통신
// 1. Aggregate에서 도메인 이벤트 발행
class Order extends AggregateRoot<OrderId> {
confirm(): void {
this.ensureCanConfirm();
this._status = OrderStatus.CONFIRMED;
// 도메인 이벤트 등록
this.addDomainEvent(new OrderConfirmedEvent({
orderId: this.id,
customerId: this._customerId,
items: this._items.map(item => ({
productId: item.productId,
quantity: item.quantity.value
})),
totalAmount: this._totalAmount,
occurredAt: new Date()
}));
}
}
// 2. 이벤트 핸들러에서 다른 Aggregate 조작
class InventoryEventHandler {
constructor(
private readonly inventoryRepository: InventoryRepository
) {}
@EventHandler(OrderConfirmedEvent)
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
for (const item of event.items) {
const inventory = await this.inventoryRepository.findByProductId(
item.productId
);
if (!inventory) {
throw new InventoryNotFoundError(item.productId);
}
inventory.reserve(Quantity.of(item.quantity));
await this.inventoryRepository.save(inventory);
}
}
}
// 3. 이벤트 발행 인프라
class ApplicationService {
constructor(
private readonly orderRepository: OrderRepository,
private readonly eventPublisher: DomainEventPublisher
) {}
@Transactional
async confirmOrder(orderId: OrderId): Promise<void> {
const order = await this.orderRepository.findById(orderId);
if (!order) throw new OrderNotFoundError(orderId);
order.confirm();
await this.orderRepository.save(order);
// 트랜잭션 커밋 후 이벤트 발행
await this.eventPublisher.publishAll(order.domainEvents);
order.clearDomainEvents();
}
}5.3 Aggregate 간 통신 패턴
패턴 1: 동기 조회 (Query)
다른 Aggregate의 정보가 필요할 때 Repository를 통해 조회
class OrderService {
async createOrder(customerId: CustomerId, items: CreateOrderItem[]): Promise<Order> {
// 다른 Aggregate 조회 (읽기 전용)
const customer = await this.customerRepository.findById(customerId);
if (!customer) throw new CustomerNotFoundError(customerId);
// 상품 정보 조회
const products = await this.productRepository.findByIds(
items.map(i => i.productId)
);
// Order Aggregate 생성 (ID만 저장)
const order = Order.create(customerId, customer.defaultAddress);
for (const item of items) {
const product = products.find(p => p.id.equals(item.productId));
order.addItem(item.productId, product.name, item.quantity, product.price);
}
return order;
}
}패턴 2: 비동기 이벤트 (Command)
다른 Aggregate의 상태를 변경해야 할 때 이벤트 발행
// Order Aggregate order.confirm(); // OrderConfirmedEvent 발행 // Inventory Aggregate (이벤트 핸들러) inventory.reserve(quantity); // 재고 예약 // Payment Aggregate (이벤트 핸들러) payment.initiate(orderId, amount); // 결제 시작
패턴 3: Saga / Process Manager
여러 Aggregate에 걸친 복잡한 비즈니스 프로세스 조율
class OrderFulfillmentSaga {
// 상태 머신으로 프로세스 관리
private state: SagaState = SagaState.STARTED;
@EventHandler(OrderConfirmedEvent)
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
// 1. 재고 예약 요청
await this.commandBus.send(new ReserveInventoryCommand(event.items));
this.state = SagaState.INVENTORY_RESERVING;
}
@EventHandler(InventoryReservedEvent)
async onInventoryReserved(event: InventoryReservedEvent): Promise<void> {
// 2. 결제 요청
await this.commandBus.send(new ProcessPaymentCommand(event.orderId));
this.state = SagaState.PAYMENT_PROCESSING;
}
@EventHandler(PaymentCompletedEvent)
async onPaymentCompleted(event: PaymentCompletedEvent): Promise<void> {
// 3. 배송 생성
await this.commandBus.send(new CreateShipmentCommand(event.orderId));
this.state = SagaState.COMPLETED;
}
// 보상 트랜잭션
@EventHandler(PaymentFailedEvent)
async onPaymentFailed(event: PaymentFailedEvent): Promise<void> {
// 재고 예약 취소
await this.commandBus.send(new ReleaseInventoryCommand(event.orderId));
this.state = SagaState.COMPENSATING;
}
}5.4 통신 패턴 선택 가이드
| 상황 | 패턴 | 예시 |
|---|---|---|
| 다른 Aggregate 정보 필요 (읽기) | 동기 조회 | 주문 생성 시 상품 가격 조회 |
| 다른 Aggregate 상태 변경 (쓰기) | 도메인 이벤트 | 주문 확정 → 재고 차감 |
| 여러 Aggregate 조율 필요 | Saga | 주문 → 결제 → 배송 프로세스 |
| 실패 시 보상 필요 | Saga + 보상 | 결제 실패 → 재고 복구 |
6. Aggregate 영속성과 Repository
6.1 Repository 규칙
규칙 1: Aggregate Root만 Repository를 가진다
내부 Entity나 Value Object는 별도 Repository가 없습니다. Root를 통해서만 저장/조회됩니다.
// ✅ 올바른 구조
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// ❌ 잘못된 구조 - 내부 Entity용 Repository
interface OrderLineItemRepository { // 이런 건 없어야 함
findById(id: number): Promise<OrderLineItem | null>;
}규칙 2: Aggregate 전체를 저장/로드
Repository는 Aggregate 전체를 원자적으로 저장하고 로드합니다. 부분 저장/로드는 일관성을 깨뜨릴 수 있습니다.
규칙 3: 컬렉션처럼 동작
Repository는 인메모리 컬렉션처럼 느껴져야 합니다. 도메인 레이어는 영속성 메커니즘을 알 필요가 없습니다.
6.2 Repository 구현
// 도메인 레이어에 정의
interface OrderRepository {
// 기본 CRUD
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
delete(order: Order): Promise<void>;
// 도메인 특화 쿼리
findByCustomerId(customerId: CustomerId): Promise<Order[]>;
findPendingOrders(): Promise<Order[]>;
findByStatus(status: OrderStatus): Promise<Order[]>;
// 존재 확인
exists(id: OrderId): Promise<boolean>;
// 페이징 (선택적)
findAll(page: number, size: number): Promise<Page<Order>>;
}// 인프라 레이어에 구현
class TypeORMOrderRepository implements OrderRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: OrderMapper
) {}
async findById(id: OrderId): Promise<Order | null> {
const entity = await this.dataSource
.getRepository(OrderEntity)
.findOne({
where: { id: id.value },
relations: ['lineItems'] // Aggregate 전체 로드
});
return entity ? this.mapper.toDomain(entity) : null;
}
async save(order: Order): Promise<void> {
const entity = this.mapper.toEntity(order);
await this.dataSource.transaction(async manager => {
// 기존 항목 삭제 후 새로 저장 (orphan removal)
await manager
.getRepository(OrderLineItemEntity)
.delete({ orderId: order.id.value });
// Aggregate 전체 저장
await manager.getRepository(OrderEntity).save(entity);
});
}
async findByCustomerId(customerId: CustomerId): Promise<Order[]> {
const entities = await this.dataSource
.getRepository(OrderEntity)
.find({
where: { customerId: customerId.value },
relations: ['lineItems'],
order: { createdAt: 'DESC' }
});
return entities.map(e => this.mapper.toDomain(e));
}
}
// Mapper: 도메인 ↔ 영속성 변환
class OrderMapper {
toDomain(entity: OrderEntity): Order {
return Order.reconstitute({
id: OrderId.from(entity.id),
customerId: CustomerId.from(entity.customerId),
status: OrderStatus.from(entity.status),
items: entity.lineItems.map(item =>
OrderLineItem.create(
item.lineNumber,
ProductId.from(item.productId),
item.productName,
Quantity.of(item.quantity),
Money.of(item.unitPrice, Currency.from(item.currency))
)
),
shippingAddress: Address.create({
street: entity.shippingStreet,
city: entity.shippingCity,
zipCode: entity.shippingZipCode,
country: entity.shippingCountry
}),
totalAmount: Money.of(entity.totalAmount, Currency.from(entity.currency)),
version: entity.version
});
}
toEntity(order: Order): OrderEntity {
const entity = new OrderEntity();
entity.id = order.id.value;
entity.customerId = order.customerId.value;
entity.status = order.status.value;
entity.totalAmount = order.totalAmount.amount;
entity.currency = order.totalAmount.currency.code;
entity.shippingStreet = order.shippingAddress.street;
entity.shippingCity = order.shippingAddress.city;
entity.shippingZipCode = order.shippingAddress.zipCode;
entity.shippingCountry = order.shippingAddress.country;
entity.version = order.version;
entity.lineItems = order.items.map(item => {
const lineEntity = new OrderLineItemEntity();
lineEntity.orderId = order.id.value;
lineEntity.lineNumber = item.lineNumber;
lineEntity.productId = item.productId.value;
lineEntity.productName = item.productName;
lineEntity.quantity = item.quantity.value;
lineEntity.unitPrice = item.unitPrice.amount;
lineEntity.currency = item.unitPrice.currency.code;
return lineEntity;
});
return entity;
}
}6.3 Optimistic Locking
// Aggregate Root에 버전 필드
abstract class AggregateRoot<T> {
protected _version: number = 0;
get version(): number { return this._version; }
incrementVersion(): void {
this._version++;
}
}
// Entity에 @Version 데코레이터
@Entity()
class OrderEntity {
@PrimaryColumn()
id: string;
@Version()
version: number;
// ... 다른 필드들
}
// Repository에서 버전 체크
class TypeORMOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
const entity = this.mapper.toEntity(order);
try {
await this.dataSource.getRepository(OrderEntity).save(entity);
} catch (error) {
if (error instanceof OptimisticLockVersionMismatchError) {
throw new ConcurrentModificationError(
`Order ${order.id} was modified by another transaction`
);
}
throw error;
}
}
}
// Application Service에서 재시도
class OrderApplicationService {
@Retryable({ maxAttempts: 3, on: ConcurrentModificationError })
async updateOrder(command: UpdateOrderCommand): Promise<void> {
const order = await this.orderRepository.findById(command.orderId);
order.updateShippingAddress(command.newAddress);
await this.orderRepository.save(order);
}
}6.4 영속성 전략 비교
| 전략 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|
| ORM (TypeORM, Prisma) | 익숙함, 생산성 | 임피던스 불일치 | 일반적인 CRUD |
| Document DB (MongoDB) | Aggregate 자연스럽게 저장 | 조인 어려움 | 복잡한 Aggregate |
| Event Sourcing | 완전한 이력, 감사 | 복잡성, 학습 곡선 | 감사 필수, 이벤트 중심 |
| JSON Column | 유연성, 스키마 변경 용이 | 쿼리 제한 | Value Object 컬렉션 |
7. 실습: Aggregate 설계 워크샵
7.1 요구사항
기능 요구사항
- • 고객이 객실을 검색하고 예약
- • 예약 시 결제 처리
- • 체크인/체크아웃 관리
- • 객실 상태 관리 (청소, 점검)
- • 예약 취소 및 환불
- • 고객 리뷰 작성
불변식
- • 같은 객실은 같은 날짜에 중복 예약 불가
- • 예약 금액 = 객실 가격 × 숙박일수
- • 체크인 24시간 전까지만 무료 취소
- • 체크아웃 후에만 리뷰 작성 가능
7.2 Aggregate 식별
┌─────────────────────────────────────────────────────────────────────────┐ │ 호텔 예약 시스템 Aggregate │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ │ │ Room Aggregate │ │Reservation Agg. │ │ Guest Aggregate │ │ │ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │ │ │ Room (Root) │ │ Reservation │ │ Guest (Root) │ │ │ │ - roomId │◄────│ (Root) │────►│ - guestId │ │ │ │ - roomNumber │ ID │ - reservationId│ ID │ - name │ │ │ │ - type │참조 │ - roomId │참조 │ - email │ │ │ │ - price │ │ - guestId │ │ - phone │ │ │ │ - status │ │ - dateRange │ │ - preferences │ │ │ │ - amenities[] │ │ - totalAmount │ └─────────────────┘ │ │ └─────────────────┘ │ - status │ │ │ │ - payment │ ┌─────────────────┐ │ │ └─────────────────┘ │ Review Aggregate│ │ │ │ ├─────────────────┤ │ │ │ Event │ Review (Root) │ │ │ ▼ │ - reviewId │ │ │ ┌─────────────────┐ │ - reservationId│ │ │ │Payment Aggregate│ │ - rating │ │ │ ├─────────────────┤ │ - comment │ │ │ │ Payment (Root) │ └─────────────────┘ │ │ │ - paymentId │ │ │ │ - reservationId│ │ │ │ - amount │ │ │ │ - status │ │ │ └─────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘
7.3 핵심 Aggregate 구현
class Reservation extends AggregateRoot<ReservationId> {
private _roomId: RoomId;
private _guestId: GuestId;
private _dateRange: DateRange;
private _totalAmount: Money;
private _status: ReservationStatus;
private _specialRequests: string[];
private constructor(id: ReservationId) {
super(id);
}
static create(
roomId: RoomId,
guestId: GuestId,
dateRange: DateRange,
pricePerNight: Money
): Reservation {
const reservation = new Reservation(ReservationId.generate());
reservation._roomId = roomId;
reservation._guestId = guestId;
reservation._dateRange = dateRange;
reservation._totalAmount = pricePerNight.multiply(dateRange.nights);
reservation._status = ReservationStatus.PENDING;
reservation._specialRequests = [];
reservation.addDomainEvent(new ReservationCreatedEvent(
reservation.id,
roomId,
guestId,
dateRange
));
return reservation;
}
confirm(paymentId: PaymentId): void {
if (this._status !== ReservationStatus.PENDING) {
throw new InvalidReservationStateError('Only pending reservations can be confirmed');
}
this._status = ReservationStatus.CONFIRMED;
this.addDomainEvent(new ReservationConfirmedEvent(
this.id,
this._roomId,
this._dateRange,
paymentId
));
}
cancel(reason: CancellationReason): void {
if (!this._status.canBeCancelled()) {
throw new ReservationCannotBeCancelledError(this._status);
}
const isFreeCancellation = this.isWithinFreeCancellationPeriod();
this._status = ReservationStatus.CANCELLED;
this.addDomainEvent(new ReservationCancelledEvent(
this.id,
this._roomId,
this._dateRange,
reason,
isFreeCancellation
));
}
checkIn(): void {
if (this._status !== ReservationStatus.CONFIRMED) {
throw new InvalidReservationStateError('Only confirmed reservations can check in');
}
if (!this._dateRange.isCheckInDate(new Date())) {
throw new EarlyCheckInError(this._dateRange.startDate);
}
this._status = ReservationStatus.CHECKED_IN;
this.addDomainEvent(new GuestCheckedInEvent(this.id, this._roomId));
}
checkOut(): void {
if (this._status !== ReservationStatus.CHECKED_IN) {
throw new InvalidReservationStateError('Only checked-in reservations can check out');
}
this._status = ReservationStatus.CHECKED_OUT;
this.addDomainEvent(new GuestCheckedOutEvent(
this.id,
this._roomId,
this._guestId
));
}
private isWithinFreeCancellationPeriod(): boolean {
const hoursUntilCheckIn = this._dateRange.hoursUntilStart();
return hoursUntilCheckIn >= 24;
}
// Getters
get roomId(): RoomId { return this._roomId; }
get guestId(): GuestId { return this._guestId; }
get dateRange(): DateRange { return this._dateRange; }
get totalAmount(): Money { return this._totalAmount; }
get status(): ReservationStatus { return this._status; }
}7.4 이벤트 기반 Aggregate 간 통신
// 예약 생성 → 객실 가용성 확인 및 잠금
class RoomAvailabilityHandler {
@EventHandler(ReservationCreatedEvent)
async handle(event: ReservationCreatedEvent): Promise<void> {
const room = await this.roomRepository.findById(event.roomId);
// 객실 가용성 확인 및 예약 잠금
room.reserveFor(event.dateRange);
await this.roomRepository.save(room);
}
}
// 예약 확정 → 결제 처리
class PaymentHandler {
@EventHandler(ReservationConfirmedEvent)
async handle(event: ReservationConfirmedEvent): Promise<void> {
const payment = Payment.create(
event.reservationId,
event.totalAmount
);
await this.paymentRepository.save(payment);
}
}
// 체크아웃 → 리뷰 요청 이메일
class ReviewRequestHandler {
@EventHandler(GuestCheckedOutEvent)
async handle(event: GuestCheckedOutEvent): Promise<void> {
const guest = await this.guestRepository.findById(event.guestId);
await this.emailService.sendReviewRequest(
guest.email,
event.reservationId
);
}
}
// 예약 취소 → 환불 처리
class RefundHandler {
@EventHandler(ReservationCancelledEvent)
async handle(event: ReservationCancelledEvent): Promise<void> {
if (event.isFreeCancellation) {
// 전액 환불
await this.paymentService.refundFull(event.reservationId);
} else {
// 부분 환불 (취소 수수료 차감)
await this.paymentService.refundPartial(event.reservationId, 0.7);
}
// 객실 가용성 복구
const room = await this.roomRepository.findById(event.roomId);
room.releaseReservation(event.dateRange);
await this.roomRepository.save(room);
}
}7.5 설계 결정 근거
| 결정 | 근거 |
|---|---|
| Room과 Reservation 분리 | 객실 정보와 예약은 독립적 생명주기. 객실 가격 변경이 기존 예약에 영향 없어야 함. |
| Payment 별도 Aggregate | 결제는 외부 PG 연동, 별도 트랜잭션. 예약과 결제 실패는 독립적으로 처리. |
| Review 별도 Aggregate | 리뷰는 체크아웃 후 언제든 작성 가능. 예약과 다른 생명주기. |
| ID 참조 사용 | Reservation은 roomId, guestId만 저장. 느슨한 결합, 독립적 확장. |
8. FAQ 및 핵심 요약
아니요. Repository는 Aggregate Root만 가집니다. 내부 Entity는 Root를 통해서만 저장/조회됩니다.
진정한 불변식만 포함하도록 분리하세요. 최종 일관성으로 충분한 부분은 별도 Aggregate로 분리하고 이벤트로 연결합니다.
원칙적으로 안 됩니다. 하나의 트랜잭션에서 하나의 Aggregate만 수정하세요. 여러 Aggregate 수정이 필요하면 도메인 이벤트와 최종 일관성을 사용합니다.
ID로만 참조합니다. 다른 Aggregate 객체를 직접 참조하지 마세요. 필요시 Repository를 통해 조회합니다.
재시도하거나 사용자에게 충돌을 알립니다. 비즈니스 요구사항에 따라 자동 병합 또는 수동 해결을 선택합니다.
Lazy Loading을 고려하거나, 정말 필요한지 재검토하세요. 무한히 증가하는 컬렉션은 별도 Aggregate로 분리를 고려합니다.
9. 핵심 요약
- • 일관성 경계를 정의하는 객체 클러스터
- • Root를 통해서만 내부 접근
- • 트랜잭션의 단위
- • 불변식을 보호
- • 작은 Aggregate를 선호
- • 진정한 불변식만 포함
- • ID로 다른 Aggregate 참조
- • 최종 일관성 수용
- • 읽기: Repository로 조회
- • 쓰기: 도메인 이벤트
- • 복잡한 프로세스: Saga
- • 실패 시: 보상 트랜잭션
- • Root만 Repository 가짐
- • Aggregate 전체를 저장/로드
- • Optimistic Locking 사용
- • 컬렉션처럼 동작
"작은 Aggregate를 설계하라. 대부분의 Aggregate는
Root와 Value Object만으로 구성될 수 있다."
— Vaughn Vernon
DDD Theory 08
Domain Events
DDD Theory 09
Repository 패턴
DDD Theory 10
Application Service