스냅샷, 버전 관리 및 이벤트 스키마 진화
스냅샷으로 복원 속도를 높이고 과거 데이터를 손상하지 않으면서 이벤트 스키마를 발전시킵니다.
스냅샷, 버전 관리 및 이벤트 스키마 진화은(는) CoddyKit의 무료 Node.js Backend Development Bootcamp 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Node.js Backend Development Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
Why Rehydration Gets Slow
In Event Sourcing, the current state of an aggregate is rebuilt by replaying every event from the start of its stream. This is called rehydration.
For a fresh bank account with 12 events, replaying them is instant. But a long-lived aggregate (an account open for 5 years, a shopping cart with thousands of edits) can accumulate tens of thousands of events. Loading and folding all of them on every command becomes the bottleneck.
- I/O cost: reading thousands of rows from the event store
- CPU cost: folding each event through the reducer
- Latency: every write must first rehydrate before validating the next command
The standard fix is a snapshot: a periodic, serialized copy of the aggregate state so you only replay events after it.
Rehydration as a Fold
Before optimizing, make rehydration explicit. An aggregate's state is a left fold over its events: state = events.reduce(apply, initialState).
Here is a minimal account aggregate. The apply function is a pure reducer — given the previous state and one event, it returns the next state. Notice it never mutates; it returns new objects so it stays easy to reason about and test.
function apply(state, event) {
switch (event.type) {
case 'AccountOpened':
return { ...state, balance: 0, version: state.version + 1 };
case 'MoneyDeposited':
return { ...state, balance: state.balance + event.amount, version: state.version + 1 };
case 'MoneyWithdrawn':
return { ...state, balance: state.balance - event.amount, version: state.version + 1 };
default:
return state;
}
}
function rehydrate(events) {
const initial = { balance: 0, version: 0 };
return events.reduce(apply, initial);
}
const events = [
{ type: 'AccountOpened' },
{ type: 'MoneyDeposited', amount: 100 },
{ type: 'MoneyWithdrawn', amount: 30 },
];
console.log(rehydrate(events)); // { balance: 70, version: 3 }What a Snapshot Actually Is
A snapshot is a saved tuple of (aggregateId, version, serializedState). The version is the event sequence number the state reflects — this is the critical part.
To rehydrate with a snapshot:
- Load the latest snapshot for the aggregate
- Seed the fold with the snapshot state instead of
initialState - Replay only events with version > snapshot.version
Key rule: a snapshot is a derived cache, never the source of truth. The event stream remains authoritative. If a snapshot is lost or corrupted, you can always rebuild it by replaying from zero.
Rehydrating From a Snapshot
This version loads from a snapshot when one exists, then folds only the newer events. The snapshot already encodes version, so we know exactly where to resume.
In production the snapshot and the events come from your store; here they are passed in so the logic runs standalone.
function apply(state, event) {
switch (event.type) {
case 'MoneyDeposited':
return { ...state, balance: state.balance + event.amount, version: event.version };
case 'MoneyWithdrawn':
return { ...state, balance: state.balance - event.amount, version: event.version };
default:
return { ...state, version: event.version };
}
}
function loadFromSnapshot(snapshot, allEvents) {
const start = snapshot ? snapshot.state : { balance: 0, version: 0 };
const fromVersion = snapshot ? snapshot.version : 0;
const newer = allEvents.filter((e) => e.version > fromVersion);
return newer.reduce(apply, start);
}
const snapshot = { version: 3, state: { balance: 70, version: 3 } };
const events = [
{ type: 'MoneyDeposited', amount: 100, version: 1 },
{ type: 'MoneyWithdrawn', amount: 30, version: 3 },
{ type: 'MoneyDeposited', amount: 50, version: 4 },
];
console.log(loadFromSnapshot(snapshot, events)); // { balance: 120, version: 4 }When to Take a Snapshot
You don't snapshot on every event — that defeats the purpose. Common strategies:
- Every N events: e.g. snapshot whenever
version % 100 === 0. Simple and predictable. - By replay cost: snapshot when the gap between current version and last snapshot exceeds a threshold.
- Async / background: a separate worker subscribes to the stream and writes snapshots off the hot path, so writes never wait on snapshotting.
Tune N empirically. Too low wastes storage and write throughput; too high means long replays. For many systems N = 50–200 is a reasonable starting point.
const SNAPSHOT_EVERY = 100;
function shouldSnapshot(currentVersion, lastSnapshotVersion) {
// Snapshot when we've crossed another multiple of N
const since = currentVersion - lastSnapshotVersion;
return since >= SNAPSHOT_EVERY;
}
console.log(shouldSnapshot(100, 0)); // true
console.log(shouldSnapshot(140, 100)); // false
console.log(shouldSnapshot(205, 100)); // trueThe Hidden Trap: Snapshots Are Versioned Too
Here is the subtle danger. A snapshot stores the shape of your state at the moment it was written. If you later change how state is structured (rename a field, add a required property, change a type), old snapshots become incompatible.
Imagine you stored { balance: 70 }, then refactored to { balanceCents: 7000 }. A six-month-old snapshot still has balance, and your new apply code expects balanceCents. Rehydration silently produces wrong state.
Rule: tag every snapshot with a schemaVersion. On load, if the snapshot's schema version doesn't match the current code, discard it and replay from the events (which are upcasted — covered next). Events are forever; snapshots are disposable.
Guarding Snapshot Schema Version
This guard checks the snapshot's schemaVersion before trusting it. If it's stale, we fall back to a full replay. Because events are the source of truth, falling back is always safe — it's just slower for that one load, and the next snapshot will be written in the new shape.
const CURRENT_SCHEMA = 2;
function chooseStartState(snapshot) {
if (snapshot && snapshot.schemaVersion === CURRENT_SCHEMA) {
return { state: snapshot.state, fromVersion: snapshot.version };
}
// Stale or missing snapshot: replay everything
return { state: { balanceCents: 0, version: 0 }, fromVersion: 0 };
}
const stale = { schemaVersion: 1, version: 300, state: { balance: 70 } };
const fresh = { schemaVersion: 2, version: 300, state: { balanceCents: 7000, version: 300 } };
console.log(chooseStartState(stale).fromVersion); // 0 -> full replay
console.log(chooseStartState(fresh).fromVersion); // 300 -> fast pathEvolving Event Schemas: The Real Constraint
Events are immutable historical facts. You can never go back and edit a MoneyDeposited event from two years ago — it really happened in that shape. Yet your code must keep evolving.
So when business needs change, you cannot rewrite history. Instead you have a small set of disciplined techniques:
- Weak schema / additive change: only ever add optional fields; never remove or repurpose existing ones.
- Upcasting: transform old event versions into the current shape at read time.
- New event type: when a change is too big, introduce a new event type and stop emitting the old one.
The golden rule: old events must remain readable forever. Every consumer reads through an upcaster, so the rest of your code only ever sees the latest shape.
Upcasting Old Events at Read Time
Upcasting means: as each event is read from the store, run it through a function that migrates older versions up to the current shape before it reaches apply. Each event carries an eventVersion so the upcaster knows what to do.
Example evolution: v1 deposits had a single amount in dollars. v2 splits money into currency + integer amountCents. Old v1 events get upcasted on the fly — without ever touching the stored data.
function upcast(event) {
if (event.type === 'MoneyDeposited' && (event.eventVersion || 1) === 1) {
return {
...event,
eventVersion: 2,
currency: 'USD',
amountCents: Math.round(event.amount * 100),
};
}
return event;
}
const stored = [
{ type: 'MoneyDeposited', amount: 100 }, // legacy v1
{ type: 'MoneyDeposited', eventVersion: 2, currency: 'USD', amountCents: 5000 },
];
const upcasted = stored.map(upcast);
console.log(upcasted[0]); // { ...amountCents: 10000, currency: 'USD', eventVersion: 2 }
console.log(upcasted.every((e) => typeof e.amountCents === 'number')); // trueChaining Upcasters Across Multiple Versions
Schemas evolve more than once. The clean pattern is a chain of single-step upcasters: v1→v2, v2→v3, v3→v4. An event enters at its stored version and is pushed forward step by step until it reaches the latest version. This keeps each migration small, testable, and independent.
Never write a giant v1→v4 function — you'd duplicate logic and it rots fast. Compose small steps instead.
const upcasters = {
1: (e) => ({ ...e, eventVersion: 2, amountCents: Math.round(e.amount * 100) }),
2: (e) => ({ ...e, eventVersion: 3, currency: e.currency || 'USD' }),
3: (e) => ({ ...e, eventVersion: 4, source: e.source || 'unknown' }),
};
const LATEST = 4;
function upcastToLatest(event) {
let e = { ...event, eventVersion: event.eventVersion || 1 };
while (e.eventVersion < LATEST) {
e = upcasters[e.eventVersion](e);
}
return e;
}
const legacy = { type: 'MoneyDeposited', amount: 42 };
const result = upcastToLatest(legacy);
console.log(result.eventVersion); // 4
console.log(result.amountCents); // 4200
console.log(result.currency); // USD
console.log(result.source); // unknownPutting It Together: Safe, Fast Loading
A production rehydration path combines everything:
- Load the latest snapshot; discard it if its
schemaVersionis stale - Read events after the (valid) snapshot version
- Upcast every event to the latest shape
- Fold them onto the start state
- Optionally write a new snapshot if the replay gap was large
This gives you O(events-since-snapshot) loads in the happy path, automatic correctness when state shape changes, and forward-compatible event reading. Snapshots make it fast; upcasting keeps it correct as the system evolves.
Operational tip: when you ship a breaking state refactor, you don't need to migrate snapshots — just bump CURRENT_SCHEMA and let the next load rebuild them lazily.
Quick Check
Test your understanding of the safe-evolution strategy.
Recap
You learned how to make Event Sourcing both fast and durable over time:
- Rehydration is a left fold over events; long streams make it slow.
- Snapshots store
(aggregateId, version, state)so you replay only events after them — a derived cache, never the source of truth. - Snapshot strategy: take one every N events or by replay-gap, ideally on a background worker.
- Tag snapshots with
schemaVersion; discard and full-replay when state shape changes — no snapshot migration needed. - Events are immutable. Evolve safely with additive-only changes, upcasting old events to the latest shape at read time, or introducing new event types.
- Chain single-step upcasters (v1→v2→v3) so every consumer sees only the current shape.
Snapshots give you speed; versioning and upcasting give you the freedom to evolve without ever breaking history.
자주 묻는 질문
“스냅샷, 버전 관리 및 이벤트 스키마 진화” 강의는 무료인가요?
네 — “스냅샷, 버전 관리 및 이벤트 스키마 진화” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Node.js Backend Development Bootcamp 강의 전체를 잠금 해제할 수 있습니다. Node.js Backend Development Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.
“스냅샷, 버전 관리 및 이벤트 스키마 진화”에서 뭘 배우나요?
스냅샷으로 복원 속도를 높이고 과거 데이터를 손상하지 않으면서 이벤트 스키마를 발전시킵니다. 브라우저에서 직접 실행하는 실습 코드로 Node.js Backend Development Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Node.js Backend Development Bootcamp을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Node.js Backend Development Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“스냅샷, 버전 관리 및 이벤트 스키마 진화” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Node.js Backend Development Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Node.js Backend Development Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 진실의 원천으로서의 이벤트 및 추가 전용 로그
- 집합체, 명령 및 도메인 이벤트 모델링
- 읽기 모델 및 프로젝션 만들기
- 스냅샷, 버전 관리 및 이벤트 스키마 진화