--- id: arch-aggregate-design title: Aggregate Design — 일관성 / 경계 / Repository category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [architecture, ddd, aggregate, vibe-coding] tech_stack: { language: "TS", applicable_to: ["Backend"] } applied_in: [] aliases: [aggregate, aggregate root, invariant, entity, value object, eventual consistency] --- # Aggregate Design > Aggregate = **한 트랜잭션의 일관성 단위**. 외부는 root 만 참조 (id). **작게 유지** — 큰 aggregate = lock contention. 다른 aggregate 간 = eventual consistency + event. ## 📖 핵심 개념 - Entity: 정체성 (id) 있는 object. - Value Object: 값으로 비교 (Money, Address). - Aggregate Root: 외부 진입점. 다른 entity 보호. - Invariant: aggregate 안 항상 참인 규칙. ## 💻 코드 패턴 ### Aggregate root + 자식 ```ts // Order = aggregate root export class Order { private items: OrderItem[] = []; private status: OrderStatus = 'open'; constructor(public readonly id: OrderId, public readonly userId: UserId) {} // 외부는 method 만 — 직접 items push X addItem(productId: ProductId, qty: number, price: Money) { this.checkInvariants(() => { if (this.status !== 'open') throw new Error('order closed'); if (this.items.length >= 100) throw new Error('too many items'); if (qty <= 0) throw new Error('qty must be positive'); }); const existing = this.items.find(i => i.productId.equals(productId)); if (existing) existing.increase(qty); else this.items.push(new OrderItem(productId, qty, price)); } total(): Money { return this.items.reduce((s, i) => s.add(i.subtotal()), Money.zero('USD')); } ship() { if (this.items.length === 0) throw new Error('cannot ship empty'); this.status = 'shipped'; } private checkInvariants(extra?: () => void) { extra?.(); if (this.total().amount.lt(0)) throw new Error('negative total'); } } // OrderItem = entity 안 aggregate, 외부 못 봄 class OrderItem { constructor(public readonly productId: ProductId, private qty: number, private price: Money) {} increase(delta: number) { this.qty += delta; } subtotal() { return this.price.mul(this.qty); } } ``` → 외부는 `Order.addItem(...)` 만. `order.items.push()` 같은 직접 변경 금지. ### Value Object ```ts export class Money { constructor(public readonly amount: Decimal, public readonly currency: Currency) { if (amount.lt(0) && !this.allowNegative) throw new Error('negative'); } add(other: Money): Money { if (this.currency !== other.currency) throw new Error('mismatch'); return new Money(this.amount.add(other.amount), this.currency); } mul(n: number): Money { return new Money(this.amount.mul(n), this.currency); } equals(other: Money): boolean { return this.amount.eq(other.amount) && this.currency === other.currency; } static zero(c: Currency) { return new Money(new Decimal(0), c); } } // Immutable — `add` 가 새 Money 반환. ``` ### Aggregate 간 — id 만 ```ts // ❌ Order 가 User entity 직접 참조 class Order { user: User; } // ✅ id 만 class Order { userId: UserId; } // 필요 시 UserRepository.find(userId) ``` → Memory + transaction 분리. ### Repository (per aggregate) ```ts interface OrderRepository { find(id: OrderId): Promise; save(order: Order): Promise; } // 한 aggregate = 한 transaction async function execute(input) { const order = await orderRepo.find(orderId); if (!order) throw ...; order.addItem(...); await orderRepo.save(order); } ``` → 다른 aggregate 같은 트랜잭션 안 변경 X. ### Invariant 위반 체크 ```ts class Order { addItem(...) { // before const totalBefore = this.total(); this.items.push(...); // after — invariant if (this.total().amount.gt(MAX_ORDER_LIMIT)) { this.items.pop(); // rollback throw new Error('order limit exceeded'); } } } ``` 또는 immutable 스타일 — 새 instance 반환. ### 작게 유지 — split aggregate ```ts // ❌ 큰 aggregate class Order { items: OrderItem[]; shipments: Shipment[]; // 다른 트랜잭션 변경 invoices: Invoice[]; // 다른 트랜잭션 변경 } // ✅ class Order { items: OrderItem[]; } class Shipment { orderId: OrderId; ... } class Invoice { orderId: OrderId; ... } ``` → Order ↔ Shipment 동기화 = event + outbox + eventual consistency. ### Domain event (aggregate 안 발생) ```ts class Order { private events: DomainEvent[] = []; ship() { this.status = 'shipped'; this.events.push(new OrderShipped(this.id, new Date())); } pullEvents(): DomainEvent[] { const e = this.events; this.events = []; return e; } } // repository.save 후 events publish async function save(order: Order) { await db.transaction(async (tx) => { await tx.orders.upsert(toRow(order)); for (const ev of order.pullEvents()) { await tx.outbox.insert({ type: ev._tag, payload: ev }); } }); } ``` ### Concurrency (optimistic locking) ```ts class Order { constructor(..., private version: number = 0) {} // 모든 변경 시 version++ } // Repository async save(order: Order) { const r = await db.update(orders) .set({ ...row, version: order.version + 1 }) .where(and(eq(orders.id, order.id), eq(orders.version, order.version))); if (r.rowCount === 0) throw new ConcurrencyError(); } ``` → 동시 변경 = retry. ### Aggregate 크기 결정 ``` 좋은 경계: - 한 트랜잭션 안에서 일관성 유지 가능 - 자주 같이 변경 - Invariant 가 묶여있음 나쁜 경계: - 다른 사용자가 자주 변경 (lock 충돌) - Items > 100 - Read 만 자주 (split read model) ``` ## 🤔 의사결정 기준 | 패턴 | 사용 | |---|---| | 작은 aggregate (1-3 entity) | 일반 | | Value object | 모든 unit, money, time, address | | 큰 read | Aggregate read X — projection | | Cross-aggregate consistency | event + saga | | 동시 변경 빈번 | optimistic lock | ## ❌ 안티패턴 - **God aggregate**: User 가 모든 거 — lock 매번. - **Aggregate 직접 참조 (memory)**: 다른 transaction 깨짐. - **Setter 모든 필드**: invariant 무의미. method 안. - **Value Object mutable**: 변경 시 race. - **Repository CRUD generic**: aggregate 의도 사라짐. - **모든 변경 = event**: noise. 비즈니스 의미만. - **Domain logic = service 만 (anemic)**: entity 는 dumb. 행위 entity 안. ## 🤖 LLM 활용 힌트 - 작은 aggregate + id 참조 + event 통신. - Value Object 적극 (Money, Email, UserId). - Invariant 는 entity method 안. ## 🔗 관련 문서 - [[Arch_DDD_Bounded_Context]] - [[Arch_Hexagonal_Clean]] - [[Backend_Event_Sourcing]]