← all posts
DEV 2026.05.02 · 14 min read Intermediate

CQRS와 Event Sourcing, 언제 쓰고 언제 피해야 하는가

은행 계좌 도메인의 완전 구현부터 안티패턴 진단, 점진적 도입 로드맵, 실제 비용과 편익까지 — CQRS/ES 도입 결정을 위한 실전 분석.


CQRS와 Event Sourcing은 함께 소개되는 경우가 많아서 “세트”처럼 느껴진다. 그런데 막상 도입해보면 예상치 못한 복잡도가 팀을 기다리고 있다. 어떤 도메인에 적합하고, 어디서부터 시작해야 하며, 무엇을 조심해야 하는가?

Event Sourcing이 자연스러운 도메인

은행 계좌는 CQRS + Event Sourcing이 가장 자연스럽게 맞는 도메인이다. 감사 로그가 법적 요구사항이고, 시간 여행이 비즈니스 요구사항이며, 읽기와 쓰기 비율이 극단적으로 차이난다.

상태 저장 방식으로 구현하면 accounts 테이블에 balance 컬럼 하나만 남는다. 감사팀이 “지난 3개월 거래 내역을 제출하라”고 요청하면, 현재 잔고만 있을 뿐 이전 거래 기록이 없다. Event Sourcing에서는 AccountOpened, MoneyDeposited, MoneyWithdrawn, TransferInitiated 같은 이벤트가 쌓이고, 이 이벤트 스트림 자체가 완전한 감사 로그가 된다.

이벤트 페이로드 설계가 핵심이다. MoneyWithdrawn 이벤트 하나에 amount, balanceAfter(절대값, 멱등 처리용), requestedBy, approvedBy, correlationId, causationId가 들어가면 “누가 언제 얼마를 출금했는가”가 이벤트 하나로 완결된다. 별도 audit_log 테이블이 필요 없다.

시간 여행도 이 구조에서 자연스럽게 나온다. “2024-01-15 오후 3시 잔고”를 알고 싶다면, 해당 시점까지의 이벤트를 로드해 Account.reconstitute(events)로 상태를 재현하면 된다.

CQRS = Event Sourcing이라는 오해

가장 흔한 오해다. CQRS의 본질은 Command와 Query를 다른 모델로 처리하는 것이고, Event Sourcing은 쓰기 모델의 저장 방식 선택이다. 둘은 독립적인 패턴이다.

CQRS는 수준별로 단계적으로 도입할 수 있다.

  • Level 1: 같은 DB, Command/Query 객체만 분리. 추가 인프라 없음.
  • Level 2: PostgreSQL Materialized View로 읽기 모델 사전 계산. 복잡한 JOIN을 제거하고 단일 테이블 조회로 대체.
  • Level 3: CDC(Debezium) 또는 DB 트리거로 별도 읽기 DB와 동기화. 쓰기/읽기를 완전히 분리.
  • Level 4: Event Sourcing 도입. 감사 로그가 정말 필요한 Aggregate에만 적용.

Level 2의 Materialized View는 추가 인프라 없이 DB만으로 읽기 최적화를 얻는 실용적인 선택이다. 반면 Level 3의 CDC는 Debezium과 Kafka라는 운영 복잡도를 함께 가져온다.

트레이드오프

DB 트리거는 쓰기 트랜잭션과 같은 컨텍스트에서 읽기 모델을 업데이트하므로 강한 일관성을 제공하지만, 트리거 오류가 쓰기 트랜잭션 실패로 이어진다. @TransactionalEventListener(phase = AFTER_COMMIT)는 쓰기 커밋 후 읽기 모델을 업데이트해 이 위험을 피하지만, 짧은 Eventual Consistency 지연이 생긴다.

조기에 잡아야 할 안티패턴 4가지

패키지만 분리한 가짜 CQRS가 가장 흔하다. OrderCommandServiceOrderQueryService로 나눴는데 둘 다 같은 orders 테이블을 직접 조회한다면, 코드만 복잡해지고 읽기 최적화는 없는 상태다. 진짜 CQRS의 최소 기준은 읽기 모델이 쓰기 모델과 다른 스키마/테이블을 사용하는 것이다.

모든 도메인에 ES를 적용하는 것도 함정이다. 사용자 설정, 공지사항, 세션 데이터는 “마지막 상태”만 중요하고 변경 이력에 비즈니스 가치가 없다. ES의 판단 기준은 하나다: “이 도메인의 변경 이력이 비즈니스 가치를 제공하는가?”

이벤트 스키마를 자주 변경하면 Upcaster가 쌓인다. 변경 1회에 5시간, 6개월 후 10회면 Upcaster 10개를 관리해야 한다. 근본 원인은 도메인 언어 대신 DB 컬럼명을 이벤트 필드에 쓰거나, 읽기 모델 요구사항을 이벤트에 반영하는 것이다. 이벤트는 도메인 사실이어야 한다.

읽기 모델로 쓰기 결정을 내리는 것도 위험하다. Eventual Consistency 상태의 읽기 모델 재고 수량을 기반으로 주문을 허용하면, 실제 재고가 0인데 뷰에서는 5로 보일 수 있다. 불변식 검증은 항상 쓰기 모델(Aggregate)에서 이루어져야 한다.

점진적 도입 — 어디서부터 시작하는가

기존 시스템에 CQRS를 전면 도입하는 것은 현실적으로 어렵다. 시작점은 “가장 느린 조회 쿼리”다.

SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20;

5개 테이블 JOIN으로 평균 200ms 걸리는 주문 목록 조회가 있다면, order_summary 읽기 모델 테이블 하나를 만들어 이 조회만 전환한다. 이중 쓰기(기존 테이블 + 읽기 모델 동시 업데이트)를 과도기로 허용하면 기존 코드 변경을 최소화할 수 있다. 조회 성능이 200ms → 5ms로 개선되면 팀이 CQRS의 실제 가치를 체감한다.

이후 Phase 2에서 OrderCommandService / OrderQueryService로 분리하고, Phase 3에서 @TransactionalEventListener 또는 Kafka 기반으로 이중 쓰기를 제거한다. Phase 4의 Event Sourcing은 감사 로그가 실제로 필요한 Aggregate에만 선택적으로 적용한다.

도입을 멈춰야 할 신호도 있다. 팀이 Eventual Consistency를 받아들이지 못하거나, 이벤트 스키마 변경이 매주 일어나거나, CQRS 관련 디버깅에 기능 개발보다 시간이 더 걸린다면 단계를 낮추거나 속도를 늦춰야 한다.

현실적인 비용 계산

학습 비용을 과소평가하면 팀이 예상치 못한 복잡도에 빠진다.

5인 팀 기준으로 첫 Aggregate 구현까지 한 달, Projection 장애 처음 대응까지 두 달, Saga 첫 구현까지 46개월이 현실적이다. 팀이 자신감을 갖기까지 612개월이 걸린다. 이 기간 동안 기능 개발 생산성이 30~50% 감소한다고 보면 된다.

운영 복잡도도 실질적이다. Projection 지연 알람, DLQ 모니터링, Consumer Group lag, 이벤트 스토어 용량 — 대시보드 패널이 15~25개 추가된다. 기존 대비 운영 부담이 약 30% 증가한다.

편익은 분명하다. 읽기 모델 도입 후 조회 성능이 10~100배 개선되고, 새 분석 뷰 추가가 Projection 하나 추가로 해결된다. 금융 감독원 조사에 이벤트 스토어로 30분 내 대응한 실제 사례도 있다.

손익분기점은 CQRS만 도입하면 46개월, CQRS + ES는 1218개월로 봐야 한다.

정리

  • CQRS와 Event Sourcing은 독립적인 패턴이다. ES 없이 CQRS만으로도 읽기 최적화의 핵심 이점을 얻을 수 있다.
  • ES는 감사 로그, 시간 여행, Projection 재구축이 실제 비즈니스 요구사항인 도메인에만 적용하라. 단순 CRUD 도메인에 ES를 얹으면 비용만 추가된다.
  • 시작은 “가장 느린 조회 쿼리 하나”다. 읽기 모델 하나의 성공이 팀의 신뢰를 만들고 다음 단계로 이어진다.
  • 안티패턴 4가지(가짜 CQRS, ES 무분별 적용, 잦은 스키마 변경, 읽기 모델로 쓰기 결정)는 조기에 감지할수록 비용이 적다.

다음 글에서는 이 시리즈의 실제 구현 — Axon Framework 없이 순수 Spring으로 이벤트 스토어와 Projection을 직접 구성하는 방법을 다룬다.