← all posts
DEV 2026.05.02 · 15 min read Intermediate

Event Sourcing은 왜 '이벤트를 진실의 원천'으로 삼는가

현재 상태 저장의 본질적 한계부터 이벤트 스토어 설계, Aggregate 재구성, 스냅샷 패턴, 스키마 진화, 그리고 도입을 피해야 할 상황까지 추적한다.


은행 계좌 잔고가 500,000원이다. 왜 500,000원인가? 상태 저장 시스템은 이 질문에 답하지 못한다. Event Sourcing은 “현재 상태 대신 모든 변화를 저장”하는 패러다임 전환으로 이 문제를 해결한다. 그런데 이 선택이 만들어내는 구조적 결과는 단순히 “이력이 남는다”는 수준을 훨씬 넘어선다. 복식부기 회계 장부의 원칙을 소프트웨어에 그대로 이식하면 무엇이 달라지는가?

회계 장부 원칙 — Event Sourcing의 원형

복식부기 회계에서 “현재 잔액”은 파생 데이터다. 원본은 거래 원장이다. 한 번 기록된 거래는 삭제하거나 수정하지 않고, 실수는 역방향 거래(정정 거래)를 추가해 바로잡는다. 현재 잔액은 모든 거래의 합산으로 계산한다.

Event Sourcing은 이 세 원칙을 그대로 따른다.

  • Append Only: 이벤트는 삭제·수정 불가. 실수 정정은 보상 이벤트(CancelMoneyTransfer) 추가.
  • 현재 상태 = 파생 데이터: AccountOpened → MoneyDeposited → MoneyWithdrawn 순으로 리플레이하면 현재 잔고가 계산된다.
  • 이벤트가 원본: UPDATE accounts SET balance = 400000이 아닌 INSERT INTO event_store (type='MoneyWithdrawn', amount=100000).

이 구조에서 이벤트 스토어의 핵심 스키마는 다음 다섯 필드로 압축된다.

CREATE TABLE event_store (
    global_seq  BIGSERIAL    NOT NULL,          -- Projection 폴링용 전역 순서
    stream_id   VARCHAR(255) NOT NULL,          -- "account-ACC-001"
    version     BIGINT       NOT NULL,          -- stream 내 순서 + 낙관적 잠금
    event_type  VARCHAR(255) NOT NULL,          -- "MoneyWithdrawn"
    payload     JSONB        NOT NULL,

    CONSTRAINT uq_stream_version UNIQUE (stream_id, version)
);

UNIQUE(stream_id, version) 제약 하나가 낙관적 잠금 전체를 담당한다. 동시에 같은 version으로 INSERT를 시도하면 하나는 DuplicateKeyException으로 충돌을 감지한다.

Aggregate 재구성 — apply()의 두 컨텍스트

Event Sourcing에서 Aggregate는 DB에 현재 상태로 저장되지 않는다. 이벤트 스트림을 순서대로 읽어 apply()를 반복 호출하면 비로소 현재 상태가 완성된다.

public static Account reconstitute(List<DomainEvent> events) {
    Account account = new Account();
    events.forEach(event -> {
        account.apply(event);
        account.version++;
    });
    return account;
}

apply() 메서드는 두 컨텍스트에서 호출된다. Command 처리 중 새 이벤트를 생성할 때, 그리고 이벤트 스토어에서 로드해 재구성할 때. 이 두 컨텍스트에서 apply()가 동일한 결과를 내려면 한 가지 원칙이 절대적이다.

apply()의 순수성 원칙

apply() 안에서 불변식 검증, 외부 의존성(DB 조회, 서비스 호출), 부수효과(이메일 발송), 비결정적 호출(Instant.now())은 금지다. 같은 이벤트를 리플레이할 때마다 다른 상태가 나오면, 재구성 결과를 신뢰할 수 없다.

불변식 검증은 Command 처리 메서드(account.withdraw(amount))에서만 한다. apply()는 “상태 전환만”이다.

재구성 비용은 이벤트 수에 선형 비례한다. 이벤트 100개면 ~15ms, 1,000개면 ~100ms, 5,000개면 ~500ms. 이 임계값을 넘으면 스냅샷이 필요해진다.

스냅샷 패턴 — O(N)을 O(M)으로

스냅샷은 특정 시점의 Aggregate 상태를 직렬화해 저장한다. 다음 로드 시 전체 이벤트를 리플레이하는 대신, 최신 스냅샷 + 그 이후 이벤트만 처리한다.

스냅샷 없음: 이벤트 5,000개 리플레이 → ~500ms
스냅샷(version=4,990) + 이후 10개: ~5ms

로딩 알고리즘은 항상 폴백을 포함해야 한다.

Optional<Snapshot> snapshot = snapshotRepo.findLatest(streamId);
Account account;
long fromVersion;

if (snapshot.isPresent()) {
    try {
        account = deserialize(snapshot.get().state());
        fromVersion = snapshot.get().version();
    } catch (Exception e) {
        log.warn("스냅샷 역직렬화 실패, 전체 리플레이: stream={}", streamId);
        account = new Account();
        fromVersion = 0;
    }
} else {
    account = new Account();
    fromVersion = 0;
}

List<DomainEvent> recentEvents = eventStore.loadEventsAfterVersion(streamId, fromVersion);
recentEvents.forEach(account::apply);

스냅샷 저장 시점은 “N개 이벤트마다”가 가장 단순하고 예측 가능하다. N=100이면 최악의 경우 99개를 리플레이하고, 하루 100개 이벤트가 생성되는 스트림에서는 하루 1번 스냅샷이 저장된다. 스냅샷을 Command 처리 경로에서 동기적으로 저장하면 주기적인 지연 스파이크가 생긴다. 비동기 배치로 분리하는 것이 낫다.

스키마 진화 — Upcasting으로 하위 호환 유지

이벤트는 영구적이다. 1년 전 이벤트 페이로드를 새 코드로 역직렬화해야 한다. 상태 저장에서 ALTER TABLE customers ADD COLUMN tier VARCHAR(20) DEFAULT 'STANDARD'는 수 초면 끝나지만, Event Sourcing에서 같은 변경은 다른 경로를 밟는다.

변경 유형별 안전성은 명확하게 구분된다.

변경 유형안전성대응
필드 추가 (기본값 있음)✅ 안전@JsonSetter(nulls = Nulls.AS_EMPTY)
필드 제거⚠️ 단계적먼저 무시 처리, 이후 제거
필드 이름 변경⚠️ Upcaster 필요구버전 → 신버전 변환 레이어
이벤트 직접 수정❌ 절대 금지DB UPDATE는 사실 조작

Upcasting은 이벤트 스토어의 원본을 건드리지 않고, 역직렬화 직후 신버전 형태로 변환하는 레이어다. MoneyTransferredsourceIdfromAccountId로 바뀌었다면 Upcaster가 로드 시점에 변환하고, Projection은 항상 최신 버전 이벤트만 처리한다.

트레이드오프

상태 저장 스키마 변경은 ALTER TABLE 한 줄이다. Event Sourcing의 같은 변경은 Upcaster 구현(수 시간) + 테스트 + Projection 재구축 여부 검토까지 하루가 걸릴 수 있다. 버전이 쌓일수록 Upcaster 체인이 길어진다. 이 비용이 감사 로그·시간 여행·재구축 능력으로 정당화되는 도메인에서만 Event Sourcing을 선택해야 한다.

장점과 어려움 — 동전의 양면

Event Sourcing이 상태 저장 대비 실질적으로 제공하는 능력은 네 가지다.

완전한 감사 로그: 이벤트 자체가 “누가, 언제, 무엇을, 왜” 기록이다. 별도 audit_log 테이블이 필요 없고, Append-Only이므로 롤백으로 삭제되지 않는다.

시간 여행: WHERE occurred_at <= '2024-01-15T15:00:00Z'로 이벤트를 필터링해 리플레이하면 그 시점의 정확한 상태를 재현한다. 분쟁 해결, 세금 계산, 규제 보고에 직접 활용된다.

이슈 재현: 문제가 발생한 stream의 이벤트를 JSON으로 내보내 로컬에서 Account.reconstitute(events)를 실행하면 프로덕션 버그를 재현할 수 있다.

Projection 재구축: 새 비즈니스 요구사항이 생기면 새 Projection을 작성하고 이벤트 스트림 처음부터 재구축한다. “도입 후부터만” 제한 없이 2년치 과거 데이터를 수 시간 만에 새 읽기 모델로 만들 수 있다.

반면 실제 어려움도 명확하다. Eventual Consistency로 인해 “방금 이체했는데 잔고가 안 바뀜” UX 문제가 발생한다. 이벤트 스토어는 OLAP 쿼리에 부적합하므로 복잡한 집계는 별도 Projection이나 OLAP DB가 필요하다. 팀 학습 비용은 “이론 2주, 프로덕션 자신감 3~6개월”로 과소평가되기 쉽다.

정리

  • Event Sourcing의 핵심은 “현재 상태 = 파생 데이터, 이벤트 = 진실의 원천”이라는 패러다임 전환이다.
  • apply()는 순수해야 한다 — 불변식 검증·외부 의존성·비결정적 호출 금지.
  • 이벤트 ~1,000개 이상이면 스냅샷이 필요하고, 스냅샷은 항상 폴백 경로를 포함해야 한다.
  • 스키마 변경은 Upcasting으로 처리한다. 이벤트 페이로드 직접 수정은 절대 금지다.
  • 단순 CRUD, MVP 단계, 팀 경험 부족, 감사 로그 불필요 도메인에서는 도입을 보류하라.