- Published on
DDD 이벤트 소싱 마이그레이션 - 중복·순서·재처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 시스템(기존 RDB 상태 기반 → 이벤트 소싱)으로 마이그레이션할 때 진짜 어려운 지점은 “이벤트를 만들었다”가 아니라 그 이벤트가 중복 없이, 올바른 순서로, 필요할 때 안전하게 재처리되도록 만드는 것입니다. 특히 DDD에서 이벤트 소싱은 애그리게이트 불변식과 커맨드 모델의 일관성, 그리고 리드 모델(프로젝션)의 최종 일관성을 동시에 다루기 때문에, 마이그레이션은 단순 데이터 이관이 아니라 정합성 계약(contract) 재정의에 가깝습니다.
이 글은 다음 3가지를 중심으로, 마이그레이션 시 흔히 겪는 실패 패턴과 대응 전략을 정리합니다.
- 중복(Deduplication): 같은 이벤트가 두 번(혹은 N번) 적용되는 문제
- 순서(Ordering): 이벤트가 뒤섞여 도착하거나, 스트림 간 순서가 애매한 문제
- 재처리(Replay/Reprocessing): 버그 수정, 스키마 변경, 프로젝션 재생성 시 안전하게 되감는 방법
중복 커밋/히스토리 꼬임이 왜 위험한지에 대한 비유로는 Git 사례가 직관적입니다. 이벤트 스트림도 결국 “히스토리”이기 때문에, 운영 중 중복이 생기면 복구가 어렵습니다. 비슷한 맥락의 장애 패턴은 Git rebase 후 PR에 커밋이 중복될 때 원인·복구에서도 참고할 만합니다.
마이그레이션의 기본 전제: 무엇을 ‘진실의 원천’으로 둘 것인가
이벤트 소싱 전환에서 가장 먼저 결정해야 할 것은 소스 오브 트루스(Source of Truth) 입니다.
전환 모델 3가지
Dual-write(이중 쓰기)
- 커맨드 처리 시 RDB 상태 갱신 + 이벤트 발행을 동시에 수행
- 장점: 빠른 전환
- 단점: 원자성/순서/중복 문제가 가장 많이 발생
Outbox 패턴 기반 단계적 전환(권장)
- 트랜잭션 내에서 RDB 상태 변경 + outbox 테이블에 이벤트 기록
- 별도 릴레이가 outbox → 이벤트 버스로 전송
- 장점: 최소 한 번(at-least-once) 전송에서 중복 제어가 쉬움
Backfill 후 Cutover(이벤트 스토어가 진실)
- 과거 상태를 이벤트로 재구성(backfill)하여 이벤트 스토어에 적재
- 이후 커맨드는 이벤트 스토어만 기록
- 장점: 이벤트 소싱 정통에 가까움
- 단점: 초기 설계/검증 비용 큼
이 글은 2번(Outbox 기반)을 기준으로 설명하되, 1/3번에서도 그대로 적용 가능한 “중복·순서·재처리”의 설계 원칙을 제시합니다.
중복: “최소 한 번” 세계에서 살아남기
분산 시스템에서 메시징은 대개 at-least-once입니다. 즉, 중복은 ‘버그’가 아니라 ‘기본값’입니다. 따라서 목표는 중복을 제거하는 것이 아니라 중복이 와도 결과가 동일하도록 만드는 것(멱등성, idempotency) 입니다.
중복이 생기는 대표 지점
- Outbox 릴레이 재시도(네트워크/브로커 타임아웃)
- 컨슈머 처리 후 ACK 전에 크래시 → 재전달
- 프로듀서가 동일 커맨드를 재시도(클라이언트 타임아웃)
- 재처리(리플레이)로 과거 이벤트를 다시 흘림
이벤트에 반드시 들어가야 하는 ID 3종 세트
eventId: 이벤트 자체의 전역 유일 ID(UUID 등)aggregateId: 애그리게이트 식별자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)
}
핵심은 append가 expectedVersion을 통해 동시성/중복을 제어한다는 점입니다. 이게 없으면 중복·순서 문제가 폭발합니다.
순서: “전체 순서”는 포기하고 “필요한 순서”만 잡기
이벤트 소싱에서 자주 하는 오해가 “모든 이벤트는 시간 순으로 정렬되어야 한다”입니다. 현실적으로 브로커/네트워크/샤딩이 있는 순간 전역(total) 순서는 비용이 너무 큽니다.
대신 DDD 관점에서 필요한 순서는 보통 이것입니다.
- 애그리게이트 단위 순서: 같은
aggregateId의 이벤트는aggregateVersion순으로 처리 - 프로젝션 단위 인과관계: 특정 읽기 모델이 요구하는 최소한의 순서만 보장
애그리게이트 단위 순서 보장 방법
- 이벤트 스토어 append 시 버전 부여(진실의 순서)
- 이벤트 스토어가
aggregateVersion을 원자적으로 증가시키며 저장
- 이벤트 스토어가
- 브로커 파티셔닝 키를 aggregateId로 설정(가능하면)
- Kafka라면 key=aggregateId → 동일 키는 파티션 내 순서 보장
- 컨슈머에서 out-of-order 버퍼링(최후의 수단)
- 일부 이벤트가 먼저 도착하면 대기열에 넣고 다음 버전이 올 때까지 홀드
- 운영 복잡도가 급증하므로 가급적 1+2로 해결
이벤트 시간(createdAt)으로 정렬하면 안 되는 이유
- 시스템 시계 드리프트
- 재처리 시 과거 이벤트가 “지금” 도착
- 동일 밀리초 내 다중 이벤트
따라서 순서 기준은 createdAt이 아니라 **stream sequence(aggregateVersion)**가 되어야 합니다.
스트림 간 순서가 필요한 경우: 사가/프로세스 매니저
예: 결제 이벤트와 주문 이벤트의 상대적 순서가 중요해 보일 수 있습니다.
이때는 전역 순서를 강제하기보다,
- 사가가 각 스트림의 버전을 기억하며 상태 머신처럼 동작
- “선행 이벤트가 없으면 대기” 또는 “보상 트랜잭션”으로 정리
즉, 순서를 강제하는 대신, 순서가 어긋날 수 있음을 모델링합니다.
재처리(Replay): 프로젝션은 언제든 다시 만들 수 있어야 한다
이벤트 소싱의 장점 중 하나는 “리드 모델을 다시 만들 수 있음”이지만, 마이그레이션 국면에서는 이게 오히려 위험 요인이 됩니다. 재처리 시 중복/순서 문제를 다시 밟고, 스키마 버전이 바뀌면 과거 이벤트를 해석 못 하는 문제가 생깁니다.
재처리 유형 3가지
- Full rebuild: 처음부터 끝까지 모든 이벤트로 프로젝션 재생성
- Partial replay: 특정 시점/버전 이후만 재생
- 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가지만 초기에 제대로 깔아두면, 이벤트 소싱은 “운영이 어려운 아키텍처”가 아니라 “변경에 강한 시스템”으로 작동하기 시작합니다.