--- id: backend-event-sourcing title: Event Sourcing — 이벤트 기반 상태 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [backend, event-sourcing, ddd, vibe-coding] tech_stack: { language: "TS / SQL / EventStore", applicable_to: ["Backend"] } applied_in: [] aliases: [event sourcing, event store, snapshot, projection, replay] --- # Event Sourcing > 상태 = 모든 이벤트의 fold. **현재 상태 저장 X — 이벤트 append-only 저장**. 재구성 / time-travel / audit 자연. 단 학습 곡선 + 복잡 — 모든 도메인에 안 어울림. ## 📖 핵심 개념 - Event: 과거 fact (`OrderCreated`, `ItemAdded`). - Aggregate: 일관성 경계 + 이벤트 producer. - Stream: aggregate 의 이벤트 list. - Projection: 이벤트 → read model 만들기. - Snapshot: 빠른 복원 위해 N 이벤트마다. ## 💻 코드 패턴 ### Event 정의 ```ts type OrderEvent = | { type: 'OrderCreated'; data: { orderId: string; userId: string; createdAt: string } } | { type: 'ItemAdded'; data: { orderId: string; itemId: string; qty: number } } | { type: 'OrderShipped'; data: { orderId: string; shippedAt: string } } | { type: 'OrderCancelled'; data: { orderId: string; reason: string } }; ``` ### Aggregate (state from events) ```ts class OrderAggregate { private events: OrderEvent[] = []; private state: { items: Item[]; status: 'open' | 'shipped' | 'cancelled' } = { items: [], status: 'open', }; static fromHistory(events: OrderEvent[]): OrderAggregate { const a = new OrderAggregate(); for (const e of events) a.apply(e); return a; } private apply(e: OrderEvent) { switch (e.type) { case 'OrderCreated': /* state init */ break; case 'ItemAdded': this.state.items.push({ id: e.data.itemId, qty: e.data.qty }); break; case 'OrderShipped': this.state.status = 'shipped'; break; case 'OrderCancelled': this.state.status = 'cancelled'; break; } } // command → 새 events addItem(itemId: string, qty: number) { if (this.state.status !== 'open') throw new Error('order closed'); const e: OrderEvent = { type: 'ItemAdded', data: { orderId: this.id, itemId, qty } }; this.apply(e); this.events.push(e); } uncommittedEvents(): OrderEvent[] { return this.events; } } ``` ### Event store (append-only) ```sql CREATE TABLE events ( global_seq BIGSERIAL PRIMARY KEY, stream_id TEXT NOT NULL, -- e.g. 'order-42' stream_seq BIGINT NOT NULL, type TEXT NOT NULL, data JSONB NOT NULL, metadata JSONB, created_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE (stream_id, stream_seq) -- optimistic concurrency ); CREATE INDEX events_stream ON events (stream_id, stream_seq); ``` ```ts async function append(streamId: string, expectedSeq: number, events: OrderEvent[]) { for (const [i, e] of events.entries()) { await db.events.insert({ streamId, streamSeq: expectedSeq + i + 1, type: e.type, data: e.data, }); // unique violation = concurrency 에러 — 재시도 } } ``` ### 로드 + 명령 + 저장 ```ts async function addItem(orderId: string, itemId: string, qty: number) { const events = await db.events.findStream(`order-${orderId}`); const order = OrderAggregate.fromHistory(events); order.addItem(itemId, qty); await append(`order-${orderId}`, events.length, order.uncommittedEvents()); } ``` ### Projection (read model) ```ts // 이벤트 흐름 구독 → 일반 테이블에 반영 async function project(e: OrderEvent) { switch (e.type) { case 'OrderCreated': await db.ordersView.insert({ id: e.data.orderId, userId: e.data.userId, items: [], status: 'open' }); break; case 'ItemAdded': await db.ordersView.update(e.data.orderId, { items: { push: { id: e.data.itemId, qty: e.data.qty } } }); break; // ... } } // global_seq 추적해서 재시작 가능 async function startProjector() { let cursor = await db.projectionCursor.get('orders'); for await (const e of streamFrom(cursor)) { await project(e); cursor = e.global_seq; await db.projectionCursor.set('orders', cursor); } } ``` ### Snapshot ```ts const SNAPSHOT_EVERY = 100; async function loadAggregate(streamId: string): Promise { const snap = await db.snapshots.findLatest(streamId); const events = await db.events.findFrom(streamId, snap?.streamSeq ?? 0); const a = OrderAggregate.fromState(snap?.state, events); return a; } ``` ### Replay (재구성) ```ts // projection 망가지면 await db.ordersView.deleteAll(); await db.projectionCursor.set('orders', 0); // projector 재시작 → 처음부터 재구성 ``` ## 🤔 의사결정 기준 | 도메인 | 적합 | |---|---| | 금융 / 거래 / 회계 | 매우 적합 | | Audit / compliance 강함 | 적합 | | 워크플로 / 도메인 복잡 | 적합 | | 단순 CRUD | 과잉 — 일반 ORM | | 강력 query (복잡 SQL) | Projection 으로 read 모델 분리 | | 작은 팀 / 빠른 MVP | 비추 | ## ❌ 안티패턴 - **이벤트 schema 변경**: 영원 — 새 event type + upcasting. - **Aggregate 가 외부 호출**: pure 해야. infrastructure 분리. - **Read 를 직접 events**: 항상 projection. - **모든 도메인 ES**: 단순 CRUD 까지 — 학습 곡선만. - **Snapshot 없음 — 큰 stream**: 로드 매번 느림. - **Projection 없이 query**: O(N) 매번. - **Events 삭제 / 수정**: append-only 깨짐. 보상 이벤트. ## 🤖 LLM 활용 힌트 - 복잡 도메인 + audit 강 → ES. - Aggregate / Event / Stream / Projection / Snapshot 5종. - Postgres + JSONB 로 시작 → EventStore DB 또는 Kurrent 로 확장. ## 🔗 관련 문서 - [[Backend_CQRS_Patterns]] - [[Backend_Saga_Patterns]] - [[Backend_Outbox_Pattern]]