Kafka와 PostgreSQL로 Event Sourcing + CQRS 실전 구현하기
금융 도메인 프로젝트에서 "3개월 전 이 계좌의 잔액이 정확히 얼마였나요?"라는 요청을 받았을 때, 당시 시스템은 현재 잔액만 DB에 저장했고 변경 이력은 별도 감사 테이블에 남겼는데 — 그 테이블이 마이그레이션 과정에서 일부 유실된 상태였습니다. 결국 로그 파일을 뒤지는 수작업으로 2주를 날렸습니다. Event Sourcing은 바로 그 상황을 구조적으로 해결하는 아키텍처입니다.
이 글에서는 TypeScript(NestJS)로 Event Sourcing + CQRS를 실전에 가깝게 구현하는 방법을 다룹니다. 이벤트 저장소는 PostgreSQL events 테이블로 구현하고, Kafka는 Projection 소비를 위한 스트리밍 레이어로 활용합니다. 예제 도메인은 은행 계좌입니다.
이 글을 읽고 나면 다음 세 가지 그림이 잡힐 겁니다:
- Aggregate가 이벤트를 생성하고 재조립하는 흐름
- PostgreSQL이 이벤트 로그로, Kafka가 스트리밍 백본으로 협력하는 구조
- Projection이 이벤트를 소비해 읽기 모델을 구축하고, 시간 여행 쿼리가 어떻게 동작하는지
핵심 개념
왜 현재 상태 대신 사건을 저장하는가
전통적인 CRUD는 DB에 현재 상태만 유지합니다. UPDATE accounts SET balance = 5000 WHERE id = 1 한 줄이면 이전 값은 사라집니다. Event Sourcing은 이렇게 말합니다: "잔액 5000원이 됐다"가 아니라 "2000원 입금 이벤트가 발생했다"를 저장하자.
기존 방식: [현재 상태] balance = 5000
ES 방식: [이벤트 로그]
AccountOpened { initialBalance: 3000 } // version=1
DepositMade { amount: 2500 } // version=2
WithdrawalMade { amount: 500 } // version=3현재 잔액이 필요하면 이벤트를 처음부터 재생(replay)합니다. "version=2 이전 시점의 잔액"이 필요하면 version ≤ 2인 이벤트만 재생합니다. 이게 시간 여행 쿼리입니다.
CQRS — 읽기와 쓰기를 분리하는 이유
이벤트 재생으로 현재 상태를 도출하는 작업을 매 조회마다 반복하면 단순 DB 조회보다 훨씬 비쌉니다. 읽기 요청이 쓰기보다 압도적으로 많은 현실에서는 더 문제가 됩니다. CQRS는 이 문제를 이렇게 해결합니다:
- Command 측: 도메인 로직 실행 → 이벤트를 PostgreSQL
events테이블에 저장 → Kafka로 발행. 정확성이 목표. - Query 측: Kafka를 소비하는 Projection이 미리 만들어둔 읽기 전용 뷰에서 조회. 속도가 목표.
flowchart TD
클라이언트 --> CMD[Command Handler]
CMD --> AGG[Aggregate]
AGG --> CMD
CMD --> PG[PostgreSQL events 테이블]
CMD --> KFK[Kafka - account-events]
PG -->|재수화 조회| CMD
KFK --> PJ1[Projection - 계좌 잔액뷰]
KFK --> PJ2[Projection - 감사 로그뷰]
KFK --> PJ3[Projection - 거래 내역뷰]
PJ1 --> RDB[PostgreSQL - 읽기 모델]
PJ2 --> RDB
PJ3 --> RDB
RDB --> QH[Query Handler]
QH --> 클라이언트Aggregate — 도메인 일관성의 경계
Aggregate는 도메인 로직의 집합입니다. 외부 Command를 받아 검증하고, 성공하면 이벤트를 생성합니다. Aggregate는 DB에 직접 쓰지 않습니다. Command Handler가 Aggregate에서 미발행 이벤트를 꺼내 EventStore에 저장합니다.
아키텍처 포지셔닝: Kafka와 PostgreSQL의 역할 분리
"Kafka를 EventStore로 쓰면 된다"는 말을 자주 듣지만, 여기에는 실용적인 한계가 있습니다. Kafka는 특정 aggregateId의 이벤트만 골라 읽는 랜덤 접근에 최적화되어 있지 않습니다. Consumer를 띄워 전체 토픽을 스캔하는 방식은 Aggregate 재수화(rehydration)에 쓰기엔 부담이 큽니다.
이 글에서는 역할을 분리합니다.
| 레이어 | 역할 |
|---|---|
PostgreSQL events 테이블 |
이벤트의 영속 저장소. aggregate_id + version 인덱스로 빠른 재수화와 시간 여행 쿼리 지원. 낙관적 잠금의 enforcement 지점. |
Kafka account-events 토픽 |
Projection 소비를 위한 append-only 스트리밍 백본. Projection들이 각자의 Consumer Group으로 이벤트를 소비해 읽기 모델 구축. |
Command Handler가 이벤트를 PostgreSQL에 INSERT한 뒤 Kafka에 발행하는 순서입니다. 이 듀얼 라이트(dual-write) 구조에서 PostgreSQL 커밋 후 Kafka 발행이 실패하면 Projection이 이벤트를 놓칩니다. 프로덕션에서는 Transactional Outbox 패턴 — PostgreSQL에 이벤트와 outbox 레코드를 같은 트랜잭션으로 쓰고, Debezium 같은 CDC 도구가 Kafka로 전달하는 방식 — 이 이 위험을 없애줍니다. 이 글에서는 핵심 흐름에 집중하기 위해 직접 발행 방식을 사용하며, 해당 트레이드오프를 감안하고 읽어주세요.
타입 정의
TypeScript 실전 구현에서 타입 정의가 빠지면 코드 흐름을 따라가기 어렵습니다. 먼저 도메인 이벤트와 커맨드를 정의합니다.
// types/domain-events.ts
import { IEvent } from '@nestjs/cqrs';
// BaseEvent가 IEvent를 확장하면 NestJS AggregateRoot.apply()에 as any 없이 넘길 수 있습니다.
export interface BaseEvent extends IEvent {
aggregateId: string;
timestamp: Date;
version: number;
}
export interface AccountOpenedEvent extends BaseEvent {
type: 'AccountOpened';
payload: { ownerId: string; initialBalance: number };
}
export interface DepositMadeEvent extends BaseEvent {
type: 'DepositMade';
payload: { amount: number; description: string };
}
export interface WithdrawalMadeEvent extends BaseEvent {
type: 'WithdrawalMade';
payload: { amount: number; description: string };
}
// 판별 유니온 — applyEvent의 switch에서 TypeScript가 각 case 안의 타입을 좁혀줍니다.
export type DomainEvent = AccountOpenedEvent | DepositMadeEvent | WithdrawalMadeEvent;// types/commands.ts
export interface OpenAccountCommand {
accountId: string;
ownerId: string;
initialBalance: number;
}
export interface DepositCommand {
accountId: string;
amount: number;
description: string;
}
export interface WithdrawCommand {
accountId: string;
amount: number;
description: string;
}Aggregate 구현
// account.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';
import { DomainEvent } from './types/domain-events';
import { OpenAccountCommand, DepositCommand, WithdrawCommand } from './types/commands';
export type AccountStatus = 'ACTIVE' | 'FROZEN' | 'CLOSED';
export interface AccountState {
id: string;
balance: number;
status: AccountStatus;
version: number;
}
export class AccountAggregate extends AggregateRoot<DomainEvent> {
private state: AccountState = { id: '', balance: 0, status: 'ACTIVE', version: 0 };
// NestJS loadFromHistory의 기본 구현은 uncommitted 목록 관리만 합니다.
// 도메인 상태를 변경하는 reduceState 호출을 여기서 연결합니다.
loadFromHistory(events: DomainEvent[]): void {
events.forEach(event => this.reduceState(event));
}
static fromSnapshot(state: AccountState): AccountAggregate {
const agg = new AccountAggregate();
agg.state = { ...state };
return agg;
}
openAccount(command: OpenAccountCommand): void {
if (this.state.id) {
throw new Error('계좌가 이미 존재합니다');
}
this.applyAndRecord({
type: 'AccountOpened',
aggregateId: command.accountId,
payload: { ownerId: command.ownerId, initialBalance: command.initialBalance },
timestamp: new Date(),
version: this.state.version + 1,
});
}
deposit(command: DepositCommand): void {
if (this.state.status !== 'ACTIVE') {
throw new Error('활성 상태의 계좌에만 입금할 수 있습니다');
}
if (command.amount <= 0) {
throw new Error('입금 금액은 0보다 커야 합니다');
}
this.applyAndRecord({
type: 'DepositMade',
aggregateId: this.state.id,
payload: { amount: command.amount, description: command.description },
timestamp: new Date(),
version: this.state.version + 1,
});
}
withdraw(command: WithdrawCommand): void {
if (this.state.status !== 'ACTIVE') {
throw new Error('활성 상태의 계좌에만 출금할 수 있습니다');
}
if (command.amount <= 0) {
throw new Error('출금 금액은 0보다 커야 합니다');
}
if (this.state.balance < command.amount) {
throw new Error('잔액이 부족합니다');
}
this.applyAndRecord({
type: 'WithdrawalMade',
aggregateId: this.state.id,
payload: { amount: command.amount, description: command.description },
timestamp: new Date(),
version: this.state.version + 1,
});
}
private applyAndRecord(event: DomainEvent): void {
this.reduceState(event);
this.apply(event); // NestJS AggregateRoot — uncommitted 목록에 추가
}
private reduceState(event: DomainEvent): void {
switch (event.type) {
case 'AccountOpened':
this.state.id = event.aggregateId;
this.state.balance = event.payload.initialBalance;
this.state.status = 'ACTIVE';
break;
case 'DepositMade':
this.state.balance += event.payload.amount;
break;
case 'WithdrawalMade':
this.state.balance -= event.payload.amount;
break;
}
this.state.version = event.version;
}
getState(): AccountState {
return { ...this.state };
}
}loadFromHistory를 오버라이드하는 이유를 짚고 넘어갑니다. NestJS AggregateRoot의 기본 loadFromHistory는 이벤트를 "uncommitted가 아님"으로 표시하는 역할만 합니다. 도메인 상태를 바꾸는 reduceState 호출은 우리가 직접 연결해야 합니다. 반면 새 이벤트를 생성할 때는 this.apply(event)가 uncommitted 목록 관리를 맡고, getUncommittedEvents()로 꺼낼 수 있습니다 — 이 두 메서드는 NestJS 내장입니다.
EventStore 구현
// event-store/event-store.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Kafka, Producer, CompressionTypes } from 'kafkajs';
import { DomainEvent } from '../types/domain-events';
export class OptimisticConcurrencyException extends Error {
constructor(aggregateId: string, expectedVersion: number) {
super(
`[${aggregateId}] 버전 충돌: 예상 버전 ${expectedVersion}은 이미 다른 커맨드가 사용했습니다`,
);
}
}
@Injectable()
export class EventStoreService implements OnModuleInit {
private producer: Producer;
constructor(@InjectDataSource() private readonly dataSource: DataSource) {
const kafka = new Kafka({
clientId: 'account-service',
brokers: [process.env.KAFKA_BROKER ?? 'localhost:9092'],
});
this.producer = kafka.producer();
}
async onModuleInit(): Promise<void> {
await this.producer.connect();
await this.ensureEventsTable();
}
async appendEvents(
aggregateId: string,
events: DomainEvent[],
expectedVersion: number,
): Promise<void> {
await this.dataSource.transaction(async (manager) => {
for (const event of events) {
try {
// UNIQUE (aggregate_id, version) 제약이 낙관적 잠금을 강제합니다.
// 두 커맨드가 동시에 같은 버전으로 쓰려 하면 두 번째 INSERT가 실패합니다.
await manager.query(
`INSERT INTO events (aggregate_id, version, type, payload, timestamp)
VALUES ($1, $2, $3, $4, $5)`,
[
event.aggregateId,
event.version,
event.type,
JSON.stringify(event.payload),
event.timestamp,
],
);
} catch (err: any) {
if (err.code === '23505') {
throw new OptimisticConcurrencyException(aggregateId, expectedVersion);
}
throw err;
}
}
});
// PostgreSQL 트랜잭션 커밋 후 Kafka 발행
// 프로덕션에서는 Transactional Outbox + CDC로 원자성을 보장하세요.
await this.producer.send({
topic: 'account-events',
compression: CompressionTypes.GZIP,
messages: events.map(event => ({
key: event.aggregateId, // 같은 Aggregate → 같은 파티션 → 순서 보장
value: JSON.stringify(event),
headers: {
eventType: event.type,
version: String(event.version),
},
})),
});
}
async readEvents(
aggregateId: string,
options?: { maxTimestamp?: Date; fromVersion?: number },
): Promise<DomainEvent[]> {
const params: unknown[] = [aggregateId];
let query = `
SELECT type, payload, aggregate_id AS "aggregateId", version, timestamp
FROM events
WHERE aggregate_id = $1
`;
if (options?.fromVersion !== undefined) {
params.push(options.fromVersion);
query += ` AND version > $${params.length}`;
}
if (options?.maxTimestamp) {
params.push(options.maxTimestamp.toISOString());
query += ` AND timestamp <= $${params.length}`;
}
query += ' ORDER BY version ASC';
const rows: Array<{
type: string;
payload: Record<string, unknown>;
aggregateId: string;
version: number;
timestamp: string;
}> = await this.dataSource.query(query, params);
return rows.map(row => ({
type: row.type,
aggregateId: row.aggregateId,
version: row.version,
timestamp: new Date(row.timestamp),
payload: row.payload,
} as DomainEvent));
}
private async ensureEventsTable(): Promise<void> {
await this.dataSource.query(`
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
aggregate_id TEXT NOT NULL,
version INT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_aggregate_version UNIQUE (aggregate_id, version)
);
CREATE INDEX IF NOT EXISTS idx_events_aggregate_id
ON events (aggregate_id, version);
`);
}
}UNIQUE (aggregate_id, version) 제약이 낙관적 동시성 제어의 핵심입니다. 별도 버전 테이블이나 Redis가 필요 없습니다. Aggregate를 재수화해 이벤트를 생성하면 version이 자동 증가하므로, 두 트랜잭션이 같은 version으로 INSERT를 시도할 때 두 번째는 PostgreSQL 제약 위반(23505)으로 거부됩니다.
Command Handler
// commands/deposit.command-handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { DepositCommand } from '../types/commands';
import { AccountAggregate } from '../account.aggregate';
import { EventStoreService } from '../event-store/event-store.service';
import { DomainEvent } from '../types/domain-events';
@CommandHandler(DepositCommand)
export class DepositCommandHandler implements ICommandHandler<DepositCommand> {
constructor(private readonly eventStore: EventStoreService) {}
async execute(command: DepositCommand): Promise<void> {
// 1. PostgreSQL에서 이 Aggregate의 전체 이벤트 조회
const events = await this.eventStore.readEvents(command.accountId);
// 2. 이벤트 재생으로 Aggregate 재조립
const account = new AccountAggregate();
account.loadFromHistory(events);
const expectedVersion = account.getState().version;
// 3. 도메인 로직 실행 (유효성 검사 포함)
account.deposit(command);
// 4. uncommitted 이벤트를 꺼내 저장 — getUncommittedEvents는 NestJS 내장
const newEvents = account.getUncommittedEvents() as DomainEvent[];
await this.eventStore.appendEvents(command.accountId, newEvents, expectedVersion);
account.uncommit();
}
}Projection — 이벤트를 읽기 모델로 변환
Projection은 Kafka Consumer입니다. 이벤트를 소비하면서 조회에 최적화된 뷰를 PostgreSQL에 유지합니다.
Kafka는 at-least-once delivery를 보장하므로 같은 이벤트가 두 번 소비될 수 있습니다. 방어가 없으면 DepositMade가 두 번 처리되어 잔액이 두 배가 됩니다. 두 가지 규칙으로 이를 막습니다:
WHERE projectionVersion < :version조건: 이미 적용된 버전은 WHERE 조건 불일치로 UPDATE가 실행되지 않습니다.- 단일
UPDATE쿼리:balance증가와lastUpdatedAt갱신을 한 쿼리로 묶어 원자성을 보장합니다. 두 개의 쿼리로 나누면 첫 쿼리 후 프로세스가 죽었을 때 상태 불일치가 생깁니다.
// projections/account-balance.projection.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Kafka, Consumer, KafkaMessage } from 'kafkajs';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AccountBalanceView } from './account-balance-view.entity';
import { DomainEvent } from '../types/domain-events';
@Injectable()
export class AccountBalanceProjection implements OnModuleInit {
private consumer: Consumer;
constructor(
@InjectRepository(AccountBalanceView)
private readonly viewRepo: Repository<AccountBalanceView>,
) {
const kafka = new Kafka({
clientId: 'balance-projection',
brokers: [process.env.KAFKA_BROKER ?? 'localhost:9092'],
});
this.consumer = kafka.consumer({ groupId: 'account-balance-projection' });
}
async onModuleInit(): Promise<void> {
await this.consumer.connect();
await this.consumer.subscribe({ topic: 'account-events', fromBeginning: true });
await this.consumer.run({
eachMessage: async ({ message }) => this.handle(message),
});
}
private async handle(message: KafkaMessage): Promise<void> {
const event = JSON.parse(message.value!.toString()) as DomainEvent;
try {
switch (event.type) {
case 'AccountOpened': {
await this.viewRepo
.createQueryBuilder()
.insert()
.values({
accountId: event.aggregateId,
balance: event.payload.initialBalance,
ownerId: event.payload.ownerId,
status: 'ACTIVE',
lastUpdatedAt: event.timestamp,
projectionVersion: event.version,
})
.orIgnore() // 멱등성: 이미 존재하면 무시
.execute();
break;
}
case 'DepositMade': {
// WHERE projectionVersion < version — 중복 소비 시 UPDATE 조건 불일치로 건너뜀
await this.viewRepo
.createQueryBuilder()
.update()
.set({
balance: () => `balance + ${event.payload.amount}`,
lastUpdatedAt: event.timestamp,
projectionVersion: event.version,
})
.where('accountId = :id AND projectionVersion < :version', {
id: event.aggregateId,
version: event.version,
})
.execute();
break;
}
case 'WithdrawalMade': {
await this.viewRepo
.createQueryBuilder()
.update()
.set({
balance: () => `balance - ${event.payload.amount}`,
lastUpdatedAt: event.timestamp,
projectionVersion: event.version,
})
.where('accountId = :id AND projectionVersion < :version', {
id: event.aggregateId,
version: event.version,
})
.execute();
break;
}
}
} catch (error) {
if (error instanceof KnownBusinessError) {
await this.sendToDeadLetterQueue(message, error);
} else {
throw error; // 일시적 오류는 re-throw → Kafka가 재시도
}
}
}
private async sendToDeadLetterQueue(message: KafkaMessage, error: Error): Promise<void> {
// 'account-events-dlq' 토픽으로 이동해 수동 검토 후 재처리
}
}Projection 재구축
버그를 수정했거나 새로운 읽기 모델이 필요할 때, 처음부터 다시 적용해야 합니다. 절차는 다음과 같습니다.
- 기존 읽기 모델 테이블 초기화
- Consumer Group offset을 처음으로 리셋:
bash
kafka-consumer-groups.sh \ --reset-offsets --to-earliest \ --group account-balance-projection \ --topic account-events \ --execute - Projection 재시작
이 과정이 가능한 이유가 Event Sourcing의 핵심 강점 중 하나입니다. 이벤트는 지워지지 않기 때문에, 언제든 읽기 모델을 버리고 새로 구축할 수 있습니다. "Projection 추가만으로 새 뷰 생성 가능"이 장점으로 꼽히는 배경입니다.
시간 여행 쿼리
// time-travel/time-travel.service.ts
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EventStoreService } from '../event-store/event-store.service';
import { AccountAggregate, AccountState } from '../account.aggregate';
import { DomainEvent } from '../types/domain-events';
@Injectable()
export class TimeTravelService {
constructor(private readonly eventStore: EventStoreService) {}
async getAccountStateAt(accountId: string, timestamp: Date): Promise<AccountState> {
const events = await this.eventStore.readEvents(accountId, {
maxTimestamp: timestamp,
});
if (events.length === 0) {
throw new NotFoundException(
`${timestamp.toISOString()} 시점에 계좌 ${accountId}가 존재하지 않습니다`,
);
}
const account = new AccountAggregate();
account.loadFromHistory(events);
return account.getState();
}
async getEventsBetween(
accountId: string,
from: Date,
to: Date,
): Promise<DomainEvent[]> {
const allEvents = await this.eventStore.readEvents(accountId);
return allEvents.filter(e => {
const t = new Date(e.timestamp);
return t >= from && t <= to;
});
}
}// time-travel/time-travel.controller.ts
import { Controller, Get, Param, Query, BadRequestException } from '@nestjs/common';
import { TimeTravelService } from './time-travel.service';
@Controller('accounts')
export class TimeTravelController {
constructor(private readonly timeTravelService: TimeTravelService) {}
@Get(':id/state')
async getStateAt(
@Param('id') accountId: string,
@Query('at') at: string,
) {
if (!at || isNaN(Date.parse(at))) {
throw new BadRequestException('at 파라미터는 ISO 8601 형식이어야 합니다');
}
return this.timeTravelService.getAccountStateAt(accountId, new Date(at));
}
}
// GET /accounts/acc-123/state?at=2025-03-15T09:00:00Z시간 여행 쿼리의 핵심은 PostgreSQL timestamp <= $n 필터에 있습니다. Kafka를 전체 스캔하는 방식으로는 이 쿼리를 효율적으로 구현할 수 없습니다. EventStore를 PostgreSQL 인덱스 기반으로 설계한 이유가 여기에 있습니다.
sequenceDiagram
participant 클라이언트
participant API서버
participant PostgreSQL
participant Aggregate
클라이언트->>API서버: GET /accounts/123/state?at=2025-03-15T09:00:00Z
API서버->>PostgreSQL: SELECT ... WHERE aggregate_id='123' AND timestamp <= '2025-03-15...' ORDER BY version
PostgreSQL-->>API서버: version 1~N 이벤트 반환
API서버->>Aggregate: new AccountAggregate(), loadFromHistory(events)
loop 이벤트 재생
Aggregate->>Aggregate: reduceState 순서대로 적용
end
Aggregate-->>API서버: 과거 시점의 AccountState
API서버-->>클라이언트: balance=3000, status=ACTIVESnapshot — 이벤트가 많아질 때
이벤트가 수천, 수만 건 쌓이면 재수화 비용이 급증합니다. Snapshot은 특정 version 시점의 상태를 저장해두고, 그 이후 이벤트만 재생하도록 단축합니다.
// snapshot/snapshot.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SnapshotEntity } from './snapshot.entity';
import { AccountAggregate, AccountState } from '../account.aggregate';
import { EventStoreService } from '../event-store/event-store.service';
@Injectable()
export class SnapshotService {
private readonly SNAPSHOT_INTERVAL = 100;
constructor(
@InjectRepository(SnapshotEntity)
private readonly snapshotRepo: Repository<SnapshotEntity>,
) {}
async loadAggregate(
aggregateId: string,
eventStore: EventStoreService,
): Promise<AccountAggregate> {
const snapshot = await this.snapshotRepo.findOne({
where: { aggregateId },
order: { version: 'DESC' },
});
let account: AccountAggregate;
let fromVersion: number | undefined;
if (snapshot) {
// fromSnapshot은 AccountAggregate.fromSnapshot(state)으로 정의한 static 메서드
account = AccountAggregate.fromSnapshot(snapshot.state as AccountState);
fromVersion = snapshot.version;
} else {
account = new AccountAggregate();
}
const remainingEvents = await eventStore.readEvents(aggregateId, { fromVersion });
account.loadFromHistory(remainingEvents);
return account;
}
async maybeSaveSnapshot(account: AccountAggregate): Promise<void> {
const state = account.getState();
if (state.version > 0 && state.version % this.SNAPSHOT_INTERVAL === 0) {
await this.snapshotRepo.save({
aggregateId: state.id,
version: state.version,
state: state,
createdAt: new Date(),
});
}
}
}언제 Event Sourcing + CQRS를 선택하는가
이 아키텍처는 복잡성이 적지 않습니다. 모든 서비스에 적용하면 오히려 개발 속도가 떨어집니다.
flowchart TD
A[새 도메인 설계] --> B{이벤트 히스토리가 핵심 요건인가\n감사 추적 시간여행 버그재현}
B -->|아니오| C[단순 CRUD]
B -->|예| D{이벤트 자체에 비즈니스 가치가 있는가\n금융 의료 컴플라이언스 커머스}
D -->|아니오| E[CDC나 감사 테이블\n또는 Outbox 패턴 고려]
D -->|예| F[Event Sourcing 적용]
F --> G{읽기 부하가 쓰기보다 압도적으로 크거나\n다양한 읽기 모델이 필요한가}
G -->|아니오| H[Event Sourcing 단독 적용도 충분]
G -->|예| I[CQRS 분리 추가]
I --> J[Bounded Context 단위로 선택적 적용]핵심 판단 기준은 하나입니다: "언제, 누가, 무엇을, 왜 바꿨는가"가 그 자체로 비즈니스 가치를 가지는 도메인인가. 금융 거래, 의료 기록, 규제 컴플라이언스가 이에 해당합니다. 단순 콘텐츠 관리나 설정값 저장은 해당하지 않을 가능성이 높습니다.
장단점 정리
| 구분 | 내용 | 실무 영향도 |
|---|---|---|
| 장점 | 완전한 감사 추적 — 별도 감사 테이블 불필요 | ★★★★★ |
| 장점 | 시간 여행 쿼리 — 과거 상태 정확한 재현 | ★★★★★ |
| 장점 | 읽기 모델 유연성 — Projection 추가만으로 새 뷰 생성 가능 | ★★★★☆ |
| 장점 | Command/Query 독립 스케일링 | ★★★★☆ |
| 장점 | 버그 재현 — 이벤트 재생으로 정확한 상태 복원 | ★★★☆☆ |
| 단점 | 최종 일관성 — Projection 반영 지연 | ★★★★☆ |
| 단점 | 이벤트 재생 성능 — Snapshot 없으면 대규모 Aggregate에서 지연 증가 | ★★★★☆ |
| 단점 | 스키마 진화 어려움 — 이벤트 구조 변경 시 Upcasting 전략 필수 | ★★★☆☆ |
| 단점 | 운영 복잡성 증가 — 구성 요소 증가 | ★★★☆☆ |
| 단점 | 강한 일관성이 필요한 곳에 부적합 | ★★★☆☆ |
실무에서 흔한 실수들
이벤트에 파생 데이터를 담기
이벤트는 "무슨 일이 일어났는가"만 담아야 합니다.
// 나쁜 예: 파생 데이터가 섞임
interface BadDepositEvent {
type: 'DepositMade';
amount: number;
balanceAfter: number; // ❌ Aggregate가 계산하면 되는 파생값
allTransactions: unknown[]; // ❌ 이벤트에 담기엔 너무 많음
}
// 좋은 예: 발생한 사실만
interface GoodDepositEvent extends BaseEvent {
type: 'DepositMade';
payload: { amount: number; description: string };
}이벤트 타입명을 동사 원형으로 짓기
이벤트는 과거에 일어난 사실입니다.
// 나쁜 예
'DepositAccount', 'CreateOrder', 'UpdateUser'
// 좋은 예 — 구체적으로 쪼갤수록 감사 로그의 가치가 높아집니다
'DepositMade', 'OrderPlaced', 'UserEmailChanged', 'UserPhoneNumberChanged'UserUpdated 대신 UserEmailChanged, UserPhoneNumberChanged처럼 구체적으로 쪼개면, 감사 로그를 보는 사람이 무엇이 바뀌었는지 즉시 알 수 있고 Projection에서 특정 변경만 구독하는 것도 쉬워집니다.
Projection 실패를 무한 retry하기
동일한 잘못된 이벤트로 계속 retry하면 Projection 전체가 멈춥니다.
await this.consumer.run({
eachMessage: async ({ message }) => {
try {
await this.handle(message);
} catch (error) {
if (error instanceof KnownBusinessError) {
await this.sendToDeadLetterQueue(message, error); // 격리 후 수동 재처리
} else {
throw error; // 일시적 오류 → Kafka가 재시도
}
}
},
});강한 일관성이 필요한 곳에 도입하기
"주문하자마자 재고 차감이 정확히 반영되어야 한다"처럼 읽기-쓰기 사이에 강한 일관성이 필요하다면, Projection 반영 지연을 전제로 하는 이 구조가 맞지 않을 수 있습니다. Bounded Context 단위로 선택적으로 적용하는 것이 현실적입니다.
마치며
이 글에서 구현한 구조를 요약하면 다음과 같습니다.
- PostgreSQL
events테이블: 이벤트의 영속 저장소.UNIQUE (aggregate_id, version)제약이 낙관적 잠금을 담당하고,timestamp필터가 시간 여행 쿼리를 지원합니다. - Kafka: Projection들이 소비하는 스트리밍 레이어.
aggregateId를 파티션 키로 사용해 순서를 보장합니다. - Aggregate:
loadFromHistory+getUncommittedEvents패턴으로 재수화와 이벤트 생성을 명확히 분리합니다. - Projection:
WHERE projectionVersion < :version조건과 단일UPDATE로 멱등성과 원자성을 동시에 확보합니다.
이 아키텍처를 처음 도입할 때 추천하는 순서는 이렇습니다:
-
도메인 이벤트 정의부터 — 현재 시스템에서 중요한 상태 변화를 과거형 이벤트로 나열해 보세요. 구체적으로 쪼갤수록 감사 로그의 가치가 높아집니다.
-
작은 Aggregate 하나로 시작 — 전체 시스템을 한 번에 전환하지 않아도 됩니다. 독립적인 Bounded Context 하나를 골라 EventStore 패턴을 적용해 보세요.
-
Projection과 시간 여행 엔드포인트 추가 — Projection 하나로 읽기 모델을 구축하고,
GET /accounts/:id/state?at=<timestamp>엔드포인트 하나를 추가해 보세요. 이벤트 히스토리가 있으면 나머지는 자연스럽게 따라옵니다.
참고 자료
- Event Sourcing, CQRS, Stream Processing and Apache Kafka — Confluent
- CQRS and Event Sourcing with Kafka — Conduktor
- CQRS and Event Sourcing in TypeScript: A Production Walkthrough — Atomic Object
- Using CQRS and Event Sourcing in NestJS — Medium
- Live Projections for Read Models with Event Sourcing and CQRS — Kurrent Blog
- Building a Scalable Architecture with KurrentDB and Kafka — Kurrent.io
- Patterns for Temporal Queries — EventSourcingDB Docs
- Event Sourcing Pattern — Azure Architecture Center
- CQRS Pattern — Azure Architecture Center
- Microservices Pattern: Event Sourcing — microservices.io
- CQRS — NestJS 공식 문서
- Event Sourcing with Kafka: A Practical Guide — Nemanja Tanasković