Published on

DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리

Authors

서로 다른 시스템(기존 RDB 상태 기반 → 이벤트 소싱)으로 마이그레이션할 때 진짜 어려운 지점은 “이벤트를 만들었다”가 아니라 그 이벤트가 중복 없이, 올바른 순서로, 필요할 때 안전하게 재처리되도록 만드는 것입니다. 특히 DDD에서 이벤트 소싱은 애그리게이트 불변식과 커맨드 모델의 일관성, 그리고 리드 모델(프로젝션)의 최종 일관성을 동시에 다루기 때문에, 마이그레이션은 단순 데이터 이관이 아니라 정합성 계약(contract) 재정의에 가깝습니다.

이 글은 다음 3가지를 중심으로, 마이그레이션 시 흔히 겪는 실패 패턴과 대응 전략을 정리합니다.

  • 중복(Deduplication): 같은 이벤트가 두 번(혹은 N번) 적용되는 문제
  • 순서(Ordering): 이벤트가 뒤섞여 도착하거나, 스트림 간 순서가 애매한 문제
  • 재처리(Replay/Reprocessing): 버그 수정, 스키마 변경, 프로젝션 재생성 시 안전하게 되감는 방법

중복 커밋/히스토리 꼬임이 왜 위험한지에 대한 비유로는 Git 사례가 직관적입니다. 이벤트 스트림도 결국 “히스토리”이기 때문에, 운영 중 중복이 생기면 복구가 어렵습니다. 비슷한 맥락의 장애 패턴은 Git rebase 후 PR에 커밋이 중복될 때 원인·복구에서도 참고할 만합니다.

마이그레이션의 기본 전제: 무엇을 ‘진실의 원천’으로 둘 것인가

이벤트 소싱 전환에서 가장 먼저 결정해야 할 것은 소스 오브 트루스(Source of Truth) 입니다.

전환 모델 3가지

  1. Dual-write(이중 쓰기)

    • 커맨드 처리 시 RDB 상태 갱신 + 이벤트 발행을 동시에 수행
    • 장점: 빠른 전환
    • 단점: 원자성/순서/중복 문제가 가장 많이 발생
  2. Outbox 패턴 기반 단계적 전환(권장)

    • 트랜잭션 내에서 RDB 상태 변경 + outbox 테이블에 이벤트 기록
    • 별도 릴레이가 outbox → 이벤트 버스로 전송
    • 장점: 최소 한 번(at-least-once) 전송에서 중복 제어가 쉬움
  3. Backfill 후 Cutover(이벤트 스토어가 진실)

    • 과거 상태를 이벤트로 재구성(backfill)하여 이벤트 스토어에 적재
    • 이후 커맨드는 이벤트 스토어만 기록
    • 장점: 이벤트 소싱 정통에 가까움
    • 단점: 초기 설계/검증 비용 큼

이 글은 2번(Outbox 기반)을 기준으로 설명하되, 1/3번에서도 그대로 적용 가능한 “중복·순서·재처리”의 설계 원칙을 제시합니다.

중복: “최소 한 번” 세계에서 살아남기

분산 시스템에서 메시징은 대개 at-least-once입니다. 즉, 중복은 ‘버그’가 아니라 ‘기본값’입니다. 따라서 목표는 중복을 제거하는 것이 아니라 중복이 와도 결과가 동일하도록 만드는 것(멱등성, idempotency) 입니다.

중복이 생기는 대표 지점

  • Outbox 릴레이 재시도(네트워크/브로커 타임아웃)
  • 컨슈머 처리 후 ACK 전에 크래시 → 재전달
  • 프로듀서가 동일 커맨드를 재시도(클라이언트 타임아웃)
  • 재처리(리플레이)로 과거 이벤트를 다시 흘림

이벤트에 반드시 들어가야 하는 ID 3종 세트

  1. eventId: 이벤트 자체의 전역 유일 ID(UUID 등)
  2. aggregateId: 애그리게이트 식별자
  3. aggregateVersion(또는 sequence): 해당 애그리게이트 스트림 내 순번

추가로 운영에서 매우 유용한 필드:

  • causationId(이 이벤트를 발생시킨 커맨드/이벤트)
  • correlationId(요청 단위 트레이싱)

컨슈머 멱등 처리 전략

1) 이벤트 ID 기반 Dedup 테이블(가장 단순)

  • 프로젝션 DB에 processed_event 테이블을 두고 eventId 유니크 제약으로 중복 차단
  • 단점: 저장 공간 증가, 고TPS에서 인덱스 부하

2) (권장) 애그리게이트 버전 기반 “최신 적용 버전” 체크

  • 프로젝션마다 last_applied_version을 저장
  • 이벤트의 aggregateVersion이 이미 적용된 버전 이하라면 스킵
  • 장점: 저장 공간 적고 빠름
  • 단점: 애그리게이트 단위 순서가 보장되어야 효과가 큼

아래는 Postgres 기준의 간단한 예시입니다.

-- 프로젝션 테이블(예: 주문 요약)
CREATE TABLE order_summary (
  order_id TEXT PRIMARY KEY,
  status TEXT NOT NULL,
  total_amount BIGINT NOT NULL,
  last_applied_version BIGINT NOT NULL
);

-- 이벤트 적용(업서트) 시 버전으로 멱등성 확보
-- 같은 버전/더 낮은 버전은 무시
INSERT INTO order_summary(order_id, status, total_amount, last_applied_version)
VALUES (:orderId, :status, :totalAmount, :version)
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status,
    total_amount = EXCLUDED.total_amount,
    last_applied_version = EXCLUDED.last_applied_version
WHERE order_summary.last_applied_version < EXCLUDED.last_applied_version;

커맨드 중복(클라이언트 재시도)까지 막아야 한다

이벤트 중복만 막고 끝내면 안 됩니다. 커맨드가 중복으로 처리되면 서로 다른 eventId를 가진 “정상 이벤트”가 두 번 생성될 수 있습니다.

해결책은 커맨드에 idempotencyKey를 두고, 애그리게이트 커맨드 핸들러 레벨에서 “이미 처리한 키”를 차단하는 것입니다.

// Kotlin/Spring 스타일 의사코드
fun handlePlaceOrder(cmd: PlaceOrder) {
  // 1) idempotencyKey로 이미 처리했는지 확인
  if (idempotencyStore.exists(cmd.idempotencyKey)) return

  // 2) 애그리게이트 로드(이벤트 재생)
  val agg = eventStore.load(cmd.orderId)

  // 3) 불변식 체크 후 이벤트 생성
  val events = agg.place(cmd)

  // 4) 이벤트 append(낙관적 락: expectedVersion)
  eventStore.append(cmd.orderId, expectedVersion = agg.version, events)

  // 5) 키 기록(동일 트랜잭션/아웃박스와 함께 처리 권장)
  idempotencyStore.save(cmd.idempotencyKey)
}

핵심은 appendexpectedVersion을 통해 동시성/중복을 제어한다는 점입니다. 이게 없으면 중복·순서 문제가 폭발합니다.

순서: “전체 순서”는 포기하고 “필요한 순서”만 잡기

이벤트 소싱에서 자주 하는 오해가 “모든 이벤트는 시간 순으로 정렬되어야 한다”입니다. 현실적으로 브로커/네트워크/샤딩이 있는 순간 전역(total) 순서는 비용이 너무 큽니다.

대신 DDD 관점에서 필요한 순서는 보통 이것입니다.

  • 애그리게이트 단위 순서: 같은 aggregateId의 이벤트는 aggregateVersion 순으로 처리
  • 프로젝션 단위 인과관계: 특정 읽기 모델이 요구하는 최소한의 순서만 보장

애그리게이트 단위 순서 보장 방법

  1. 이벤트 스토어 append 시 버전 부여(진실의 순서)
    • 이벤트 스토어가 aggregateVersion을 원자적으로 증가시키며 저장
  2. 브로커 파티셔닝 키를 aggregateId로 설정(가능하면)
    • Kafka라면 key=aggregateId → 동일 키는 파티션 내 순서 보장
  3. 컨슈머에서 out-of-order 버퍼링(최후의 수단)
    • 일부 이벤트가 먼저 도착하면 대기열에 넣고 다음 버전이 올 때까지 홀드
    • 운영 복잡도가 급증하므로 가급적 1+2로 해결

이벤트 시간(createdAt)으로 정렬하면 안 되는 이유

  • 시스템 시계 드리프트
  • 재처리 시 과거 이벤트가 “지금” 도착
  • 동일 밀리초 내 다중 이벤트

따라서 순서 기준은 createdAt이 아니라 **stream sequence(aggregateVersion)**가 되어야 합니다.

스트림 간 순서가 필요한 경우: 사가/프로세스 매니저

예: 결제 이벤트와 주문 이벤트의 상대적 순서가 중요해 보일 수 있습니다.

이때는 전역 순서를 강제하기보다,

  • 사가가 각 스트림의 버전을 기억하며 상태 머신처럼 동작
  • “선행 이벤트가 없으면 대기” 또는 “보상 트랜잭션”으로 정리

즉, 순서를 강제하는 대신, 순서가 어긋날 수 있음을 모델링합니다.

재처리(Replay): 프로젝션은 언제든 다시 만들 수 있어야 한다

이벤트 소싱의 장점 중 하나는 “리드 모델을 다시 만들 수 있음”이지만, 마이그레이션 국면에서는 이게 오히려 위험 요인이 됩니다. 재처리 시 중복/순서 문제를 다시 밟고, 스키마 버전이 바뀌면 과거 이벤트를 해석 못 하는 문제가 생깁니다.

재처리 유형 3가지

  1. Full rebuild: 처음부터 끝까지 모든 이벤트로 프로젝션 재생성
  2. Partial replay: 특정 시점/버전 이후만 재생
  3. Fix-forward: 과거 이벤트는 그대로 두고, 보정 이벤트를 추가

운영 안전성만 보면 대체로 Fix-forward가 최우선, 그다음이 Partial, 마지막이 Full rebuild입니다(특히 대용량일수록).

리플레이 안전장치 1: 프로젝션 체크포인트

프로젝션별로 “어디까지 처리했는지”를 저장해야 합니다.

  • 애그리게이트 단위 체크포인트: last_applied_version (앞서 설명)
  • 글로벌 체크포인트: 이벤트 스토어의 globalPosition(있다면)

이벤트 스토어가 전역 위치를 제공하지 않는다면, 마이그레이션 단계에서는 프로젝션을 애그리게이트 단위로 재생성하는 도구를 별도 제공하는 편이 안전합니다.

리플레이 안전장치 2: 스키마/업캐스팅(Upcasting)

이벤트는 과거의 계약입니다. 필드가 바뀌면 과거 이벤트를 읽을 수 있어야 합니다.

전략:

  • 이벤트에 schemaVersion 포함
  • 컨슈머(또는 이벤트 스토어 접근 계층)에서 업캐스터로 최신 형태로 변환
// TypeScript 업캐스터 예시
type OrderPlacedV1 = { schemaVersion: 1; orderId: string; amount: number }
type OrderPlacedV2 = { schemaVersion: 2; orderId: string; totalAmount: number; currency: string }

type OrderPlaced = OrderPlacedV2

function upcastOrderPlaced(e: OrderPlacedV1 | OrderPlacedV2): OrderPlaced {
  if (e.schemaVersion === 2) return e
  return {
    schemaVersion: 2,
    orderId: e.orderId,
    totalAmount: e.amount,
    currency: "KRW",
  }
}

업캐스팅은 “이벤트를 수정”하는 게 아니라, 해석 계층에서만 변환하는 게 원칙입니다.

리플레이 안전장치 3: 재처리 모드에서의 중복/부하 제어

재처리는 대량 트래픽을 유발합니다.

  • 컨슈머에 replayMode=true를 두고
    • 외부 사이드 이펙트(알림 발송, 외부 API 호출)는 비활성화
    • DB 배치/커밋 단위를 늘려 성능 최적화
  • 리플레이 전용 워커 풀/큐 분리

또한 재처리 중 DB/네트워크 타임아웃이 늘어나 재시도가 폭증하면 중복이 더 늘 수 있습니다. 쿠버네티스 환경이라면 네트워크/프록시 계층에서 증상이 증폭되기도 하므로, 장애 시나리오 점검은 Kubernetes gRPC UNAVAILABLE·RST_STREAM 원인과 Envoy·NGINX 대응 같은 글의 튜닝 포인트도 함께 보는 것이 좋습니다.

마이그레이션 실전 시나리오: 상태 기반 RDB → 이벤트 스토어

여기서는 흔한 “기존 주문 테이블이 있고, 이벤트 소싱으로 전환” 케이스를 가정합니다.

1단계: 이벤트 계약 정의(도메인 이벤트 vs 통합 이벤트)

  • 도메인 이벤트: 애그리게이트 내부 의미(불변식 중심)
  • 통합 이벤트: 다른 바운디드 컨텍스트와 공유(호환성/버전 중심)

마이그레이션 중에는 도메인 이벤트를 먼저 안정화하고, 외부 전파는 통합 이벤트로 별도 레이어를 두는 편이 안전합니다.

2단계: Outbox로 이벤트 발행 파이프라인 구축

  • 기존 트랜잭션에 outbox insert 추가
  • 릴레이가 outbox를 읽어 브로커로 발행
  • 발행 성공 마킹(또는 삭제)

이때 outbox 릴레이는 반드시 중복 발행 가능하다고 가정하고, 컨슈머가 멱등해야 합니다.

3단계: 프로젝션을 “재생 가능한 형태”로 만들기

  • 모든 프로젝션 테이블에 last_applied_version(또는 processed_event) 도입
  • 재처리 도구(특정 aggregateId만 재생, 특정 기간만 재생 등) 준비

4단계: Backfill(과거 데이터 이벤트화)

가장 위험한 구간입니다.

  • 과거 레코드를 이벤트로 만들 때, 반드시 결정론적(deterministic) 이어야 함
    • 동일 입력 → 동일 이벤트 시퀀스가 나오도록
  • backfill 이벤트에는
    • isBackfill=true
    • 별도 correlationId(작업 배치 ID)
    • 명확한 aggregateVersion 부여

5단계: Cutover(커맨드 소스 전환)

  • 커맨드 처리의 진실을 RDB에서 이벤트 스토어로 옮김
  • 이 시점부터는
    • 애그리게이트는 이벤트로만 로드
    • 상태 테이블은 프로젝션(파생 데이터)

체크리스트: 중복·순서·재처리 관점에서 반드시 확인할 것

중복

  • 컨슈머가 멱등한가? (버전 기반/이벤트ID 기반)
  • 커맨드에 idempotencyKey가 있는가?
  • 이벤트 append가 expectedVersion(낙관적 락)을 강제하는가?

순서

  • 애그리게이트 스트림 내 sequence(aggregateVersion)가 진실인가?
  • 브로커 파티셔닝 키가 aggregateId인가?
  • createdAt으로 정렬/판단하는 로직이 숨어있지 않은가?

재처리

  • 프로젝션 체크포인트가 있는가?
  • 업캐스팅/스키마 버전 전략이 있는가?
  • replayMode에서 사이드 이펙트를 차단하는가?

결론: “정확히 한 번”을 믿지 말고, 계약으로 이겨라

DDD에서 이벤트 소싱 마이그레이션의 본질은 기술 선택이 아니라 정합성 계약입니다.

  • 중복은 피할 수 없으니 멱등성으로 흡수하고
  • 전역 순서를 강제하지 말고 애그리게이트 단위 순서를 확실히 잡고
  • 재처리는 언젠가 반드시 필요하니 체크포인트/업캐스팅/리플레이 모드를 초기에 설계해야 합니다.

이 3가지만 초기에 제대로 깔아두면, 이벤트 소싱은 “운영이 어려운 아키텍처”가 아니라 “변경에 강한 시스템”으로 작동하기 시작합니다.