TypeScript로 CQRS + Event Sourcing 직접 구현하기 — EventStore, 이벤트 리플레이, Projection까지
CQRS와 Event Sourcing을 처음 접했을 때 솔직히 이런 생각을 했습니다. "이벤트를 전부 저장한다고? 그냥 현재 상태만 DB에 박으면 안 되나?" 그런데 핀테크 프로젝트에서 "3개월 전 이 계좌의 잔액이 왜 이랬는지 추적해주세요"라는 요구사항을 받고 나서 완전히 생각이 바뀌었습니다. 일반 CRUD 방식이라면 변경 이력이 남아있지 않아 복원 자체가 불가능한 상황이었거든요.
이 글은 CQRS와 Event Sourcing이 어떤 문제를 해결하는지, 그리고 TypeScript로 직접 EventStore를 설계하고 이벤트 리플레이로 상태를 복원하며 Projection으로 읽기 모델을 동기화하는 법을 계좌 관리 시스템 예시로 끝까지 풀어냅니다. 라이브러리 소개가 아니라, 직접 짜보면서 맞닥뜨리는 설계 고민들을 함께 따라가는 방식으로 구성했습니다.
대상은 복잡한 도메인 로직이나 감사 추적이 필요한 시스템을 TypeScript로 설계하는 백엔드·풀스택 개발자입니다. NestJS나 KurrentDB 같은 특정 라이브러리보다는, 핵심 패턴의 내부 동작 원리를 직접 이해하면 어떤 프레임워크를 써도 빠르게 적용할 수 있습니다.
핵심 개념
읽기와 쓰기는 근본적으로 다른 문제다
CQRS(Command Query Responsibility Segregation)의 출발점은 단순합니다. 읽기(Query)와 쓰기(Command)를 같은 모델로 처리하는 것은 두 가지 다른 문제를 하나의 해법으로 억지로 해결하려는 시도입니다.
쓰기 측은 도메인 규칙 검증, 트랜잭션 일관성, 동시성 제어가 핵심입니다. 읽기 측은 다양한 형태의 집계와 역정규화, 빠른 응답 속도가 중요합니다. 하나의 모델로 양쪽을 다 잘 처리하기는 사실상 불가능합니다. CQRS는 이 둘을 분리합니다.
Event Sourcing은 여기서 한 발 더 나아갑니다. 현재 상태 대신 상태 변화를 일으킨 모든 이벤트를 불변 레코드로 저장합니다. 계좌의 잔액을 balance: 50000이라는 컬럼 하나로 관리하는 대신, AccountCreated, MoneyDeposited, MoneyWithdrawn 이벤트의 시퀀스가 진실의 원천(Source of Truth)이 됩니다. 현재 잔액은 이 이벤트들을 순서대로 적용해서 계산하는 값입니다.
핵심 타입 정의
계좌 관리 시스템을 예시로 삼겠습니다. 먼저 이벤트의 기본 구조부터 잡습니다. 이벤트 이름은 반드시 과거형 동사입니다. CreateAccount가 아니라 AccountCreated. 이미 일어난 사실을 기록하는 것이므로, 과거형이 의미상 정확합니다.
interface DomainEvent<T = unknown> {
readonly eventId: string;
readonly eventType: string;
readonly aggregateId: string; // 이 시스템에서는 streamId와 동일 (예: 'account-uuid')
readonly aggregateVersion: number;
readonly occurredAt: Date;
readonly payload: T;
}
interface AccountCreatedPayload {
ownerId: string;
initialBalance: number;
}
interface MoneyDepositedPayload {
amount: number;
description: string;
}
interface MoneyWithdrawnPayload {
amount: number;
description: string;
}
// 계좌 도메인 이벤트 discriminated union
// accountReducer에서 캐스팅 없이 payload 타입이 자동으로 좁혀집니다
type AccountEvent =
| DomainEvent<AccountCreatedPayload> & { eventType: 'AccountCreated' }
| DomainEvent<MoneyDepositedPayload> & { eventType: 'MoneyDeposited' }
| DomainEvent<MoneyWithdrawnPayload> & { eventType: 'MoneyWithdrawn' };AccountEvent를 굳이 따로 정의하는 이유는 바로 다음 단계에서 확인됩니다. switch문에서 eventType을 판별할 때 event.payload의 타입이 캐스팅 없이 자동으로 좁혀지게 만들기 위해서입니다.
실전 적용
1단계 — EventStore 구현 (PostgreSQL)
EventStore는 이벤트를 스트림 단위로 관리합니다. 절대로 수정하거나 삭제하지 않고, 오직 추가(append)만 합니다. PostgreSQL로 직접 구현해봅니다. 소규모 시스템이나 KurrentDB를 도입하기 전 검증 단계에서 충분히 실용적인 선택입니다.
CREATE TABLE events (
global_position BIGSERIAL,
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
stream_id VARCHAR(255) NOT NULL,
event_type VARCHAR(255) NOT NULL,
aggregate_version INTEGER NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
payload JSONB NOT NULL,
UNIQUE (stream_id, aggregate_version)
);
CREATE INDEX idx_events_stream ON events (stream_id, aggregate_version);
CREATE INDEX idx_events_global_position ON events (global_position);UNIQUE (stream_id, aggregate_version) 제약이 동시성 제어의 핵심입니다. 두 트랜잭션이 동시에 같은 스트림에 같은 버전으로 쓰려 하면 하나는 반드시 실패합니다. global_position은 이벤트 구독과 Projection 재구성에서 안전한 커서로 씁니다.
인터페이스를 먼저 정의합니다.
interface EventStore {
append(streamId: string, events: DomainEvent[], expectedVersion: number): Promise<void>;
readStream(streamId: string, fromVersion?: number): Promise<DomainEvent[]>;
readAllBatch(
fromPosition: number,
limit: number
): Promise<{ events: DomainEvent[]; nextPosition: number }>;
subscribeToAll(handler: (event: DomainEvent) => Promise<void>): void;
}이제 구현체입니다. append의 낙관적 잠금 동작에 집중해서 보세요.
import { Pool } from 'pg';
import { randomUUID } from 'crypto';
class OptimisticConcurrencyError extends Error {
constructor(streamId: string, expectedVersion: number) {
super(`Stream ${streamId}의 예상 버전 ${expectedVersion}이 맞지 않습니다`);
this.name = 'OptimisticConcurrencyError';
}
}
class PostgresEventStore implements EventStore {
constructor(private pool: Pool) {}
async append(
streamId: string,
events: DomainEvent[],
expectedVersion: number
): Promise<void> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// 빠른 실패를 위한 버전 확인 — 실제 동시성 가드는 UNIQUE 제약이 담당합니다
// PostgreSQL은 집계 함수와 FOR UPDATE를 같은 쿼리에서 허용하지 않으므로
// SELECT MAX에는 행 잠금을 걸지 않습니다
const { rows } = await client.query(
`SELECT MAX(aggregate_version) AS current FROM events WHERE stream_id = $1`,
[streamId]
);
const current = rows[0].current ?? -1;
if (current !== expectedVersion) {
throw new OptimisticConcurrencyError(streamId, expectedVersion);
}
for (const event of events) {
await client.query(
`INSERT INTO events
(id, stream_id, event_type, aggregate_version, occurred_at, payload)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
event.eventId, // 핸들러가 생성한 ID를 그대로 사용
streamId,
event.eventType,
event.aggregateVersion,
event.occurredAt,
JSON.stringify(event.payload),
]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
// UNIQUE 위반 = 동시 쓰기 충돌 → OptimisticConcurrencyError로 변환
if ((err as { code?: string }).code === '23505') {
throw new OptimisticConcurrencyError(streamId, expectedVersion);
}
throw err;
} finally {
client.release();
}
}
async readStream(streamId: string, fromVersion = 0): Promise<DomainEvent[]> {
const { rows } = await this.pool.query(
`SELECT * FROM events
WHERE stream_id = $1 AND aggregate_version >= $2
ORDER BY aggregate_version ASC`,
[streamId, fromVersion]
);
return rows.map(this.rowToEvent);
}
async readAllBatch(
fromPosition: number,
limit: number
): Promise<{ events: DomainEvent[]; nextPosition: number }> {
const { rows } = await this.pool.query(
`SELECT * FROM events
WHERE global_position > $1
ORDER BY global_position ASC
LIMIT $2`,
[fromPosition, limit]
);
const events = rows.map(this.rowToEvent);
const nextPosition =
rows.length > 0 ? rows[rows.length - 1].global_position : fromPosition;
return { events, nextPosition };
}
subscribeToAll(handler: (event: DomainEvent) => Promise<void>): void {
let lastPosition = 0;
setInterval(async () => {
try {
const { events, nextPosition } = await this.readAllBatch(lastPosition, 100);
for (const event of events) {
await handler(event);
}
lastPosition = nextPosition;
} catch (err) {
console.error('이벤트 구독 처리 중 오류:', err);
}
}, 100);
}
private rowToEvent(row: Record<string, unknown>): DomainEvent {
return {
eventId: row.id as string,
eventType: row.event_type as string,
aggregateId: row.stream_id as string,
aggregateVersion: row.aggregate_version as number,
occurredAt: row.occurred_at as Date,
payload: row.payload,
};
}
}subscribeToAll의 폴링은 global_position 커서 덕분에 타임스탬프 기반 폴링보다 훨씬 안정적입니다. 다만 BIGSERIAL 값이 INSERT 시점에 할당되기 때문에, 느리게 커밋된 트랜잭션이 커서 뒤에 남겨지는 간헐적 누락 가능성은 완전히 제거되지 않습니다. 프로덕션에서는 PostgreSQL LISTEN/NOTIFY나 KurrentDB $all 구독처럼 이벤트 버스가 이 역할을 맡는 것이 안전합니다.
2단계 — 이벤트 리플레이로 애그리게이트 복원
Event Sourcing에서 애그리게이트의 현재 상태는 이벤트 스트림을 reduce로 접어서 계산합니다.
interface AccountState {
id: string;
ownerId: string;
balance: number;
version: number;
isClosed: boolean;
}
// AccountEvent 유니언을 매개변수로 받으면 switch 분기마다 payload가 자동으로 좁혀집니다
const accountReducer = (
state: AccountState | null,
event: AccountEvent
): AccountState => {
switch (event.eventType) {
case 'AccountCreated':
return {
id: event.aggregateId,
ownerId: event.payload.ownerId, // AccountCreatedPayload로 자동 추론
balance: event.payload.initialBalance,
version: event.aggregateVersion,
isClosed: false,
};
case 'MoneyDeposited':
if (!state) throw new Error('잘못된 이벤트 순서입니다');
return {
...state,
balance: state.balance + event.payload.amount, // MoneyDepositedPayload로 자동 추론
version: event.aggregateVersion,
};
case 'MoneyWithdrawn':
if (!state) throw new Error('잘못된 이벤트 순서입니다');
return {
...state,
balance: state.balance - event.payload.amount,
version: event.aggregateVersion,
};
}
};
async function rehydrateAccount(
eventStore: EventStore,
accountId: string
): Promise<{ state: AccountState; version: number }> {
const streamId = `account-${accountId}`;
const events = await eventStore.readStream(streamId);
if (events.length === 0) {
throw new Error(`계좌 ${accountId}를 찾을 수 없습니다`);
}
// EventStore가 반환하는 DomainEvent[]를 AccountEvent[]로 경계 캐스팅
const state = (events as AccountEvent[]).reduce(
(acc, event) => accountReducer(acc, event),
null as AccountState | null
) as AccountState;
return { state, version: state.version };
}accountReducer가 순수 함수라는 점이 핵심입니다. 같은 이벤트 시퀀스를 주면 항상 같은 상태가 나옵니다. 사이드 이펙트가 없으니 테스트하기도 쉽고, 어떤 시점의 상태든 재현할 수 있습니다.
이벤트가 수만 건 쌓이면 매번 처음부터 읽는 게 느리지 않을까? 맞습니다. 이럴 때 스냅샷을 씁니다. 특정 버전의 상태를 별도 테이블에 저장해두고, 그 이후 이벤트만 읽어오는 방식입니다.
interface Snapshot {
streamId: string;
state: AccountState;
version: number;
}
interface SnapshotStore {
save(snapshot: Snapshot): Promise<void>;
getLatest(streamId: string): Promise<Snapshot | null>;
}
async function rehydrateWithSnapshot(
eventStore: EventStore,
snapshotStore: SnapshotStore,
accountId: string
): Promise<{ state: AccountState; version: number }> {
const streamId = `account-${accountId}`;
const snapshot = await snapshotStore.getLatest(streamId);
const fromVersion = snapshot ? snapshot.version + 1 : 0;
const events = await eventStore.readStream(streamId, fromVersion);
const initialState = snapshot ? snapshot.state : null;
const state = (events as AccountEvent[]).reduce(
(acc, event) => accountReducer(acc, event),
initialState
) as AccountState;
return { state, version: state.version };
}스냅샷을 언제 찍을지 기준은 팀마다 다르지만, 이벤트 100건마다 한 번씩 찍는 전략이 일반적입니다.
3단계 — Command Handler와 도메인 로직
Command는 의도를 표현하고, Handler는 도메인 규칙을 검증한 뒤 이벤트를 생성해 EventStore에 저장합니다.
새 스트림을 처음 생성할 때는 expectedVersion으로 -1을 전달합니다. 이 값은 "이 스트림에 아직 이벤트가 없다"는 의미이며, append 내부에서 MAX(aggregate_version) NULL → -1과 비교해 일치하는 경우에만 삽입을 허용합니다. 팀 내에서 이 규약을 명시적으로 문서화해두는 것이 좋습니다.
interface CreateAccountCommand {
type: 'CreateAccount';
accountId: string;
ownerId: string;
initialBalance: number;
}
interface WithdrawMoneyCommand {
type: 'WithdrawMoney';
accountId: string;
amount: number;
description: string;
}
class CreateAccountHandler {
constructor(private eventStore: EventStore) {}
async handle(command: CreateAccountCommand): Promise<void> {
const { accountId, ownerId, initialBalance } = command;
const streamId = `account-${accountId}`;
const event: DomainEvent<AccountCreatedPayload> = {
eventId: randomUUID(),
eventType: 'AccountCreated',
aggregateId: streamId,
aggregateVersion: 0, // 첫 이벤트의 버전은 0
occurredAt: new Date(),
payload: { ownerId, initialBalance },
};
// 새 스트림: expectedVersion = -1 (이벤트가 없는 상태)
await this.eventStore.append(streamId, [event], -1);
}
}
class WithdrawMoneyHandler {
constructor(private eventStore: EventStore) {}
async handle(command: WithdrawMoneyCommand): Promise<void> {
const { accountId, amount, description } = command;
// 1. 이벤트 리플레이로 현재 상태 복원
const { state, version } = await rehydrateAccount(this.eventStore, accountId);
// 2. 도메인 규칙 검증
if (state.isClosed) throw new Error('닫힌 계좌에서는 출금할 수 없습니다');
if (state.balance < amount) throw new Error(`잔액 부족: 현재 잔액 ${state.balance}원`);
if (amount <= 0) throw new Error('출금액은 0보다 커야 합니다');
const streamId = `account-${accountId}`;
const event: DomainEvent<MoneyWithdrawnPayload> = {
eventId: randomUUID(),
eventType: 'MoneyWithdrawn',
aggregateId: streamId,
aggregateVersion: version + 1,
occurredAt: new Date(),
payload: { amount, description },
};
// 3. EventStore에 저장 — expectedVersion = 현재 버전
await this.eventStore.append(streamId, [event], version);
}
}두 Handler가 같은 계좌에 동시에 출금을 시도하면 어떻게 될까요? 먼저 저장에 성공한 쪽이 버전을 올리고, 나중에 도착한 쪽은 OptimisticConcurrencyError를 받습니다. DB 수준의 비관적 잠금 없이 동시성을 제어하는 방식입니다.
커맨드 처리 흐름을 시각으로 확인해봅니다.
커맨드 성공과 읽기 모델 갱신 사이에 짧은 불일치 창이 존재합니다. 최종적 일관성(Eventual Consistency)은 이 패턴의 본질적 특성입니다. 즉시 일관성이 비즈니스 요건이라면 설계를 재검토해볼 필요가 있습니다.
4단계 — Projection으로 읽기 모델 동기화
Projection은 EventStore의 이벤트를 구독해서 읽기에 최적화된 뷰를 만들고 유지합니다. 읽기 모델 스키마는 다음과 같습니다.
CREATE TABLE account_read_models (
account_id VARCHAR(255) PRIMARY KEY,
owner_id VARCHAR(255) NOT NULL,
balance BIGINT NOT NULL DEFAULT 0,
last_applied_version INTEGER NOT NULL DEFAULT -1,
last_updated_at TIMESTAMPTZ NOT NULL
);last_applied_version이 멱등성의 핵심입니다. 같은 이벤트가 두 번 전달되더라도 WHERE last_applied_version < $version 조건이 맞지 않아 UPDATE가 조용히 no-op로 끝납니다. balance = balance + $amount 형태의 상대적 업데이트는 이 가드 없이는 중복 반영이 됩니다.
class AccountProjection {
constructor(private db: Pool) {}
async handle(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case 'AccountCreated': return this.onAccountCreated(event);
case 'MoneyDeposited': return this.onMoneyDeposited(event);
case 'MoneyWithdrawn': return this.onMoneyWithdrawn(event);
}
}
private async onAccountCreated(event: DomainEvent): Promise<void> {
const payload = event.payload as AccountCreatedPayload;
// ON CONFLICT DO NOTHING: 같은 이벤트가 두 번 처리돼도 중복 삽입 없음
await this.db.query(
`INSERT INTO account_read_models
(account_id, owner_id, balance, last_applied_version, last_updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (account_id) DO NOTHING`,
[
event.aggregateId,
payload.ownerId,
payload.initialBalance,
event.aggregateVersion,
event.occurredAt,
]
);
}
private async onMoneyDeposited(event: DomainEvent): Promise<void> {
const payload = event.payload as MoneyDepositedPayload;
// last_applied_version < $2: 같은 이벤트 중복 처리 방지
await this.db.query(
`UPDATE account_read_models
SET balance = balance + $1,
last_applied_version = $2,
last_updated_at = $3
WHERE account_id = $4
AND last_applied_version < $2`,
[payload.amount, event.aggregateVersion, event.occurredAt, event.aggregateId]
);
}
private async onMoneyWithdrawn(event: DomainEvent): Promise<void> {
const payload = event.payload as MoneyWithdrawnPayload;
await this.db.query(
`UPDATE account_read_models
SET balance = balance - $1,
last_applied_version = $2,
last_updated_at = $3
WHERE account_id = $4
AND last_applied_version < $2`,
[payload.amount, event.aggregateVersion, event.occurredAt, event.aggregateId]
);
}
}마지막으로 EventStore와 Projection을 연결하는 부트스트랩 코드입니다. 이 연결이 없으면 두 컴포넌트는 독립적으로 존재할 뿐입니다.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const eventStore = new PostgresEventStore(pool);
const projection = new AccountProjection(pool);
// EventStore 구독 → Projection 연결
eventStore.subscribeToAll(async (event) => {
await projection.handle(event);
});5단계 — Projection 재구성
Projection 로직 버그를 수정했거나 새로운 읽기 모델이 필요할 때, 과거 이벤트 전체를 다시 처리할 수 있습니다. 이것이 Event Sourcing의 강력한 장점 중 하나입니다. EventStore 인터페이스에 추가한 readAllBatch를 그대로 사용합니다.
class ProjectionRebuilder {
constructor(private eventStore: EventStore, private db: Pool) {}
async rebuild(projection: AccountProjection): Promise<void> {
console.log('읽기 모델 재구성 시작...');
await this.db.query('TRUNCATE TABLE account_read_models');
let position = 0;
let processed = 0;
const BATCH_SIZE = 1000;
while (true) {
const { events, nextPosition } = await this.eventStore.readAllBatch(
position,
BATCH_SIZE
);
if (events.length === 0) break;
for (const event of events) {
await projection.handle(event);
processed++;
}
position = nextPosition;
console.log(`${processed}개 이벤트 처리 완료`);
}
console.log(`재구성 완료: 총 ${processed}개 이벤트 처리`);
}
}TRUNCATE 후 재구성하는 방식은 단순하지만 재구성 완료 전까지 읽기 모델이 비어 있다는 단점이 있습니다. 무중단이 필요하다면 새 테이블 이름으로 재구성한 뒤 뷰를 전환하는 blue-green 방식을 고려할 수 있습니다.
장단점 분석
언제 쓰면 좋고 언제 피해야 하는가
| 구분 | 내용 |
|---|---|
| 적합한 도메인 | 금융 거래, 의료 기록, 법적 문서 관리, 이커머스 주문 처리 |
| 적합한 이유 | 감사 추적 필수, 시간 여행 디버깅, 읽기·쓰기 모델 차이가 큰 경우 |
| 부적합한 도메인 | 단순 설정 관리, CRUD가 전부인 마스터 데이터 |
| 부적합한 이유 | 복잡도 대비 이점이 없음, 팀 학습 비용이 비즈니스 가치를 초과 |
장점과 단점
| 장점 | 단점 |
|---|---|
| 완전한 감사 추적이 이벤트 로그 자체에 내장 | 팀 전체의 학습 비용이 큼 |
| 특정 시점으로 상태 재현 가능 (시간 여행 디버깅) | 최종적 일관성으로 인한 불일치 창 존재 |
| 읽기·쓰기 인프라를 독립적으로 스케일링 | 이벤트 스키마 수정 불가, 업캐스팅 전략 필요 |
| 새로운 Projection을 과거 데이터로 즉시 생성 가능 | 이벤트 수 증가 시 재구성 비용 증가 |
| 마이크로서비스 간 이벤트 기반 통합이 자연스러움 | 멱등성 처리를 모든 Projection에 직접 구현해야 함 |
실무에서 자주 하는 실수들
스트림 ID를 단순하게 설계하는 경우. account-${id} 형태를 권장합니다. 단순히 ${id}만 쓰면 다른 애그리게이트 타입과 충돌할 수 있습니다.
이벤트 페이로드를 나중에 수정하려는 시도. 이벤트는 불변입니다. 스키마를 바꿔야 한다면 MoneyWithdrawnV2 같이 새 버전 이벤트를 추가하고, 읽기 측에서 두 버전을 모두 처리해야 합니다.
Projection 핸들러에서 상대적 업데이트에 멱등성 가드를 빠뜨리는 경우. balance = balance + $amount 형태의 UPDATE는 같은 이벤트가 두 번 처리되면 잔액이 두 번 반영됩니다. AND last_applied_version < $version 조건을 항상 붙이세요.
새 스트림 생성 시 expectedVersion을 0으로 전달하는 경우. 이벤트가 없는 스트림의 현재 버전은 -1입니다. 계좌 생성 시 expectedVersion: -1을 전달해야 한다는 규약을 팀 내에서 명시적으로 문서화해두는 것이 좋습니다.
모든 도메인에 무조건 적용하려는 시도. 단순한 설정 데이터나 마스터 코드 관리에 Event Sourcing을 붙이는 건 과도한 설계입니다. 이 패턴은 읽기와 쓰기 모델이 실질적으로 다르고, 이벤트 이력 자체가 비즈니스 가치를 갖는 도메인에서 정당화됩니다.
마치며
지금까지 다룬 내용을 정리하면 이렇습니다.
- EventStore는 append-only + 낙관적 잠금이 핵심입니다.
FOR UPDATE없이UNIQUE (stream_id, aggregate_version)제약과 PostgreSQL 오류 코드23505를 잡는 것만으로 구현할 수 있습니다. - 이벤트 리플레이는
reduce로 상태를 계산하는 순수 함수 패턴입니다. 같은 이벤트 시퀀스는 언제나 같은 상태를 만들어냅니다. - Projection 멱등성은 버전 가드로 보장합니다.
AND last_applied_version < $version조건 하나가 상대적 업데이트의 중복 반영 문제를 막아줍니다. - 최종적 일관성과 이벤트 스키마 불변성은 트레이드오프입니다. 비즈니스 요건이 이를 수용할 수 있는지 먼저 확인하세요.
지금 시작해보고 싶다면 이 순서로 접근해보세요.
-
PostgreSQL
events테이블로 간단한 EventStore를 직접 만들어봅니다. 작은 도메인 하나를 골라 이벤트 타입 3~5개를 정의하고append/readStream을 구현하면서 낙관적 잠금이 어떻게 동작하는지 확인해볼 수 있습니다. -
reducer 함수로 이벤트 리플레이를 구현해봅니다. 상태를 DB 컬럼이 아니라 이벤트 목록의
reduce결과로 계산하는 흐름을 직접 작성해보면 Event Sourcing의 핵심 개념이 분명해집니다. -
Projection 하나를 붙이고 리빌드를 직접 돌려봅니다.
TRUNCATE후 전체 이벤트를 다시 처리해서 읽기 모델이 재구성되는 과정을 확인하고 나면, 이 패턴이 왜 새로운 View를 과거 데이터로 만들 수 있는지 자연스럽게 이해됩니다.
라이브러리를 먼저 잡지 않고 직접 구현해보길 권하는 이유는, @nestjs/cqrs나 KurrentDB를 도입했을 때 추상화 뒤에서 어떤 일이 벌어지는지 알고 쓰는 것과 모르고 쓰는 것의 차이가 꽤 크기 때문입니다. 버그를 디버깅하거나 성능 문제를 해결할 때 그 차이가 드러납니다.
참고 자료
- CQRS and Event Sourcing in TypeScript: A Production Walkthrough — Atomic Object
- Complete Event Sourcing Guide: Node.js, TypeScript, and EventStore Implementation Tutorial
- Building a NestJS Web Application with EventStoreDB — Kurrent 공식 블로그
- Building a CQRS and Event Sourcing Application for Hotel Management with NestJS and EventStoreDB — Medium
- Navigating CQRS and Event Sourcing: My Journey with NestJS and EventStoreDB — Digital Frontiers Blog
- castore 공식 문서 — Making Event Sourcing easy
- Event Sourcing Pattern — Microsoft Azure Architecture Center
- Beyond Aggregates: Lean, Functional Event Sourcing
- Event Sourcing in Backend Systems: Auditability, Replays, and Operational Trade-offs
- How to Build Event Sourcing Systems in Node.js — OneUptime Blog