CQRS 프로젝션은 왜 Kafka Consumer처럼 동작하는가
이벤트 스트림을 읽기 모델로 변환하는 프로젝션의 내부 동작부터 Blue/Green 재구축, 장애 격리, Eventual Consistency 처리까지 하나의 설계 철학을 추적한다.
- 01 CQRS는 왜 필요한가 — 단일 모델이 붕괴되는 과정
- 02 CQRS는 왜 Command와 Query를 완전히 다른 세계로 나누는가
- 03 Event Sourcing은 왜 '이벤트를 진실의 원천'으로 삼는가
- 04 CQRS 프로젝션은 왜 Kafka Consumer처럼 동작하는가
- 05 CQRS + Event Sourcing의 설계 결정은 어디서 오는가
- 06 Axon과 순수 Spring으로 배우는 CQRS+ES 설계 결정
- 07 CQRS와 Event Sourcing, 언제 쓰고 언제 피해야 하는가
CQRS에서 쓰기 모델과 읽기 모델 사이에는 프로젝션(Projection)이 있다. 이벤트가 저장되는 순간 읽기 모델은 아직 바뀌지 않는다. 프로젝션이 그 이벤트를 소비해 읽기 모델에 반영할 때까지 지연이 존재한다. 이 지연이 어디서 오는지, 그리고 그 지연을 어떻게 안전하게 관리하는지 이해하면 CQRS 시스템의 대부분이 보인다. 그렇다면 프로젝션은 정확히 어떻게 동작하는가?
프로젝션은 Kafka Consumer다
프로젝션의 동작 방식은 Kafka Consumer와 구조적으로 동일하다. 이벤트 스트림을 구독하고, 처리한 위치(오프셋)를 추적하고, 장애 후 재시작 시 그 위치부터 재처리한다.
projection_checkpoints 테이블:
┌──────────────────────────┬──────────────────┬─────────────────┐
│ projection_name │ last_processed │ updated_at │
├──────────────────────────┼──────────────────┼─────────────────┤
│ OrderSummaryProjection │ global_seq=10500 │ 2024-01-15 ... │
│ OrderSearchProjection │ global_seq=9870 │ 2024-01-15 ... │
│ AnalyticsProjection │ global_seq=8200 │ 2024-01-14 ... │
└──────────────────────────┴──────────────────┴─────────────────┘
각 프로젝션은 독립적인 global_seq를 가진다. OrderSummaryProjection이 10500까지 처리했다고 AnalyticsProjection이 그 위치로 점프하지 않는다. 처리 속도가 달라도 서로 독립이다.
체크포인트는 읽기 모델 업데이트와 같은 트랜잭션 안에 저장해야 한다. 읽기 모델 업데이트는 성공했는데 체크포인트 저장이 실패하면 재시작 시 같은 이벤트를 중복 처리한다. 반대로 체크포인트만 먼저 저장됐다가 프로세스가 죽으면 해당 이벤트는 영영 반영되지 않는다. 원자성이 핵심이다.
읽기 모델은 화면에서 역설계한다
읽기 모델을 설계하는 올바른 방향은 화면 → DB 스키마다. “어떤 데이터가 있는가”가 아니라 “이 화면에 무엇이 필요한가”에서 시작한다.
주문 목록 화면에 주문번호, 고객명, 상태, 총금액, 배송지가 필요하다면 order_summary 테이블은 그 컬럼을 모두 비정규화해 담는다. 조회 시 JOIN이 필요하다면 비정규화가 덜 된 것이다. 목표는 단일 SELECT로 화면을 완성하는 것이다.
비정규화 전략은 세 가지다. 외래키를 이름/값으로 풀어 저장하고(customer_id → customer_name), 집계를 사전 계산해 저장하고(total_amount, item_count), 1:N 관계는 JSONB 배열로 단일 행에 담는다. 단, 자주 바뀌는 데이터는 비정규화하지 않는다. 동기화 비용이 편익을 초과한다.
역할이 다른 읽기 모델은 분리한다. 같은 OrderConfirmed 이벤트 하나가 주문 목록용, 배송 대기열용, 매출 통계용, Elasticsearch 검색 인덱스용으로 서로 다른 읽기 모델을 각각 업데이트한다. 각 읽기 모델은 자신의 역할에 맞는 인덱스와 저장소를 가진다.
Eventual Consistency는 설계 문제다
정상적인 경우 이벤트 저장부터 읽기 모델 반영까지 20~50ms가 걸린다. 사용자가 Command 직후 즉시 GET 요청을 보내면 구버전 데이터를 받을 수 있다. 이것을 기술 스택의 한계로 받아들이지 말고 UX 설계 문제로 접근해야 한다.
가장 실용적인 전략은 낙관적 UI 업데이트다. Command 전송과 동시에 클라이언트가 UI를 즉시 업데이트하고, 실패하면 롤백한다.
const confirmOrder = async (orderId) => {
const previousState = orders.find(o => o.id === orderId);
// 즉시 UI 업데이트
setOrders(orders.map(o =>
o.id === orderId ? { ...o, status: 'CONFIRMED' } : o
));
try {
await api.post(`/orders/${orderId}/confirm`);
} catch (error) {
// 실패 시 롤백
setOrders(orders.map(o =>
o.id === orderId ? previousState : o
));
}
};
실시간 반영이 중요한 경우 SSE로 서버가 Projection 완료를 클라이언트에 push한다. 계좌 잔고처럼 강한 일관성이 진짜로 필요한 경우는 읽기 모델이 아닌 쓰기 모델(Aggregate 직접 조회)에서 제공한다.
강한 일관성을 읽기 모델에서 제공하려면 이벤트 리플레이 비용이 든다. 스냅샷 패턴으로 완화할 수 있지만, 대부분의 조회는 50ms 지연을 수용하는 쪽이 확장성과 성능 모두에서 유리하다. 강한 일관성이 필요한 케이스를 정확히 식별해 선별적으로 적용하는 것이 핵심이다.
Blue/Green 재구축으로 무중단 업데이트
읽기 모델은 언제든 재설계될 수 있다. 프로젝션 버그로 데이터가 오염됐거나, 새 필드가 추가됐거나, 저장소를 교체해야 할 때 Event Sourcing의 핵심 강점이 드러난다. 과거 이벤트가 모두 보존돼 있으므로 언제든 읽기 모델을 처음부터 재구축할 수 있다.
재구축 중에도 서비스는 계속 운영돼야 한다. Blue/Green Projection 전략이 이를 해결한다.
현재: Blue Projection → order_summary (서비스 중)
재구축: Green Projection → order_summary_v2 (병렬 실행)
단계:
1. order_summary_v2 생성 (기존 테이블 유지)
2. Green Projection 시작 (global_seq = 0부터)
3. 재구축 완료 → 데이터 검증 (건수, 샘플 비교)
4. DB View 교체 → 트래픽 전환 (무중단)
5. Blue Projection 중단, 기존 테이블 삭제
재구축 실패 시 Blue가 그대로 살아있어 자동으로 롤백된다. 전환 전 충분한 검증 시간도 확보된다. 단, 재구축 기간 동안 저장 공간과 처리 비용이 두 배가 된다.
장애 격리와 멱등성
프로젝션 장애는 피할 수 없다. 중요한 것은 하나의 프로젝션 장애가 다른 프로젝션에 전파되지 않는 것이다.
각 프로젝션이 독립 Kafka Consumer Group을 가지면 격리가 보장된다. OrderSummaryProjection이 멈춰도 OrderSearchProjection은 자신의 오프셋으로 계속 처리한다.
멱등성은 재시도 전략을 자유롭게 만든다. 같은 이벤트를 두 번 처리해도 동일한 결과가 나와야 한다. 가장 단순한 구현은 Upsert다.
INSERT INTO order_summary (order_id, status, last_event_seq, ...)
VALUES (?, 'CONFIRMED', ?, ...)
ON CONFLICT (order_id)
DO UPDATE SET
status = EXCLUDED.status,
last_event_seq = EXCLUDED.last_event_seq
WHERE order_summary.last_event_seq < EXCLUDED.last_event_seq;
last_event_seq 조건이 중요하다. 이벤트가 순서 역전으로 도착해도 더 오래된 이벤트가 최신 상태를 덮어쓰지 않는다.
일시적 오류는 지수 백오프로 재시도하고, 재시도 한도를 초과하거나 복구 불가능한 오류는 Dead Letter Queue로 이동한다. DLQ는 다음 이벤트 처리를 막지 않으며, 운영팀이 원인을 분석한 뒤 재처리하거나 건너뛸 수 있다.
정리
- 프로젝션은 Kafka Consumer와 동일한 구조다. 오프셋(체크포인트)을 읽기 모델 업데이트와 같은 트랜잭션에 저장해야 원자성이 보장된다.
- 읽기 모델은 화면에서 역설계한다. JOIN 없는 단일 SELECT가 목표이며, 비정규화는 그 수단이다.
- Eventual Consistency는 낙관적 UI 업데이트와 SSE push로 UX 문제로 해결한다. 강한 일관성이 필요한 케이스는 선별적으로 쓰기 모델에서 제공한다.
- 읽기 모델 재구축은 Blue/Green Projection으로 무중단 수행한다. Event Sourcing이 이것을 가능하게 한다.
- 멱등 Upsert와 독립 Consumer Group이 장애 격리의 두 축이다.
CQRS 프로젝션의 모든 설계 결정은 하나의 원칙으로 수렴한다 — 이벤트 스트림은 불변이고, 읽기 모델은 언제든 재구축 가능하다.