이벤트 소싱 + CQRS TypeScript 구현 가이드 — 명령과 조회를 분리하고 모든 상태 변화를 이벤트 로그로 보존하는 아키텍처 실전
**업캐스터(Upcaster)**로 읽는 시점에 변환합니다.
버전 관리 전략은 두 가지입니다.
- 타입명에 버전 포함:
OrderPlacedEvent@1,OrderPlacedEvent@2처럼 이벤트 타입명에 버전을 포함시킵니다. 명시적이고 이벤트 레코드만 봐도 버전이 보입니다. - 별도 버전 컬럼:
event_store테이블에schema_version컬럼을 추가합니다. 타입명이 깔끔하지만 레코드를 열어봐야 버전을 알 수 있습니다.
Upcaster는 EventStoreService.readStream()이 레코드를 반환하기 전에 변환을 적용합니다. Aggregate와 Projection은 항상 최신 버전만 봅니다. 이벤트 구조 변경이 누적될 경우 v1→v2→v3처럼 체인 업캐스터로 단계별 변환을 쌓으면 관리하기 쉽습니다. 각 업캐스터는 딱 한 버전 차이만 처리합니다.
// event-upcaster.ts
@Injectable()
export class EventUpcaster {
upcast(record: EventRecord): EventRecord {
// v1 레코드: currency 필드가 없음 → v2로 올림
if (
record.eventType === 'OrderPlacedEvent' &&
!('currency' in record.payload)
) {
return {
...record,
payload: { ...record.payload, currency: 'KRW' },
};
}
return record;
}
}EventStoreService.readStream() 안에서 위 upcaster를 호출합니다(readStream 구현은 앞서 본 것처럼 records.map((r) => this.upcaster.upcast(r))).
장단점 분석
장점
| 항목 | 설명 |
|---|---|
| 완전한 감사 이력 | 모든 상태 변화가 이벤트로 기록됩니다. "누가 언제 무엇을 왜 바꿨는가"를 언제든 추적할 수 있습니다 |
| 시간여행 디버깅 | 특정 시점의 상태를 이벤트 재생으로 정확히 재현할 수 있습니다 |
| Projection 자유도 | 비즈니스 요구사항이 바뀌면 새로운 읽기 모델을 이벤트 로그에서 언제든 생성할 수 있습니다 |
| 읽기 성능 최적화 | Query Side를 조회 패턴에 맞게 완전히 다른 스키마로 구성할 수 있습니다 |
| 느슨한 결합 | 새로운 이벤트 소비자를 기존 코드 수정 없이 추가할 수 있습니다 |
단점과 도전 과제
| 항목 | 현실적인 고통 포인트 |
|---|---|
| 최종 일관성 | Command 직후 Query가 최신 데이터를 반환하지 않습니다. Optimistic UI나 폴링 전략을 미리 설계해야 합니다 |
| 이벤트 스키마 진화 | 초기 도메인 이해가 부족한 상태에서 이벤트 구조를 잘못 잡으면 나중에 바꾸는 비용이 큽니다 |
| 운영 복잡도 | Event Store, Projection DB, 메시지 브로커... 관리해야 할 컴포넌트가 늘어납니다 |
| 학습 곡선 | 팀 전체가 패턴을 이해하지 못하면 코드 일관성이 무너집니다 |
| 스토리지 증가 | 이벤트는 삭제되지 않으므로 장기적으로 스토리지 전략이 필요합니다 |
도입하지 말아야 할 때
이 패턴이 항상 정답은 아닙니다. 아래 케이스라면 오버엔지니어링입니다.
- 단순 CRUD 도메인: 사용자 프로필, 앱 설정처럼 상태 변화 이력이 필요 없는 경우
- 도메인 이해 초기 단계: 이벤트 구조를 확정하기엔 도메인 지식이 부족한 경우. 이벤트 스키마를 나중에 바꾸는 건 생각보다 훨씬 고통스럽습니다
- 소규모 팀 + 빠른 MVP: 구조적 오버헤드가 개발 속도를 잡아먹습니다
2026년 기준 실무 컨센서스는 선택적 적용입니다. 결제, 주문, 재고처럼 감사 이력이 중요하고 복잡도가 높은 Bounded Context에만 도입하고, 나머지는 기존 방식을 유지하는 게 현실적인 접근입니다.
실무에서 흔한 실수
- 이벤트와 명령을 혼동:
CreateOrder(명령)와OrderCreated(이벤트)는 다릅니다. 이벤트는 과거 시제, 불변, 삭제 불가여야 합니다. - Aggregate 경계를 너무 크게 잡기: Order Aggregate 안에 Customer 정보까지 넣으면 경합 조건(contention)이 심해집니다. Aggregate는 작게 유지하세요.
- Projection을 Event Store와 같은 DB에 넣기: 분리 목적이 사라집니다. 읽기 모델은 독립적인 저장소를 써야 최적화가 의미 있습니다.
- 직렬화 전략 없이 이벤트 저장:
JSON.stringify(eventInstance)는 프로토타입 정보를 소실시킵니다. 역직렬화 시instanceof체크가 깨집니다. 타입 필드(discriminator)와 팩토리 함수로 직렬화/역직렬화를 명시적으로 처리하세요.
마치며
이벤트 소싱과 CQRS는 "상태 변화의 원인을 추적할 수 없다"는 문제를 구조적으로 제거합니다. 모든 변화가 이벤트 로그에 남으므로 감사 추적이 따라오고, CQRS로 읽기 모델을 분리하면 조회 성능도 독립적으로 최적화할 수 있습니다. 단, 복잡도가 높은 Bounded Context에만 선택적으로 적용하는 것이 2026년 기준 실무 컨센서스입니다.
지금 시작할 수 있는 세 단계를 제안합니다.
1단계: 기존 서비스에서 감사 이력이 가장 필요한 도메인 하나를 고릅니다. 결제나 주문처럼 "이 데이터가 왜 바뀌었는지 알고 싶다"는 요구가 자주 나오는 곳이 좋은 후보입니다.
2단계: PostgreSQL 기반의 간단한 Event Store 테이블부터 만들어 봅니다. KurrentDB 같은 전용 솔루션은 나중에 도입해도 늦지 않습니다. @nestjs/cqrs로 Command/Query 분리를 먼저 익히는 것이 순서입니다.
3단계: 하나의 Aggregate에 Event Sourcing을 적용하고, Projection을 만들어 이벤트 재생이 실제로 동작하는지 확인합니다. 그 과정에서 이벤트 직렬화 전략, 최종 일관성 처리, 버전 관리 컨벤션을 팀과 함께 결정하면 됩니다.
참고 자료
- CQRS and Event Sourcing in TypeScript: A Production Walkthrough — Atomic Object
- CQRS Pattern — Azure Architecture Center (Microsoft Learn)
- Event Sourcing Pattern — Azure Architecture Center (Microsoft Learn)
- NestJS 공식 CQRS 문서
- @ocoda/event-sourcing 공식 문서
- Navigating CQRS and Event Sourcing with NestJS and EventStoreDB — Medium
- Building CQRS and Event Sourcing for Hotel Management with NestJS and EventStoreDB — Medium
- Snapshots in Event Sourcing — Kurrent 공식 블로그
- Building a scalable event-driven architecture with KurrentDB and Kafka — Kurrent
- Event Sourcing, CQRS and Micro Services: Real FinTech Example — DEV Community
- How to Build Event Sourcing Systems in Node.js — OneUptime Blog
- GitHub: yerinadler/typescript-event-sourcing-sample-app
- GitHub: ocoda/event-sourcing