TypeScript와 KurrentDB로 주문·결제 상태를 이벤트로 저장하고 임의 시점에서 재구성하기
"어제 오후 2시 32분에 이 주문의 결제 상태가 정확히 어땠나요?" 고객사 감사팀에서 이런 질문을 받으면 어떻게 답하시겠습니까? updated_at 컬럼으로 충분하다고 생각하다가 막상 DB를 열어보면 현재 상태만 남고 그 전의 흔적은 사라져 있는 경우가 많습니다. 이 상황에서 정확하게 대답하려면 상태가 아닌 변화 자체를 저장해야 합니다.
**CQRS(Command Query Responsibility Segregation)**와 Event Sourcing을 함께 쓰면 이 문제를 구조적으로 해결할 수 있습니다. 상태를 덮어쓰는 대신 OrderCreated → PaymentProcessed → OrderShipped 같은 이벤트를 순서대로 불변 레코드로 쌓아두고, 언제든 특정 시점으로 되감아 볼 수 있습니다. 2025년에 EventStoreDB가 KurrentDB로 리브랜딩되면서 gRPC API 기반의 클라이언트 생태계가 새로 정착했고, Node.js 쪽 프로덕션 패턴도 많이 성숙해졌습니다.
이 글에서는 NestJS + TypeScript + KurrentDB 스택으로 주문·결제 시스템을 구현하면서, 이벤트 스트림 설계와 저장, Projection으로 읽기 모델 만들기, 임의 시점으로 상태를 재구성하는 Temporal Query 패턴까지 순서대로 살펴봅니다. 복잡도와 흔한 실수도 솔직하게 담았습니다.
핵심 개념
CQRS: 읽기와 쓰기는 다른 문제다
CQRS의 핵심은 데이터를 변경하는 Command(쓰기) 경로와 조회하는 Query(읽기) 경로를 완전히 분리하는 것입니다. Command 측은 도메인 로직과 유효성 검증에 집중하고, Query 측은 조회에 최적화된 Read Model(Projection)을 제공합니다. 두 경로는 독립적으로 스케일링할 수 있고, 서로 다른 데이터 저장소를 사용할 수 있습니다.
읽기와 쓰기 사이에는 **결과적 일관성(Eventual Consistency)**이 존재합니다. 쓰기 완료 직후 읽기 모델이 즉시 갱신되지 않을 수 있다는 뜻입니다. 이건 단점이기도 하지만, 읽기 모델을 언제든 이벤트 로그에서 재생성할 수 있다는 강점의 이면이기도 합니다.
Event Sourcing: 상태가 아닌 변화를 저장하기
일반 CRUD 시스템은 엔티티의 현재 상태를 저장합니다. orders 테이블의 status 컬럼을 PAID로 업데이트하면 이전 흔적은 사라집니다. Event Sourcing은 반대입니다. 현재 상태 대신 상태를 변화시킨 모든 이벤트를 불변 레코드로 순서대로 저장합니다. 현재 상태는 필요할 때 이벤트를 처음부터 재생(replay)해서 계산합니다.
-- 일반 CRUD: 현재 상태만 남는다
UPDATE orders SET status = 'PAID', updated_at = NOW() WHERE id = '123';[
{ "type": "OrderCreated", "orderId": "123", "createdAt": "2026-07-15T09:00:00Z" },
{ "type": "PaymentProcessed", "orderId": "123", "amount": 59000, "processedAt": "2026-07-15T09:01:23Z" },
{ "type": "OrderShipped", "orderId": "123", "trackingNumber": "KR123456", "shippedAt": "2026-07-15T14:30:00Z" }
]핵심 구성 요소
| 구성 요소 | 역할 | 예시 |
|---|---|---|
| Aggregate | 커맨드를 처리하고 이벤트를 발행하는 일관성 경계 | Order, Payment |
| Event Stream | 특정 애그리거트의 이벤트 시퀀스 | order-{orderId} |
| Projection | 이벤트를 구독해 읽기 모델을 생성·갱신 | 주문 목록 뷰, 결제 현황 대시보드 |
| Snapshot | 특정 시점의 상태를 저장해 재생 비용을 줄이는 최적화 | N번째 이벤트마다 저장 |
임의 시점 재구성: Temporal Query
Event Sourcing의 실용적 가치 중 핵심입니다. KurrentDB는 이벤트를 저장할 때 서버에서 event.created 타임스탬프를 기록합니다. 이 타임스탬프를 기준으로 특정 시점 이전의 이벤트만 재생하면 그 시점의 정확한 상태를 복원할 수 있습니다.
한 가지 중요한 설계 원칙이 있습니다. 비즈니스 페이로드에 담긴 시간 필드(createdAt, processedAt 등)는 클라이언트가 임의로 조작할 수 있어 감사 목적으로는 신뢰할 수 없습니다. 서버 기록 시각인 event.created를 Temporal Query의 기준 시계로 사용하는 것이 올바른 설계입니다.
"그 시각에 도대체 무슨 일이 있었나?"를 코드 수준에서 재현할 수 있게 됩니다.
실전 적용
환경 설정: KurrentDB 로컬에 띄우기
KurrentDB는 Docker로 빠르게 시작할 수 있습니다. 관리 UI는 http://localhost:2113에서 확인할 수 있습니다.
docker run -d --name kurrentdb \
-p 2113:2113 \
-p 2114:2114 \
--env KURRENTDB_INSECURE=true \
kurrent/kurrentdb:latestNote: KurrentDB 리브랜딩 이후 레거시 TCP 클라이언트 프로토콜이 제거되었습니다. Node.js에서는 gRPC 기반의 공식 클라이언트 패키지를 사용합니다. 정확한 이미지 태그와 환경변수 이름은 버전마다 다를 수 있으니 KurrentDB 공식 문서를 기준으로 확인하세요.
npm install @kurrent/kurrentdb-client @nestjs/cqrs zod drizzle-orm이벤트 타입과 메타데이터 정의
도메인 이벤트 타입과 함께 EventMetadata를 처음부터 정의해두는 것이 좋습니다. 나중에 추가하려면 모든 이벤트 핸들러를 수정해야 하기 때문입니다.
// src/order/events/order.events.ts
import { z } from 'zod';
export interface EventMetadata {
correlationId: string; // 같은 비즈니스 흐름을 묶는 ID
causationId: string; // 이 이벤트를 유발한 이벤트의 ID
userId?: string;
}
export const OrderCreatedSchema = z.object({
orderId: z.string().uuid(),
customerId: z.string().uuid(),
items: z.array(z.object({
productId: z.string(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})),
totalAmount: z.number().positive(),
createdAt: z.string().datetime(),
});
export const PaymentProcessedSchema = z.object({
orderId: z.string().uuid(),
paymentId: z.string().uuid(),
amount: z.number().positive(),
method: z.enum(['CARD', 'BANK_TRANSFER', 'VIRTUAL_ACCOUNT']),
processedAt: z.string().datetime(),
});
export const OrderShippedSchema = z.object({
orderId: z.string().uuid(),
trackingNumber: z.string(),
carrier: z.string(),
shippedAt: z.string().datetime(),
});
export type OrderCreated = z.infer<typeof OrderCreatedSchema>;
export type PaymentProcessed = z.infer<typeof PaymentProcessedSchema>;
export type OrderShipped = z.infer<typeof OrderShippedSchema>;
export type OrderEvent =
| { type: 'OrderCreated'; data: OrderCreated; metadata: EventMetadata }
| { type: 'PaymentProcessed'; data: PaymentProcessed; metadata: EventMetadata }
| { type: 'OrderShipped'; data: OrderShipped; metadata: EventMetadata };Aggregate 구현
Aggregate는 커맨드를 받아 유효성 검증 후 이벤트를 발행하고, 이벤트를 재생해 자신의 상태를 재구성하는 책임을 집니다. apply 메서드를 순수하게 유지하는 게 핵심입니다. 스냅샷 지원을 위한 fromSnapshot과 replayEvents도 함께 정의합니다.
// src/order/aggregates/order.aggregate.ts
import { OrderEvent, EventMetadata } from '../events/order.events';
export type OrderStatus = 'CREATED' | 'PAYMENT_PROCESSED' | 'SHIPPED' | 'CANCELLED';
export interface OrderItem {
productId: string;
quantity: number;
price: number;
}
export interface OrderState {
orderId: string;
customerId: string;
status: OrderStatus;
totalAmount: number;
items: OrderItem[];
paymentId?: string;
trackingNumber?: string;
version: number;
}
export class OrderAggregate {
private state: Partial<OrderState> = { version: 0 };
private uncommittedEvents: OrderEvent[] = [];
static reconstitute(events: OrderEvent[]): OrderAggregate {
const aggregate = new OrderAggregate();
for (const event of events) aggregate.apply(event);
return aggregate;
}
static fromSnapshot(state: OrderState): OrderAggregate {
const aggregate = new OrderAggregate();
aggregate.state = { ...state };
return aggregate;
}
replayEvents(events: OrderEvent[]): void {
for (const event of events) this.apply(event);
}
createOrder(
orderId: string,
customerId: string,
items: OrderItem[],
totalAmount: number,
metadata: EventMetadata,
): void {
if (this.state.orderId) throw new Error('Order already exists');
this.raise({
type: 'OrderCreated',
data: { orderId, customerId, items, totalAmount, createdAt: new Date().toISOString() },
metadata,
});
}
processPayment(
paymentId: string,
amount: number,
method: 'CARD' | 'BANK_TRANSFER' | 'VIRTUAL_ACCOUNT',
metadata: EventMetadata,
): void {
if (this.state.status !== 'CREATED') {
throw new Error(`Cannot process payment: current status is ${this.state.status}`);
}
this.raise({
type: 'PaymentProcessed',
data: {
orderId: this.state.orderId!,
paymentId,
amount,
method,
processedAt: new Date().toISOString(),
},
metadata,
});
}
private raise(event: OrderEvent): void {
this.apply(event);
this.uncommittedEvents.push(event);
}
private apply(event: OrderEvent): void {
const nextVersion = (this.state.version ?? 0) + 1;
switch (event.type) {
case 'OrderCreated':
this.state = {
...this.state,
orderId: event.data.orderId,
customerId: event.data.customerId,
totalAmount: event.data.totalAmount,
items: event.data.items,
status: 'CREATED',
version: nextVersion,
};
break;
case 'PaymentProcessed':
this.state = {
...this.state,
status: 'PAYMENT_PROCESSED',
paymentId: event.data.paymentId,
version: nextVersion,
};
break;
case 'OrderShipped':
this.state = {
...this.state,
status: 'SHIPPED',
trackingNumber: event.data.trackingNumber,
version: nextVersion,
};
break;
}
}
getState(): Readonly<Partial<OrderState>> { return this.state; }
getUncommittedEvents(): OrderEvent[] { return [...this.uncommittedEvents]; }
clearUncommittedEvents(): void { this.uncommittedEvents = []; }
}EventRepository: KurrentDB에 이벤트 저장하기
두 가지 중요한 포인트가 있습니다. 첫째, 스트림이 아직 존재하지 않을 때의 첫 번째 이벤트 저장은 NO_STREAM 상수로 표현해야 합니다. bigint 타입만으로는 신규 스트림 생성을 표현할 수 없어 런타임 오류가 발생합니다. 둘째, 이벤트를 끝까지 모두 읽어야 하므로 maxCount 상한을 두지 않고 async iterator를 드레인합니다. Temporal Query에 쓸 서버 기록 시각(event.created)도 함께 반환합니다.
// src/order/repositories/event.repository.ts
import { Injectable } from '@nestjs/common';
import {
KurrentDBClient,
jsonEvent,
FORWARDS,
START,
NO_STREAM,
StreamNotFoundError,
} from '@kurrent/kurrentdb-client';
import { OrderEvent } from '../events/order.events';
export interface StoredEvent {
event: OrderEvent;
recordedAt: Date; // KurrentDB 서버가 기록한 시각 (event.created)
revision: bigint;
}
@Injectable()
export class OrderEventRepository {
constructor(private readonly client: KurrentDBClient) {}
async append(
orderId: string,
events: OrderEvent[],
expectedRevision: typeof NO_STREAM | bigint,
): Promise<void> {
const streamName = `order-${orderId}`;
const toWrite = events.map(e =>
jsonEvent({ type: e.type, data: e.data, metadata: e.metadata })
);
await this.client.appendToStream(streamName, toWrite, { expectedRevision });
}
async loadEvents(orderId: string): Promise<StoredEvent[]> {
return this.readFrom(orderId, START);
}
async loadEventsAfterVersion(orderId: string, afterVersion: number): Promise<StoredEvent[]> {
return this.readFrom(orderId, BigInt(afterVersion + 1));
}
private async readFrom(
orderId: string,
fromRevision: typeof START | bigint,
): Promise<StoredEvent[]> {
const streamName = `order-${orderId}`;
const results: StoredEvent[] = [];
try {
const stream = this.client.readStream(streamName, {
direction: FORWARDS,
fromRevision,
});
for await (const { event } of stream) {
if (!event) continue;
results.push({
event: {
type: event.type as OrderEvent['type'],
data: event.data as OrderEvent['data'],
metadata: event.metadata as OrderEvent['metadata'],
},
recordedAt: event.created,
revision: event.revision,
});
}
} catch (err) {
if (err instanceof StreamNotFoundError) return [];
throw err;
}
return results;
}
}Temporal Query 구현: 임의 시점 상태 재구성
recordedAt(서버 기록 시각)을 기준으로 필터링하므로, 비즈니스 페이로드 필드 누락이나 Invalid Date 문제 없이 안정적으로 동작합니다.
// src/order/queries/get-order-at-time.query.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { OrderEventRepository } from '../repositories/event.repository';
import { OrderAggregate } from '../aggregates/order.aggregate';
export class GetOrderAtTimeQuery {
constructor(
public readonly orderId: string,
public readonly timestamp: Date,
) {}
}
@QueryHandler(GetOrderAtTimeQuery)
export class GetOrderAtTimeQueryHandler
implements IQueryHandler<GetOrderAtTimeQuery>
{
constructor(private readonly repo: OrderEventRepository) {}
async execute(query: GetOrderAtTimeQuery) {
const stored = await this.repo.loadEvents(query.orderId);
const events = stored
.filter(({ recordedAt }) => recordedAt <= query.timestamp)
.map(({ event }) => event);
return OrderAggregate.reconstitute(events).getState();
}
}// 사용 예시: 어제 오후 2시 32분의 주문 상태 조회
const state = await queryBus.execute(
new GetOrderAtTimeQuery(
'order-uuid-here',
new Date('2026-07-14T14:32:00Z'),
),
);
// → { status: 'PAYMENT_PROCESSED', paymentId: 'pay-xxx', totalAmount: 59000, ... }Projection으로 읽기 모델 갱신하기
subscribeToAll에 스트림 이름 접두어 필터를 걸어 order- 스트림 이벤트만 수신합니다. 필터링 없이 전체 구독을 사용하면 KurrentDB 내부 시스템 이벤트($-접두어)까지 함께 들어와 처리 비용이 늘어납니다.
체크포인트 저장이 핵심입니다. 저장하지 않으면 재시작마다 처음부터 재처리해 insert 시 중복 키 충돌이 발생합니다. DrizzleService는 drizzle-orm 커넥션을 감싼 얇은 NestJS 주입 가능 래퍼입니다.
// src/order/projections/order-summary.projection.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { KurrentDBClient, START } from '@kurrent/kurrentdb-client';
import { eq } from 'drizzle-orm';
import { DrizzleService } from '../db/drizzle.service';
import { orderSummaries, projectionCheckpoints } from '../db/schema';
import { OrderCreated, PaymentProcessed, OrderShipped } from '../events/order.events';
const PROJECTION_ID = 'order-summary';
@Injectable()
export class OrderSummaryProjection implements OnModuleInit {
constructor(
private readonly client: KurrentDBClient,
private readonly db: DrizzleService,
) {}
onModuleInit() {
this.startProjection().catch(console.error);
}
private async startProjection() {
const savedPosition = await this.loadCheckpoint();
// 스트림 이름 접두어 필터로 system 이벤트 제외 — 정확한 filter API는 현행 클라이언트 문서 참조
const subscription = this.client.subscribeToAll({
fromPosition: savedPosition ?? START,
filter: { streamName: { prefix: ['order-'] } },
});
for await (const resolved of subscription) {
const { event, commitPosition } = resolved;
if (!event) continue;
switch (event.type) {
case 'OrderCreated': {
const data = event.data as OrderCreated;
await this.db
.insert(orderSummaries)
.values({
orderId: data.orderId,
customerId: data.customerId,
status: 'CREATED',
totalAmount: data.totalAmount,
createdAt: data.createdAt,
})
.onConflictDoNothing(); // 이미 처리된 이벤트는 건너뜀
break;
}
case 'PaymentProcessed': {
const data = event.data as PaymentProcessed;
await this.db
.update(orderSummaries)
.set({ status: 'PAYMENT_PROCESSED', paymentId: data.paymentId })
.where(eq(orderSummaries.orderId, data.orderId));
break;
}
case 'OrderShipped': {
const data = event.data as OrderShipped;
await this.db
.update(orderSummaries)
.set({ status: 'SHIPPED', trackingNumber: data.trackingNumber })
.where(eq(orderSummaries.orderId, data.orderId));
break;
}
}
if (commitPosition !== undefined) {
await this.saveCheckpoint(commitPosition);
}
}
}
private async loadCheckpoint(): Promise<{ commit: bigint; prepare: bigint } | undefined> {
const row = await this.db.query.projectionCheckpoints.findFirst({
where: eq(projectionCheckpoints.projectionId, PROJECTION_ID),
});
if (!row) return undefined;
return { commit: BigInt(row.commitPosition), prepare: BigInt(row.preparePosition) };
}
private async saveCheckpoint(position: { commit: bigint; prepare: bigint }): Promise<void> {
await this.db
.insert(projectionCheckpoints)
.values({
projectionId: PROJECTION_ID,
commitPosition: position.commit.toString(),
preparePosition: position.prepare.toString(),
})
.onConflictDoUpdate({
target: projectionCheckpoints.projectionId,
set: {
commitPosition: position.commit.toString(),
preparePosition: position.prepare.toString(),
},
});
}
}스냅샷으로 재생 비용 줄이기
이벤트가 수천 건 이상 쌓이면 매번 처음부터 재생하는 비용이 무시하기 어렵습니다. 주기적으로 스냅샷을 저장하고, 재구성 시에는 스냅샷 이후 이벤트만 재생합니다.
// src/order/repositories/snapshot.repository.ts
import { Injectable } from '@nestjs/common';
import { OrderState } from '../aggregates/order.aggregate';
export interface Snapshot {
streamName: string;
version: number;
state: string; // JSON.stringify(OrderState)
savedAt: string;
}
@Injectable()
export class SnapshotRepository {
// 프로덕션에서는 PostgreSQL이나 Redis 등 영속 저장소로 교체
private readonly store = new Map<string, Snapshot>();
async save(snapshot: Snapshot): Promise<void> {
this.store.set(snapshot.streamName, snapshot);
}
async findLatest(streamName: string): Promise<Snapshot | null> {
return this.store.get(streamName) ?? null;
}
}// src/order/services/aggregate-loader.service.ts
import { Injectable } from '@nestjs/common';
import { OrderAggregate, OrderState } from '../aggregates/order.aggregate';
import { OrderEventRepository } from '../repositories/event.repository';
import { SnapshotRepository } from '../repositories/snapshot.repository';
const SNAPSHOT_THRESHOLD = 100;
@Injectable()
export class AggregateLoaderService {
constructor(
private readonly eventRepo: OrderEventRepository,
private readonly snapshotRepo: SnapshotRepository,
) {}
async load(orderId: string): Promise<OrderAggregate> {
const streamName = `order-${orderId}`;
const snapshot = await this.snapshotRepo.findLatest(streamName);
if (snapshot) {
const state = JSON.parse(snapshot.state) as OrderState;
const aggregate = OrderAggregate.fromSnapshot(state);
const remaining = await this.eventRepo.loadEventsAfterVersion(orderId, snapshot.version);
aggregate.replayEvents(remaining.map(s => s.event));
return aggregate;
}
const stored = await this.eventRepo.loadEvents(orderId);
const aggregate = OrderAggregate.reconstitute(stored.map(s => s.event));
const currentVersion = aggregate.getState().version ?? 0;
if (currentVersion > 0 && currentVersion % SNAPSHOT_THRESHOLD === 0) {
await this.snapshotRepo.save({
streamName,
version: currentVersion,
state: JSON.stringify(aggregate.getState()),
savedAt: new Date().toISOString(),
});
}
return aggregate;
}
}장단점 분석
장점
| 항목 | 설명 |
|---|---|
| 완전한 감사 추적 | 모든 상태 변화가 이벤트로 기록되어 규제 준수·디버깅에 바로 활용됩니다 |
| 시간 여행 쿼리 | 임의 시점의 상태를 재구성하는 Temporal Query를 코드 레벨에서 구현할 수 있습니다 |
| 독립적 스케일링 | 읽기/쓰기 워크로드를 별도로 최적화하고 스케일링할 수 있습니다 |
| 내결함성 | Read DB가 손상되어도 이벤트 로그에서 언제든 재생성이 가능합니다 |
| 도메인 표현력 | 비즈니스 이벤트가 코드에 명시적으로 드러납니다 |
| 이벤트 드리븐 통합 | 이벤트를 Kafka로 발행해 다른 마이크로서비스와 느슨하게 결합할 수 있습니다 |
단점 및 주의사항
| 항목 | 설명 |
|---|---|
| 복잡도 증가 | 단순한 CRUD 앱 대비 아키텍처 복잡도가 현저히 높습니다 |
| 결과적 일관성 | 쓰기 직후 읽기 모델이 즉시 갱신되지 않을 수 있습니다 |
| 스토리지 증가 | 이벤트는 삭제하지 않으므로 스토리지가 지속 증가합니다. 아카이빙 전략이 필요합니다 |
| 이벤트 스키마 진화 | 이벤트 타입 변경 시 하위 호환성 유지(Upcasting)가 필요합니다 |
| 학습 곡선 | DDD·Aggregate 설계 원칙에 대한 팀 전체의 이해가 선행되어야 합니다 |
Upcasting이란 저장된 구버전 이벤트를 현재 스키마로 변환하는 과정입니다. 예를 들어 OrderCreated v1에 없던 channel 필드를 v2에서 추가했다면, 오래된 이벤트를 재생할 때 channel: 'UNKNOWN'으로 채워주는 변환 레이어가 필요합니다. 이벤트는 한 번 저장되면 수정할 수 없기 때문에 이 변환 로직을 버전별로 누적 관리해야 합니다. 일반 코드 리팩터링과는 차원이 다른 부담입니다.
프로덕션에서 흔한 실수 3가지
1. 이벤트 스키마를 처음부터 과하게 세분화하기
이벤트 타입이 한 번 수백만 건 쌓이면 스키마를 바꿀 때마다 Upcasting 레이어를 새로 작성해야 합니다. 처음엔 도메인을 큼직하게 나누고, 실제로 분리가 필요한 순간에 쪼개는 전략이 훨씬 현실적입니다.
2. Projection 재생 시간 과소평가하기
Read DB를 새로 만들거나 Projection 로직을 수정하면 이벤트 전체를 재생해야 합니다. 이벤트가 수백만 건이면 수 시간이 걸릴 수 있습니다. 스냅샷 전략과 재생 속도 테스트는 초기에 함께 설계해두는 게 훨씬 낫습니다.
3. Correlation ID / Causation ID를 나중에 추가하려 하기
분산 시스템에서 이벤트 체인을 추적하려면 각 이벤트 메타데이터에 correlationId(같은 비즈니스 흐름 ID)와 causationId(이 이벤트를 유발한 이벤트 ID)가 필요합니다. 이 글에서 EventMetadata 인터페이스를 이벤트 타입 정의 시점부터 포함한 이유입니다. 나중에 추가하면 모든 커맨드 핸들러와 이벤트 생성 코드를 수정해야 합니다.
이 패턴이 맞는 경우 vs 아닌 경우
| 적합한 경우 | 부적합한 경우 |
|---|---|
| 감사 이력이 법적·비즈니스적으로 필수인 시스템 | 단순 CRUD 앱 |
| 시간 여행 쿼리·상태 이력이 필요한 경우 | 강한 즉각적 일관성이 절대적으로 필요한 도메인 |
| 읽기/쓰기 비율이 크게 불균형한 시스템 | 팀 규모가 작고 DDD 숙련도가 낮은 초기 단계 |
| 마이크로서비스 간 이벤트 기반 통합이 필요한 경우 | 이벤트 스토어 운영 비용을 감당하기 어려운 경우 |
마치며
CQRS + Event Sourcing은 상태를 "현재 값"이 아닌 "변화의 기록"으로 바라보는 패러다임 전환입니다. 이 구조를 갖추고 나면 "왜 이 상태가 됐는지"를 코드로 재현할 수 있게 됩니다.
핵심을 정리하면 세 가지입니다.
- 이벤트 스트림이 단일 진실의 원천(Single Source of Truth) 이 됩니다. Read DB가 날아가도 이벤트 로그만 있으면 복구할 수 있습니다.
- Temporal Query의 기준 시계는
event.created(서버 기록 시각) 여야 합니다. 비즈니스 페이로드의 시간 필드는 클라이언트가 조작할 수 있어 감사 목적에는 신뢰할 수 없습니다. - 복잡도는 분명히 높습니다. 도입 전 팀 전체가 DDD·Aggregate 개념을 이해하고 있는지 확인하는 것이 무엇보다 중요합니다.
지금 시작해볼 수 있는 3단계:
-
로컬에 KurrentDB를 Docker로 띄워보고 이벤트 하나를 저장·조회해보시면 전체 감이 잡힙니다.
http://localhost:2113UI에서 스트림을 눈으로 확인하는 게 처음엔 가장 빠른 이해 경로입니다. -
Oskar Dudycz의
EventSourcing.NodeJS레포지토리를 클론해서 쇼핑카트 예제를 단계별로 실행해보시면 좋습니다. 실제 프로덕션 수준의 패턴이 잘 정리되어 있습니다. -
기존 시스템의 도메인 하나를 골라 이벤트 타입만 정의해보는 것도 좋은 출발점입니다.
OrderCreated,PaymentProcessed처럼 과거형 동사로 이름을 붙이다 보면 도메인을 완전히 다른 시각으로 바라보게 됩니다.
참고 자료
- EventSourcing.NodeJS - Oskar Dudycz (GitHub)
- Straightforward Event Sourcing with TypeScript and NodeJS - Event-Driven.io
- Building A NestJS Web Application With EventStoreDB - Kurrent 공식 블로그
- KurrentDB 공식 문서
- @kurrent/kurrentdb-client - npm
- CQRS Pattern - Azure Architecture Center (Microsoft)
- Microservices.io - Event Sourcing Pattern
- CQRS and Event Sourcing in Practice - Java Code Geeks
- KurrentDB GitHub
- NestJS CQRS + Event Sourcing 공식 강의