CRUD
현재 상태 모델 하나가 쓰기와 읽기를 담당한다.
규칙과 조회가 단순하고 즉시 일관성이 중요한 기본 경로분류: 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 모델로 쓰고 읽기이 네 조합을 먼저 구분해야 다음과 같은 잘못된 결론을 피할 수 있다.
필수 선수지식은 트랜잭션의 원자성, DDD의 Aggregate 경계, 분산 시스템의 일관성 모델이다. DB 로그 복제의 내부 동작과 서비스 분할 패턴은 각각 DB Replication & Sharding, MSA Patterns에서 선택 심화로 이어서 읽을 수 있다.
전통적인 CRUD(Create, Read, Update, Delete)는 대부분의 시스템에 좋은 기본값이다. 하나의 트랜잭션 모델과 저장소를 사용하면 코드 경로, 장애 지점, 배포 단위가 적고 쓰기 직후 같은 데이터를 읽기도 쉽다.
문제는 시스템이 커졌다는 사실 자체가 아니다. 한 모델이 서로 다른 변화 이유를 동시에 감당할 때 긴장이 생긴다.
주문 시스템의 쓰기 모델은 다음 질문에 답해야 한다.
PAID -> SHIPPED 순서로만 전이하는가?반면 주문 목록 조회 모델은 다른 질문에 답해야 한다.
정규화된 쓰기 스키마는 중복을 줄이고 무결성을 보호한다. 그러나 목록 한 화면을 위해 6개 테이블을 매번 JOIN하면 조회 비용과 변경 이유가 쓰기 모델 안으로 들어온다. 반대로 화면 모양대로 역정규화한 테이블을 쓰기 원본으로 삼으면 한 Command가 여러 중복 필드를 동시에 갱신해야 한다.
다음 숫자는 표준이 아니라 팀이 측정 결과를 토론하기 위한 예시 가드레일이다.
주문 생성: 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로 문제가 해결되면 그쪽이 더 단순하다.
CQRS는 세 단계로 적용할 수 있다.
1단계: 코드 모델만 분리 Command handler / Query handler 같은 DB, 같은 트랜잭션 저장소
2단계: 스키마 또는 테이블 분리 write tables / read projection tables 같은 DB 인스턴스
3단계: 저장소와 배포 경로 분리 PostgreSQL write model Elasticsearch/Redis/PostgreSQL read model1단계만으로도 쓰기 규칙과 조회 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 모델과 트랜잭션이 더 정확하고 싸다.
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=8B를 무조건 재시도하면 안 된다. 최신 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 | 분할 가능한 업무 식별자와 순서 범위 |
Projection(프로젝션) 은 쓰기 모델의 변경에서 파생한 조회 목적별 현재 표현, 즉 derived read model 결과다.
projector(프로젝터) 는 변경 기록을 읽어 projection을 생성하거나 갱신하는 함수 또는 프로세스다.
예를 들어 projector는 OrderCreated, PaymentCaptured, OrderShipped를 소비해 고객별 주문 목록 projection의 한 행을 갱신할 수 있다.
write model Order Aggregate / normalized tables | | domain event, outbox event, or CDC record vprojector | +--> order_list_by_customer +--> order_search_index +--> daily_sales_summary같은 이벤트에서 여러 projection을 만들 수 있다. 각 projection은 조회 목적과 freshness 계약이 다르므로 독립적으로 재구축하고 확장할 수 있다.
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만으로는 부족하다.
가능한 대응은 다음과 같다.
PENDING_SYNC 상태와 freshness를 제품 상태로 표현한다.모든 Query를 writer로 우회하면 분리한 읽기 부하가 다시 쓰기 저장소로 몰린다. read-your-writes가 필요한 경로만 좁혀야 한다.
projector가 살아 있다는 health check 하나로 읽기 모델의 상태를 알 수 없다. 처리 성능, 현재 조회의 신선도, 남은 작업량은 서로 다른 질문이므로 다음 세 지표를 분리한다.
processing_latency = processed_at - occurred_atfreshness_lag = now - last_projected_event.occurred_atbacklog = source_high_watermark - checkpointprocessing_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 범위를 먼저 본다.
다음 계산은 이벤트 발생 시각의 지연이 아니라 쌓인 이벤트 수와 drain 시간에만 적용한다.
평상시 유입률: 500 events/sconsumer 중단: 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에서 실행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/replay3. projection rebuild/replay 중 들어오는 새 이벤트도 같은 순서 경계로 반영4. v1과 v2의 row count, checksum, 표본 결과 비교5. Query 라우팅을 v2로 전환6. 안정화 뒤 v1 제거운영 projection을 비운 뒤 같은 이름으로 재생하면 재구축 동안 사용자 조회가 깨진다. side-by-side 저장소와 명시적 checkpoint가 필요하다.
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-01v2 MoneyWithdrawn -100,000원 2024-01-05v3 MoneyDeposited +300,000원 2024-01-10v4 MoneyWithdrawn -200,000원 2024-01-15v5 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는 같은 입력에 항상 같은 상태를 내야 한다.
좋은 이벤트 이름은 과거형 사실을 표현한다.
MoneyDepositedOrderPaidShippingAddressCorrectedUpdateOrder처럼 의도를 기록하면 어떤 규칙을 통과해 무엇이 실제로 일어났는지 알기 어렵다.
OrderUpdated처럼 너무 넓은 이벤트도 replay와 schema evolution 때 의미가 부족하다.
이미 발생한 사실을 잘못 기록했다면 일반 업무 흐름에서는 과거 이벤트를 덮어쓰지 않는다. 새 정정 이벤트를 추가한다.
v10 MoneyWithdrawn 100,000원v11 WithdrawalReversed 100,000원, reversesEventId=v10이것은 Saga의 보상 트랜잭션과 닮았지만 같은 개념은 아니다. 정정 이벤트는 한 stream의 역사와 상태를 바로잡고, Saga 보상은 여러 로컬 트랜잭션으로 진행된 장기 업무를 의미상 보상한다.
“append-only”도 물리적으로 어떤 바이트도 영원히 삭제할 수 없다는 뜻으로 과장하면 안 된다. 법적 삭제, 암호키 폐기, 잘못 유입된 민감정보 제거, 저장소 손상 복구에는 별도 통제 절차가 필요하다. 정상 도메인 변경 API가 과거 이벤트를 임의 수정하지 않는다는 것이 핵심이다.
감사 로그(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 안의 순서와 OCCschemaVersion: 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 Store | stream revision을 저장소가 기본 계약으로 제공 | expected revision append API가 충돌을 반환 | stream 읽기·subscription·projection 도구가 준비됨, 별도 운영 역량 필요 | 긴 stream replay가 직접적이지만 보존·삭제·백업 정책을 제품 모델에 맞춰야 함 |
| DynamoDB | partition key + version sort key, 별도 head item | transaction에서 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차 후보로 둔다. 이유는 다음과 같다.
구현은 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 식별자와 순서 경계부터 다시 봐야 한다.
선택을 확정하기 전에 반드시 다음 질문에 숫자로 답한다.
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 순서가 깨진다.
이벤트 수가 늘면 재수화 비용은 적용할 이벤트 수에 비례한다.
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개로 고정하지 않는다.
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 요구를 먼저 확인한다.
과거 이벤트는 배포된 과거 코드의 데이터 계약이다. 새 코드가 모든 과거 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 실패 신호는 다음과 같다.
Aggregate event를 특정 시점까지만 replay하면 과거 상태를 재현할 수 있다. 새 projector로 전체 stream을 projection replay하면 당시에는 없던 조회 모델도 만들 수 있다.
하지만 Aggregate replay가 외부 세계의 과거까지 완벽히 재현한다는 뜻은 아니다. 과거 환율 API, 삭제된 외부 상품 정보, 현재 코드의 비결정적 시간 함수에 의존하면 같은 이벤트로 다른 결과가 나올 수 있다.
이벤트 payload에는 상태 전이에 필요했던 결정 결과를 보존한다.
예를 들어 PaymentCaptured에는 당시 확정된 금액과 통화를 담고, Aggregate replay 중 현재 가격 API를 다시 호출하지 않는다.
“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는 위치 외에도 다음 증거를 요구한다.
0이거나, 제외한 event ID와 이유가 승인되어 있다.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,104new CANCELLED count = 82,117difference = 1313건을 “전체의 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 완료가 아니다.
두 패턴을 결합하면 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 가능한 부작용 경계를 유지해야 한다는 점이다.
현재 상태 모델 하나가 쓰기와 읽기를 담당한다.
규칙과 조회가 단순하고 즉시 일관성이 중요한 기본 경로쓰기 판단과 읽기 표현을 분리하되 현재 상태를 저장할 수 있다.
읽기·쓰기 모델의 변화 이유와 확장 요구가 실제로 다를 때이벤트 stream을 현재 상태의 권위 있는 원천으로 삼는다.
업무 이력과 과거 재현·새 모델 replay가 핵심 가치일 때이벤트 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 -> projectorDB 커밋 후 직접 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 consumersCDC record는 “orders.status가 PENDING에서 PAID로 바뀌었다”는 행 변경을 알려 줄 수 있다. 그러나 왜 바뀌었는지, 어떤 도메인 결정을 뜻하는지는 스키마만으로 충분하지 않을 수 있다. CDC를 사용한다고 Event Sourcing이 되는 것은 아니다. 여전히 현재 상태 테이블이 source of truth다.
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가 되지는 않는다.
확인할 계약은 다음과 같다.
EventStoreDB/KurrentDB는 stream 저장과 읽기가 중심이고, Kafka는 전달과 스트리밍이 중심이다. 둘을 함께 써서 Event Store는 원본을, Kafka는 외부 fan-out을 담당할 수도 있다.
| 요구 | 우선 검토 | Event Sourcing이 추가로 필요한 신호 |
|---|---|---|
| 누가 설정을 바꿨는지 조회 | audit table, activity log | 그 이력으로 상태를 재구성해야 함 |
| 과거 행 값 조회 | temporal table, history table | 도메인 사실 단위 replay가 필요 |
| 검색 인덱스 동기화 | Outbox 또는 CDC | event stream이 source of truth여야 함 |
| 규제 증적 | 변경 불가 저장·접근 로그 | 업무 상태도 이벤트에서 복원해야 함 |
| 디버깅 | trace + structured log | 과거 Aggregate를 정확히 재현해야 함 |
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 updateEvent 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, 중앙 상태의 병목과 팀 소유권을 함께 본다.
POST /orders -> 201, sourceVersion=42GET /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를 재시도하면 이미 생성된 주문이 중복될 수 있다.
이 증상은 정상 eventual consistency가 아니다. DB 쓰기와 이벤트 발행의 dual write 순서가 잘못됐거나, projection이 폐기된 이벤트를 처리했거나, rebuild 전환이 원자적이지 않았을 수 있다. Outbox 트랜잭션과 projection lineage를 확인한다.
기존 예시에서 정상 미발행 건수가 < 100건인데 15,430건이 쌓이고, 가장 오래된 행이 5분을 넘었다면 relay 중단이나 broker 실패 신호다.
100건·5분은 보편 임계가 아니라 평상시 유입률과 발행 처리율에 맞춰 정하는 예시다.
건수만 보면 저트래픽에서 오래 멈춘 한 건을 놓칠 수 있다. 다음을 함께 본다.
이벤트 수와 Aggregate load p95의 상관을 본다.
BankAccount.rehydrate()가 12초까지 늘었다면 snapshot 부재, 지나치게 큰 Aggregate, 느린 upcaster, stream 읽기 인덱스를 구분한다.
snapshot을 도입하기 전에 apply 함수가 외부 I/O를 하는지 확인한다. Aggregate replay 안의 네트워크 호출은 snapshot으로 가려도 projection rebuild와 과거 재현에서 다시 실패한다.
OrderCreated eventId=e-42-> 이메일 2회-> 매출 합계 2배at-least-once 전달의 정상 가능성을 애플리케이션이 흡수하지 못한 것이다. event ID의 유일 범위, 처리 기록과 부작용의 원자성, 외부 provider의 idempotency 지원을 확인한다.
Date.now(), 난수, 현재 환율 API, 순서 없는 컬렉션 순회가 apply/upcaster 안에 들어갔을 수 있다.
같은 stream과 같은 코드 version에서 checksum이 달라지면 결정성 위반으로 본다.
구버전 이벤트 fixture를 포함한 upcaster 테스트가 빠졌거나, 의미 변경을 optional field로만 처리했을 수 있다.
실패한 eventType + schemaVersion + streamVersion을 기록하고 원본 이벤트를 임의 수정하지 않는다.
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 gap | partition high watermark와 consumer offset 사이의 broker backlog | partition별 record 위치 차이이며 partition 간 offset을 직접 비교하지 않는다 |
| projector checkpoint / streamVersion | checkpoint는 projection이 적용한 global 또는 partition 위치이고, streamVersion은 한 Aggregate stream 안의 순서와 OCC 기준이다 | 같은 좌표계끼리만 비교하며 streamVersion은 다른 stream과 비교하지 않는다 |
| end-to-end processing latency | processed_at - occurred_at, 즉 이벤트 하나의 발생 시각부터 read model 갱신 트랜잭션 반영까지의 시간이다 | 이벤트별 시간이며 p50·p95·p99 분포로 전체 처리 경로를 본다 |
| projection apply delay | processed_at - committed_at, 즉 source transaction 커밋부터 read model 갱신 트랜잭션 반영까지의 시간이다 | write commit 이후 비동기 projection 구간만 분리해 본다 |
| freshness lag | now - 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을 전체 재구축한다.
명시적 TypeORM version CAS나 Event Store expected version 실패가 정상적인 드문 충돌이 아니라 지속적 처리량 제한이 된 상태다.
예전 version 3을 읽은 두 요청 중 하나가 version 4를 먼저 쓰면 다른 하나가 실패하는 것은 정상이다.
하지만 재시도 후에도 계속 충돌하면 hot key, 긴 Command 처리 시간, 지나치게 넓은 Aggregate를 먼저 본다.
기존 예시처럼 Lambda 첫 요청이 8~12초, 이후 요청이 100~200ms이고 Nest 초기화가 7초 이상이라면 CQRS 개념 자체보다 framework handler scanning과 cold start를 측정해야 한다.
handler 수 50개가 임계라는 고정 법칙은 없다.
bundle 크기, DI graph, runtime, provisioned concurrency 비용을 함께 비교한다.
특히 다음 반례에서는 기본값을 Event Sourcing으로 두지 않는다.
기존 문서는 Collaborative, Compliant, Complex, Changing의 4C 중 3개 이상이면 후보라는 경험적 질문을 제시했다.
이 숫자를 도입 공식으로 쓰면 안 되지만, 여러 사용자의 동시 변경·규제 이력·복잡한 상태 전이·새 projection 요구가 실제로 겹치는지 토론하는 보조 질문으로는 쓸 수 있다.
“나중에 커질 것 같아서”는 충분한 도입 근거가 아니다. 현재의 측정 가능한 strain과 운영 가능한 consistency contract가 있어야 한다.
세 이름을 한 축의 성숙도 단계처럼 보면 “CRUD 다음은 CQRS, 그다음은 Event Sourcing”이라는 잘못된 도입 순서가 생긴다. 실제로는 모델 분리 여부와 상태의 원천이라는 두 축의 조합이다.
| 선택 | 쓰기 상태의 source of truth | 읽기 모델 | 일관성·운영 비용 | 선택할 강한 신호 | 피해야 할 신호 |
|---|---|---|---|---|---|
| CRUD | 현재 상태 행 | 같은 모델 또는 단순 DTO | 가장 단순, read-your-writes 직접 제공 | 작은 모델, 단순 조회, 현재 상태가 핵심, 즉시 일관성 요구 | 읽기 표현 때문에 쓰기 schema가 반복해서 왜곡되고 측정된 병목이 지속 |
| state-based CQRS | 현재 상태 행 | 별도 projection | 동기화 지연·멱등성·rebuild 운영 | 쓰기 불변식과 읽기 표현이 다르게 변하고, 읽기 부하·검색 요구가 큼 | projection SLA와 운영 소유자가 없고 코드 모델 분리만으로 충분 |
| Event Sourcing | Aggregate event stream | 재수화 상태 또는 단순한 동일 모델 | schema 호환·OCC·snapshot·replay 책임 | 과거 상태와 의사결정 이력이 제품 가치이고 stream 단위 복원이 중심 | 감사 로그만 필요하거나 삭제·보존·결정적 replay를 감당하지 못함 |
| CQRS + Event Sourcing | Aggregate 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 + transaction | CQRS를 기본 템플릿으로 강제 |
| 복잡한 조회만 병목 | CQRS projection, cache, replica | Event Sourcing까지 묶어 도입 |
| DB 변경을 범용 복제 | CDC | row change를 domain event로 간주 |
| 명시적 업무 이벤트 발행 | Transactional Outbox | DB write 뒤 직접 publish |
| 이벤트가 상태의 정본 | Event Store + replay | broker retention을 영구 원본으로 가정 |
| 여러 서비스 장기 업무 | Saga | Event Sourcing이 보상을 해결한다고 가정 |
CQRS와 Event Sourcing은 happy-path API 테스트 하나로 검증하기 어렵다. 구조가 약속하는 실패 의미를 계층별로 테스트해야 한다.
Given: OrderCreated(v1) PaymentCaptured(v2)
When: ShipOrder
Then: OrderShipped가 생성되고 version은 3it("결제된 주문만 배송한다", () => { const order = Order.rehydrate([ orderCreated({ version: 1 }), paymentCaptured({ version: 2 }), ]);
const newEvents = order.ship();
expect(newEvents).toEqual([orderShipped({ version: 3 })]);});반례도 필요하다. 취소된 주문, 미결제 주문, 이미 배송된 주문에서 Command가 이벤트를 만들지 않는지 검증한다.
full replay(events 1..12000)== replay(snapshot@11500, events 11501..12000)상태 equality뿐 아니라 Aggregate version과 중요한 derived value를 비교한다. apply 로직을 바꾼 배포에서 오래된 snapshot을 폐기해야 하는지도 테스트한다.
운영에서 실제로 저장된 각 eventType + schemaVersion fixture를 현재 코드로 읽는다.
최신 이벤트만 생성하는 테스트로는 과거 stream 파손을 잡지 못한다.
same event twice -> same final rowv5 then v4 -> v4가 최신 상태를 덮지 않음crash before commit -> retry로 한 번의 결과에 수렴rebuild from beginning -> live projection과 checksum 일치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 | Command 쓰기 모델과 Query 읽기 모델의 책임 분리 |
| Command | 상태 변경 의도와 불변식 검증을 요청하는 메시지 |
| Query | 상태를 바꾸지 않고 조회 결과를 요청하는 메시지 |
| Aggregate | 한 Command에서 함께 불변식을 지키는 일관성 경계 |
| Invariant | 성공한 상태 전이 뒤에도 항상 참이어야 하는 업무 규칙 |
| Projection | 변경 로그에서 파생한 조회 목적별 현재 표현인 read model 결과 |
| Projector | 변경 기록을 읽어 projection을 생성·갱신하는 함수 또는 프로세스 |
| Eventual Consistency | 업데이트 중단 뒤 결국 수렴한다는 계약, 시간 상한은 별도 |
| Event Sourcing | 이벤트 stream을 상태의 권위 있는 원천으로 삼는 영속화 |
| Event Store | stream 순서, 버전, 원자적 append를 보장하는 저장소 |
| Aggregate Replay | Aggregate event를 순서대로 다시 적용해 상태를 복원하는 과정 |
| Projection Rebuild/Replay | 원천 이벤트를 새 read model에 적용해 projection을 재구축하는 과정 |
| Rehydrate | Aggregate replay로 현재 상태를 복원하는 과정 |
| Snapshot | 특정 stream version의 파생 상태를 저장한 Aggregate replay 가속 장치 |
| Upcaster | 구버전 이벤트를 현재 코드가 읽는 형태로 변환하는 계층 |
| OCC | expected version과 current version을 비교하는 낙관적 동시성 제어 |
| Outbox | 상태 변경과 발행 예정 이벤트를 같은 트랜잭션에 기록하는 패턴 |
| CDC | WAL 같은 DB 로그에서 행 변경을 캡처하는 방식 |
| Idempotent Projector | 중복 이벤트에도 한 번 적용한 결과로 수렴하는 projector |
본문을 이해하는 데 아래 도구는 필요하지 않다. 특정 NestJS·TypeORM·PostgreSQL·Kafka·AWS 환경에서 메커니즘을 관찰할 때만 펼쳐 본다.
@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가 의존하는 모델과 실패 계약이 실제로 다른지다.
@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의 affected는 0이다.
반드시 affected === 1을 성공으로 판정해야 한다.
여기서는 @VersionColumn을 사용하지 않고 version을 일반 column으로 두어 CAS 계약을 분명히 했다.
다른 entity에서 decorator를 version counter로 쓸 수는 있지만, 이 예시의 동시성 안전성을 증명하는 것은 명시적 version predicate와 영향 행 수 검사다.
기존 예시는 첫 충돌 뒤 50ms, 다음에는 100ms처럼 최대 3회 재시도했지만, 최신 상태를 다시 읽고 Command 의미를 재검증해야 한다.
충돌이 드문 편이면 짧은 backoff가 처리량을 보존한다.
마지막 재고처럼 경쟁이 잦고 대기 순서가 중요하면 SELECT ... FOR UPDATE 같은 비관적 잠금이 후보지만, 잠금 대기와 deadlock 비용을 측정해야 한다.
@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; }); }}트랜잭션을 강제로 실패시켜 orders와 outbox_events가 함께 롤백되는지 확인한다.
relay 발행 뒤 상태 기록 전에 프로세스를 종료해 같은 eventId가 재발행되고 projector가 중복을 무시하는지도 확인한다.
SELECT COUNT(*) AS pending_count, MIN(created_at) AS oldest_pending_atFROM outbox_eventsWHERE published_at IS NULL;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이 필요하다.
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가 계속 쌓이지 않도록 충분한 유예 시간이 지난 createdAt과 currentVersion=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 절차를 재시도한다. 정리는 최신 createdAt과 currentVersion을 다시 평가해 두 삭제 조건이 여전히 참일 때만 재시도하고, 아니면 삭제를 중단하거나 다음 정리 주기로 미룬다.
기존 문서가 제시한 AWS 공식 제한은 한 item 400KB, transaction 100개 unique item 또는 4MB다.
제품 제한은 설계 시점에 AWS DynamoDB Constraints에서 다시 확인한다.
큰 payload는 객체 저장소에 두고 이벤트에는 불변 pointer와 checksum을 저장하는 선택을 검토한다.
다음 입력은 본문의 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가 더 나은 설계다.