Event Sourcing에서 스냅샷이 비용 곡선을 바꾸는 지점
Event Sourcing으로 운영 중인 서비스가 어느 날 갑자기 특정 Aggregate 로드에서 눈에 띄는 지연을 보이기 시작하는 순간이 있습니다. 보통 그때 팀 안에서 "스냅샷 써야 하는 거 아니야?"라는 말이 처음 나옵니다. 이 질문의 핵심은 스냅샷을 쓸 수 있느냐가 아니라, 지금 쓰는 게 실제로 이득이냐입니다.
이 글은 Event Sourcing을 이미 운영 중인 백엔드·아키텍처 개발자를 전제로, 스냅샷 도입이 비용 곡선을 바꾸는 지점(손익분기점)을 어디에서 찾을지, 그리고 실제로 적용할 때 어떤 함정이 기다리는지를 중심으로 정리합니다.
Aggregate 재구성 비용이 왜 문제가 되는가
재구성(Rehydration)의 구조
Event Sourcing에서 Aggregate를 로드한다는 건 결국 이벤트 스토어에서 해당 Aggregate의 모든 이벤트를 읽어 순서대로 적용하는 일입니다. 이 과정을 흔히 Rehydration이라 부릅니다.
이벤트가 10개, 100개일 때는 아무 문제가 없습니다. 특정 Aggregate가 수년간 운영되면서 이벤트를 수천, 수만 건 쌓아왔다면 이야기가 달라집니다. 매 로드마다 그 이벤트 전부를 가져와서 재생하는 비용이 O(N)으로 선형 증가하기 때문입니다.
어떤 Aggregate가 위험한가
모든 Aggregate가 이 문제를 겪지는 않습니다. 핵심은 스트림이 닫히는가, 열려 있는가입니다.
주문(Order) Aggregate를 떠올려보면, 주문 생성 → 결제 완료 → 배송 시작 → 배송 완료로 흐름이 끝나면 더 이상 이벤트가 붙지 않습니다. 이벤트가 최대 수십 건에서 멈추는 **닫힌 스트림(closed stream)**입니다.
재고(Inventory) Aggregate는 다릅니다. 상품이 팔릴 때마다 이벤트가 쌓이고, 인기 상품이라면 하루에도 수백 건씩 발생하며 시스템이 운영되는 한 계속 커집니다. 스냅샷 없이는 사실상 무한정 성장하는 **열린 스트림(open-ended stream)**입니다.
손익분기점을 수식으로 다시 보기
두 비용 경로의 비교
스냅샷 도입 여부는 결국 두 가지 비용 경로의 비교로 환원됩니다.
- 경로 A: 전체 이벤트 N건 재생 비용
- 경로 B: 스냅샷 로드 비용 + 스냅샷 이후 이벤트 M건 재생 비용
로드 1회당 발생 비용을 모델링하면 아래 부등식이 성립할 때 스냅샷이 이득입니다.
C_snapshot_load + M × c_apply < N × c_apply여기서 c_apply는 이벤트 한 건당 재생 비용(apply 로직 + 역직렬화 시간)이고, C_snapshot_load는 스냅샷 조회·역직렬화 비용입니다. 쓰기 측 비용까지 포함하려면 로드 R회당 생성되는 스냅샷 수 S를 함께 고려해야 합니다.
S × C_snapshot_save + R × (C_snapshot_load + M × c_apply)
< R × N × c_apply함의는 두 가지입니다. 첫째, c_apply가 작으면 (apply 로직이 가볍고 이벤트가 작으면) 손익분기점의 N이 매우 커집니다 — 스냅샷이 쉽게 이득이 되지 않습니다. 둘째, 쓰기 대비 로드 횟수(R/S)가 작으면 스냅샷 저장 비용을 회수하지 못합니다. 쓰고 나서 거의 읽지 않는 Aggregate는 스냅샷 후보가 아니다라는 결론이 여기서 나옵니다.
비용 곡선의 교차 지점
X축을 N(스트림 길이), Y축을 로드 비용으로 두면 경로 A는 기울기 c_apply의 직선, 경로 B는 M을 재설정 간격으로 거의 평탄한 선입니다. 두 선이 만나는 지점이 손익분기점이며, 이 지점은 라이브러리나 커뮤니티가 미리 알려줄 수 없고 각 시스템의 c_apply와 로드 패턴으로 결정됩니다.
경험칙 수치 — 참고용
이벤트 수 임계값에 대해 커뮤니티에서 자주 언급되는 경험칙이 있습니다. 특정 라이브러리 문서가 권위를 갖는다기보다, 여러 사례에서 반복적으로 등장하는 수준의 느슨한 기준으로 받아들이는 게 적절합니다.
| 이벤트 수 | 커뮤니티 통념 |
|---|---|
| 100개 미만 | 보통 불필요 |
| 100 ~ 1,000개 | 측정 후 결정 |
| 1,000개 초과 | 검토 가치 있음 |
중요한 포인트는 이벤트 수가 아니라 팀의 SLA 대비 실제 재생 시간으로 판단하는 것입니다. 어떤 팀의 p99 허용치가 500ms라면 재생 100ms는 아직 여유가 있고, 10ms SLA 서비스라면 500건의 재생도 이미 위험할 수 있습니다. Kurrent 커뮤니티의 Snapshotting Sensible Defaults 스레드에서도 같은 맥락 — 측정이 먼저, 도입은 그 다음 — 을 반복해 강조합니다.
구현 시나리오별 코드
Axon Framework: 이벤트 카운트 기반 트리거
Axon Framework는 공식적으로 이벤트 카운트 기반 트리거를 지원합니다. EventCountSnapshotTriggerDefinition은 N개 이벤트마다 스냅샷을 생성합니다.
@Bean
public SnapshotTriggerDefinition snapshotTriggerDefinition(Snapshotter snapshotter) {
return new EventCountSnapshotTriggerDefinition(snapshotter, 100);
}
@Aggregate(snapshotTriggerDefinition = "snapshotTriggerDefinition")
public class InventoryAggregate {
@AggregateIdentifier
private String productId;
private int quantity;
}커스텀 트리거 조건(예: 특정 이벤트 타입에서만 스냅샷)이 필요하면 SnapshotTriggerDefinition 인터페이스를 직접 구현하는 접근을 쓸 수 있습니다. 아래는 개념적 예시이며, 실제 구현 시에는 사용 중인 Axon 버전의 공식 레퍼런스를 함께 확인해야 합니다.
// 개념적 예시 - 특정 이벤트 발생 시에만 스냅샷 트리거
public class EventTypeTrigger implements SnapshotTrigger {
private final Snapshotter snapshotter;
private final String aggregateType;
private String aggregateIdentifier;
@Override
public void eventHandled(EventMessage<?> msg) {
if (msg.getPayload() instanceof InventoryRestockedEvent && aggregateIdentifier != null) {
snapshotter.scheduleSnapshot(aggregateType, aggregateIdentifier);
}
}
// initializationFinished 등 생략
}술어 기반 접근의 장점은 "N개마다"가 아니라 의미 있는 상태 전환 시점(재고 전량 교체, 정산 마감 등)에 스냅샷을 찍을 수 있다는 점입니다.
Elixir Commanded: use 매크로 옵션
Commanded 공식 문서는 스냅샷 설정을 use 매크로 옵션으로 전달하는 방식을 안내합니다.
defmodule MyApp.InventoryAggregate do
use Commanded.Aggregates.Aggregate,
application: MyApp.Application,
snapshot_every: 100,
snapshot_version: 1
defstruct [:product_id, :quantity, :reserved]
endsnapshot_version은 Aggregate 구조가 변경될 때 올리는 값으로, 이전 버전 스냅샷을 자동으로 무시하도록 하는 안전장치입니다.
EventStoreDB: 수동 구현과 스트림 미존재 처리
EventStoreDB는 공식 스냅샷 API가 없기 때문에 별도 스트림에 수동으로 저장하는 패턴을 씁니다. 아래는 개념적 예시이며, 처음 로드 시 스냅샷 스트림이 아예 존재하지 않을 수 있으므로 StreamNotFoundException을 반드시 처리해야 합니다.
public async Task<(AggregateState? state, long version)> LoadSnapshotAsync(string aggregateId)
{
var snapshotStreamName = $"snapshot-{aggregateId}";
try
{
var result = _client.ReadStreamAsync(
Direction.Backwards,
snapshotStreamName,
StreamPosition.End,
maxCount: 1
);
if (await result.ReadState == ReadState.StreamNotFound)
return (null, 0);
await foreach (var @event in result)
{
var snapshot = JsonSerializer.Deserialize<SnapshotData>(@event.Event.Data.Span);
if (snapshot?.SchemaVersion != CurrentSchemaVersion)
return (null, 0);
return (snapshot.State, snapshot.Version);
}
}
catch (StreamNotFoundException)
{
return (null, 0);
}
return (null, 0);
}핵심은 스냅샷이 없거나 스키마 버전이 안 맞으면 깔끔히 폴백하는 것입니다. 전체 이벤트 재생이 항상 안전망으로 작동해야 합니다.
트레이드오프와 복합 최적화
도입 효과의 조건
스냅샷의 효과는 조건부입니다. 아래 표는 효과가 나타나는 조건을 정리한 것입니다.
| 항목 | 내용 |
|---|---|
| 재구성 성능 | 장기 스트림의 로드 시간을 O(N) → O(M), M은 스냅샷 이후 이벤트 수 |
| 읽기 집약 워크로드 | Hot Aggregate를 반복 로드하는 시스템에서 효과 극대화 |
| 쓰기 집약 워크로드 | 쓰고 즉시 로드하지 않는 패턴이라면 효과 미미 |
| 점진적 도입 | 스냅샷 없이도 시스템이 정상 동작하므로 추후 도입 가능 |
재생 시간 단축폭을 단일 수치로 일반화하기는 어렵습니다. 이벤트 크기, apply 로직 복잡도, 스토어 레이턴시에 따라 편차가 크기 때문에, 실제 효과는 자신의 시스템에서 before/after 벤치마크로 확인해야 합니다. 또한 스냅샷 단독보다 CQRS Read Side 분리, 이벤트 스토어 파티셔닝, 캐시 레이어와 결합한 복합 최적화를 쓰는 사례가 많다는 점도 염두에 둘 가치가 있습니다.
실무에서 자주 만나는 함정
함정 1: 스냅샷이 실제로 쓰이고 있는지 확인하지 않는 것
스냅샷이 생성·저장은 되지만 직렬화 설정 불일치로 로드 시 무시되고 매번 전체 이벤트가 재생되는 사례가 있습니다. 도입 후에는 반드시 로그·메트릭에서 "스냅샷 적용 비율"을 관측해야 합니다. 저장만 되고 활용되지 않으면 디스크만 쓰는 셈입니다.
함정 2: 스키마 변경 시 기존 스냅샷 깨짐
Aggregate 구조가 바뀌면 이전에 저장된 스냅샷이 역직렬화 불가 상태가 됩니다. 대응은 스냅샷 페이로드에 스키마 버전을 포함하고, 버전이 다르면 전체 이벤트 재생으로 폴백하는 것입니다.
{
"schema_version": 2,
"product_id": "prod-123",
"quantity": 500,
"reserved": 30
}핵심 원칙은 하나입니다 — 스냅샷은 항상 최적화 수단일 뿐, 스냅샷이 없거나 손상되어도 전체 이벤트 재생으로 정확한 상태 복원이 가능해야 합니다. 스냅샷에 의존하는 설계는 금물입니다.
함정 3: 과도한 스냅샷 생성
이벤트마다 스냅샷을 찍으면 저장 비용은 선형으로 증가하는데 로드 측 이점은 거의 없습니다. 앞서 세운 부등식 S × C_snapshot_save + R × (C_snapshot_load + M × c_apply) < R × N × c_apply에서 S가 커질수록 좌변만 커지므로 손익이 뒤집힙니다. 보통은 수십~수백 이벤트당 1회, 또는 의미 있는 상태 전환 시점을 선택합니다.
스냅샷 저장소 선택
| 전략 | 특징 | 적합한 상황 |
|---|---|---|
| 동일 DB, 별도 테이블 | 트랜잭션 일관성 보장, 쿼리 단순 | 소규모~중규모 |
| 별도 DB | 독립적 확장 가능, 최종 일관성 수용 필요 | 고성능, 대규모 |
| Redis 캐시 레이어 | 인메모리 속도, TTL 관리 가능 | Hot Aggregate, 반복 읽기 |
직접 손익분기점을 찾아보려면
이 글의 핵심은 외부 기준을 그대로 가져다 쓰기보다 자신의 시스템에서 교차점을 직접 확인하는 흐름입니다. 아래 순서대로 접근하면 큰 리스크 없이 도입 여부를 결정할 수 있습니다.
- 측정 — 운영 중인 Aggregate 중 가장 길어진 스트림의 로드 시간(p50/p95/p99)을 수집합니다. 로드 횟수 R과 쓰기 횟수도 함께 봅니다.
- 모델링 —
c_apply,C_snapshot_load,C_snapshot_save를 벤치마크로 추정하고 앞서 세운 부등식에 대입합니다. 손익분기점의 N을 계산합니다. - 도입 — 가장 효과가 클 Aggregate 하나부터 스냅샷을 적용합니다. 스키마 버전 키와 폴백 경로를 반드시 포함합니다.
- 검증 — 스냅샷 적용 비율, 재생 이벤트 수 분포, p99 로드 시간을 before/after로 비교합니다. 효과가 모델링 예측과 어긋나면 간격·저장소 선택을 재검토합니다.
이 과정을 거치면 "스냅샷을 쓰느냐 마느냐"가 아니라 "우리 시스템에서 손익분기 N은 몇인가"로 질문이 선명해집니다. 그 지점을 숫자로 아는 팀과 모르는 팀의 운영 품질은 분명히 갈립니다.