본문으로 건너뛰기
김신건의 로그

[Pattern] Event Sourcing: 상태 대신 이벤트 누적

· 수정 · 📖 약 1분 · 426자/단어 #event-sourcing #cqrs #architecture #pattern #backend
Event Sourcing, ES, event store, aggregate, snapshot, replay

정의

Event Sourcing = 현재 상태 대신 과거 이벤트 시퀀스 를 저장. 현재 상태 = event 모두 replay 한 결과.

전통: state = { balance: 100 }
ES:   events = [Deposited(50), Deposited(60), Withdrawn(10)]
      state = events.reduce(apply) = { balance: 100 }

핵심 약속

  • 과거 모든 변화 기록. 감사 / 디버깅 / 시간 여행 자유.
  • 읽기 모델 자유 (어떻게든 projection).
  • replay 로 새 view 생성 (옛 데이터에서 새 통찰).
  • 비파괴. 옛 이벤트는 불변.

구조

flowchart LR
    Cmd[Command<br/>Deposit 50] --> Agg[Aggregate]
    Agg -->|emit| Event["Deposited(50)"]
    Event --> Store[(Event Store<br/>append-only)]
    Store -->|project| V1[(Balance View)]
    Store -->|project| V2[(Audit View)]
    Store -->|project| V3[(Search View)]

Event Store

이벤트 = 불변 사실. 형식:

{
  "event_id": "evt_abc",
  "aggregate_id": "account_42",
  "aggregate_type": "Account",
  "event_type": "Deposited",
  "version": 5,
  "payload": { "amount": 50, "currency": "USD" },
  "metadata": { "user_id": "...", "correlation_id": "..." },
  "occurred_at": "2026-06-25T12:00:00Z"
}
Store특징
Kafkalog + retention
EventStoreDBES 전용
PostgreSQLappend-only 테이블 + outbox
DynamoDBpartition key = aggregate_id
AxonJava 프레임워크

Replay (재계산)

sequenceDiagram
    autonumber
    participant App
    participant Store as Event Store
    App->>Store: load events for aggregate_id=42
    Store-->>App: [e1, e2, e3, e4, e5]
    App->>App: state = e1.apply(empty)<br/>state = e2.apply(state)<br/>...
    Note over App: state 복원 완료

Snapshot

이벤트 수 만이면 replay 가 느림. 주기적 snapshot 저장:

flowchart LR
    Store[(Event Store)] --> Snap[(Snapshot Store)]
    Note1["Snapshot at version 1000:<br/>{ balance: 5000 }"]
    Load[Load aggregate] --> Snap
    Snap -->|version 1000 부터| Replay[event 1001 ~ 현재 replay]
def load_aggregate(id):
    snapshot = snapshot_store.latest(id)
    events = event_store.from_version(id, snapshot.version)
    state = snapshot.state
    for e in events:
        state = e.apply(state)
    return state

CQRS 와의 자연 결합

flowchart TB
    Cmd --> Write[Write side]
    Write --> Store[(Event Store)]
    Store -->|stream| P1[Projection 1] --> View1[(Read View 1)]
    Store -->|stream| P2[Projection 2] --> View2[(Read View 2)]
    Store -->|stream| P3[Projection 3] --> View3[(Read View 3)]
    Query --> View1 & View2 & View3

자세한 건 cqrs.

새 View 추가

기존 데이터에서 새 인사이트 가 필요할 때:

sequenceDiagram
    Dev->>Code: 새 projection 함수 추가
    Code->>Store: from version 0 부터 replay
    Store-->>Code: 모든 이벤트
    Code->>NewView: 새 view 채우기 (수 시간 ~ 일)
    Note over Code,NewView: 이후 실시간 stream 으로 유지

IMPORTANT

이게 ES 의 가장 큰 매력. 과거에 안 묻던 질문나중에 데이터로 만들 수 있다.

적합 / 부적합

flowchart TD
    Good[적합]
    Good --> G1["감사 추적 필수 (금융, 의료)"]
    Good --> G2[과거 시점 상태 조회]
    Good --> G3["복잡 비즈니스 (주문 흐름)"]
    Good --> G4[CQRS 가 자연스러운 도메인]
    Bad[부적합]
    Bad --> B1[단순 CRUD]
    Bad --> B2["GDPR 의 *잊혀질 권리* (이벤트 불변과 충돌)"]
    Bad --> B3[빠른 prototype]

함정: 이벤트 진화

v1: Deposited(amount: int)
v2: Deposited(amount: int, currency: string)   ← currency 추가

옛 이벤트는 currency 가 없다. 처리 시:

전략의미
Default value옛 이벤트에 currency=“USD” 가정
Up-castload 시 옛 → 새 형태로 변환
Lazy migration처리 시점에 보강
Re-write event (안 권장)불변 원칙 위반

GDPR 의 잊혀질 권리 문제

flowchart TD
    Q[GDPR: 사용자 데이터 삭제 요구]
    Q --> Issue[ES: 이벤트는 불변 → 어떻게 지움?]
    Issue --> S1["1. Crypto-shredding: 개인정보 *암호화*, key 만 삭제"]
    Issue --> S2["2. Event redaction: 특정 필드 null 처리"]
    Issue --> S3["3. 별도 저장: 개인정보는 가변 DB, 이벤트는 reference"]

흔한 함정

WARNING

  1. 모든 도메인에 ES = 단순 CRUD 까지 복잡 모델. 진짜 도메인 가치 있는 곳에만.
  2. 이벤트 schema 영구 보존 안 함 = 1년 뒤 옛 이벤트 해석 불가. 스키마 진화 정책.
  3. 이벤트 크기 폭증 = 작은 이벤트 + 정기 snapshot. 큰 binary 는 reference.
  4. Aggregate 경계 잘못 = aggregate 가 너무 크면 concurrent write 충돌, 너무 작으면 일관성 사라짐.

관련 위키

이 글의 용어 (5개)
[Distributed] Kafka: 분산 로그, partition, consumer groupdistributed-systems
정의 Apache Kafka = 분산 commit log. 고처리량 (수백만 msg/s), 영속, 수평 확장. event-driven 아키텍처 의 de facto. 핵심 개념: …
[Pattern] CQRS: Command / Query 분리, read modeldistributed-systems
정의 CQRS (Command Query Responsibility Segregation) = 쓰기 모델 과 읽기 모델 을 분리. 같은 데이터를 다른 형태 로 저장 / 조회. […
[Pattern] Outbox Pattern: DB + 메시지의 원자성distributed-systems
정의 Outbox Pattern = DB 변경 + 메시지 발행 의 원자성 보장. 이중 쓰기 (dual write) 문제 의 표준 해결. 문제: Dual Write | 시나리오 |…
[Pattern] Saga: 분산 트랜잭션의 보상 흐름distributed-systems
정의 Saga = 여러 service 의 로컬 트랜잭션 을 연결한 긴 흐름. 한 단계 실패 시 이전 단계의 보상 (compensating) 트랜잭션 으로 논리적 롤백. [!IMP…
[Redis] Pub/Sub vs Streams: 휘발 신호 vs 영속 로그database-internals
정의 - Pub/Sub ( / ): 지금 듣고 있는 구독자 에게만 메시지가 전달되는 휘발성 신호. 영속 없음, ACK 없음. fan-out. - Streams ( / / / ):…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기