[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
---
|
||||
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<Order | null>;
|
||||
save(order: Order): Promise<void>;
|
||||
}
|
||||
|
||||
// 한 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]]
|
||||
Reference in New Issue
Block a user