Files
2nd/10_Wiki/Topics/Coding/Backend_Event_Sourcing.md
T
2026-05-09 21:08:02 +09:00

5.7 KiB

id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
id title category status source_trust_level verification_status created_at updated_at tags tech_stack applied_in aliases
backend-event-sourcing Event Sourcing — 이벤트 기반 상태 Coding draft B conceptual 2026-05-09 2026-05-09
backend
event-sourcing
ddd
vibe-coding
language applicable_to
TS / SQL / EventStore
Backend
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 정의

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)

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)

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);
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 에러 — 재시도
  }
}

로드 + 명령 + 저장

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)

// 이벤트 흐름 구독 → 일반 테이블에 반영
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

const SNAPSHOT_EVERY = 100;

async function loadAggregate(streamId: string): Promise<OrderAggregate> {
  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 (재구성)

// 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 로 확장.

🔗 관련 문서