- Published on
이벤트 소싱 스냅샷 불일치 버그 추적법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
이벤트 소싱(Event Sourcing)은 "상태"가 아니라 "사건(이벤트)"을 진실의 원천으로 삼습니다. 하지만 운영 환경에서는 성능을 위해 스냅샷(snapshot)을 두고, 리플레이(replay) 비용을 줄입니다. 문제는 스냅샷이 이벤트 스트림과 불일치(inconsistency) 하게 되면, 같은 aggregate를 읽어도 어떤 경로(스냅샷+이후 이벤트 vs 전체 이벤트 리플레이)로 복원하느냐에 따라 결과가 달라지는 치명적인 버그가 생긴다는 점입니다.
이 글은 "스냅샷 불일치"를 단순히 "스냅샷 다시 뜨면 되지"로 끝내지 않고, 버그를 재현하고, 관측 가능하게 만들고, 원인을 분류한 뒤, 구조적으로 고쳐서 재발을 막는 추적법을 다룹니다.
스냅샷 불일치가 만들어내는 전형적인 증상
스냅샷 불일치는 보통 다음 중 하나로 관측됩니다.
- 동일 aggregate 조회 결과가 노드/요청마다 다름(캐시/스냅샷 히트 여부에 따라)
- 특정 시점 이후로만 상태가 이상함(스냅샷 생성 시점과 연관)
- 재빌드(전체 리플레이)하면 정상인데, 운영 읽기 경로에서는 비정상
- "중복 적용" 또는 "누락 적용"의 형태로 나타남(금액/수량/상태 전이에서 흔함)
이 증상은 대개 "이벤트 저장" 자체보다 복원 경로의 경계(version/offset) 처리에서 발생합니다.
먼저 정의부터: 스냅샷이 반드시 가져야 할 불변 조건
스냅샷을 제대로 디버깅하려면, 스냅샷 레코드가 최소한 무엇을 보장해야 하는지부터 고정해야 합니다.
필수 메타데이터(권장)
aggregate_idsnapshot_version(또는last_event_version)snapshot_payloadcreated_at- (선택)
event_stream_id/tenant_id/schema_version - (선택)
checksum(payload 해시)
불변 조건(invariants)
- 스냅샷은 정확히
snapshot_version까지의 이벤트를 적용한 결과여야 함 - 복원 시에는
snapshot_version다음 이벤트부터 적용해야 함 - 이벤트 스트림은 aggregate 단위로 단조 증가하는 버전(또는 오프셋)을 가져야 함
- 이벤트 저장이 성공했는데 스냅샷이 실패해도, 읽기 결과가 틀리면 안 됨(최악의 경우 느려져야지, 달라지면 안 됨)
이 네 가지를 기준으로 원인을 분류하면 추적 속도가 빨라집니다.
원인 분류: 스냅샷 불일치의 7가지 패턴
아래 패턴 중 하나로 떨어지는 경우가 대부분입니다.
1) 오프바이원(Off-by-one): >= vs >
가장 흔합니다. 예를 들어 스냅샷이 version=100까지 반영했는데, 복원 시 >= 100 이벤트부터 다시 적용하면 100번 이벤트가 중복 적용됩니다.
2) 스냅샷이 "미완성" 상태를 저장
스냅샷 생성 로직이 이벤트 적용 도중 예외가 났거나, 비동기 스냅샷 작업이 중간 상태를 저장하는 경우입니다.
3) 이벤트 버전 할당/저장 순서의 경쟁 조건
- 버전은 증가했는데 이벤트 저장이 롤백됨
- 이벤트는 저장됐는데 버전이 중복됨
- 동일 aggregate에 대한 동시 커맨드 처리에서 optimistic lock 누락
4) 이벤트 업캐스팅/스키마 변경으로 재생 결과가 달라짐
과거 이벤트를 새 모델로 해석하는 업캐스터(upcaster)가 바뀌면, 스냅샷은 옛 해석을 담고 있고 리플레이는 새 해석을 적용해 서로 다른 상태가 됩니다.
5) 비결정성(non-determinism)이 이벤트 적용에 섞임
이벤트 핸들러가 현재 시간, 랜덤, 외부 API 결과를 참조하면 리플레이 결과가 달라집니다. 이벤트 소싱에서는 이벤트 적용은 반드시 결정적이어야 합니다.
6) 멀티스트림/사이드이펙트 이벤트를 잘못 합침
aggregate A의 스냅샷을 만들면서, 다른 스트림의 이벤트(예: projection용)를 섞어 적용하는 경우.
7) 읽기 경로의 캐시와 스냅샷이 서로 다른 버전 기준을 사용
예: 캐시는 last_event_id 기준, 스냅샷은 version 기준. 또는 캐시 무효화가 이벤트 커밋보다 먼저/늦게 일어남.
재현 전략: "같은 입력"으로 두 경로를 강제로 비교하기
스냅샷 불일치 버그는 로그만으로는 잡기 어렵습니다. 핵심은 동일 aggregate에 대해 다음 두 복원 결과를 비교하는 "진단 API"를 만드는 것입니다.
- Path A: 스냅샷 로드 → 이후 이벤트 적용
- Path B: 이벤트 0부터 전체 리플레이
두 결과가 다르면, 그 순간부터는 "재현"이 된 것입니다.
예시(Typescript) - 진단용 리빌드 비교 함수
type Event = { version: number; type: string; data: unknown };
interface Snapshot<S> {
version: number;
state: S;
}
function applyEvent<S>(state: S, e: Event): S {
// 반드시 결정적이어야 함
// (시간/랜덤/외부 IO 금지)
return reducer(state, e);
}
async function rebuildFromScratch<S>(events: Event[], initial: S): Promise<Snapshot<S>> {
let s = initial;
for (const e of events) s = applyEvent(s, e);
const last = events.at(-1)?.version ?? 0;
return { version: last, state: s };
}
async function rebuildFromSnapshot<S>(snapshot: Snapshot<S>, eventsAfter: Event[]): Promise<Snapshot<S>> {
let s = snapshot.state;
for (const e of eventsAfter) s = applyEvent(s, e);
const last = eventsAfter.at(-1)?.version ?? snapshot.version;
return { version: last, state: s };
}
function assertSameState<S>(a: Snapshot<S>, b: Snapshot<S>) {
const aj = JSON.stringify(a.state);
const bj = JSON.stringify(b.state);
if (aj !== bj) {
throw new Error(
`SNAPSHOT_MISMATCH: snapshotPathVersion=${a.version}, fullReplayVersion=${b.version}`
);
}
}
이 함수 자체는 단순하지만, 실제 추적에서는 아래 정보를 함께 수집해야 합니다.
- 스냅샷 버전
Vs - 이벤트 스트림의 마지막 버전
Ve - 스냅샷 이후 적용한 첫 이벤트 버전
Vfirst - 적용 이벤트 개수
- 스냅샷 생성 시간과 이벤트 커밋 시간 간격
관측(Observability): "버전 경계"를 로그/메트릭으로 고정
스냅샷 불일치는 대부분 경계 조건 문제이므로, 로그에 아래를 반드시 남기면 추적 난이도가 크게 내려갑니다.
로그에 남길 필드
aggregate_idsnapshot_versionloaded_snapshot_id(있다면)events_loaded_from_version(예:snapshot_version + 1)events_loaded_to_versionevents_countrebuild_latency_mssnapshot_write_latency_ms
트래픽이 많아 로그 비용이 문제라면, 샘플링/필드 최소화를 고려해야 합니다. 로그 비용 최적화는 CloudWatch Logs 비용 폭증 원인과 절감 10가지를 같이 참고하면 좋습니다.
메트릭 추천
snapshot_mismatch_total(진단 경로에서만이라도)snapshot_hit_ratiorebuild_events_count(p95/p99)optimistic_lock_conflict_total
실전 디버깅 절차(체크리스트)
운영에서 "스냅샷 불일치" 의심이 들면, 아래 순서로 진행하는 것이 빠릅니다.
1) 단일 aggregate로 범위를 좁힌다
- 고객이 제보한
aggregate_id를 고정 - 동일 id에 대해 read API를 여러 번 호출해 결과가 흔들리는지 확인
2) 이벤트 스트림과 스냅샷을 "그대로" 덤프한다
- 이벤트:
(version, type, committed_at, payload) - 스냅샷:
(snapshot_version, created_at, payload, schema_version)
3) 두 경로 결과를 비교하고 "처음 어긋나는 이벤트"를 찾는다
가장 좋은 방법은 이벤트를 하나씩 적용하며 비교하는 것입니다.
function diffAtFirstDivergence<S>(initial: S, events: Event[], snapshot: Snapshot<S>) {
// full replay state
let full = initial;
// snapshot-based state: snapshot 이전은 이미 적용됐다고 가정
let snap = snapshot.state;
for (const e of events) {
full = applyEvent(full, e);
// snapshot version 이하는 snapshot 경로에서 건너뜀
if (e.version > snapshot.version) {
snap = applyEvent(snap, e);
}
if (JSON.stringify(full) !== JSON.stringify(snap)) {
return { divergedAt: e.version, event: e };
}
}
return null;
}
divergedAt == snapshot.version근처면 오프바이원 가능성이 큼- 특정 이벤트 타입에서만 어긋나면 비결정성/업캐스팅/버그 reducer 가능성이 큼
4) 동시성/락을 확인한다
다음 중 하나라도 해당되면, 이벤트 버전이 "정상적으로" 보이더라도 실제로는 경합이 숨어 있을 수 있습니다.
- 동일 aggregate에 대한 커맨드가 병렬 처리됨
- optimistic lock 실패가 재시도 없이 무시됨
- 이벤트 저장과 스냅샷 저장이 서로 다른 트랜잭션/세션에서 수행됨
5) 스냅샷 생성 타이밍을 점검한다
스냅샷을 "N개 이벤트마다" 생성하는 경우,
- 스냅샷 job이 늦게 돌면서 이미 더 뒤 이벤트가 커밋된 상태에서 옛 버전 기준으로 저장
- 또는 스냅샷 생성 중 읽어온 이벤트 범위가 잘못되어 누락
이때는 스냅샷 생성 쿼리의 범위를 코드/SQL 레벨에서 확인해야 합니다.
데이터 저장소 관점에서의 안전한 패턴
스냅샷 불일치를 구조적으로 줄이려면, 저장소에서 보장해야 할 것이 있습니다.
이벤트 저장: aggregate별 유니크 버전(또는 오프셋)
PostgreSQL이라면 보통 다음을 둡니다.
CREATE TABLE events (
aggregate_id TEXT NOT NULL,
version BIGINT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
committed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (aggregate_id, version)
);
그리고 커맨드 처리 시에는
- 현재 버전을 읽고
expected_version과 비교한 뒤INSERT를 수행하며 충돌 시 재시도
이 흐름이 깨지면 스냅샷이 아니라 이벤트 스트림 자체가 흔들립니다.
스냅샷 저장: "마지막 반영 이벤트 버전"을 강제
CREATE TABLE snapshots (
aggregate_id TEXT NOT NULL,
snapshot_version BIGINT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (aggregate_id)
);
여기서 중요한 운영 규칙:
- 스냅샷 업데이트는 단조 증가해야 함(과거 버전으로 되돌아가면 안 됨)
예를 들어:
UPDATE snapshots
SET snapshot_version = $2,
payload = $3,
created_at = now()
WHERE aggregate_id = $1
AND snapshot_version <= $2;
이 조건이 없으면, 늦게 도착한 스냅샷 작업이 더 최신 스냅샷을 덮어써서 불일치를 만들 수 있습니다.
업캐스팅/스키마 변경이 원인일 때의 추적 포인트
이벤트 소싱에서 스키마 변경은 흔하고, 스냅샷 불일치의 고질병이기도 합니다.
- 스냅샷에는
schema_version을 반드시 넣고 - 업캐스터는 버전별로 순수 함수로 관리
- "스냅샷 생성 시 사용한 업캐스터 버전"과 "현재 리플레이 업캐스터 버전"이 다르면 결과가 달라질 수 있음을 인정
해결책은 보통 둘 중 하나입니다.
- 스냅샷도 로드 시 업캐스팅(스냅샷 payload 마이그레이션)
- 스냅샷을 버리고 재생성(대규모면 비용 큼)
비결정성 제거: 이벤트 핸들러를 순수 함수로 만들기
리플레이가 언제나 같은 상태를 만들려면, reducer/apply는 결정적이어야 합니다.
나쁜 예:
new Date()로 만료 시간 계산- 외부 환율 API를 조회해 금액 계산
- 랜덤 ID 생성
좋은 예:
- 시간은 커맨드 처리 시점에 계산해 이벤트 payload에 넣기
- 외부 조회 결과는 이벤트로 캡처하기(예:
RateFixed이벤트)
운영에서 자주 쓰는 "응급 처치"와 그 한계
불일치가 터졌을 때 흔히 하는 처치:
- 스냅샷 삭제 후 전체 리플레이로 강제
- 스냅샷 재생성 배치 실행
이는 증상을 가릴 수는 있지만, 원인이 경계 조건/락/업캐스팅이라면 다시 터집니다. 게다가 전체 리플레이는 DB/CPU 부하를 크게 올릴 수 있습니다. 데이터베이스가 과부하로 치닫는다면(예: vacuum 폭주, CPU 100%), 원인 분리와 성능 대응을 병행해야 합니다. PostgreSQL 기반이면 PostgreSQL RDS autovacuum 폭주로 CPU 100% 해결 같은 운영 체크리스트도 함께 보는 편이 안전합니다.
회귀 방지: 테스트와 가드레일
마지막으로, 같은 유형의 버그가 재발하지 않게 시스템에 "가드"를 넣어야 합니다.
1) 스냅샷-리플레이 동치성(property) 테스트
- 임의의 이벤트 시퀀스를 생성
- 중간 버전에서 스냅샷을 뜨고
- 두 경로 결과가 항상 같음을 검증
2) 런타임 가드(샘플링)
- 0.1% 요청만이라도 백그라운드에서 full replay를 수행해 비교
- mismatch면 알람 + 해당 aggregate의 이벤트/스냅샷 덤프를 자동 수집
3) 배포 시 업캐스터/스냅샷 스키마 호환성 체크
- 업캐스터 변경 PR에는 "기존 스냅샷과 호환" 여부를 명시
- 호환 불가면 스냅샷 마이그레이션/재생성 계획을 함께 배포
결론
이벤트 소싱에서 스냅샷 불일치는 "스냅샷이 틀렸다"가 아니라, 대개 버전 경계 처리, 동시성 제어, 업캐스팅, 결정성 중 하나가 무너졌다는 신호입니다. 가장 빠른 추적법은 (1) 동일 aggregate에 대해 (2) 스냅샷 경로와 전체 리플레이 경로를 강제로 비교하고, (3) 처음 어긋나는 이벤트 버전을 찾아 원인 패턴에 매핑하는 것입니다.
운영에서의 핵심은 관측 가능성입니다. snapshot_version과 events_loaded_from_version 같은 경계 정보를 로그/메트릭으로 고정하면, "재현 불가" 상태에서 벗어나 버그를 과학적으로 추적할 수 있습니다.