개인정보처리방침© 2026 DEV BAK - 기술블로그. All rights reserved.
DEV BAK - 기술블로그
포스트 검색
Architecture

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원 입금 이벤트가 발생했다"를 저장하자.

yaml
기존 방식: [현재 상태] 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이 미리 만들어둔 읽기 전용 뷰에서 조회. 속도가 목표.
mermaid
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 실전 구현에서 타입 정의가 빠지면 코드 흐름을 따라가기 어렵습니다. 먼저 도메인 이벤트와 커맨드를 정의합니다.

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;
typescript
// 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 구현

typescript
// 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 구현

typescript
// 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

typescript
// 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가 두 번 처리되어 잔액이 두 배가 됩니다. 두 가지 규칙으로 이를 막습니다:

  1. WHERE projectionVersion < :version 조건: 이미 적용된 버전은 WHERE 조건 불일치로 UPDATE가 실행되지 않습니다.
  2. 단일 UPDATE 쿼리: balance 증가와 lastUpdatedAt 갱신을 한 쿼리로 묶어 원자성을 보장합니다. 두 개의 쿼리로 나누면 첫 쿼리 후 프로세스가 죽었을 때 상태 불일치가 생깁니다.
typescript
// 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 재구축

버그를 수정했거나 새로운 읽기 모델이 필요할 때, 처음부터 다시 적용해야 합니다. 절차는 다음과 같습니다.

  1. 기존 읽기 모델 테이블 초기화
  2. Consumer Group offset을 처음으로 리셋:
    bash
    kafka-consumer-groups.sh \
      --reset-offsets --to-earliest \
      --group account-balance-projection \
      --topic account-events \
      --execute
  3. Projection 재시작

이 과정이 가능한 이유가 Event Sourcing의 핵심 강점 중 하나입니다. 이벤트는 지워지지 않기 때문에, 언제든 읽기 모델을 버리고 새로 구축할 수 있습니다. "Projection 추가만으로 새 뷰 생성 가능"이 장점으로 꼽히는 배경입니다.


시간 여행 쿼리

typescript
// 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;
    });
  }
}
typescript
// 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 인덱스 기반으로 설계한 이유가 여기에 있습니다.

mermaid
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=ACTIVE

Snapshot — 이벤트가 많아질 때

이벤트가 수천, 수만 건 쌓이면 재수화 비용이 급증합니다. Snapshot은 특정 version 시점의 상태를 저장해두고, 그 이후 이벤트만 재생하도록 단축합니다.

typescript
// 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를 선택하는가

이 아키텍처는 복잡성이 적지 않습니다. 모든 서비스에 적용하면 오히려 개발 속도가 떨어집니다.

mermaid
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 전략 필수 ★★★☆☆
단점 운영 복잡성 증가 — 구성 요소 증가 ★★★☆☆
단점 강한 일관성이 필요한 곳에 부적합 ★★★☆☆

실무에서 흔한 실수들

이벤트에 파생 데이터를 담기

이벤트는 "무슨 일이 일어났는가"만 담아야 합니다.

typescript
// 나쁜 예: 파생 데이터가 섞임
interface BadDepositEvent {
  type: 'DepositMade';
  amount: number;
  balanceAfter: number;          // ❌ Aggregate가 계산하면 되는 파생값
  allTransactions: unknown[];    // ❌ 이벤트에 담기엔 너무 많음
}
 
// 좋은 예: 발생한 사실만
interface GoodDepositEvent extends BaseEvent {
  type: 'DepositMade';
  payload: { amount: number; description: string };
}

이벤트 타입명을 동사 원형으로 짓기

이벤트는 과거에 일어난 사실입니다.

typescript
// 나쁜 예
'DepositAccount', 'CreateOrder', 'UpdateUser'
 
// 좋은 예 — 구체적으로 쪼갤수록 감사 로그의 가치가 높아집니다
'DepositMade', 'OrderPlaced', 'UserEmailChanged', 'UserPhoneNumberChanged'

UserUpdated 대신 UserEmailChanged, UserPhoneNumberChanged처럼 구체적으로 쪼개면, 감사 로그를 보는 사람이 무엇이 바뀌었는지 즉시 알 수 있고 Projection에서 특정 변경만 구독하는 것도 쉬워집니다.

Projection 실패를 무한 retry하기

동일한 잘못된 이벤트로 계속 retry하면 Projection 전체가 멈춥니다.

typescript
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로 멱등성과 원자성을 동시에 확보합니다.

이 아키텍처를 처음 도입할 때 추천하는 순서는 이렇습니다:

  1. 도메인 이벤트 정의부터 — 현재 시스템에서 중요한 상태 변화를 과거형 이벤트로 나열해 보세요. 구체적으로 쪼갤수록 감사 로그의 가치가 높아집니다.

  2. 작은 Aggregate 하나로 시작 — 전체 시스템을 한 번에 전환하지 않아도 됩니다. 독립적인 Bounded Context 하나를 골라 EventStore 패턴을 적용해 보세요.

  3. 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ć
#EventSourcing#CQRS#Kafka#PostgreSQL#NestJS#TypeScript
공유하기

목차

핵심 개념왜 현재 상태 대신 사건을 저장하는가CQRS — 읽기와 쓰기를 분리하는 이유Aggregate — 도메인 일관성의 경계아키텍처 포지셔닝: Kafka와 PostgreSQL의 역할 분리타입 정의Aggregate 구현EventStore 구현Command HandlerProjection — 이벤트를 읽기 모델로 변환Projection 재구축시간 여행 쿼리Snapshot — 이벤트가 많아질 때언제 Event Sourcing + CQRS를 선택하는가장단점 정리실무에서 흔한 실수들이벤트에 파생 데이터를 담기이벤트 타입명을 동사 원형으로 짓기Projection 실패를 무한 retry하기강한 일관성이 필요한 곳에 도입하기마치며참고 자료

추천 포스트

Expand-Contract 패턴으로 무중단 PostgreSQL 스키마 마이그레이션 구현하기
Architecture

Expand-Contract 패턴으로 무중단 PostgreSQL 스키마 마이그레이션 구현하기

컬럼 이름 변경·테이블 분리·외래키 추가를 롤백 가능한 3단계로 배포하는 실전 가이드 배포 전날 팀 채널에 이런 메시지가 올라온 경험, 많을 겁니다. "내일 DB 마이그레이션 있으니 새벽 트래픽 낮을 때 진행합시다." RENAME COLUMN 하나 때문에 서비스 점검…

2026년 07월 20일읽는 데 22분
PostgreSQL 이벤트 스토어로 구현하는 CQRS와 Read Model on-demand 재구성
Architecture

PostgreSQL 이벤트 스토어로 구현하는 CQRS와 Read Model on-demand 재구성

감사(Audit) 요구사항이 있는 도메인에서 "어떻게 이 상태가 됐는지"를 설명하지 못하는 시스템은 구조적 결함을 안고 있습니다. accounts 테이블에 balance 컬럼 하나를 두는 방식은 현재 잔액은 알려주지만, 누가 언제 왜 그 잔액이 됐는지는 영원히 묻힙니다…

2026년 07월 18일읽는 데 28분
ElectricSQL Shapes API로 PostgreSQL을 브라우저에 실시간 동기화하기 — Local-First 웹 앱에서 오프라인 쓰기와 서버 DB를 연결하는 sync-engine 패턴
Architecture

ElectricSQL Shapes API로 PostgreSQL을 브라우저에 실시간 동기화하기 — Local-First 웹 앱에서 오프라인 쓰기와 서버 DB를 연결하는 sync-engine 패턴

실시간 기능을 WebSocket으로 직접 구현하다 보면 어느 순간 재연결 로직이 비즈니스 로직의 두 배가 된 파일을 마주하게 됩니다. 연결 상태 추적, 오프라인 큐잉, 재연결 시 상태 재조정 — 이 인프라성 코드가 서비스의 핵심 기능을 잠식합니다. ElectricSQL…

2026년 07월 18일읽는 데 21분
TypeScript로 CQRS + Event Sourcing 직접 구현하기 — EventStore, 이벤트 리플레이, Projection까지
Architecture

TypeScript로 CQRS + Event Sourcing 직접 구현하기 — EventStore, 이벤트 리플레이, Projection까지

CQRS와 Event Sourcing을 처음 접했을 때 솔직히 이런 생각을 했습니다. "이벤트를 전부 저장한다고? 그냥 현재 상태만 DB에 박으면 안 되나?" 그런데 핀테크 프로젝트에서 "3개월 전 이 계좌의 잔액이 왜 이랬는지 추적해주세요"라는 요구사항을 받고 나서…

2026년 07월 17일읽는 데 25분
TypeScript와 KurrentDB로 주문·결제 상태를 이벤트로 저장하고 임의 시점에서 재구성하기
Architecture

TypeScript와 KurrentDB로 주문·결제 상태를 이벤트로 저장하고 임의 시점에서 재구성하기

"어제 오후 2시 32분에 이 주문의 결제 상태가 정확히 어땠나요?" 고객사 감사팀에서 이런 질문을 받으면 어떻게 답하시겠습니까? updated at 컬럼으로 충분하다고 생각하다가 막상 DB를 열어보면 현재 상태만 남고 그 전의 흔적은 사라져 있는 경우가 많습니다. 이…

2026년 07월 15일읽는 데 24분
이벤트 소싱 + CQRS TypeScript 구현 가이드 — 명령과 조회를 분리하고 모든 상태 변화를 이벤트 로그로 보존하는 아키텍처 실전
Architecture

이벤트 소싱 + CQRS TypeScript 구현 가이드 — 명령과 조회를 분리하고 모든 상태 변화를 이벤트 로그로 보존하는 아키텍처 실전

업캐스터(Upcaster) 로 읽는 시점에 변환합니다. 버전 관리 전략은 두 가지입니다. 1. 타입명에 버전 포함 : OrderPlacedEvent@1 , OrderPlacedEvent@2 처럼 이벤트 타입명에 버전을 포함시킵니다. 명시적이고 이벤트 레코드만 봐도 버전…

2026년 07월 12일읽는 데 8분