콘텐츠로 이동

CQRS & Event Sourcing

분류: Layer 9 - 아키텍처 & 설계 패턴

CQRS(Command Query Responsibility Segregation) 는 상태를 바꾸는 Command와 상태를 읽는 Query가 서로 다른 모델을 사용하도록 책임을 분리하는 아키텍처 패턴이다. 핵심은 데이터베이스를 두 개 쓰는 것이 아니라 쓰기 판단과 읽기 표현을 같은 모델에 억지로 맞추지 않는 것이다.

Aggregate(애그리거트) 는 한 Command에서 함께 일관성을 지켜야 하는 객체와 값의 경계다. Event Sourcing(이벤트 소싱) 은 Aggregate의 현재 상태를 행 하나로 덮어쓰는 대신, 상태를 만든 도메인 이벤트를 순서 있는 로그에 저장하고 그 로그를 현재 상태의 원천으로 삼는 영속화 방식이다. 이벤트를 다시 적용하는 Aggregate replay로 현재 상태와 과거 상태를 재구성한다.

두 패턴은 자주 결합하지만 서로를 요구하지 않는다.

아래 조합을 읽기 전에 동기화 용어를 먼저 잡자. Projection(프로젝션) 은 변경 기록에서 파생한 조회 목적별 read model 결과이고, projector(프로젝터) 는 그 결과를 생성하거나 갱신하는 함수 또는 프로세스다. Transactional Outbox(트랜잭셔널 아웃박스) 는 상태 변경과 발행할 메시지를 같은 DB 트랜잭션에 기록하는 패턴이다. CDC(Change Data Capture, 변경 데이터 캡처) 는 DB의 변경 로그를 읽어 행 변경을 스트림으로 전달하는 방식이다. Rehydration(재수화) 은 저장된 이벤트를 순서대로 적용해 Aggregate 상태를 복원하는 과정이다.

CQRS만 사용:
정규화된 주문 테이블에 현재 상태 저장
-> Outbox/CDC로 주문 조회 projection 갱신
Event Sourcing만 사용:
주문 이벤트 스트림이 source of truth
-> 같은 Aggregate를 재수화해 단순 조회
CQRS + Event Sourcing:
이벤트 스트림에 쓰기
-> 별도 projector가 검색·목록 모델 생성
둘 다 사용하지 않음:
하나의 CRUD 모델로 쓰고 읽기

이 네 조합을 먼저 구분해야 다음과 같은 잘못된 결론을 피할 수 있다.

  • CQRS를 도입한다고 반드시 메시지 브로커나 별도 DB가 필요한 것은 아니다.
  • CQRS의 읽기 모델을 갱신하는 방법이 반드시 Event Sourcing인 것은 아니다.
  • 이벤트를 발행한다고 그 시스템이 Event Sourcing인 것은 아니다.
  • 감사 로그가 있다고 그 로그가 상태의 source of truth인 것은 아니다.

필수 선수지식은 트랜잭션의 원자성, DDD의 Aggregate 경계, 분산 시스템의 일관성 모델이다. DB 로그 복제의 내부 동작과 서비스 분할 패턴은 각각 DB Replication & Sharding, MSA Patterns에서 선택 심화로 이어서 읽을 수 있다.


2. 선행 기술의 한계: 왜 한 모델이 strained 되는가

섹션 제목: “2. 선행 기술의 한계: 왜 한 모델이 strained 되는가”

전통적인 CRUD(Create, Read, Update, Delete)는 대부분의 시스템에 좋은 기본값이다. 하나의 트랜잭션 모델과 저장소를 사용하면 코드 경로, 장애 지점, 배포 단위가 적고 쓰기 직후 같은 데이터를 읽기도 쉽다.

문제는 시스템이 커졌다는 사실 자체가 아니다. 한 모델이 서로 다른 변화 이유를 동시에 감당할 때 긴장이 생긴다.

주문 시스템의 쓰기 모델은 다음 질문에 답해야 한다.

  • 주문 상태가 PAID -> SHIPPED 순서로만 전이하는가?
  • 재고와 결제 결과를 바탕으로 이 Command를 허용해도 되는가?
  • 같은 주문 버전에 두 Command가 동시에 성공하지 않는가?
  • 하나의 트랜잭션에서 보호해야 하는 불변식은 무엇인가?

반면 주문 목록 조회 모델은 다른 질문에 답해야 한다.

  • 고객명, 상품 썸네일, 결제 요약을 JOIN 없이 반환할 수 있는가?
  • 검색과 정렬에 필요한 필드가 한 행이나 문서에 준비되어 있는가?
  • 페이지당 수십 건을 낮은 p95 지연으로 읽을 수 있는가?
  • 읽기 전용 권한과 마스킹 규칙을 표현하기 쉬운가?

정규화된 쓰기 스키마는 중복을 줄이고 무결성을 보호한다. 그러나 목록 한 화면을 위해 6개 테이블을 매번 JOIN하면 조회 비용과 변경 이유가 쓰기 모델 안으로 들어온다. 반대로 화면 모양대로 역정규화한 테이블을 쓰기 원본으로 삼으면 한 Command가 여러 중복 필드를 동시에 갱신해야 한다.

2.1 수치 예시: 분리가 문제를 실제로 줄이는가

섹션 제목: “2.1 수치 예시: 분리가 문제를 실제로 줄이는가”

다음 숫자는 표준이 아니라 팀이 측정 결과를 토론하기 위한 예시 가드레일이다.

주문 생성:
100 requests/s
p95 80ms
주문 목록:
1,000 requests/s
6-table JOIN
p95 800ms
읽기 : 쓰기 = 10 : 1

이 경우 읽기 경로만 별도 projection으로 만들면 목록 요청 1,000개가 복잡한 쓰기 스키마를 반복해서 훑는 일을 줄일 수 있다. 쓰기 모델은 주문 불변식에 집중하고, 읽기 모델은 고객별 주문 카드에 필요한 필드를 미리 가진다.

이전의 더 약한 신호인 읽기:쓰기 = 5:1, 조회 p95 500ms도 후보를 찾는 출발점으로 쓸 수 있다. 하지만 비율이나 지연 하나가 CQRS 도입을 자동 결정하지는 않는다. 인덱스 하나, 쿼리 수정, 캐시 또는 read replica로 문제가 해결되면 그쪽이 더 단순하다.

2.2 CQRS의 철학은 물리 분리보다 책임 분리다

섹션 제목: “2.2 CQRS의 철학은 물리 분리보다 책임 분리다”

CQRS는 세 단계로 적용할 수 있다.

1단계: 코드 모델만 분리
Command handler / Query handler
같은 DB, 같은 트랜잭션 저장소
2단계: 스키마 또는 테이블 분리
write tables / read projection tables
같은 DB 인스턴스
3단계: 저장소와 배포 경로 분리
PostgreSQL write model
Elasticsearch/Redis/PostgreSQL read model

1단계만으로도 쓰기 규칙과 조회 DTO가 서로의 구조를 끌고 다니는 문제를 줄일 수 있다. 3단계로 갈수록 독립 확장과 저장 기술 선택 폭은 커지지만, 동기화 지연·중복 전달·재구축·관측 지점도 늘어난다.

Command는 상태 변경 의도를 표현한다. ShipOrder는 “배송됨을 저장하라”가 아니라 “현재 규칙상 이 주문을 배송 상태로 전이해도 되는지 판단하고 수행하라”는 요청이다.

Query는 상태를 바꾸지 않고 읽기 결과를 반환한다. 읽기 모델은 도메인 Entity를 그대로 노출할 필요가 없으며, 호출자에게 필요한 DTO(Data Transfer Object)나 materialized view를 반환할 수 있다.

CQRS는 “Command는 절대 값을 반환하면 안 된다”는 문법 규칙이 아니다. 새 ID, 승인된 버전, 처리 상태처럼 쓰기 결과를 반환할 수 있다. 다만 Command 처리 중 편의를 위해 복잡한 조회 화면까지 조립하기 시작하면 분리 목적이 흐려진다.

다음 관리자 화면을 생각해 보자.

테이블: feature_flags
필드: key, enabled, description, updated_at
트래픽:
읽기 3 requests/s
쓰기 0.02 requests/s
목록 p95 12ms
요구:
저장 성공 직후 목록에 즉시 보여야 함

이 모델을 Command DB와 Query DB로 나누면 해결되는 병목은 거의 없다. 대신 projector worker, 재시도, processing latency·freshness lag·backlog 대시보드, 재구축 절차가 새로 생긴다. 이 경우 단일 CRUD 모델과 트랜잭션이 더 정확하고 싸다.


3. CQRS 쓰기 모델: Aggregate와 불변식 경계

섹션 제목: “3. CQRS 쓰기 모델: Aggregate와 불변식 경계”

Aggregate 는 하나의 Command에서 함께 일관성을 지켜야 하는 객체와 값의 경계다. Aggregate Root는 그 경계에 들어오는 변경의 유일한 진입점이다.

Aggregate는 관련 있어 보이는 모든 데이터를 한 덩어리로 모으는 모델이 아니다. 하나의 원자적 변경에서 보호해야 하는 불변식(invariant) 으로 경계를 정한다. 불변식은 어떤 성공한 변경 뒤에도 참이어야 하는 업무 규칙이다.

주문 Aggregate가 보호할 수 있는 규칙은 다음과 같다.

  • 취소된 주문은 배송할 수 없다.
  • 결제가 완료되지 않은 주문은 배송할 수 없다.
  • 같은 주문은 한 번만 배송 시작 상태로 전이한다.

반면 전체 창고 재고와 모든 주문을 하나의 Aggregate로 묶으면 Command마다 거대한 잠금·재생 경계가 된다. 재고 수량의 원자적 차감은 Inventory Aggregate나 조건부 쓰기가 보호하고, 주문과 재고 사이 장기 흐름은 Saga 같은 별도 프로세스가 조율한다.

ShipOrder(order-42, expectedVersion=7)
1. order-42의 버전 7 상태를 읽는다.
2. canShip()으로 PAID 여부와 취소 여부를 검사한다.
3. OrderShipped 이벤트 또는 새 현재 상태를 만든다.
4. 저장소의 현재 버전이 여전히 7일 때만 버전 8로 저장한다.
5. 성공 결과와 새 버전을 반환한다.

이 흐름에서 Query projection은 불변식을 판정하는 source of truth가 아니다. projection은 늦을 수 있기 때문이다. 재고가 1개 남았다는 검색 문서를 두 요청이 동시에 읽고 각자 주문을 승인하면 2개가 판매될 수 있다.

Optimistic Concurrency Control(OCC, 낙관적 동시성 제어) 은 충돌이 드물다고 가정하되, 저장 시점에 읽었던 버전이 아직 최신인지 검사한다.

핵심 조건은 다음과 같다.

append 또는 update 성공 조건:
expectedVersion == currentVersion

두 사용자가 같은 주문 버전 7을 읽었다고 하자.

시간 ->
Command A: read v7 ---- validate ---- append event #8 성공
Command B: read v7 -------- validate -------- append event #8 실패
currentVersion=8

B를 무조건 재시도하면 안 된다. 최신 v8을 다시 읽고 비즈니스 규칙을 다시 평가해야 한다. A가 이미 주문을 취소했다면 B의 배송 Command는 재시도 성공이 아니라 업무 충돌로 끝나야 한다.

async function handleShip(command: ShipOrder) {
for (let attempt = 1; attempt <= 3; attempt += 1) {
const order = await repository.load(command.orderId);
const expectedVersion = order.version;
order.ship(); // 현재 상태에서 불변식을 다시 검사한다.
try {
return await repository.save(order, expectedVersion);
} catch (error) {
if (!(error instanceof VersionConflict) || attempt === 3) throw error;
}
}
}

재시도 횟수 3은 예시다. 충돌률이 계속 높으면 backoff를 늘리는 것보다 Aggregate 경계가 너무 넓은지, 하나의 hot key에 쓰기가 집중되는지, 비관적 잠금이나 직렬화된 처리 경로가 맞는지 확인한다.

신호의미먼저 확인할 것
서로 무관한 Command도 버전 충돌Aggregate가 너무 큼같은 원자 경계가 정말 필요한가
한 Command가 여러 Aggregate를 한 트랜잭션으로 잠금경계 밖 불변식을 로컬 ACID로 강제Saga 또는 업무 규칙 재설계
projection 값을 읽어 승인 판단stale read가 불변식에 침투write model의 조건부 쓰기
충돌 시 입력을 그대로 자동 재시도최신 상태에서 의미가 달라질 수 있음재수화 후 규칙 재평가
한 stream의 이벤트가 지나치게 빠르게 증가hot Aggregate분할 가능한 업무 식별자와 순서 범위

4. CQRS 읽기 모델: Projection과 최종 일관성

섹션 제목: “4. CQRS 읽기 모델: Projection과 최종 일관성”

Projection(프로젝션) 은 쓰기 모델의 변경에서 파생한 조회 목적별 현재 표현, 즉 derived read model 결과다. projector(프로젝터) 는 변경 기록을 읽어 projection을 생성하거나 갱신하는 함수 또는 프로세스다. 예를 들어 projector는 OrderCreated, PaymentCaptured, OrderShipped를 소비해 고객별 주문 목록 projection의 한 행을 갱신할 수 있다.

write model
Order Aggregate / normalized tables
|
| domain event, outbox event, or CDC record
v
projector
|
+--> order_list_by_customer
+--> order_search_index
+--> daily_sales_summary

같은 이벤트에서 여러 projection을 만들 수 있다. 각 projection은 조회 목적과 freshness 계약이 다르므로 독립적으로 재구축하고 확장할 수 있다.

4.1 Eventual consistency는 “나중에 맞는다”보다 구체적이어야 한다

섹션 제목: “4.1 Eventual consistency는 “나중에 맞는다”보다 구체적이어야 한다”

Eventual consistency(최종 일관성) 는 새 업데이트가 멈추면 복제본이나 projection이 결국 수렴한다는 계약이다. 언제 수렴하는지, 지연 중 무엇을 반환하는지, 파이프라인이 멈추면 어떻게 복구하는지는 별도로 정해야 한다.

주문 생성 직후의 타임라인을 보자.

t=0ms Command가 write DB에 order-42 저장
t=20ms 트랜잭션 커밋, API 201 응답
t=80ms Outbox relay가 OrderCreated 발행
t=140ms projector가 메시지 수신
t=190ms read model upsert 커밋
t=50ms의 GET /orders -> order-42가 아직 없을 수 있음
t=220ms의 GET /orders -> order-42가 보임

이 예에서 OrderCreated의 end-to-end processing latency는 발생 시각인 t=0ms부터 read model 커밋까지 약 190ms다. write DB 커밋을 시작점으로 따로 보면 projection apply delay는 190ms - 20ms = 170ms다. 마지막 projected event가 이 이벤트이고 현재 시각이 t=190ms라면 freshness lag도 190ms - 0ms = 190ms다. source와 checkpoint가 같다면 backlog는 0이지만, backlog가 없다고 신선도 지연까지 0인 것은 아니다. 제품 요구가 “주문 성공 후 1초 안에 목록 반영”이라면 만족한다. “성공 응답 뒤 시작한 모든 읽기는 즉시 최신”이라는 read-your-writes 요구라면 비동기 projection만으로는 부족하다.

가능한 대응은 다음과 같다.

  • Command 응답에 새 주문 요약과 버전을 함께 반환한다.
  • 특정 상세 조회만 writer에서 읽는다.
  • 클라이언트가 받은 버전 이상을 projection이 처리할 때까지 제한 시간만 기다린다.
  • PENDING_SYNC 상태와 freshness를 제품 상태로 표현한다.

모든 Query를 writer로 우회하면 분리한 읽기 부하가 다시 쓰기 저장소로 몰린다. read-your-writes가 필요한 경로만 좁혀야 한다.

4.2 Lag와 backlog를 손으로 계산하기

섹션 제목: “4.2 Lag와 backlog를 손으로 계산하기”

projector가 살아 있다는 health check 하나로 읽기 모델의 상태를 알 수 없다. 처리 성능, 현재 조회의 신선도, 남은 작업량은 서로 다른 질문이므로 다음 세 지표를 분리한다.

processing_latency = processed_at - occurred_at
freshness_lag = now - last_projected_event.occurred_at
backlog = source_high_watermark - checkpoint
  • processing_latency는 이벤트 하나가 발생한 뒤 read model 갱신 트랜잭션에 반영될 때까지 걸린 시간이다. p50·p95·p99 분포로 처리 경로의 지연을 본다.
  • freshness_lag는 지금 시각과 projector가 마지막으로 반영한 이벤트의 발생 시각 차이다. 사용자가 보는 읽기 모델이 현실에서 얼마나 뒤처졌는지 묻는다.
  • backlog는 source의 최신 위치인 high watermark와 projector checkpoint의 차이다. 두 값이 같은 offset 또는 연속 event sequence 단위일 때만 “남은 이벤트 수”로 해석할 수 있다.

세 값은 함께 움직일 때도 있지만 같은 지표가 아니다. worked scenario로 차이를 확인해 보자.

12:00:00 source high watermark = 1,000,000
projector checkpoint = 999,900
마지막 반영 event.occurred_at = 11:59:58
방금 처리한 event: occurred_at=11:59:58.000,
processed_at=12:00:00.000
processing_latency = 2초
freshness_lag = 2초
backlog = 100 events

여기서는 세 지표가 모두 작아서 정상처럼 보인다. 그런데 12:01:00부터 주문 유입이 멈추고, projector도 checkpoint 1,000,000에서 멈췄다고 하자. backlog는 0이고 마지막으로 관측된 processing latency도 낮지만, 12:11:00의 freshness lag는 10분이다. 이것이 장애인지 정상인지는 source에도 새 이벤트가 없는지 확인해야 결정할 수 있다. 즉 freshness lag만 보면 조용한 시간대와 projector 정지를 구분할 수 없다.

반대 상황도 가능하다. 과거 데이터 1,000,000개를 재구축하는 projector가 초당 5,000개를 처리하고 각 이벤트의 DB 적용은 20ms 안에 끝난다고 하자. 이 수치는 단일 worker의 순차 처리량이 아니다. 이벤트 하나에 20ms가 걸리면 단일 순차 처리 슬롯의 이론상 최대치는 1 / 0.02 = 50 events/s이므로, 5,000 events/s에는 이상적으로 약 5,000 * 0.02 = 100개의 동시 in-flight 처리 슬롯이 필요하다. 실제 worker 수는 worker당 concurrency와 batching에 따라 달라지며, 같은 처리량을 내는 batch·vectorized 쓰기를 사용할 수도 있다. 실제 시스템은 경합과 네트워크 오버헤드를 고려해 추가 여유를 둬야 한다. 개별 처리 코드의 service time은 짧아도 과거 이벤트의 occurred_at이 한 달 전이면 processing latency와 freshness lag는 매우 크고 backlog도 크다. 이는 live SLA를 위반한 장애일 수도 있고, 사용자에게 아직 연결하지 않은 새 projection의 정상적인 backfill일 수도 있다. 따라서 지표에는 projector_name, mode=live|rebuild, source partition, checkpoint를 함께 붙인다.

마지막으로 backlog가 작아도 processing latency가 커질 수 있다. 초당 이벤트가 한두 개뿐인데 각 이벤트가 read DB lock을 8초 기다린다면 backlog는 순간적으로 1~2개에 불과해도 사용자 반영은 늦다. 이때 consumer 수를 늘리기보다 lock contention과 projection transaction 범위를 먼저 본다.

4.3 중단 뒤 backlog 해소 시간을 계산하기

섹션 제목: “4.3 중단 뒤 backlog 해소 시간을 계산하기”

다음 계산은 이벤트 발생 시각의 지연이 아니라 쌓인 이벤트 수와 drain 시간에만 적용한다.

평상시 유입률: 500 events/s
consumer 중단: 300s (5분)
복구 후 처리율: 800 events/s
쌓인 backlog:
500 * 300 = 150,000 events
복구 중 순감소율:
800 - 500 = 300 events/s
backlog 해소 시간:
150,000 / 300 = 500s = 8분 20초

여기서 150,000 / 300 = 500초는 source high watermark와 checkpoint 사이에 쌓인 150,000개를 순감소율 300 events/s로 비우는 시간이다. 이를 곧바로 “freshness lag가 500초”라고 부르면 안 된다. 이벤트 유입이 균일하고 발생 순서대로 처리한다는 가정 아래에서는 consumer 재시작 시 read model이 약 5분 뒤처지고, 따라잡는 동안 freshness lag가 점차 줄어든다. 유입이 burst 형태거나 오래된 이벤트와 최신 이벤트를 우선순위 처리하면 backlog와 freshness lag의 관계는 달라진다. health check가 초록이어도 freshness SLA는 실패할 수 있다.

메시지 브로커와 Outbox relay는 보통 at-least-once 전달을 사용한다. 발행 후 ACK나 published_at 기록 전에 프로세스가 죽으면 같은 이벤트가 다시 온다.

Idempotent projector(멱등 projector) 는 같은 이벤트를 여러 번 적용해도 최종 읽기 상태가 한 번 적용한 것과 같게 만든다.

async function project(event: OrderEvent) {
await db.transaction(async (tx) => {
const claimed = await tx.processedEvents.insertIfAbsent({
projector: "order-list-v2",
eventId: event.eventId,
});
if (!claimed) return;
await tx.orderList.upsert(toOrderListRow(event));
});
}

processedEvents 기록과 read model 갱신을 같은 트랜잭션에 넣어야 한다. 먼저 처리 완료를 기록하고 read model 쓰기 전에 죽으면 이벤트를 영원히 건너뛴다. read model을 먼저 쓰고 처리 기록 전에 죽으면 중복 적용될 수 있다.

합계 projection은 특히 주의한다.

위험:
OrderPaid(30,000원) 중복 수신
total = total + 30,000 두 번 실행
-> 60,000원
안전 후보:
event_id unique claim + total 증가를 같은 transaction에서 실행

4.5 중복과 순서 역전은 다른 문제다

섹션 제목: “4.5 중복과 순서 역전은 다른 문제다”

event ID 중복 제거는 OrderShipped(v5)OrderPaid(v4)보다 먼저 도착하는 순서 역전을 해결하지 않는다. Aggregate별 버전을 projection 행에 기록하면 오래된 이벤트가 최신 상태를 덮는 일을 막을 수 있다.

async function applyOrderStatus(event: OrderStatusChanged) {
await readDb.query(
`UPDATE order_list
SET status = $1, source_version = $2
WHERE order_id = $3
AND source_version < $2`,
[event.status, event.aggregateVersion, event.orderId],
);
}

단, 모든 이벤트를 건너뛰어도 된다는 뜻은 아니다. v4가 합계 증가이고 v5가 상태 변경이라면 v4 누락은 여전히 오류다. 상태 덮어쓰기, 누적 계산, 집합 추가처럼 연산 의미에 따라 순서 정책을 정한다.

조회 요구가 바뀌거나 projector 버그를 고치면 기존 read model을 버리고 원천에서 다시 만들 수 있어야 한다.

안전한 blue/green 재구축 흐름은 다음과 같다.

1. order_list_v2 빈 저장소 생성
2. 정한 시작 위치부터 projection rebuild/replay
3. projection rebuild/replay 중 들어오는 새 이벤트도 같은 순서 경계로 반영
4. v1과 v2의 row count, checksum, 표본 결과 비교
5. Query 라우팅을 v2로 전환
6. 안정화 뒤 v1 제거

운영 projection을 비운 뒤 같은 이름으로 재생하면 재구축 동안 사용자 조회가 깨진다. side-by-side 저장소와 명시적 checkpoint가 필요하다.


5. Event Sourcing: 이벤트를 상태의 원천으로 선택하기

섹션 제목: “5. Event Sourcing: 이벤트를 상태의 원천으로 선택하기”

Event Sourcing의 핵심 질문은 “이벤트를 발행할까?”가 아니다. 현재 상태를 복원할 때 무엇을 권위 있는 원천으로 읽을 것인가? 다.

현재 상태 행이 권위 있고 이벤트가 알림용이면 state-based persistence다. 이벤트 스트림을 순서대로 적용해야 권위 있는 현재 상태가 만들어지면 Event Sourcing이다.

현재 잔액만 저장한 CRUD 행은 결과를 보여 준다.

accounts:
id=account-1
balance=1,000,000원
updated_at=2024-01-20

하지만 어떤 업무 사실이 이 결과를 만들었는지는 이 행만으로 알 수 없다. Event Sourcing은 다음과 같은 stream을 source of truth로 저장한다.

stream: bank-account/account-1
v1 MoneyDeposited +500,000원 2024-01-01
v2 MoneyWithdrawn -100,000원 2024-01-05
v3 MoneyDeposited +300,000원 2024-01-10
v4 MoneyWithdrawn -200,000원 2024-01-15
v5 MoneyDeposited +500,000원 2024-01-20
----------------
현재 잔액 1,000,000원

처음 세 이벤트만 재생하면 1월 10일 상태 700,000원을 얻는다. 처음 두 이벤트만 재생하면 1월 5일 상태 400,000원을 얻는다.

state_n = fold(apply, initial_state, events[1..n])

이 식에서 이벤트는 단순 로그 문장이 아니라 상태 전이의 입력이다. apply는 같은 입력에 항상 같은 상태를 내야 한다.

5.2 이벤트는 이미 일어난 도메인 사실이다

섹션 제목: “5.2 이벤트는 이미 일어난 도메인 사실이다”

좋은 이벤트 이름은 과거형 사실을 표현한다.

  • MoneyDeposited
  • OrderPaid
  • ShippingAddressCorrected

UpdateOrder처럼 의도를 기록하면 어떤 규칙을 통과해 무엇이 실제로 일어났는지 알기 어렵다. OrderUpdated처럼 너무 넓은 이벤트도 replay와 schema evolution 때 의미가 부족하다.

이미 발생한 사실을 잘못 기록했다면 일반 업무 흐름에서는 과거 이벤트를 덮어쓰지 않는다. 새 정정 이벤트를 추가한다.

v10 MoneyWithdrawn 100,000원
v11 WithdrawalReversed 100,000원, reversesEventId=v10

이것은 Saga의 보상 트랜잭션과 닮았지만 같은 개념은 아니다. 정정 이벤트는 한 stream의 역사와 상태를 바로잡고, Saga 보상은 여러 로컬 트랜잭션으로 진행된 장기 업무를 의미상 보상한다.

“append-only”도 물리적으로 어떤 바이트도 영원히 삭제할 수 없다는 뜻으로 과장하면 안 된다. 법적 삭제, 암호키 폐기, 잘못 유입된 민감정보 제거, 저장소 손상 복구에는 별도 통제 절차가 필요하다. 정상 도메인 변경 API가 과거 이벤트를 임의 수정하지 않는다는 것이 핵심이다.

5.3 Audit log와 source of truth는 다르다

섹션 제목: “5.3 Audit log와 source of truth는 다르다”

감사 로그(Audit log)는 누가 언제 어떤 작업을 했는지 조사하기 위한 기록이다. Event Sourcing의 event log는 애플리케이션 상태를 재구성하는 권위 있는 입력이다.

질문보조 감사 로그Event Sourcing event log
현재 상태의 원천현재 상태 테이블이벤트 stream
replay로 상태 복원보장하지 않음핵심 계약
기록 내용사용자·API·before/after 등도메인 상태 전이에 필요한 사실
순서와 버전조사 편의 수준일 수 있음Aggregate별 엄격한 순서 필요
누락 영향감사 공백상태 복원 불가 또는 오염

CRUD 시스템도 DB audit table, temporal table, CDC, 애플리케이션 활동 로그로 충분한 감사성을 만들 수 있다. “누가 설정을 바꿨는지 보고 싶다”만으로 Event Sourcing을 도입하면 재생·versioning·projection 운영 비용을 과하게 지불할 수 있다.

반대로 Event Sourcing을 사용한다고 감사 요구가 자동 충족되는 것도 아니다. 이벤트에 actor, reason, correlation ID가 없거나 민감정보 접근 기록이 별도라면 감사 질문에 답하지 못한다.

Event Store 는 Aggregate별 stream과 버전을 보존하고, 기대 버전이 맞을 때 새 이벤트 묶음을 원자적으로 append하는 저장소다.

최소 event envelope는 다음 정보를 가진다.

interface EventEnvelope<T> {
eventId: string;
streamId: string;
streamVersion: number;
eventType: string;
schemaVersion: number;
occurredAt: string;
actorId?: string;
correlationId?: string;
causationId?: string;
payload: T;
}
  • eventId: 중복 식별
  • streamId: Aggregate의 이벤트 경계
  • streamVersion: stream 안의 순서와 OCC
  • schemaVersion: payload 해석 규칙
  • correlationId: 같은 업무 흐름 연결
  • causationId: 직접 원인이 된 Command나 이벤트 연결

벽시계 occurredAt만으로 순서를 정하지 않는다. 서버 시계가 어긋나거나 같은 밀리초에 여러 이벤트가 생길 수 있다. Aggregate 상태에는 저장소가 보장한 streamVersion 순서를 사용한다.

Event Store는 제품 이름보다 위의 계약을 어떤 방식으로 구현하고 운영할지로 선택한다. 다음 표의 “전용 Event Store”는 특정 제품 추천이 아니라 stream append와 subscription을 일급 기능으로 제공하는 저장소 범주를 뜻한다.

선택지Aggregate별 순서conditional append운영 특성replay·retention tradeoff
PostgreSQL 사용자 정의 표(stream_id, version) unique key와 head row로 보장transaction 안에서 head version CAS 후 event INSERT기존 백업·모니터링·SQL 역량 재사용, schema와 도구는 직접 소유임의 stream 조회와 JOIN이 쉽지만 대규모 replay가 OLTP 부하와 경쟁할 수 있음
전용 Event Storestream revision을 저장소가 기본 계약으로 제공expected revision append API가 충돌을 반환stream 읽기·subscription·projection 도구가 준비됨, 별도 운영 역량 필요긴 stream replay가 직접적이지만 보존·삭제·백업 정책을 제품 모델에 맞춰야 함
DynamoDBpartition key + version sort key, 별도 head itemtransaction에서 head CAS와 event Put을 함께 수행서버 관리가 적고 partition 단위 확장, hot key와 비용 모델 주의stream query는 단순하지만 전체 replay·export·장기 보관 경로를 따로 설계해야 함

Kafka 같은 broker는 별도 행으로 넣지 않았다. retention이 Aggregate 수명보다 짧거나 임의 stream의 expected version append와 재수화를 제공하지 않으면 전달 로그이지 Event Store 계약을 충족한 상태가 아니기 때문이다. 반대로 팀이 이 계약을 직접 구현했다면 기술적으로 사용할 수는 있지만, broker offset과 stream version을 분리하고 source-of-truth 수준의 백업·삭제·복구를 책임져야 한다.

다음 수치는 저장소 후보를 비교하기 위한 planning assumption이며 실제 측정값이 아니다.

Aggregate 수: 200만 주문/년
주문당 이벤트: 평균 8개, p99 35개
평균 event envelope: 1.5KB
활성 구간 append: 평균 180 events/s
프로모션 peak: 2,500 events/s, 20분
보존: 7년
전체 projection rebuild: 분기 1회 이하
현재 팀: PostgreSQL 24시간 운영 경험 있음
새 저장소 on-call 여력: 없음

용량 계획에 가정한 평균 8 events/order를 적용하면 연간 event payload는 십진 단위로 2,000,000 * 8 * 1.5KB = 24,000,000KB, 즉 약 24GB다. 인덱스, JSON 저장 오버헤드, 복제본, WAL(Write-Ahead Log), 백업을 포함하면 실제 저장 비용은 이보다 몇 배 클 수 있지만, 이 계산만으로 “무한 확장 저장소”가 필요한 규모라고 결론 내릴 수는 없다. WAL은 DB가 데이터 페이지보다 먼저 순차 로그에 변경을 기록해 장애 복구와 복제를 가능하게 하는 구조다. peak 2,500 events/s도 append가 짧고 stream head 충돌이 주문별로 분산된다면 기존 PostgreSQL이 감당하는지 부하 시험할 수 있는 범위다.

이 조건에서는 PostgreSQL 사용자 정의 Event Store를 1차 후보로 둔다. 이유는 다음과 같다.

  1. 팀이 백업, point-in-time recovery, replica lag, index bloat를 이미 운영한다.
  2. 주문 stream이 짧아 단일 Aggregate 재수화가 전체 scan을 요구하지 않는다.
  3. projection 전체 rebuild가 드물어 OLTP와 시간을 분리하거나 replica·export 경로에서 수행할 수 있다.
  4. 별도 저장소를 도입해 얻는 stream 도구의 이득보다 새로운 장애 모델과 on-call 비용이 현재 더 크다.

구현은 event_stream_heads(stream_id, current_version)events(stream_id, stream_version, event_id, ...)를 한 transaction에서 갱신하고, (stream_id, stream_version)event_id에 unique constraint를 둔다. 그 다음 peak의 2배인 5,000 events/s append, p99 35개 stream read, replica에서의 projection replay가 쓰기 SLA를 침범하지 않는지 검증한다.

조건이 바뀌면 결정도 바뀐다. 수십 개 projector가 지속적으로 catch-up subscription을 사용하고 stream 단위 운영 도구가 핵심이 되며, 이를 맡을 팀이 생기면 전용 Event Store가 후보가 된다. AWS에서 수백만 개 stream에 쓰기가 고르게 분산되고 인프라 서버 운영을 줄이는 것이 우선이며, 전체 export와 비용 예측을 이미 설계했다면 DynamoDB가 후보가 된다. 반대로 하나의 인기 계정 stream에 초당 수천 append가 몰리면 DynamoDB의 전체 규모가 커도 그 partition은 hot key가 된다. 이 문제는 저장소 이름이 아니라 Aggregate 식별자와 순서 경계부터 다시 봐야 한다.

선택을 확정하기 전에 반드시 다음 질문에 숫자로 답한다.

  • 한 stream과 전체 store의 event count 분포는 어떠한가?
  • append p95·p99와 허용 충돌률은 얼마인가?
  • 전체 replay가 live traffic과 공유할 I/O 예산은 얼마인가?
  • 법적 보존 종료, 개인정보 삭제, 백업 복구를 어떤 단위로 수행하는가?
  • 저장소 장애 때 “마지막으로 안전한 stream version”을 어떻게 증명하는가?

Rehydrate(재수화) 는 초기 상태에 이벤트를 순서대로 적용해 Aggregate 상태를 복원하는 과정이다.

type BankEvent =
| { type: "MONEY_DEPOSITED"; amount: number }
| { type: "MONEY_WITHDRAWN"; amount: number }
| { type: "WITHDRAWAL_REVERSED"; amount: number };
type BankAccountState = {
balance: number;
version: number;
};
function applyBankEvent(
state: BankAccountState,
event: BankEvent,
): BankAccountState {
switch (event.type) {
case "MONEY_DEPOSITED":
case "WITHDRAWAL_REVERSED":
return {
balance: state.balance + event.amount,
version: state.version + 1,
};
case "MONEY_WITHDRAWN":
return {
balance: state.balance - event.amount,
version: state.version + 1,
};
}
}
function rehydrate(events: BankEvent[]): BankAccountState {
return events.reduce(applyBankEvent, { balance: 0, version: 0 });
}
const current = rehydrate([
{ type: "MONEY_DEPOSITED", amount: 500_000 },
{ type: "MONEY_WITHDRAWN", amount: 100_000 },
{ type: "MONEY_DEPOSITED", amount: 300_000 },
]);
// current = { balance: 700000, version: 3 }

applyBankEvent 안에서 이메일을 보내거나 외부 결제를 호출하면 안 된다. replay할 때 과거 이메일과 결제가 다시 실행되기 때문이다. Aggregate의 apply 함수와 projector는 저장된 이벤트를 상태로 바꾸는 결정적 로직이어야 한다.

외부 부작용은 live event delivery 경로의 별도 handler가 수행하고, replay 모드와 구분한다. 그 handler도 event ID나 provider idempotency key로 중복을 견뎌야 한다.

한 Command가 여러 이벤트를 만들 수 있으므로 이벤트 묶음 append는 원자적이어야 한다.

interface EventStore {
read(
streamId: string,
afterVersion?: number,
): Promise<EventEnvelope<unknown>[]>;
append(
streamId: string,
expectedVersion: number,
events: Array<Omit<EventEnvelope<unknown>, "streamVersion">>,
): Promise<number>;
}
현재 stream version = 12
Command A append(expectedVersion=12, [OrderPaid])
-> v13 성공
Command B append(expectedVersion=12, [OrderCancelled])
-> WrongExpectedVersion 실패

Event Store가 “같은 version 행이 이미 있는지”만 검사하는 것보다, stream의 현재 끝이 expected version인지 원자적으로 검사해야 한다. 중간 version이 빠진 stream이나 두 writer가 서로 다른 끝을 만든 상태를 허용하면 replay 순서가 깨진다.

5.7 Snapshot은 source of truth가 아니라 가속 장치다

섹션 제목: “5.7 Snapshot은 source of truth가 아니라 가속 장치다”

이벤트 수가 늘면 재수화 비용은 적용할 이벤트 수에 비례한다.

replay_cost ≈ event_count * average_apply_cost

이벤트 12,000개, 이벤트당 apply 비용을 예시로 0.2ms라고 하면 전체 replay는 약 2.4초다. version 11,500의 snapshot이 있으면 남은 500개만 적용하므로 약 100ms로 줄어든다.

state_current = replay(events[snapshot.version + 1 .. current], snapshot.state)

snapshot은 특정 version에서 계산한 파생 상태다. 삭제해도 event stream에서 다시 만들 수 있어야 한다. snapshot만 남기고 과거 이벤트를 버리면 원래 Event Sourcing이 제공하던 역사 재생과 새 projection 구축 능력이 사라진다.

snapshot 주기를 무조건 500개로 고정하지 않는다.

  • Aggregate load p95가 예산을 넘는가?
  • stream 길이 분포의 tail이 얼마나 긴가?
  • snapshot 저장 비용과 검증 비용은 얼마인가?
  • 이벤트 apply 로직 변경 뒤 snapshot을 폐기하거나 migration할 수 있는가?

snapshot에는 최소한 streamId, streamVersion, snapshotSchemaVersion, 상태 payload를 둔다. 가능하면 checksum이나 생성 코드 버전을 기록해 오래된 snapshot을 조용히 신뢰하는 일을 막는다.

snapshot 무효화 경계는 “코드가 배포되었는가”가 아니라 같은 이벤트 prefix가 같은 Aggregate 상태를 만드는가다.

변경기존 snapshot 처리이유
Command 입력 검증만 강화보통 유지과거 이벤트를 state로 적용하는 의미는 바뀌지 않음
Query projector의 표시 필드 추가Aggregate snapshot 유지read model rebuild 문제이며 Aggregate 재수화와 별개
Aggregate state 필드 추가, 이벤트로 계산 가능migration 또는 재생snapshot payload schema가 새 state를 담지 못함
apply의 금액 계산·상태 전이 의미 수정폐기 후 전체 재생같은 event prefix가 이전 snapshot과 다른 state를 만들어야 함
upcaster가 구버전 이벤트의 해석을 변경폐기 후 전체 재생snapshot 생성 때 사용한 정규화 이벤트 의미가 달라짐
snapshot 직후 이벤트 handler의 외부 발행 변경보통 유지replay state가 아니라 live side effect 경로의 변경

예를 들어 v1 코드가 FeeCharged.amount를 원 단위로 잘못 적용했고 v2에서 센트 단위였음을 바로잡았다면, snapshot version 이후 이벤트만 새 코드로 재생해서는 안 된다. snapshot 안의 잔액 자체가 구버전 의미로 계산되었기 때문이다. 반대로 이메일 제목 템플릿만 바뀌었다면 snapshot을 폐기할 이유가 없다.

기존 운영 예시의 실패 규모도 보존해 보자.

서비스 운영 1년
event store 사용량 500GB
긴 BankAccount stream의 rehydrate 12초

이 숫자만 보고 모든 stream에 snapshot을 넣지는 않는다. 기존 문서의 경험적 가드레일은 이벤트 < 500개면 우선 측정하고, load p99가 200ms를 넘거나 stream이 1,000개를 넘는 구간에서 snapshot을 검토하며, 필요하면 500개 단위로 저장하는 것이었다. 이 값들은 저장소·payload·apply 비용에 따라 달라지는 예시이지 보편 법칙이 아니다.

2년이 지난 이벤트를 저비용 객체 저장소로 옮기는 cold storage도 가능하지만, Event Sourcing의 source of truth를 단순 삭제하는 정책이어서는 안 된다. Aggregate 재수화와 전체 projection rebuild가 필요할 때 투명하게 다시 읽을 수 있고, stream version의 연속성과 무결성을 검증할 수 있어야 한다. 서로 상쇄되는 이벤트를 합치는 compaction도 같은 이유로 원본 역사·감사·과거 replay 요구를 먼저 확인한다.

5.8 이벤트 schema versioning과 upcasting

섹션 제목: “5.8 이벤트 schema versioning과 upcasting”

과거 이벤트는 배포된 과거 코드의 데이터 계약이다. 새 코드가 모든 과거 payload를 계속 읽을 수 있어야 한다.

단순한 additive change는 기본값으로 흡수할 수 있다.

type OrderShippedV1 = {
schemaVersion: 1;
orderId: string;
shippedAt: string;
};
type OrderShippedV2 = {
schemaVersion: 2;
orderId: string;
shippedAt: string;
recipientEmail: string | null;
};
function upcastOrderShipped(
event: OrderShippedV1 | OrderShippedV2,
): OrderShippedV2 {
if (event.schemaVersion === 1) {
return { ...event, schemaVersion: 2, recipientEmail: null };
}
return event;
}

Upcaster 는 저장된 구버전 이벤트를 읽을 때 현재 코드가 이해하는 형태로 변환하는 계층이다. 저장된 원본을 bulk UPDATE하지 않으므로 변환 규칙을 테스트하고 되돌리기 쉽다.

단위 변경처럼 정보가 충분한 변환은 명시적으로 upcast할 수 있다.

type PaymentCapturedV1 = {
schemaVersion: 1;
orderId: string;
amountInWon: number;
};
type PaymentCapturedV2 = {
schemaVersion: 2;
orderId: string;
money: { amountMinor: number; currency: "KRW" };
};
function upcastPaymentCaptured(
event: PaymentCapturedV1 | PaymentCapturedV2,
): PaymentCapturedV2 {
if (event.schemaVersion === 1) {
return {
schemaVersion: 2,
orderId: event.orderId,
// 이 시스템의 KRW minor-unit 정책이 1원임을 계약으로 고정했다.
money: { amountMinor: event.amountInWon, currency: "KRW" },
};
}
return event;
}

이 변환이 안전한 이유는 v1의 모든 숫자가 KRW 원 단위였다는 과거 계약이 있기 때문이다. 만약 v1 amount가 판매 국가에 따라 원, 달러, 엔을 섞어 담았고 통화 필드가 없었다면 upcaster는 정확한 currency와 minor unit을 복원할 수 없다. 현재 주문 테이블의 통화를 JOIN해 채우는 방법도 과거 시점의 통화가 수정되지 않았다는 증거가 없다면 결정적 replay가 아니다.

그러나 모든 변경이 optional field 하나로 끝나지는 않는다.

v1 AddressChanged { fullAddress: "서울시 ..." }
v2 ShippingAddressChanged { country, postalCode, line1 }
v2 BillingAddressChanged { country, postalCode, line1 }

하나의 과거 사실을 두 의미로 분리할 정보가 v1에 없다면 upcaster가 추측해서는 안 된다. 명시적 기본 정책, 별도 migration event, 수동 보정 또는 구버전 전용 해석기가 필요하다.

더 위험한 것은 필드 모양은 같지만 업무 의미가 바뀌는 경우다.

v1 DiscountApplied { amount: 5000 }
의미: 상품 정가에서 차감한 쿠폰 할인액
v2 DiscountApplied { amount: 5000 }
의미: 쿠폰 + 포인트 + 판매자 지원금을 합친 고객 혜택액

이름과 타입이 같아도 두 이벤트는 같은 사실이 아니다. v1을 v2로 그대로 읽으면 “쿠폰 비용” projection이 포인트와 지원금을 포함하는 새 의미와 섞인다. 과거 v1에 구성 항목이 없으므로 coupon=amount, points=0, subsidy=0으로 채우는 것은 데이터 변환이 아니라 업무 가정이다. 재무 보고가 목적이면 v1 전용 의미를 유지하고 projector가 schema별로 집계하거나, 별도 정산 원장에서 근거를 가져와 검증 가능한 migration event를 append해야 한다. 새 의미에는 CustomerBenefitAppliedV2처럼 다른 event type을 주는 편이 역사 해석을 더 정직하게 만든다.

versioning 실패 신호는 다음과 같다.

  • 새 배포 뒤 오래된 stream만 재수화에 실패한다.
  • live 이벤트는 처리되지만 전체 Aggregate replay가 특정 version에서 멈춘다.
  • 서로 다른 projector가 같은 구버전 이벤트를 다르게 해석한다.
  • 필드 이름은 같지만 단위·통화·시간대 의미가 조용히 바뀐다.

5.9 Aggregate replay와 projection rebuild/replay는 시간 여행과 미래 모델 생성의 기반이다

섹션 제목: “5.9 Aggregate replay와 projection rebuild/replay는 시간 여행과 미래 모델 생성의 기반이다”

Aggregate event를 특정 시점까지만 replay하면 과거 상태를 재현할 수 있다. 새 projector로 전체 stream을 projection replay하면 당시에는 없던 조회 모델도 만들 수 있다.

하지만 Aggregate replay가 외부 세계의 과거까지 완벽히 재현한다는 뜻은 아니다. 과거 환율 API, 삭제된 외부 상품 정보, 현재 코드의 비결정적 시간 함수에 의존하면 같은 이벤트로 다른 결과가 나올 수 있다.

이벤트 payload에는 상태 전이에 필요했던 결정 결과를 보존한다. 예를 들어 PaymentCaptured에는 당시 확정된 금액과 통화를 담고, Aggregate replay 중 현재 가격 API를 다시 호출하지 않는다.

5.10 Projection rebuild/replay correctness와 종료 조건

섹션 제목: “5.10 Projection rebuild/replay correctness와 종료 조건”

“consumer가 끝까지 읽었다”는 말만으로 replay 성공을 선언할 수 없다. replay 시작 뒤 source에 새 이벤트가 계속 들어오므로, 먼저 검증할 경계를 고정해야 한다.

rebuild 시작 시:
target_high_watermark = source의 현재 마지막 위치 8,400,000
historical phase:
event 1 .. 8,400,000을 새 projection에 적용
catch-up phase:
8,400,001 이후 live event를 계속 적용

historical phase의 완료 조건은 checkpoint가 캡처한 target_high_watermark에 도달하는 것이다. live traffic이 계속되면 global backlog가 영원히 정확히 0이 아닐 수 있으므로, 움직이는 최신 위치를 무작정 쫓는 것을 batch replay의 종료 조건으로 삼지 않는다. 전환 전 catch-up phase에서는 최근 일정 시간 동안 freshness_lag가 SLA 안에 있고 source high watermark와 checkpoint의 차이가 허용치 안인지 별도로 본다.

correctness는 위치 외에도 다음 증거를 요구한다.

  1. 처리 실패와 dead-letter event가 0이거나, 제외한 event ID와 이유가 승인되어 있다.
  2. source event 수와 projection row 수를 같은 수로 비교하지 않는다. 여러 이벤트가 한 행으로 접히거나 한 이벤트가 여러 행을 만들 수 있기 때문이다.
  3. 대신 Aggregate별 최종 source version, 업무 합계, partition별 checksum, 대표 query 결과를 비교한다.
  4. 동일한 고정 event fixture를 빈 저장소에 두 번 replay했을 때 같은 checksum이 나와야 한다.
  5. replay 모드에서는 이메일·결제·외부 webhook 같은 side effect handler가 실행되지 않았음을 확인한다.
  6. old와 new projection을 같은 query corpus로 비교하고, 차이가 projector 변경 의도와 일치해야 한다.

worked example로 주문 상태 projection v2를 재구축한다고 하자. source에는 이벤트 8,400,000개, 주문 1,050,000개가 있다. OrderCreated 하나가 주문 행 하나를 만들고 이후 상태 이벤트는 같은 행을 갱신하므로 event count와 row count가 다른 것은 정상이다. 대신 각 주문 행의 source_version이 해당 주문 stream의 마지막 version과 같은지 검사한다. 전체 주문 금액은 취소·환불 의미를 반영한 별도 업무 checksum으로 비교하고, PAID, SHIPPED, CANCELLED 상태별 건수를 old/new에서 대조한다.

다음 차이가 발견되었다고 하자.

old CANCELLED count = 82,104
new CANCELLED count = 82,117
difference = 13

13건을 “전체의 0.001%라 작다”고 자동 허용하지 않는다. 새 projector가 과거 CancellationRequested를 최종 취소로 잘못 해석했다면 금액이 작아도 의미 오류다. 표본 13건의 stream을 추적해 새 규칙으로 의도된 차이인지 확인하고, 의도된 migration 목록과 정확히 일치할 때만 전환한다.

snapshot을 사용하는 Aggregate replay도 같은 원칙을 따른다. 고정 event prefix에 대해 full replay(events 1..N)snapshot@K + replay(events K+1..N)의 state, version, checksum이 같아야 한다. 이 동치가 깨지면 snapshot을 재생성하거나 무효화 규칙을 수정하기 전까지 replay 완료가 아니다.


6. CQRS와 Event Sourcing의 결합과 경계

섹션 제목: “6. CQRS와 Event Sourcing의 결합과 경계”

두 패턴을 결합하면 Event Store에 성공적으로 append된 이벤트를 projector가 읽어 Query 모델을 만든다.

Command
-> Aggregate load/replay
-> invariant check
-> append events with expectedVersion
-> durable event stream
|-> order detail projection
|-> customer order list projection
|-> search projection
|-> analytics projection

이 구조의 장점은 하나의 사실 기록에서 목적이 다른 읽기 모델을 다시 만들 수 있다는 점이다. 비용은 event schema를 장기간 호환하고, projector lag와 실패를 운영하며, replay 가능한 부작용 경계를 유지해야 한다는 점이다.

CQRS와 Event Sourcing 선택

CRUD

현재 상태 모델 하나가 쓰기와 읽기를 담당한다.

규칙과 조회가 단순하고 즉시 일관성이 중요한 기본 경로

CQRS

쓰기 판단과 읽기 표현을 분리하되 현재 상태를 저장할 수 있다.

읽기·쓰기 모델의 변화 이유와 확장 요구가 실제로 다를 때

Event Sourcing

이벤트 stream을 현재 상태의 권위 있는 원천으로 삼는다.

업무 이력과 과거 재현·새 모델 replay가 핵심 가치일 때

CQRS + Event Sourcing

이벤트 stream에서 목적별 projection을 만든다.

복잡한 쓰기 규칙과 여러 조회 모델, replay 가치가 함께 있을 때

Event Sourcing을 쓰지 않는 CQRS도 쓰기 모델 변경을 읽기 모델에 전달해야 한다. 대표 선택은 Outbox와 CDC다.

Transactional Outbox 는 도메인 상태 변경과 발행할 integration event를 같은 로컬 DB 트랜잭션에 저장한다. 별도 relay가 Outbox를 읽어 브로커로 발행한다.

트랜잭션 안:
UPDATE orders ...
INSERT outbox(event_id, event_type, payload) ...
커밋 뒤:
relay -> broker -> projector

DB 커밋 후 직접 publish()하는 dual write는 두 실패 창이 있다.

DB 성공 -> publish 전 crash
쓰기 모델만 변경되고 read model은 영원히 모름
publish 성공 -> DB rollback
존재하지 않는 변경이 read model에 보임

Outbox는 DB 상태와 “발행할 사실의 기록”을 원자적으로 묶지만 exactly-once delivery를 보장하지 않는다. relay가 발행 후 published_at 갱신 전에 죽을 수 있으므로 중복 발행과 멱등 소비를 전제로 한다.

CDC(Change Data Capture) 는 앞서 설명한 PostgreSQL WAL 같은 DB 변경 로그를 읽어 행 변경을 스트림으로 변환한다. 애플리케이션 코드가 모든 갱신을 Outbox에 쓰지 않아도 read model을 동기화할 수 있다.

PostgreSQL write tables
-> WAL logical decoding
-> Debezium
-> Kafka
-> projector consumers

CDC record는 “orders.status가 PENDING에서 PAID로 바뀌었다”는 행 변경을 알려 줄 수 있다. 그러나 왜 바뀌었는지, 어떤 도메인 결정을 뜻하는지는 스키마만으로 충분하지 않을 수 있다. CDC를 사용한다고 Event Sourcing이 되는 것은 아니다. 여전히 현재 상태 테이블이 source of truth다.

6.2 Outbox event와 domain event를 구분한다

섹션 제목: “6.2 Outbox event와 domain event를 구분한다”

Aggregate 안의 domain event는 해당 Bounded Context의 업무 사실이다. Outbox에 내보내는 integration event는 다른 Context가 장기간 의존할 공개 계약이다.

둘을 무조건 같은 payload로 쓰면 내부 모델 변경이 모든 소비자에게 전파된다. 필요하면 트랜잭션 경계에서 domain event를 안정적인 integration event로 변환한다.

내부 domain event:
PaymentCaptured { paymentAggregateState ... }
외부 integration event:
OrderPaymentConfirmedV2 {
eventId, orderId, amount, currency, occurredAt
}

Kafka처럼 append-only log를 제공하는 브로커가 Event Store 역할을 할 수 있는 설계도 있다. 하지만 Kafka 토픽을 쓴다는 이유만으로 자동으로 Event Store가 되지는 않는다.

확인할 계약은 다음과 같다.

  • Aggregate별 순서와 expected version append를 보장하는가?
  • 상태 수명만큼 이벤트를 보존하는가?
  • 임의 stream을 효율적으로 읽어 Aggregate를 재수화할 수 있는가?
  • schema와 삭제·백업·복구 정책이 source of truth 수준인가?
  • projector checkpoint와 event stream version을 혼동하지 않는가?

EventStoreDB/KurrentDB는 stream 저장과 읽기가 중심이고, Kafka는 전달과 스트리밍이 중심이다. 둘을 함께 써서 Event Store는 원본을, Kafka는 외부 fan-out을 담당할 수도 있다.

6.4 감사만 필요하면 더 단순한 선택을 먼저 본다

섹션 제목: “6.4 감사만 필요하면 더 단순한 선택을 먼저 본다”
요구우선 검토Event Sourcing이 추가로 필요한 신호
누가 설정을 바꿨는지 조회audit table, activity log그 이력으로 상태를 재구성해야 함
과거 행 값 조회temporal table, history table도메인 사실 단위 replay가 필요
검색 인덱스 동기화Outbox 또는 CDCevent stream이 source of truth여야 함
규제 증적변경 불가 저장·접근 로그업무 상태도 이벤트에서 복원해야 함
디버깅trace + structured log과거 Aggregate를 정확히 재현해야 함

7. Saga와의 관계: 선행 패턴이지만 필수 결합은 아니다

섹션 제목: “7. Saga와의 관계: 선행 패턴이지만 필수 결합은 아니다”

Saga는 여러 서비스의 로컬 트랜잭션을 장기 업무로 연결하고 실패 시 보상하는 패턴이다. CQRS는 Command와 Query 모델을 분리하고, Event Sourcing은 이벤트를 상태의 원천으로 저장한다. 세 패턴은 해결하는 문제가 다르다.

Saga 질문:
주문, 결제, 재고의 이미 커밋된 로컬 단계를 어떻게 진행·보상할까?
CQRS 질문:
쓰기 판단과 읽기 표현을 왜, 어디까지 분리할까?
Event Sourcing 질문:
Aggregate 상태의 권위 있는 원천을 현재 행과 이벤트 중 무엇으로 둘까?

Saga는 Event Sourcing 없이 동작할 수 있다. 각 서비스가 일반 CRUD 테이블을 갱신하고 같은 트랜잭션의 Outbox에 다음 이벤트를 기록하면 된다.

Order DB current-state update + Outbox(OrderCreated)
-> Payment DB current-state update + Outbox(PaymentCaptured)
-> Inventory DB current-state update

Event Sourcing을 쓰는 서비스라면 Saga 단계와 보상 결과가 event stream에 자연스럽게 남을 수 있다. 하지만 replay 가능성이 Saga의 보상·멱등성·격리 문제를 대신 해결하지 않는다. 환불 같은 외부 부작용은 여전히 provider idempotency key와 대사(reconciliation)가 필요하다.

앞선 Saga Pattern에서 배운 correlationId, causationId, 보상 상태는 CQRS projection의 추적에도 유용하다. 다만 이 문서는 Saga 구현을 다시 전개하지 않고 이벤트의 source-of-truth와 읽기 모델 경계에 집중한다.

OrderCreated
eventId=e-1
correlationId=checkout-123
causationId=command-create-order
PaymentCaptured
eventId=e-2
correlationId=checkout-123
causationId=e-1
InventoryReservationFailed
eventId=e-3
correlationId=checkout-123
causationId=e-2

이 메타데이터는 흐름을 관찰하게 할 뿐 Saga 상태 머신을 대신하지 않는다. 기존의 “서비스 3개 이하면 Choreography, 4개 이상이면 Orchestration” 기준은 초기 질문으로는 쓸 수 있지만, 서비스 수만으로 결정하면 약하다. 보상 분기 수, 사람이 개입하는 대기 상태, 전체 deadline, 중앙 상태의 병목과 팀 소유권을 함께 본다.


8.1 Command 성공인데 Query에서 보이지 않는다

섹션 제목: “8.1 Command 성공인데 Query에서 보이지 않는다”
POST /orders -> 201, sourceVersion=42
GET /orders -> order-42 없음

먼저 processing latency, freshness lag, backlog 중 무엇이 커졌는지 분리하고 허용된 지연인지 SLA 위반인지 판단한다. broker high watermark, projector checkpoint, 마지막 projected event의 occurred_at, 가장 최근 source version과 read model의 source version을 비교한다. 무조건 Command를 재시도하면 이미 생성된 주문이 중복될 수 있다.

8.2 Query에는 있는데 write model에는 없다

섹션 제목: “8.2 Query에는 있는데 write model에는 없다”

이 증상은 정상 eventual consistency가 아니다. DB 쓰기와 이벤트 발행의 dual write 순서가 잘못됐거나, projection이 폐기된 이벤트를 처리했거나, rebuild 전환이 원자적이지 않았을 수 있다. Outbox 트랜잭션과 projection lineage를 확인한다.

기존 예시에서 정상 미발행 건수가 < 100건인데 15,430건이 쌓이고, 가장 오래된 행이 5분을 넘었다면 relay 중단이나 broker 실패 신호다. 100건·5분은 보편 임계가 아니라 평상시 유입률과 발행 처리율에 맞춰 정하는 예시다.

건수만 보면 저트래픽에서 오래 멈춘 한 건을 놓칠 수 있다. 다음을 함께 본다.

  • pending count
  • oldest pending age
  • relay publish error rate
  • broker ACK latency
  • duplicate publish count

8.4 Event Store는 커지는데 load가 느려진다

섹션 제목: “8.4 Event Store는 커지는데 load가 느려진다”

이벤트 수와 Aggregate load p95의 상관을 본다. BankAccount.rehydrate()12초까지 늘었다면 snapshot 부재, 지나치게 큰 Aggregate, 느린 upcaster, stream 읽기 인덱스를 구분한다.

snapshot을 도입하기 전에 apply 함수가 외부 I/O를 하는지 확인한다. Aggregate replay 안의 네트워크 호출은 snapshot으로 가려도 projection rebuild와 과거 재현에서 다시 실패한다.

8.5 같은 이벤트가 두 번 적용된다

섹션 제목: “8.5 같은 이벤트가 두 번 적용된다”
OrderCreated eventId=e-42
-> 이메일 2회
-> 매출 합계 2배

at-least-once 전달의 정상 가능성을 애플리케이션이 흡수하지 못한 것이다. event ID의 유일 범위, 처리 기록과 부작용의 원자성, 외부 provider의 idempotency 지원을 확인한다.

8.6 Aggregate replay 결과가 배포마다 달라진다

섹션 제목: “8.6 Aggregate replay 결과가 배포마다 달라진다”

Date.now(), 난수, 현재 환율 API, 순서 없는 컬렉션 순회가 apply/upcaster 안에 들어갔을 수 있다. 같은 stream과 같은 코드 version에서 checksum이 달라지면 결정성 위반으로 본다.

8.7 새 schema 배포 뒤 오래된 stream만 실패한다

섹션 제목: “8.7 새 schema 배포 뒤 오래된 stream만 실패한다”

구버전 이벤트 fixture를 포함한 upcaster 테스트가 빠졌거나, 의미 변경을 optional field로만 처리했을 수 있다. 실패한 eventType + schemaVersion + streamVersion을 기록하고 원본 이벤트를 임의 수정하지 않는다.

8.8 CDC pipeline이 조용히 뒤처진다

섹션 제목: “8.8 CDC pipeline이 조용히 뒤처진다”

connector 프로세스가 살아 있어도 replication slot, LSN(Log Sequence Number), broker offset, projector checkpoint 사이 간극이 커질 수 있다. 각 값은 서로 다른 대상과 단위를 측정한다.

관측값무엇을 측정하는가단위와 해석 경계
WAL byte gap현재 WAL LSN과 confirmed_flush_lsn 사이의 connector 미확인 구간, 또는 현재 WAL LSN과 restart_lsn 사이의 slot 보존 구간byte 위치 차이이며 이벤트 수나 시간 지연이 아니다
broker offset gappartition high watermark와 consumer offset 사이의 broker backlogpartition별 record 위치 차이이며 partition 간 offset을 직접 비교하지 않는다
projector checkpoint / streamVersioncheckpoint는 projection이 적용한 global 또는 partition 위치이고, streamVersion은 한 Aggregate stream 안의 순서와 OCC 기준이다같은 좌표계끼리만 비교하며 streamVersion은 다른 stream과 비교하지 않는다
end-to-end processing latencyprocessed_at - occurred_at, 즉 이벤트 하나의 발생 시각부터 read model 갱신 트랜잭션 반영까지의 시간이다이벤트별 시간이며 p50·p95·p99 분포로 전체 처리 경로를 본다
projection apply delayprocessed_at - committed_at, 즉 source transaction 커밋부터 read model 갱신 트랜잭션 반영까지의 시간이다write commit 이후 비동기 projection 구간만 분리해 본다
freshness lagnow - last_projected_event.occurred_at, 즉 현재 시각에 읽기 모델이 현실에서 얼마나 뒤처졌는지현재 신선도의 시간 지표이며 개별 이벤트 처리 시간과 별개다

따라서 이벤트 발생부터 read model 반영까지는 end-to-end processing latency, source commit부터 반영까지는 projection apply delay다. freshness lag는 마지막 반영 이벤트의 발생 시각을 기준으로 현재 읽기 모델의 뒤처짐을 나타내므로, 같은 타임라인에서도 두 지연과 값이 다를 수 있다. DB 승격이나 업그레이드 뒤 slot이 사라졌다면 일부 변경이 건너뛰어졌을 가능성을 확인한다.

부분 patch가 안전하다는 증거가 없으면 source DB snapshot이나 보존된 event stream에서 새 projection을 전체 재구축한다.

8.9 OCC 충돌이 특정 Aggregate에 집중된다

섹션 제목: “8.9 OCC 충돌이 특정 Aggregate에 집중된다”

명시적 TypeORM version CAS나 Event Store expected version 실패가 정상적인 드문 충돌이 아니라 지속적 처리량 제한이 된 상태다. 예전 version 3을 읽은 두 요청 중 하나가 version 4를 먼저 쓰면 다른 하나가 실패하는 것은 정상이다. 하지만 재시도 후에도 계속 충돌하면 hot key, 긴 Command 처리 시간, 지나치게 넓은 Aggregate를 먼저 본다.

8.10 환경 초기화 비용을 패턴 비용으로 오해하지 않는다

섹션 제목: “8.10 환경 초기화 비용을 패턴 비용으로 오해하지 않는다”

기존 예시처럼 Lambda 첫 요청이 8~12초, 이후 요청이 100~200ms이고 Nest 초기화가 7초 이상이라면 CQRS 개념 자체보다 framework handler scanning과 cold start를 측정해야 한다. handler 수 50개가 임계라는 고정 법칙은 없다. bundle 크기, DI graph, runtime, provisioned concurrency 비용을 함께 비교한다.


9. 의사결정 기준: 언제 무엇을 선택할까

섹션 제목: “9. 의사결정 기준: 언제 무엇을 선택할까”
  1. 쓰기 불변식과 읽기 표현이 실제로 다른 속도로 변하는가?
  2. 측정된 읽기 병목이 인덱스·쿼리 개선·캐시·replica로 해결되지 않는가?
  3. projection staleness budget을 숫자로 합의할 수 있는가?
  4. 중복·순서·rebuild·lag를 관측하고 운영할 소유자가 있는가?
  5. 코드 모델 분리만으로 충분한지 먼저 시험했는가?
  1. 이벤트 이력이 단순 감사가 아니라 업무의 권위 있는 사실인가?
  2. 과거 상태 재현이나 새 projection의 전체 replay가 실제 제품 가치인가?
  3. 이벤트 schema를 수년간 호환할 책임을 감당할 수 있는가?
  4. 개인정보 삭제와 보존 규정을 event log에서 해결할 수 있는가?
  5. 외부 부작용 없이 결정적으로 replay할 수 있는가?
  6. snapshot, upcaster, projection rebuild를 테스트할 수 있는가?

특히 다음 반례에서는 기본값을 Event Sourcing으로 두지 않는다.

  • 개인정보 삭제가 잦은 도메인: append-only 원칙과 삭제권을 함께 만족하려면 PII(Personally Identifiable Information)를 event payload 밖의 별도 저장소에 두거나, 필드별 암호화 키를 폐기하는 crypto shredding 같은 추가 설계가 필요하다. 키 폐기 뒤에도 비민감 이벤트가 replay 가능한지 검증해야 한다.
  • replay가 외부 부작용에 의존하는 도메인: 과거 이벤트를 적용하며 결제·메일·현재 환율 API를 다시 호출해야 한다면 결정적 복원이 깨진다. 당시 확정된 결정 결과를 이벤트에 보존하거나 Event Sourcing을 피한다.
  • 현재 상태만 가치가 있는 짧은 수명 데이터: 세션이나 외부에서 계속 새로 들어오는 날씨처럼 과거 상태 재현이 제품 가치가 아니면 현재 상태 저장이 더 직접적이다.

기존 문서는 Collaborative, Compliant, Complex, Changing의 4C3개 이상이면 후보라는 경험적 질문을 제시했다. 이 숫자를 도입 공식으로 쓰면 안 되지만, 여러 사용자의 동시 변경·규제 이력·복잡한 상태 전이·새 projection 요구가 실제로 겹치는지 토론하는 보조 질문으로는 쓸 수 있다.

  • 읽기와 쓰기가 같은 작은 모델을 사용한다.
  • 조회 지연과 부하가 이미 예산 안이다.
  • 쓰기 직후 모든 읽기에 최신 상태가 필요하다.
  • 이력은 audit table로 충분하고 상태 replay 가치가 없다.
  • 팀이 broker, projector, replay pipeline을 운영할 여력이 없다.
  • 문제의 원인이 모델 결합이 아니라 잘못된 인덱스나 N+1 query다.

“나중에 커질 것 같아서”는 충분한 도입 근거가 아니다. 현재의 측정 가능한 strain과 운영 가능한 consistency contract가 있어야 한다.

9.4 CRUD / CQRS / Event Sourcing 결정 매트릭스

섹션 제목: “9.4 CRUD / CQRS / Event Sourcing 결정 매트릭스”

세 이름을 한 축의 성숙도 단계처럼 보면 “CRUD 다음은 CQRS, 그다음은 Event Sourcing”이라는 잘못된 도입 순서가 생긴다. 실제로는 모델 분리 여부상태의 원천이라는 두 축의 조합이다.

선택쓰기 상태의 source of truth읽기 모델일관성·운영 비용선택할 강한 신호피해야 할 신호
CRUD현재 상태 행같은 모델 또는 단순 DTO가장 단순, read-your-writes 직접 제공작은 모델, 단순 조회, 현재 상태가 핵심, 즉시 일관성 요구읽기 표현 때문에 쓰기 schema가 반복해서 왜곡되고 측정된 병목이 지속
state-based CQRS현재 상태 행별도 projection동기화 지연·멱등성·rebuild 운영쓰기 불변식과 읽기 표현이 다르게 변하고, 읽기 부하·검색 요구가 큼projection SLA와 운영 소유자가 없고 코드 모델 분리만으로 충분
Event SourcingAggregate event stream재수화 상태 또는 단순한 동일 모델schema 호환·OCC·snapshot·replay 책임과거 상태와 의사결정 이력이 제품 가치이고 stream 단위 복원이 중심감사 로그만 필요하거나 삭제·보존·결정적 replay를 감당하지 못함
CQRS + Event SourcingAggregate event stream목적별 projection 여러 개두 패턴의 비용을 모두 부담복잡한 불변식, 여러 읽기 모델, 과거 재현과 새 projection 생성이 모두 큼“확장 가능해 보인다” 외에 replay와 모델 분리의 독립 근거가 없음

결정은 가장 단순한 행에서 시작한다. 예를 들어 주문 목록 JOIN이 느리지만 과거 주문 상태 복원이 제품 요구가 아니라면 state-based CQRS면 충분하다. 반대로 규제된 원장처럼 사건 순서와 과거 잔액 재현이 핵심이지만 조회가 단순하다면 Event Sourcing만 사용할 수 있다. 두 문제의 근거가 각각 존재할 때만 CQRS + Event Sourcing을 선택한다.

구체적인 도입 질문은 다음 순서로 좁힌다.

1. 현재 CRUD의 측정된 문제는 무엇인가?
- 없다 -> CRUD 유지
2. 문제는 읽기 표현/부하와 쓰기 불변식의 충돌인가?
- 예 -> 코드 수준 CQRS부터 실험
3. 이벤트 자체가 권위 있는 업무 사실이어야 하는가?
- 아니오 -> state-based CQRS
- 예 -> Event Sourcing 비용 검토
4. 독립적인 읽기 projection도 여러 개 필요한가?
- 아니오 -> Event Sourcing 단독 가능
- 예 -> CQRS + Event Sourcing 후보

“감사 이력이 필요하다”는 3번의 자동 가 아니다. 현재 상태 테이블과 별도 감사 로그로 요구를 만족하면서 replay 가치가 없다면 Event Sourcing을 추가하지 않는다. “조회가 많다”도 2번의 자동 가 아니다. 인덱스, cache, read replica로 해결되고 쓰기 모델과 읽기 표현의 변화 이유가 같다면 모델 분리 비용을 지불할 필요가 없다.

조건우선 후보피해야 할 단순화
단순 현재 상태와 즉시 읽기CRUD + transactionCQRS를 기본 템플릿으로 강제
복잡한 조회만 병목CQRS projection, cache, replicaEvent Sourcing까지 묶어 도입
DB 변경을 범용 복제CDCrow change를 domain event로 간주
명시적 업무 이벤트 발행Transactional OutboxDB write 뒤 직접 publish
이벤트가 상태의 정본Event Store + replaybroker retention을 영구 원본으로 가정
여러 서비스 장기 업무SagaEvent Sourcing이 보상을 해결한다고 가정

10. 테스트 계약과 다음 문서로의 handoff

섹션 제목: “10. 테스트 계약과 다음 문서로의 handoff”

CQRS와 Event Sourcing은 happy-path API 테스트 하나로 검증하기 어렵다. 구조가 약속하는 실패 의미를 계층별로 테스트해야 한다.

10.1 Aggregate 단위 테스트: given / when / then

섹션 제목: “10.1 Aggregate 단위 테스트: given / when / then”
Given:
OrderCreated(v1)
PaymentCaptured(v2)
When:
ShipOrder
Then:
OrderShipped가 생성되고 version은 3
it("결제된 주문만 배송한다", () => {
const order = Order.rehydrate([
orderCreated({ version: 1 }),
paymentCaptured({ version: 2 }),
]);
const newEvents = order.ship();
expect(newEvents).toEqual([orderShipped({ version: 3 })]);
});

반례도 필요하다. 취소된 주문, 미결제 주문, 이미 배송된 주문에서 Command가 이벤트를 만들지 않는지 검증한다.

10.2 Aggregate replay와 snapshot 동치성

섹션 제목: “10.2 Aggregate replay와 snapshot 동치성”
full replay(events 1..12000)
== replay(snapshot@11500, events 11501..12000)

상태 equality뿐 아니라 Aggregate version과 중요한 derived value를 비교한다. apply 로직을 바꾼 배포에서 오래된 snapshot을 폐기해야 하는지도 테스트한다.

운영에서 실제로 저장된 각 eventType + schemaVersion fixture를 현재 코드로 읽는다. 최신 이벤트만 생성하는 테스트로는 과거 stream 파손을 잡지 못한다.

10.4 Projector 멱등성과 순서 테스트

섹션 제목: “10.4 Projector 멱등성과 순서 테스트”
same event twice -> same final row
v5 then v4 -> v4가 최신 상태를 덮지 않음
crash before commit -> retry로 한 번의 결과에 수렴
rebuild from beginning -> live projection과 checksum 일치

10.5 Consistency contract 통합 테스트

섹션 제목: “10.5 Consistency contract 통합 테스트”

Command 성공 뒤 projection이 SLA 안에 source version을 따라오는지 검증한다. “즉시 보인다”를 sleep 한 번으로 확인하지 말고, 제한 시간 동안 version을 poll하고 실제 lag를 기록한다.

후속 Testing Strategy는 Unit, Integration, E2E의 비용과 피드백 속도를 선택하는 기준을 다룬다. 이 문서에서 정의한 Aggregate 불변식, event store append, projector rebuild, processing latency·freshness lag·backlog SLA를 어느 테스트 계층에 둘지 그 기준으로 배치하면 된다.


CQRS와 Event Sourcing 설계 복습

  • CQRS의 논리적 모델 분리와 물리적 DB 분리를 구분할 수 있다
  • Command가 Aggregate 불변식을 보호하고 Query projection이 그 판단 원천이 아닌 이유를 설명할 수 있다
  • Event Sourcing이 CQRS의 필수 구성요소가 아닌 이유를 네 가지 조합으로 설명할 수 있다
  • audit log와 source-of-truth event log의 차이를 설명할 수 있다
  • expectedVersion을 이용한 낙관적 동시성 충돌 타임라인을 그릴 수 있다
  • processing latency, freshness lag, backlog를 구분하고 backlog 해소 시간을 손으로 계산할 수 있다
  • projector의 멱등성과 Aggregate별 순서 처리를 구분할 수 있다
  • snapshot이 source of truth가 아니라 replay 가속 장치인 이유를 설명할 수 있다
  • schema version과 upcaster로 과거 이벤트를 읽는 방법을 설명할 수 있다
  • Outbox, CDC, Event Store, broker의 역할을 구분할 수 있다
  • Saga가 Event Sourcing 없이도 동작하는 흐름을 설명할 수 있다
  • CQRS나 Event Sourcing보다 CRUD가 나은 조건을 제시할 수 있다

키워드설명
CQRSCommand 쓰기 모델과 Query 읽기 모델의 책임 분리
Command상태 변경 의도와 불변식 검증을 요청하는 메시지
Query상태를 바꾸지 않고 조회 결과를 요청하는 메시지
Aggregate한 Command에서 함께 불변식을 지키는 일관성 경계
Invariant성공한 상태 전이 뒤에도 항상 참이어야 하는 업무 규칙
Projection변경 로그에서 파생한 조회 목적별 현재 표현인 read model 결과
Projector변경 기록을 읽어 projection을 생성·갱신하는 함수 또는 프로세스
Eventual Consistency업데이트 중단 뒤 결국 수렴한다는 계약, 시간 상한은 별도
Event Sourcing이벤트 stream을 상태의 권위 있는 원천으로 삼는 영속화
Event Storestream 순서, 버전, 원자적 append를 보장하는 저장소
Aggregate ReplayAggregate event를 순서대로 다시 적용해 상태를 복원하는 과정
Projection Rebuild/Replay원천 이벤트를 새 read model에 적용해 projection을 재구축하는 과정
RehydrateAggregate replay로 현재 상태를 복원하는 과정
Snapshot특정 stream version의 파생 상태를 저장한 Aggregate replay 가속 장치
Upcaster구버전 이벤트를 현재 코드가 읽는 형태로 변환하는 계층
OCCexpected version과 current version을 비교하는 낙관적 동시성 제어
Outbox상태 변경과 발행 예정 이벤트를 같은 트랜잭션에 기록하는 패턴
CDCWAL 같은 DB 로그에서 행 변경을 캡처하는 방식
Idempotent Projector중복 이벤트에도 한 번 적용한 결과로 수렴하는 projector

13. 선택 부록: 환경 의존 구현과 관찰 절차

섹션 제목: “13. 선택 부록: 환경 의존 구현과 관찰 절차”

본문을 이해하는 데 아래 도구는 필요하지 않다. 특정 NestJS·TypeORM·PostgreSQL·Kafka·AWS 환경에서 메커니즘을 관찰할 때만 펼쳐 본다.

NestJS에서 CommandBus와 QueryBus 경계 확인

@nestjs/cqrs는 책임을 라우팅하는 도구다. 라이브러리를 설치했다고 읽기·쓰기 DB가 자동 분리되거나 Event Sourcing이 활성화되지는 않는다.

@CommandHandler(CreateTaskCommand)
export class CreateTaskHandler {
constructor(private readonly tasks: TaskWriteRepository) {}
execute(command: CreateTaskCommand) {
return this.tasks.create(command);
}
}
@QueryHandler(GetTasksQuery)
export class GetTasksHandler {
constructor(private readonly tasks: TaskReadRepository) {}
execute(query: GetTasksQuery) {
return this.tasks.findByUser(query.userId);
}
}

관찰할 것은 controller와 decorator 수가 아니라 두 handler가 의존하는 모델과 실패 계약이 실제로 다른지다.

TypeORM에서 expected version CAS 구현

@VersionColumn은 TypeORM이 entity를 갱신할 때 version 값을 증가시키는 별도 편의 기능이다. 그러나 decorator가 있다는 사실만으로 Command가 요구하는 WHERE version = expectedVersion 비교와 충돌 판정이 보장된다고 가정하지 않는다. 명시적인 Compare-And-Swap(CAS, 비교 후 교체)는 조건부 UPDATE와 영향 행 수로 구현한다.

@Entity()
class Order {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
status: string;
@Column({ type: "integer", default: 0 })
version: number;
}
async function updateStatus(
repository: Repository<Order>,
orderId: string,
expectedVersion: number,
nextStatus: string,
): Promise<number> {
const result = await repository
.createQueryBuilder()
.update(Order)
.set({
status: nextStatus,
version: () => '"version" + 1',
})
.where("id = :orderId", { orderId })
.andWhere('"version" = :expectedVersion', { expectedVersion })
.execute();
if (result.affected !== 1) {
throw new VersionConflict(orderId, expectedVersion);
}
return expectedVersion + 1;
}

이 QueryBuilder가 만드는 핵심 조건은 UPDATE ... WHERE id = ? AND version = ?다. 두 트랜잭션이 version 3을 읽고 하나가 먼저 version 4로 갱신하면 다른 UPDATE의 affected0이다. 반드시 affected === 1을 성공으로 판정해야 한다.

여기서는 @VersionColumn을 사용하지 않고 version을 일반 column으로 두어 CAS 계약을 분명히 했다. 다른 entity에서 decorator를 version counter로 쓸 수는 있지만, 이 예시의 동시성 안전성을 증명하는 것은 명시적 version predicate와 영향 행 수 검사다. 기존 예시는 첫 충돌 뒤 50ms, 다음에는 100ms처럼 최대 3회 재시도했지만, 최신 상태를 다시 읽고 Command 의미를 재검증해야 한다.

충돌이 드문 편이면 짧은 backoff가 처리량을 보존한다. 마지막 재고처럼 경쟁이 잦고 대기 순서가 중요하면 SELECT ... FOR UPDATE 같은 비관적 잠금이 후보지만, 잠금 대기와 deadlock 비용을 측정해야 한다.

TypeORM 트랜잭션으로 Outbox 원자성 확인
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler {
constructor(private readonly dataSource: DataSource) {}
execute(command: CreateOrderCommand): Promise<string> {
return this.dataSource.transaction(async (manager) => {
const order = manager.create(Order, {
customerId: command.customerId,
status: "PENDING",
});
await manager.save(order);
await manager.insert(OutboxEvent, {
eventId: randomUUID(),
aggregateId: order.id,
eventType: "OrderCreated",
payload: { orderId: order.id, customerId: order.customerId },
occurredAt: new Date(),
});
return order.id;
});
}
}

트랜잭션을 강제로 실패시켜 ordersoutbox_events가 함께 롤백되는지 확인한다. relay 발행 뒤 상태 기록 전에 프로세스를 종료해 같은 eventId가 재발행되고 projector가 중복을 무시하는지도 확인한다.

SELECT
COUNT(*) AS pending_count,
MIN(created_at) AS oldest_pending_at
FROM outbox_events
WHERE published_at IS NULL;
CDC와 broker checkpoint를 대조할 관찰 지점

PostgreSQL replication slot의 restart_lsn·confirmed_flush_lsn, broker high watermark, projector checkpoint를 같은 파이프라인 순서로 대조한다. connector가 active여도 세 위치의 간극이 계속 커지면 freshness는 회복되지 않는다. 재구축은 운영 projection을 비우지 않고 새 consumer identity와 새 저장소에서 수행하며, 본문 5.10의 high watermark·checksum·side effect 금지 조건을 만족한 뒤 라우팅한다. broker retention이 전체 역사보다 짧다면 broker만으로 full replay할 수 없으므로 Event Store나 검증된 source snapshot이 필요하다.

DynamoDB를 Event Store로 쓸 때의 version 조건

PK=aggregateId, SK=version 구조는 Aggregate별 순서 조회에 적합하다. 그러나 같은 version의 중복만 막지 말고 stream 끝의 expected version 계약을 트랜잭션으로 구현해야 한다.

아래 방식은 stream을 만들 때 head item을 currentVersion=0으로 먼저 생성한다. head 생성은 attribute_not_exists(streamId) 조건으로 한 번만 성공하며, 첫 이벤트 append와 이후 append는 모두 같은 currentVersion = :expected CAS를 사용한다.

const createdAt = new Date().toISOString();
await client.send(
new PutItemCommand({
TableName: "event-stream-heads",
Item: marshall({ streamId, currentVersion: 0, createdAt }),
ConditionExpression: "attribute_not_exists(streamId)",
}),
);

이미 존재하는 stream을 여는 경우 head 생성의 conditional failure는 “현재 stream을 읽어 이어서 append하라”는 의미이고, 임의로 head를 덮어쓰면 안 된다. 새 stream의 첫 append는 expectedVersion=0, 다음 append는 성공 결과인 expectedVersion=1을 사용한다.

await client.send(
new TransactWriteItemsCommand({
TransactItems: [
{
Update: {
TableName: "event-stream-heads",
Key: marshall({ streamId }),
UpdateExpression: "SET currentVersion = :next",
ConditionExpression: "currentVersion = :expected",
ExpressionAttributeValues: marshall({
":expected": expectedVersion,
":next": expectedVersion + 1,
}),
},
},
{
Put: {
TableName: "events",
Item: marshall({
aggregateId: streamId,
version: expectedVersion + 1,
eventId: event.eventId,
eventType: event.eventType,
payload: event.payload,
}),
ConditionExpression: "attribute_not_exists(aggregateId)",
},
},
],
}),
);

events 테이블의 key schema가 (aggregateId, version)이므로 Put 조건은 해당 복합 key의 이벤트가 아직 없음을 검사한다. 첫 append에서 head 0 -> 1과 event version 1 Put이 함께 커밋되고, 후속 append에서 head 1 -> 2와 event version 2 Put이 함께 커밋된다. 두 writer가 모두 expected version 1로 시작하면 head CAS는 하나만 성공하므로 version 2 이벤트도 하나만 남는다.

head 생성이 성공한 뒤 첫 append 전에 프로세스가 죽으면 이벤트가 없는 currentVersion=0 head가 남을 수 있다. 이 빈 head는 이후 첫 append가 다시 expectedVersion=0 CAS를 수행할 수 있으므로 append 정확성에는 해가 없다. 다만 사용하지 않는 stream ID가 계속 쌓이지 않도록 충분한 유예 시간이 지난 createdAtcurrentVersion=0을 모두 만족할 때만 head를 조건부 삭제한다.

const cutoff = new Date(Date.now() - EMPTY_HEAD_GRACE_PERIOD_MS).toISOString();
await client.send(
new DeleteItemCommand({
TableName: "event-stream-heads",
Key: marshall({ streamId }),
ConditionExpression: "currentVersion = :empty AND createdAt <= :cutoff",
ExpressionAttributeValues: marshall({
":empty": 0,
":cutoff": cutoff,
}),
}),
);

currentVersion=0이면 head 생성 뒤 성공한 이벤트 append가 없다는 뜻이므로 별도 이벤트 조회 없이 빈 head임을 판정할 수 있다. 정리와 첫 append가 경합해 append가 먼저 성공하면 delete 조건이 실패하고, 정리가 먼저 성공하면 append의 head CAS가 실패한다. 조건부 실패를 받은 쪽은 최신 head를 다시 읽는다. append는 head가 없으면 새 createdAt으로 다시 생성하고, head가 있으면 그 currentVersion에서 전체 append 절차를 재시도한다. 정리는 최신 createdAtcurrentVersion을 다시 평가해 두 삭제 조건이 여전히 참일 때만 재시도하고, 아니면 삭제를 중단하거나 다음 정리 주기로 미룬다.

기존 문서가 제시한 AWS 공식 제한은 한 item 400KB, transaction 100개 unique item 또는 4MB다. 제품 제한은 설계 시점에 AWS DynamoDB Constraints에서 다시 확인한다. 큰 payload는 객체 저장소에 두고 이벤트에는 불변 pointer와 checksum을 저장하는 선택을 검토한다.

이벤트 replay를 명령 없이 직접 확인

다음 입력은 본문의 rehydrate와 같은 결과를 내야 한다.

const events: BankEvent[] = [
{ type: "MONEY_DEPOSITED", amount: 500_000 },
{ type: "MONEY_WITHDRAWN", amount: 100_000 },
{ type: "MONEY_DEPOSITED", amount: 300_000 },
];
console.log(rehydrate(events));
console.log(rehydrate(events.slice(0, 2)));
{ balance: 700000, version: 3 }
{ balance: 400000, version: 2 }

두 번 실행한 결과가 같고 외부 부작용이 발생하지 않아야 한다.

Lambda에서 첫 요청이 8~12초, warm 요청이 100~200ms라면 CQRS 처리 시간과 runtime 초기화를 분리한다. CloudWatch Init Duration과 bootstrap span으로 bundle·DI graph 비용을 확인하며, handler 수만으로 CQRS 패턴의 실패라고 결론 내리지 않는다.



CQRS는 읽기와 쓰기의 저장 기술을 무조건 둘로 나누는 패턴이 아니라, 불변식을 판단하는 쓰기 모델과 조회 목적의 읽기 모델을 분리하는 선택이다. Event Sourcing은 그 위에 필수로 붙는 기능이 아니라, 이벤트 stream을 상태의 source of truth로 삼을 가치가 있을 때 선택하는 영속화 방식이다.

CQRS를 선택했다면 processing latency·freshness lag·backlog, read-your-writes, 멱등성, 순서와 rebuild를 운영해야 한다. Event Sourcing까지 선택했다면 Aggregate별 version append, 결정적 replay, snapshot, schema version과 upcaster를 장기 계약으로 책임져야 한다. 이 비용보다 읽기·쓰기 strain과 역사 replay의 가치가 작다면 CRUD가 더 나은 설계다.