CQRS + Event Sourcing의 설계 결정은 어디서 오는가
완전한 흐름의 각 단계 책임부터 원자성 보장, DDD 통합, 처리 보장, 성능 최적화, MSA 통합까지 — 하나의 철학이 만드는 아키텍처를 추적한다.
- 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와 Event Sourcing을 처음 도입하면 각 구성 요소 — Command, Aggregate, Event Store, Kafka, Projection, Read Model — 를 개별적으로 이해하게 된다. 그런데 “이체 후 잔고가 안 바뀐다”는 장애가 발생했을 때, 어느 단계에서 문제가 생겼는지 5분 안에 파악하려면 전체 흐름이 머릿속에 하나의 그림으로 있어야 한다. 이 아키텍처의 모든 설계 결정은 단 하나의 원칙에서 나온다 — 쓰기와 읽기를 분리하되, 각 단계의 실패가 다른 단계로 전파되지 않도록 격리한다. 그 원칙이 어떻게 구체적인 구현 선택들로 이어지는가?
완전한 흐름 — 각 단계의 책임
성공 경로를 타임라인으로 보면 설계 의도가 명확해진다.
T=0ms Client → POST /accounts/ACC-001/transfer
T=2ms Controller → CommandBus (LoggingMiddleware → IdempotencyMiddleware)
T=3ms CommandHandler: EventStore에서 스트림 로드
T=12ms Aggregate 재구성 (이벤트 15개 리플레이)
T=14ms 불변식 검증 + 이벤트 생성 (MoneyTransferred)
T=15ms EventStore INSERT + Outbox INSERT (같은 트랜잭션) → COMMIT
T=15ms HTTP 202 Accepted
[비동기]
T=100ms OutboxPublisher → Kafka 발행
T=150ms AccountSummaryProjection → 읽기 모델 업데이트
T=200ms Client → GET /balance → 400000 반환
T=15ms에서 HTTP 응답이 나간다는 점이 핵심이다. 이벤트 스토어 저장 = 처리 완료. Kafka 발행과 읽기 모델 업데이트는 비동기다. 클라이언트가 202를 받은 시점에 읽기 모델은 아직 이전 상태일 수 있다.
각 단계의 책임은 엄격하게 분리된다. Controller는 HTTP 파싱과 권한 확인만 한다. Aggregate는 불변식 검증과 이벤트 생성만 한다. EventStore는 Append-Only 저장과 낙관적 잠금만 한다. Projection은 이벤트를 읽기 모델로 변환하는 일만 한다. 이 분리가 장애 격리를 만든다 — 읽기 모델 DB가 다운돼도 쓰기 경로는 영향받지 않는다.
이벤트 저장과 발행의 원자성 문제
이벤트 스토어와 Kafka는 서로 다른 리소스다. XA 트랜잭션 없이는 두 저장소에 원자적으로 쓸 수 없다. 비원자적 발행이 만드는 두 시나리오를 보면 왜 Transactional Outbox가 필요한지 즉각 이해된다.
- 저장 성공, 발행 실패: 이벤트 스토어는 정상이지만 Projection이 이벤트를 받지 못한다. 읽기 모델이 영구적으로 구버전을 유지한다.
- 발행 성공, 저장 실패: Projection이 읽기 모델을 업데이트했지만 이벤트 스토어에는 이벤트가 없다. 다음 Aggregate 재구성 시 이 이벤트가 없으므로 쓰기 모델과 읽기 모델이 다른 상태가 된다. 초과 출금이 가능해진다.
해결은 Outbox 테이블을 이벤트 스토어와 같은 DB 트랜잭션에 포함하는 것이다.
@CommandHandler
@Transactional
public void handle(WithdrawMoneyCommand cmd) {
Account account = accountRepository.findById(cmd.accountId()).orElseThrow();
account.withdraw(Money.of(cmd.amount()), cmd.requestedBy());
// 이벤트 스토어 + Outbox: 같은 트랜잭션
eventStore.appendEvents("account-" + cmd.accountId(), account.getPendingEvents());
outboxRepository.save(account.pullPendingEvents(), "account-" + cmd.accountId());
}
별도 OutboxPublisher가 500ms 간격으로 published = false 레코드를 폴링해 Kafka에 발행한다. SELECT FOR UPDATE SKIP LOCKED로 다중 인스턴스 간 중복 발행을 방지한다. 이벤트 스토어에 있으면 결국 Kafka에도 발행된다 — Eventual Consistency의 보장 방향이 명확하다.
대규모 환경에서는 Debezium CDC가 대안이다. 이벤트 스토어의 WAL을 직접 읽어 Kafka에 발행하므로 Outbox 테이블이 불필요하고 처리량도 높다. 다만 Kafka Connect 클러스터 운영 복잡도가 따른다.
DDD와의 결합 — Aggregate, Saga, ACL
CQRS + Event Sourcing은 DDD 전술 설계와 자연스럽게 맞물린다.
Aggregate는 세 가지 역할을 동시에 수행한다. Command를 받아 불변식을 검증하고, 이벤트를 생성하고, 이벤트 리플레이로 현재 상태를 재구성한다. 이벤트 스토어에서 15개 이벤트를 로드해 apply()를 순서대로 호출하면 현재 잔고가 나온다 — 별도의 현재 상태 컬럼이 없다.
Saga는 여러 Aggregate에 걸친 프로세스를 조율한다. 이벤트를 수신하고 Command를 발행하는 방식으로, 각 단계가 독립 트랜잭션이다. 결제 실패 시 재고 예약 취소 Command를 발행하는 보상 트랜잭션이 분산 환경에서 “롤백”을 대체한다.
내부 이벤트와 외부 이벤트의 분리는 MSA 환경에서 결정적이다. Event Sourcing용 내부 이벤트(MoneyTransferred)는 구현 세부사항을 포함할 수 있다. 다른 Bounded Context에 발행하는 외부 이벤트(FundsTransferred)는 공개 계약으로, 구현 세부사항을 제거하고 안정적으로 유지한다. Anti-Corruption Layer가 외부 이벤트를 내부 도메인 언어로 변환해 외부 서비스의 스키마 변경으로부터 내부 모델을 보호한다.
처리 보장과 멱등 설계
Kafka는 기본적으로 At-Least-Once 전달이다. Projection이 같은 이벤트를 두 번 처리하면 잔고가 두 번 차감될 수 있다. 이 문제를 해결하는 핵심은 멱등 처리다.
누적 연산(balance += amount)은 멱등하지 않다. 이벤트 페이로드에 balanceAfter를 포함해 절대값으로 업데이트하면 중복 처리해도 결과가 같다. Upsert와 last_event_seq 조건을 결합하면 오래된 이벤트로 덮어쓰는 것도 방지한다.
INSERT INTO account_summary (account_id, balance, last_event_seq, updated_at)
VALUES (?, ?, ?, NOW())
ON CONFLICT (account_id) DO UPDATE SET
balance = EXCLUDED.balance,
last_event_seq = EXCLUDED.last_event_seq,
updated_at = NOW()
WHERE account_summary.last_event_seq < EXCLUDED.last_event_seq
체크포인트를 읽기 모델 업데이트와 같은 DB 트랜잭션에 저장하면, 재시작 시 마지막으로 성공한 오프셋 이후부터 재처리할 수 있다. At-Least-Once + 멱등 처리 = Exactly-Once 효과. Kafka 트랜잭션의 성능 오버헤드 없이 동등한 보장을 얻는다.
DB 체크포인트 방식은 읽기 모델이 RDB일 때만 원자적으로 처리할 수 있다. Redis나 Elasticsearch를 읽기 모델로 쓰는 경우, 중간에 RDB를 진실의 원천으로 두거나(RDB → Redis 동기화), 이벤트 ID를 Redis SET NX로 중복 감지하는 방식으로 대응한다. 완전한 원자성은 포기하되 TTL로 자연 복구를 허용하는 것이 실용적이다.
성능 — 인덱스, 병렬화, Hot/Cold 분리
이벤트 스토어의 핵심 쿼리 패턴은 두 가지다. (stream_id, version) 복합 인덱스로 Aggregate 로드를 커버하고, (global_seq) 유니크 인덱스로 Projection 폴링을 커버한다. 이 두 인덱스 없이는 이벤트가 수천만 건을 넘는 순간 조회가 수 초로 늘어난다.
Projection 병렬화는 순서 보장과 충돌한다. 해결책은 stream_id를 Kafka 파티션 키로 사용하는 것이다. 같은 stream_id는 같은 파티션 → 같은 Consumer → 순서 보장. 다른 stream_id는 다른 파티션 → 병렬 처리. 파티션 수 = Consumer 인스턴스 수 = 최대 병렬도.
읽기 모델은 Hot Path와 Cold Path로 분리한다. 잔고, 주문 상태 같이 응답 시간이 100ms 미만이어야 하는 데이터는 Redis + 인덱싱된 RDB로 서빙한다. 월별 매출 통계, 사용자 행동 분석처럼 수 초 지연이 허용되는 데이터는 ClickHouse나 BigQuery 같은 OLAP으로 처리한다. PostgreSQL에서 수억 건 집계를 제거하는 것이 이 분리의 핵심 동기다.
정리
- 이벤트 스토어 저장이 완료되면 처리 완료다. Kafka 발행과 읽기 모델 업데이트는 비동기이며, 장애가 쓰기 경로로 역전파되지 않는다.
- Transactional Outbox는 DB 트랜잭션만으로 이벤트 발행 원자성에 근접한다. 대규모에서는 Debezium CDC로 전환을 검토한다.
- At-Least-Once + 멱등 Projection은 Exactly-Once와 동등한 효과를 낸다. 절대값 저장과 Upsert가 핵심 도구다.
- MSA 환경에서 내부 이벤트와 외부 이벤트를 분리하지 않으면, 서비스 내부 설계 변경이 계약 위반으로 이어진다. Consumer-Driven Contract Testing으로 이를 자동으로 감지한다.
이 아키텍처의 복잡도는 각 단계가 단일 책임을 갖고 독립적으로 실패를 처리하는 구조에서 나온다. 복잡도를 감수하는 이유가 명확할 때, 각 설계 결정이 비로소 설명 가능해진다.