CQRS와 이벤트 소싱 실전 분리 전략: 읽기/쓰기 모델을 나누고 이벤트 스토어를 운영하면서 배운 것들

읽기와 쓰기가 같은 모델을 공유할 때 생기는 일
결제 시스템을 운영하다 보면 특이한 현상을 목격하게 됩니다. 쓰기 트래픽이 몰리는 시간대에 주문 목록 조회가 같이 느려집니다. 원인을 추적하면 항상 같은 곳에 도달합니다. 쓰기와 읽기가 동일한 테이블, 동일한 인덱스, 동일한 ORM 모델을 공유하고 있다는 사실입니다. 결제 상태를 업데이트하는 트랜잭션이 걸어 둔 행 잠금이 조회 쿼리의 응답 시간을 끌어올립니다. 인덱스를 쓰기에 맞게 최적화하면 조회 성능이 떨어지고, 조회를 위한 컬럼을 추가하면 쓰기 모델이 무거워집니다.
이 상황을 아키텍처 언어로 표현하면, 명령(Command)과 조회(Query)가 하나의 도메인 모델을 공유하고 있고 그 결과 둘 다 어중간하게 최적화되어 있다는 것입니다. Martin Fowler는 자신의 블로그에서 이 문제의 해법으로 CQRS(Command Query Responsibility Segregation)를 명확하게 설명했고, 그 이후 이 패턴은 복잡한 도메인을 다루는 시스템에서 표준적인 선택지가 되었습니다.
이 글은 CQRS와 이벤트 소싱을 개념 수준이 아니라 실제 운영 수준에서 다룹니다. 쓰기 모델과 읽기 모델을 어떻게 나누는지, 이벤트 스토어 스키마를 어떻게 설계하는지, Projection 동기화 파이프라인을 어떻게 구성하는지, 그리고 운영에서 마주치는 함정이 어떤 것인지까지 실무적인 흐름으로 정리합니다.
1. CQRS를 도입하기 전에 묻는 질문: 지금 구조의 무엇이 문제인가
CQRS가 필요한 시점을 알려주는 신호가 몇 가지 있습니다. 가장 명확한 신호는 읽기와 쓰기의 스케일링 방향이 달라지는 때입니다. 주문 도메인에서는 결제 처리, 상태 전이, 재고 차감이 쓰기 집약적 작업이고, 주문 목록 조회, 매출 집계, 배송 추적 현황은 읽기 집약적 작업입니다. 두 종류의 작업이 동일한 데이터베이스 인스턴스, 동일한 테이블 구조를 공유하면 어느 쪽도 독립적으로 스케일링하기 어렵습니다.
두 번째 신호는 도메인 모델이 점점 두꺼워지는 현상입니다. JPA Entity 하나가 다섯 개의 조인 테이블과 얽혀 있고, 조회 API마다 다른 조합의 컬럼이 필요해 N+1 쿼리나 fetch join 지옥에 빠지는 상황이 전형적입니다. 이때 도메인 모델은 비즈니스 불변식을 지키기 위한 용도와 조회 응답 DTO를 만들기 위한 용도 사이에서 두 역할을 동시에 수행하고 있습니다.
세 번째 신호는 감사 요구사항과 디버깅 어려움입니다. 주문 상태가 왜 취소로 바뀌었는지 추적하려면 application 로그를 뒤지거나, updated_at을 비교하거나, 별도로 구성한 history 테이블을 확인해야 합니다. 현재 상태를 저장하는 방식(State-based persistence)에서는 "언제, 무슨 이유로 상태가 바뀌었는가"를 재현하기 어렵습니다.
반대로 CQRS가 과잉 설계가 되는 상황도 분명합니다. CRUD 위주의 단순 관리 화면, 트래픽이 낮은 내부 어드민 도구, 팀 규모가 작아서 인프라 복잡도를 감당하기 어려운 경우입니다. 이 판단 기준은 글 마지막 섹션에서 다시 다룹니다.
2. CQRS 핵심 개념: Command와 Query를 왜 물리적으로 분리하는가
Microsoft Azure Architecture Center는 CQRS를 "데이터를 읽는 작업과 데이터를 업데이트하는 작업을 서로 다른 모델로 분리하는 패턴"이라고 정의합니다. 이 정의에서 핵심 단어는 "모델"입니다. 단순히 메서드를 분리하는 것이 아니라, 도메인 객체의 책임 자체를 나누는 것입니다.
Command 쪽은 비즈니스 불변식 보호에 집중합니다. "결제 금액이 주문 총액을 초과할 수 없다", "취소된 주문은 다시 결제될 수 없다"와 같은 규칙을 집행하는 것이 Command 모델의 책임입니다. 이 모델은 조회 편의성이나 응답 속도보다 정확성과 일관성이 우선입니다. 성능보다 올바름이 먼저입니다.
Query 쪽은 사용자가 보고 싶은 형태로 데이터를 빠르게 반환하는 데 집중합니다. 정규화된 테이블을 조인하는 대신, 미리 비정규화된 뷰 또는 별도 저장소(Redis, Elasticsearch, 읽기 전용 복제본)에서 응답합니다. Query 모델에는 도메인 불변식이 없습니다. 잘못된 조회 결과가 반환되면 다음 조회 때 올바른 값이 오면 됩니다. Eventual Consistency를 허용하는 대신 읽기 성능을 극대화합니다.
물리적 분리의 실질적 이점은 두 모델을 독립적으로 발전시킬 수 있다는 점입니다. 쓰기 모델은 도메인 로직이 복잡해지면 Aggregate 경계를 재조정하고, 읽기 모델은 새로운 조회 요구사항이 생기면 새 Projection을 추가합니다. 두 변경이 서로를 방해하지 않습니다.
3. Write Model 설계: Command Handler, Aggregate, Domain Event
쓰기 모델은 세 요소로 구성됩니다. Command, Command Handler, Aggregate입니다.
Command는 시스템에 의도를 전달하는 값 객체입니다. PlaceOrderCommand, CancelOrderCommand, ApplyPaymentCommand처럼 과거형이 아닌 현재 명령형으로 이름을 짓습니다. Command는 검증되지 않은 사용자 의도입니다. 실행 가능한지 여부는 Aggregate가 판단합니다.
Command Handler는 Command를 받아 해당 Aggregate를 로드하고, 비즈니스 메서드를 호출한 뒤, 발생한 Domain Event를 이벤트 스토어에 저장합니다.
// TypeScript Command Handler 패턴 (주문 도메인)
interface PlaceOrderCommand {
orderId: string;
customerId: string;
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
totalAmount: number;
}
class PlaceOrderCommandHandler {
constructor(
private readonly eventStore: EventStore,
private readonly orderRepository: OrderRepository,
) {}
async handle(command: PlaceOrderCommand): Promise<void> {
// 1. Aggregate 로드 (이벤트 스토어에서 replay)
const order = await this.orderRepository.load(command.orderId);
// 2. 비즈니스 로직 실행 — Aggregate가 불변식 검사
const events = order.placeOrder({
customerId: command.customerId,
items: command.items,
totalAmount: command.totalAmount,
});
// 3. 발생한 Domain Event를 이벤트 스토어에 append
await this.eventStore.append(command.orderId, events, order.version);
}
}
class Order {
private version: number = 0;
private status: 'new' | 'placed' | 'paid' | 'cancelled' = 'new';
private pendingEvents: DomainEvent[] = [];
placeOrder(params: {
customerId: string;
items: Array<{ productId: string; quantity: number; unitPrice: number }>;
totalAmount: number;
}): DomainEvent[] {
// 불변식 검사
if (this.status !== 'new') {
throw new Error('Cannot place order in status: ' + this.status);
}
if (params.items.length === 0) {
throw new Error('Order must have at least one item');
}
const event: OrderPlacedEvent = {
type: 'OrderPlaced',
aggregateId: this.id,
version: this.version + 1,
occurredAt: new Date().toISOString(),
payload: {
customerId: params.customerId,
items: params.items,
totalAmount: params.totalAmount,
},
};
// Aggregate 자신의 상태를 이벤트로 먼저 반영
this.apply(event);
return this.pendingEvents;
}
private apply(event: DomainEvent): void {
if (event.type === 'OrderPlaced') {
this.status = 'placed';
this.version = event.version;
}
this.pendingEvents.push(event);
}
}
이 패턴의 핵심은 Aggregate가 직접 상태를 변경하지 않고 Domain Event를 생성한다는 점입니다. 상태 변경은 항상 apply(event) 메서드를 통해 이루어지며, 이 방식 덕분에 이벤트 스토어에서 이벤트를 순서대로 다시 재생(replay)하면 언제든 동일한 상태를 복원할 수 있습니다.
4. Read Model 설계: Projection, Denormalized View, 인덱스 전략
읽기 모델은 조회 목적에 맞게 철저하게 비정규화합니다. 정규화는 쓰기 일관성을 위한 것이지, 조회 성능을 위한 것이 아닙니다. 읽기 모델에서 조인은 가능하면 피합니다.
주문 목록 화면을 예로 들면, 화면에 필요한 정보는 주문 번호, 고객명, 주문 금액, 상태, 주문 일시입니다. 이 데이터가 정규화 모델에서는 orders, customers, order_items 세 테이블에 분산되어 있습니다. 읽기 모델은 이 정보를 order_list_view라는 단일 테이블에 미리 조합해 둡니다.
| 항목 | 쓰기 모델(Write Model) | 읽기 모델(Read Model) |
|---|---|---|
| 목적 | 비즈니스 불변식 보호, 정확한 상태 전이 | 빠른 조회, 사용자 화면에 최적화된 응답 |
| 정규화 수준 | 높음 (3NF 기준) | 낮음 (비정규화, 중복 허용) |
| 일관성 | 즉각적(Immediate Consistency) | 결과적(Eventual Consistency) |
| 스케일링 방향 | 쓰기 처리량 최적화, 수직 스케일 우선 | 읽기 복제본, 캐시, 검색 인덱스 수평 확장 |
| 인덱스 전략 | 쓰기 성능 저하 최소화, 필수 인덱스만 | 조회 패턴에 따라 인덱스 자유롭게 추가 |
| 변경 주체 | Domain Event가 발생할 때만 변경 | Projection 파이프라인이 비동기로 갱신 |
Projection은 Domain Event를 구독하여 읽기 모델을 갱신하는 컴포넌트입니다. OrderPlaced 이벤트가 발생하면 order_list_view에 행을 insert하고, PaymentApplied 이벤트가 발생하면 해당 행의 status를 업데이트합니다. Projection은 언제든 처음부터 다시 실행(replay)하면 읽기 모델을 재구성할 수 있어야 합니다. 이를 위해 Projection 로직은 멱등해야 합니다. 같은 이벤트를 두 번 처리해도 읽기 모델이 동일한 상태여야 합니다.
인덱스 전략은 실제 조회 패턴을 기반으로 결정합니다. 고객별 주문 목록 조회가 많다면 customer_id 인덱스를 우선합니다. 날짜 범위 조회가 주된 패턴이라면 created_at 기반 범위 인덱스를 추가합니다. 읽기 모델의 인덱스 변경은 쓰기 모델에 전혀 영향을 주지 않습니다.
5. 이벤트 소싱이란 무엇인가: DB 상태가 아닌 이벤트 시퀀스로 진실 저장하기
이벤트 소싱(Event Sourcing)은 현재 상태 대신 상태를 만들어 낸 이벤트 시퀀스를 영속화하는 패턴입니다. Martin Fowler의 EventSourcing 아티클은 이를 "현재 상태를 저장하는 대신, 발생한 모든 이벤트를 저장하고 현재 상태는 이벤트를 재생해 파생시킨다"라고 설명합니다.
은행 계좌로 비유하면 이해하기 쉽습니다. State-based persistence는 현재 잔액만 저장합니다. 잔액이 50,000원이라는 사실만 알 수 있습니다. Event Sourcing은 입금 100,000원, 출금 30,000원, 출금 20,000원이라는 이벤트 시퀀스를 저장합니다. 현재 잔액(50,000원)은 이 시퀀스를 재생한 결과입니다. 더 중요한 것은 어떤 시점의 잔액이든, 어떤 이유로 잔액이 바뀌었는지를 완전히 재현할 수 있다는 점입니다.
주문 도메인에 적용하면 이런 시퀀스가 됩니다.
OrderCreated → version 1, 2026-04-10T10:00:00Z
OrderItemAdded → version 2, 2026-04-10T10:00:01Z
PaymentInitiated → version 3, 2026-04-10T10:01:30Z
PaymentApplied → version 4, 2026-04-10T10:01:32Z
OrderShipped → version 5, 2026-04-10T14:00:00Z
현재 상태가 "배송 중"이라는 것은 이 시퀀스를 순서대로 적용한 결과입니다. 특정 시점의 상태가 필요하면 해당 시점까지의 이벤트만 재생합니다. 감사 로그(audit log)를 별도로 구성할 필요가 없습니다. 이벤트 스토어 자체가 완전한 감사 추적이기 때문입니다.
microservices.io의 Event Sourcing 패턴 문서는 이 방식의 추가적인 이점으로 "도메인 이벤트를 신뢰할 수 있는 방식으로 발행할 수 있다"는 점을 꼽습니다. 이벤트 스토어에 저장된 이벤트를 Projection 파이프라인이 구독하면 별도의 outbox 패턴 없이도 쓰기 일관성과 읽기 모델 갱신을 연결할 수 있습니다. 트랜잭셔널 아웃박스 패턴이 "이벤트 발행 의도"를 저장하는 방식이라면, 이벤트 소싱은 "이벤트 자체"가 영속화의 단위입니다. 두 패턴의 차이와 선택 기준은 트랜잭셔널 아웃박스 패턴 실전 가이드에서 자세히 다루고 있습니다.
6. 이벤트 스토어 스키마 설계: append-only 테이블, 버전 충돌 처리
이벤트 스토어의 핵심 제약은 append-only입니다. 한번 저장된 이벤트는 수정하거나 삭제하지 않습니다. 이 제약이 이벤트 소싱의 신뢰성 기반입니다.
-- 이벤트 스토어 테이블 스키마 (PostgreSQL)
CREATE TABLE event_store (
id UUID NOT NULL DEFAULT gen_random_uuid(),
aggregate_id VARCHAR(36) NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
payload JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_event_store PRIMARY KEY (id),
-- 낙관적 잠금을 위한 유니크 제약: 같은 aggregate의 같은 version은 단 하나만 존재
CONSTRAINT uq_aggregate_version UNIQUE (aggregate_id, version)
);
-- aggregate_id 기반 재생(replay) 성능을 위한 인덱스
CREATE INDEX idx_event_store_aggregate
ON event_store (aggregate_id, version ASC);
-- 이벤트 타입별 Projection 처리를 위한 인덱스
CREATE INDEX idx_event_store_type_occurred
ON event_store (event_type, occurred_at ASC);
(aggregate_id, version) 유니크 제약이 낙관적 잠금(Optimistic Locking)의 핵심입니다. Command Handler가 이벤트를 append할 때 예상 version을 함께 전달하고, 데이터베이스가 유니크 제약으로 충돌을 감지합니다. 두 개의 동시 Command가 같은 Aggregate에 version 3 이벤트를 append하려 하면, 하나는 성공하고 다른 하나는 unique_violation 에러를 받습니다. 실패한 Command Handler는 이벤트를 다시 로드한 뒤 재시도합니다.
대용량 이벤트 스토어 운영에는 PostgreSQL 파티셔닝을 적극 활용합니다. 날짜 기반으로 월 단위 파티션을 만들면 오래된 파티션을 통째로 아카이브 테이블스페이스로 이동하거나 개별 파티션을 drop하는 작업이 빠릅니다. 자세한 구문과 동작 방식은 PostgreSQL 파티셔닝 문서를 참고합니다.
payload는 JSONB 타입을 씁니다. 이벤트 스키마가 진화할 때 컬럼 추가 마이그레이션 없이 새 필드를 추가할 수 있습니다. metadata에는 causation_id(이 이벤트를 유발한 Command ID), correlation_id(분산 트레이싱 ID), 사용자 ID, IP 등 비즈니스 외적 메타 정보를 넣습니다. 이 분리 덕분에 비즈니스 payload 스키마 변경이 메타 정보 처리 로직에 영향을 주지 않습니다.
7. Projection 동기화 파이프라인: Polling vs CDC(Change Data Capture) 비교
이벤트 스토어에 이벤트가 저장된 뒤 읽기 모델이 갱신되기까지의 경로가 Projection 파이프라인입니다. 두 가지 방식이 실무에서 주로 사용됩니다.
Polling 방식은 Projection 워커가 주기적으로 이벤트 스토어를 폴링합니다. 마지막으로 처리한 이벤트의 occurred_at 또는 id를 체크포인트로 저장하고, 다음 폴링 때 그 이후의 이벤트만 가져옵니다.
-- Projection 폴링 쿼리 예시
-- checkpoint_store 테이블에 마지막 처리 이벤트 occurred_at 저장
SELECT
id,
aggregate_id,
aggregate_type,
event_type,
version,
payload,
occurred_at
FROM event_store
WHERE occurred_at > $1 -- $1: 마지막 체크포인트
AND event_type IN ('OrderPlaced', 'PaymentApplied', 'OrderShipped', 'OrderCancelled')
ORDER BY occurred_at ASC, id ASC
LIMIT 500;
-- 처리 완료 후 체크포인트 업데이트
UPDATE projection_checkpoints
SET last_processed_at = $2,
updated_at = NOW()
WHERE projection_name = 'order_list_view';
CDC(Change Data Capture) 방식은 PostgreSQL의 WAL(Write-Ahead Log)을 읽어 이벤트를 캡처합니다. Debezium 같은 CDC 도구가 WAL에서 event_store 테이블의 insert를 감지하고 Kafka 토픽으로 전달합니다. Projection 워커는 Kafka를 구독해 이벤트를 처리합니다.
| 항목 | Polling 방식 | CDC 방식 |
|---|---|---|
| 구현 복잡도 | 낮음 (SQL + 스케줄러면 충분) | 높음 (Debezium, Kafka 인프라 필요) |
| 이벤트 지연 | 폴링 주기에 비례 (최소 수백ms~수초) | 매우 낮음 (WAL 기반, 수십ms 수준) |
| 이벤트 스토어 부하 | SELECT 쿼리 부하 발생 | WAL 읽기로 메인 테이블 부하 없음 |
| 운영 비용 | 낮음 | 높음 (Debezium 클러스터 관리) |
| 재시도 처리 | 체크포인트 롤백으로 간단 | Kafka offset 관리 필요 |
| 추천 상황 | 중소 규모, 수초 지연 허용 | 대규모, 실시간 Projection 필요 |
이벤트 기반 아키텍처에서 CDC를 활용하는 방식은 고가용성 이벤트 기반 아키텍처 설계 글에서도 언급하고 있습니다. 포인트 적립 도메인처럼 실시간성이 중요한 경우 CDC가 유리하고, 주문 통계 집계처럼 수초 지연이 허용되는 경우 Polling으로 시작하는 것이 현실적입니다.
8. Eventual Consistency 다루기: 버전 토큰·조건부 쿼리 패턴
CQRS에서 가장 까다로운 UX 문제는 "방금 내가 주문했는데 목록에 안 보인다"는 상황입니다. 쓰기 모델은 성공했지만 Projection 파이프라인이 아직 읽기 모델을 갱신하지 못한 경우입니다. Eventual Consistency는 아키텍처의 트레이드오프이지 버그가 아니지만, 사용자는 그 차이를 느끼지 못합니다.
이를 완화하는 실무 패턴이 버전 토큰(version token)입니다. Command API가 성공 응답을 줄 때 해당 Aggregate의 현재 version을 함께 반환합니다. 클라이언트는 이 version을 가지고 조회 요청을 보냅니다. 읽기 모델 API는 요청받은 version이 Projection에 반영되지 않았다면 짧은 시간 동안 대기하거나, 아직 반영 중이라는 응답을 줍니다.
// 버전 토큰 기반 조건부 조회 패턴
interface QueryOrderRequest {
orderId: string;
minVersion?: number; // Command 응답에서 받은 version
}
async function queryOrder(req: QueryOrderRequest): Promise<OrderView | null> {
const MAX_WAIT_MS = 2000;
const POLL_INTERVAL_MS = 100;
const startTime = Date.now();
while (Date.now() - startTime < MAX_WAIT_MS) {
const view = await orderListViewRepository.findById(req.orderId);
// minVersion이 없거나, 읽기 모델이 요청 버전 이상으로 갱신된 경우
if (!req.minVersion || (view && view.version >= req.minVersion)) {
return view;
}
// 아직 Projection이 따라오지 않았으면 짧게 대기 후 재시도
await sleep(POLL_INTERVAL_MS);
}
// 타임아웃: 최신 데이터가 없어도 현재 읽기 모델 반환 (UI에서 처리)
return orderListViewRepository.findById(req.orderId);
}
이 방식은 클라이언트가 Eventual Consistency를 인식하고 협력하는 구조입니다. 모든 조회에 이 패턴을 적용할 필요는 없습니다. 방금 내가 만든 데이터를 즉시 조회하는 패턴(read-your-own-writes)에만 선택적으로 적용하면 됩니다. 주문 목록 전체를 조회하는 경우처럼 내가 만든 특정 데이터를 직접 참조하지 않는 패턴에서는 몇 초의 지연이 UX에 큰 영향을 주지 않습니다.
9. 스냅샷 전략: 언제 찍고, 어떻게 재생 시간을 줄이는가
이벤트 소싱의 실용적 문제는 Aggregate를 로드할 때마다 전체 이벤트 히스토리를 replay해야 한다는 점입니다. 이벤트가 수백 개면 괜찮지만, 활성 주문 Aggregate가 수천 개의 이벤트를 가지게 되면 로드 시간이 눈에 띄게 길어집니다.
스냅샷(Snapshot)은 특정 version에서의 Aggregate 상태를 직렬화해 저장하는 메커니즘입니다. 다음번 로드 시 전체 이벤트 대신 가장 최근 스냅샷을 가져온 뒤, 스냅샷 이후의 이벤트만 replay합니다.
스냅샷 시점을 결정하는 기준으로 일반적으로 두 가지를 씁니다. 첫째는 이벤트 건수 기준입니다. 이벤트가 N개 쌓일 때마다 스냅샷을 찍습니다. 포인트 적립 도메인처럼 이벤트가 자주 발생하는 경우에 적합합니다. 둘째는 시간 기준입니다. 매 24시간마다 현재 상태를 스냅샷합니다. 이벤트 발생 빈도가 낮은 도메인에 적합합니다.
스냅샷 테이블 구조는 단순합니다.
CREATE TABLE aggregate_snapshots (
aggregate_id VARCHAR(36) NOT NULL,
aggregate_type VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
state JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_snapshots PRIMARY KEY (aggregate_id, version)
);
-- Aggregate 로드 시: 가장 최근 스냅샷 조회
SELECT version, state
FROM aggregate_snapshots
WHERE aggregate_id = $1
ORDER BY version DESC
LIMIT 1;
-- 스냅샷 이후 이벤트만 replay
SELECT id, event_type, version, payload, occurred_at
FROM event_store
WHERE aggregate_id = $1
AND version > $2 -- $2: 스냅샷의 version
ORDER BY version ASC;
스냅샷의 함정은 스냅샷 상태의 역직렬화 스키마가 이벤트 스키마와 따로 버전 관리되어야 한다는 점입니다. 이벤트는 불변이지만 Aggregate 클래스는 코드 변경으로 필드가 추가되거나 타입이 바뀔 수 있습니다. 오래된 스냅샷을 최신 Aggregate 클래스로 역직렬화할 때 마이그레이션 로직이 필요합니다. 이 복잡성 때문에 스냅샷은 실제로 replay가 느려지는 것을 확인한 뒤에 도입하는 것이 맞습니다. 조기 최적화는 스냅샷 관리 복잡도만 높입니다.
10. 운영 함정 모음: 이벤트 스키마 변경(Event Versioning), 대용량 Replay 실패 케이스
이벤트 소싱을 실제로 운영하면 가장 많이 당황하는 순간이 이벤트 스키마 변경입니다. 일반 데이터베이스라면 컬럼 추가나 타입 변경을 마이그레이션 스크립트로 처리하면 됩니다. 그러나 이벤트 스토어에서는 과거 이벤트를 수정할 수 없습니다. 스토어에는 구버전 이벤트와 신버전 이벤트가 공존해야 하며, Aggregate의 apply() 메서드는 두 버전 모두를 처리할 수 있어야 합니다.
실무에서 쓰는 전략은 Upcasting입니다. 이벤트를 로드할 때 구버전 이벤트를 신버전 포맷으로 변환하는 Upcaster를 중간에 끼워 넣습니다.
// Event Upcasting 패턴
// OrderPlaced v1: { customerId, totalAmount }
// OrderPlaced v2: { customerId, totalAmount, currency } — currency 필드 추가
class OrderPlacedUpcaster {
upcast(rawEvent: RawEventRecord): OrderPlacedEventV2 {
const payload = rawEvent.payload;
const schemaVersion = payload.schemaVersion ?? 1;
if (schemaVersion === 1) {
// v1 → v2 변환: currency 기본값 적용
return {
...rawEvent,
payload: {
...payload,
currency: 'KRW', // 구버전 이벤트는 기본값으로 채움
schemaVersion: 2,
},
};
}
return rawEvent as OrderPlacedEventV2;
}
}
이 전략의 포인트는 Aggregate의 apply() 메서드가 항상 최신 버전 이벤트 타입만 처리하면 된다는 점입니다. 버전 변환 책임은 Upcaster가 가집니다. 이벤트 스키마 변경 시 체크리스트는 다음과 같습니다. Upcaster 작성 → 새 버전 이벤트 구조 확정 → 기존 이벤트 포맷 문서화 → 통합 테스트에서 구버전 이벤트 replay 검증.
대용량 Replay 실패도 흔한 함정입니다. 전체 이벤트 스토어를 처음부터 재생해 새 Projection을 만들어야 하는 상황을 생각해봅시다. 이벤트가 수백만 건이면 메모리에 한꺼번에 올릴 수 없습니다. 스트리밍 처리와 배치 청크 분할이 필수입니다. Projection 재생 중에도 신규 이벤트는 계속 쌓입니다. 재생이 끝난 뒤 따라잡기(catch-up) 구간에서 이벤트 순서 처리가 꼬이면 읽기 모델이 잘못된 상태가 됩니다. 실무에서는 Projection 재생을 별도 shadow 테이블에 먼저 채운 뒤, 재생 완료 후 원본 테이블과 원자적으로 교체하는 blue-green Projection 전략을 씁니다.
또 하나의 함정은 이벤트 스토어 테이블 크기 관리입니다. append-only 특성상 이벤트 스토어는 계속 커집니다. 오래된 이벤트를 아카이브 스토리지로 이동하되, Aggregate 재구성에 필요한 이벤트는 반드시 접근 가능해야 합니다. 스냅샷이 있다면 스냅샷 이전 이벤트는 아카이브해도 Aggregate 재생에 영향이 없습니다.
11. 도입 결정 기준: CQRS가 과잉 설계가 되는 팀 규모와 도메인 조건
CQRS와 이벤트 소싱은 강력하지만 도입 비용이 큽니다. 잘못된 상황에서 도입하면 팀 생산성을 갉아먹고 불필요한 운영 복잡도만 남습니다.
도입을 고려할 조건을 정리하면 이렇습니다. 읽기와 쓰기 트래픽의 비율과 성격이 명확히 다를 때, 도메인 불변식이 복잡하고 상태 전이 이력이 중요할 때, 여러 조회 화면이 서로 다른 집계 형태로 같은 데이터를 요구할 때, 감사 로그가 법적·운영적 필수 요건일 때입니다.
반면 CQRS를 피해야 하는 조건도 있습니다.
- 팀이 2~3명이고 도메인이 단순한 CRUD 수준일 때. 아키텍처 복잡도가 팀 역량을 초과합니다.
- 이벤트 스키마 변경이 매우 잦은 초기 스타트업 단계. Upcaster 관리 비용이 개발 속도를 저하시킵니다.
- 일관성 지연을 절대 허용하지 않는 도메인 전체에 적용하려 할 때. 모든 읽기가 즉시 일관성을 요구한다면 Eventual Consistency 기반 CQRS는 맞지 않습니다.
- 인프라 운영 인력이 없는 상황에서 CDC 파이프라인까지 도입하려 할 때.
실용적인 접근은 전체 시스템에 한 번에 도입하지 않는 것입니다. 트래픽이 집중되고 도메인 복잡도가 높은 서비스 하나(예: 주문 도메인)에 먼저 적용하고, 운영 경험을 쌓은 뒤 확장 여부를 결정합니다.
결론
CQRS와 이벤트 소싱은 복잡한 도메인과 다양한 읽기 요구사항이 공존하는 시스템에서 명확한 구조적 이점을 줍니다. 쓰기 모델은 비즈니스 불변식 보호에 집중하고, 읽기 모델은 조회 최적화에 집중합니다. 이벤트 시퀀스를 영속화하면 완전한 상태 이력과 유연한 Projection 재생이 가능해집니다. 하지만 이 모든 것은 운영 복잡도를 전제로 합니다.
실무 도입 전 체크리스트입니다.
- 읽기와 쓰기 트래픽 비율이 충분히 달라서 별도 최적화가 실질적으로 필요한가
- 팀에 이벤트 스키마 버전 관리(Upcasting)를 일상적으로 수행할 역량이 있는가
- Projection 파이프라인 장애 시 읽기 모델이 stale해지는 상황을 UX와 운영 양쪽에서 수용할 수 있는가
- 이벤트 스토어 크기 증가에 대응하는 아카이브 및 파티셔닝 전략이 준비되어 있는가
- 대용량 Projection replay 시나리오를 사전에 테스트하고 소요 시간을 측정해봤는가
CQRS는 정답이 아닙니다. "이 도메인의 읽기와 쓰기 복잡도가 단일 모델로 감당하기 어려운 수준인가"라는 질문에 명확히 "예"라고 답할 수 있을 때 도입을 검토합니다. 그 전까지는 단순한 구조를 유지하는 것이 더 나은 선택입니다.