- Published on
Event Sourcing 스냅샷 꼬임 - 중복·유실 복구 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Event Sourcing에서 스냅샷(snapshot)은 “성능 최적화 수단”이지 “진실의 원천(source of truth)”이 아닙니다. 진실은 이벤트 로그(event stream)에 있고, 스냅샷은 특정 시점까지의 이벤트를 접어둔 캐시입니다.
그런데 운영 환경에서는 스냅샷이 꼬이면서 다음과 같은 현상이 종종 발생합니다.
- 중복 적용: 이미 처리한 이벤트가 재적용되어 잔액/재고가 2번 증가
- 유실처럼 보임: 특정 이벤트가 상태에 반영되지 않은 것처럼 보이거나, 스냅샷 이후 이벤트가 건너뛰어진 것처럼 보임
- 리플레이가 불안정: 같은 스트림을 리빌드했는데 결과가 매번 달라짐
이 글은 “스냅샷이 꼬였다”는 모호한 표현을 정확한 실패 모드로 분해하고, 중복·유실을 안전하게 복구하는 실전 절차를 제시합니다. (DB는 PostgreSQL 예시, 언어는 Java/Kotlin 스타일로 설명하지만 원리는 동일합니다.)
스냅샷 꼬임의 대표 증상과 실패 모드
스냅샷 문제는 대부분 아래 3가지 범주로 귀결됩니다.
1) 스냅샷 버전(리비전) 불일치
스냅샷이 last_event_revision = 150이라고 적혀있는데, 실제로 스냅샷 내용은 148까지 반영된 상태일 수 있습니다. 그러면 149~150 이벤트가 스냅샷에도 없고, 리플레이도 건너뛰면 유실처럼 보이는 상태가 됩니다.
반대로 last_event_revision이 실제보다 낮으면, 리플레이 시 이미 반영된 이벤트를 다시 적용해 중복이 발생할 수 있습니다.
2) 스냅샷 저장의 원자성(atomicity) 붕괴
스냅샷 저장 로직이 다음처럼 분리되어 있으면 위험합니다.
- (1) 스냅샷 본문 저장
- (2) 메타데이터(버전) 업데이트
중간에 장애/타임아웃/재시도가 끼면, 본문과 메타데이터가 서로 다른 시점을 가리키게 됩니다.
3) 동시성 경합: 두 개의 스냅샷 라이터
같은 aggregate(동일 스트림)에 대해 두 워커가 동시에 스냅샷을 만들면:
- 더 오래 걸린(늦게 커밋된) 스냅샷이 더 과거 버전인데도 최신처럼 덮어쓰는 문제가 생깁니다.
- 또는 “최신 스냅샷 1개만 유지” 정책이라면, 레이스에서 진 쪽이 최신 스냅샷을 삭제/교체하며 꼬임을 만들 수 있습니다.
재현 가능한 장애 시나리오: 중복 vs 유실
복구를 잘하려면 “중복처럼 보이는 것”과 “유실처럼 보이는 것”을 구분해야 합니다.
중복처럼 보이는 경우
- 이벤트 스트림은 정상
- 스냅샷이 낮은 버전을 가리켜 리플레이 구간이 넓어짐
- 그런데 이벤트 핸들러가 비결정적(non-deterministic) 이거나 멱등(idempotent)하지 않으면 리플레이 때 값이 튀며 “중복”처럼 보입니다.
예: 이벤트 적용 시 now()를 저장하거나, 외부 API를 호출해 부수효과를 내는 경우.
유실처럼 보이는 경우
- 스냅샷이 높은 버전을 가리키는데 실제 본문은 덜 반영됨
- 로더가
snapshot.last_revision + 1부터만 읽으면, 일부 이벤트를 건너뛰어 상태에서 빠집니다.
즉 “유실”이라기보다 잘못된 스냅샷이 리플레이 범위를 잘라먹는 문제인 경우가 많습니다.
데이터 모델: 안전한 스냅샷 스키마
스냅샷 테이블은 최소한 아래 필드를 갖추는 것을 권합니다.
aggregate_idsnapshot_revision(스냅샷이 반영한 마지막 이벤트 리비전)payload(상태 스냅샷)payload_hash(무결성 검증)created_at
PostgreSQL 예시:
CREATE TABLE snapshots (
aggregate_id TEXT NOT NULL,
snapshot_revision BIGINT NOT NULL,
payload BYTEA NOT NULL,
payload_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (aggregate_id, snapshot_revision)
);
-- “최신 스냅샷” 조회를 빠르게
CREATE INDEX snapshots_latest_idx
ON snapshots (aggregate_id, snapshot_revision DESC);
핵심은 (aggregate_id, snapshot_revision) 복합 PK로 “같은 리비전 스냅샷 중복 저장”을 구조적으로 차단하는 것입니다.
스냅샷 저장을 원자적으로 만드는 패턴
스냅샷은 이벤트 스트림과 달리 “필수 데이터”가 아니므로, 가장 중요한 목표는 불완전한 스냅샷이 읽히지 않게 하는 것입니다.
패턴 A: 단일 INSERT (append-only) + 최신 선택
스냅샷을 덮어쓰지 말고 추가만 하세요.
- 장점: 레이스에 강함(늦게 커밋된 과거 스냅샷이 최신을 덮어쓰지 못함)
- 단점: 저장 공간 증가(주기적 정리 필요)
INSERT INTO snapshots (aggregate_id, snapshot_revision, payload, payload_hash)
VALUES ($1, $2, $3, $4)
ON CONFLICT (aggregate_id, snapshot_revision) DO NOTHING;
읽을 때는 항상 최신을 선택:
SELECT *
FROM snapshots
WHERE aggregate_id = $1
ORDER BY snapshot_revision DESC
LIMIT 1;
패턴 B: 스냅샷 라이터 단일화(락/리더-팔로워)
같은 aggregate에 대해 동시에 스냅샷을 만들지 않게 하는 방식입니다.
- 애플리케이션 레벨:
aggregate_id기반 분산 락(예: Redis) - DB 레벨: advisory lock
PostgreSQL advisory lock 예시:
SELECT pg_advisory_xact_lock(hashtext($1)); -- $1 = aggregate_id
트랜잭션 안에서 락을 잡고 스냅샷을 만들면 동시성 꼬임이 크게 줄어듭니다.
로딩 로직: “스냅샷을 믿지 않는” 방어적 리플레이
복구 관점에서 가장 중요한 원칙은 이것입니다.
- 스냅샷은 힌트일 뿐
- 이벤트 스트림이 정합성의 기준
따라서 로더는 스냅샷을 가져오더라도, 다음을 검증해야 합니다.
- 스냅샷의
snapshot_revision이 실제 이벤트 스트림 범위 내인지 - 스냅샷 payload가 손상되지 않았는지(hash)
- 스냅샷 이후 이벤트를 연속 리비전으로 가져오는지(갭 체크)
간단한 의사코드:
class Loaded<A> {
A state;
long revision; // 마지막으로 적용한 이벤트 리비전
}
Loaded<Aggregate> load(String aggregateId) {
Snapshot snap = snapshotRepo.findLatest(aggregateId);
Aggregate state;
long fromRevision;
if (snap != null && snapRepo.verifyHash(snap)) {
state = snap.toAggregate();
fromRevision = snap.snapshotRevision + 1;
} else {
state = Aggregate.empty();
fromRevision = 1;
}
List<Event> events = eventStore.readFrom(aggregateId, fromRevision);
// 갭(유실) 체크: 이벤트 리비전이 연속인지 확인
long expected = fromRevision;
for (Event e : events) {
if (e.revision != expected) {
throw new IllegalStateException(
"gap detected: expected=" + expected + " actual=" + e.revision);
}
state.apply(e);
expected++;
}
return new Loaded<>(state, expected - 1);
}
갭 체크는 “유실처럼 보이는” 문제를 조기에 폭발시켜, 조용히 잘못된 상태를 만들지 않게 해줍니다.
복구 전략: 중복·유실을 안전하게 되돌리는 절차
스냅샷 꼬임을 복구할 때의 목표는 단 하나입니다.
- 이벤트 스트림 기준으로 상태를 재구성하고
- 스냅샷을 재생성하여 성능을 회복
1단계: 이벤트 스트림 무결성부터 확인
먼저 이벤트가 정말 유실됐는지, 스냅샷이 잘못된 것인지 분리해야 합니다.
- 스트림별 이벤트 리비전이 1..N 연속인지 확인
- 중복 리비전(동일 revision 2개)이 있는지 확인
PostgreSQL 예시(연속성 체크는 시스템마다 다르지만 핵심은 “갭 탐지”):
-- events(aggregate_id, revision, payload, created_at)
WITH ordered AS (
SELECT
aggregate_id,
revision,
lag(revision) OVER (PARTITION BY aggregate_id ORDER BY revision) AS prev_revision
FROM events
WHERE aggregate_id = $1
)
SELECT *
FROM ordered
WHERE prev_revision IS NOT NULL
AND revision <> prev_revision + 1
ORDER BY revision;
갭이 없다면 “유실”은 대부분 스냅샷/로딩 로직 문제입니다.
2단계: 스냅샷을 격리(무효화)하고 전체 리빌드
운영 중에는 잘못된 스냅샷이 계속 읽히면 장애가 반복됩니다. 가장 안전한 방법은:
- 해당 aggregate의 스냅샷을 일시적으로 사용하지 않도록 격리
- 이벤트 1부터 끝까지 리플레이해 상태를 재계산
격리 방법은 2가지가 흔합니다.
- (A) 스냅샷 테이블에서 해당 aggregate의 스냅샷을 삭제/아카이브
- (B)
snapshot_revision을 -1로 마킹하거나valid=false컬럼으로 무효화
append-only 설계를 했다면 (A)가 비교적 안전합니다.
-- 문제 aggregate만 스냅샷 제거
DELETE FROM snapshots WHERE aggregate_id = $1;
그 다음 애플리케이션/배치로 리빌드 후 최신 스냅샷을 다시 생성합니다.
3단계: “중복”이 진짜 중복인지(부수효과) 확인
이벤트는 한 번만 존재하는데 값이 2번 증가했다면, 대개 아래 중 하나입니다.
- 이벤트 핸들러가 멱등하지 않음(리플레이 때마다 누적)
- 이벤트 적용 로직이 비결정적(시간/랜덤/외부 조회)
- 리드모델(projector)이 at-least-once로 중복 처리했는데 dedupe가 없음
해결은 “스냅샷”이 아니라 “이벤트 적용/프로젝션” 계층에 있습니다.
- 이벤트 적용은 순수 함수에 가깝게(외부 I/O 금지)
- 프로젝터는
event_id또는(aggregate_id, revision)기준으로 dedupe
프로젝션 dedupe 테이블 예시:
CREATE TABLE projection_offsets (
projector_name TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
last_revision BIGINT NOT NULL,
PRIMARY KEY (projector_name, aggregate_id)
);
업데이트 시에는 낙관적 잠금처럼 revision 단조 증가를 강제합니다.
4단계: 재발 방지용 “스냅샷 검증”을 상시화
스냅샷을 로드할 때마다 전 이벤트를 다시 읽을 수는 없지만, 최소한 아래는 상시 검증이 가능합니다.
- payload hash 검증
- snapshot_revision이 이벤트의 max revision보다 크지 않은지
- snapshot_revision 이후 첫 이벤트 revision이 정확히
snapshot_revision + 1인지(1건만 읽어서 확인)
운영 팁: DB/인프라 문제로 스냅샷 꼬임이 촉발될 때
스냅샷 저장은 “쓰기”이고, 이벤트 리플레이는 “읽기+CPU/메모리”를 크게 씁니다. 아래 이슈는 스냅샷 꼬임을 직접 만들기보다, 타임아웃/재시도/부분 실패를 유발해 원자성 붕괴를 촉발합니다.
- DB 지연/락 경합: 스냅샷 저장 트랜잭션이 타임아웃 → 재시도 중복
- 노드 메모리 압박: 리플레이 중 OOM → 스냅샷 생성 도중 프로세스 종료
리플레이/재빌드 작업이 큰 메모리를 먹는다면, OOM으로 인한 비정상 종료를 먼저 의심해야 합니다. 관련해서는 리눅스 OOM Killer로 프로세스 죽음 진단·방지에서 커널 로그 기반으로 원인을 좁히는 방법이 도움이 됩니다.
또한 PostgreSQL을 쓰고 이벤트/스냅샷 테이블이 커지면 vacuum/인덱스 bloat로 지연이 커질 수 있습니다. 이 경우 PostgreSQL VACUUM 안 먹을 때 - autovacuum 튜닝처럼 autovacuum 설정을 점검해 쓰기 지연을 줄이는 것이 재발 방지에 유효합니다.
실전 체크리스트(요약)
설계
- 스냅샷은 append-only로 저장하고 최신을 선택한다.
- PK를
(aggregate_id, snapshot_revision)로 두어 중복을 구조적으로 막는다. - 가능하면 aggregate 단위로 스냅샷 라이터를 단일화(advisory lock 등).
로딩
- 스냅샷 hash 검증
snapshot_revision기반 리플레이 시 갭 체크- 의심스러운 스냅샷은 즉시 무시하고 full replay로 폴백
복구
- 이벤트 스트림 연속성부터 확인(갭/중복)
- 스냅샷 격리(삭제/무효화) → full replay로 상태 재구성 → 스냅샷 재생성
- “중복”이 프로젝션 중복/비멱등에서 발생했는지 분리
결론
Event Sourcing에서 스냅샷 꼬임은 대개 “이벤트가 깨졌다”기보다 스냅샷의 버전/원자성/동시성이 깨진 결과로 나타납니다. 복구의 정답은 스냅샷을 억지로 고치는 것이 아니라, 이벤트 스트림을 기준으로 리빌드하고 스냅샷을 안전한 방식으로 재생성하는 것입니다.
스냅샷을 append-only로 만들고(덮어쓰기 금지), 로더에서 갭 체크와 무결성 검증을 넣는 것만으로도 “중복·유실처럼 보이는 장애”의 상당수를 예방하거나, 최소한 조기에 감지해 더 큰 데이터 오염을 막을 수 있습니다.