CQRS와 이벤트 소싱의 운영 경계
CQRS는 읽기와 쓰기의 요구사항 차이를 분리해 각각 최적화하고, Event Sourcing은 현재 상태가 아니라 변경 이벤트를 저장해 이력과 재생 가능성을 확보한다. 두 패턴은 Outbox, Kafka, CDC, WAL, 저장소 선택, 운영 실패 모드까지 함께 보아야 실제 시스템에서 의미가 생긴다.
Script Companion
오디오와 함께 스크립트 보기
- 01
CQRS와 Event Sourcing의 출발점은 새로운 패턴을 쓰자는 욕심이 아니라, 단일 CRUD 모델이 너무 많은 책임을 동시에 떠안는다는 압력이다. 하나의 모델이 쓰기 검증, 조인 기반 조회, 권한 필터링, 감사 추적을 모두 처리하면 읽기와 쓰기 어느 쪽도 선명하게 최적화하기 어렵다. 읽기는 빠른 응답과 복잡한 조인이 중요하고, 쓰기는 데이터 정합성과 트랜잭션이 중요하다. 이 차이가 커질수록 모델을 나누는 이유가 생긴다.
- 02
CQRS, 즉 Command Query Responsibility Segregation은 상태를 바꾸는 Command와 상태를 읽는 Query를 분리하는 설계다. 은행 창구로 비유하면 입출금 창구는 정확성이 중요하고, 잔액 조회 창구는 빠른 응답이 중요하다. 하나의 창구가 모든 일을 맡으면 입출금 때문에 조회가 느려지고, 조회 때문에 입출금 처리도 영향을 받는다. CQRS는 이 두 창구를 다른 모델, 때로는 다른 저장소로 나누어 각자의 요구사항에 맞춘다.
- 03
쓰기 모델은 Aggregate 불변식, Command 검증, 트랜잭션, 동시성 제어에 집중한다. 정규화된 스키마로 데이터 무결성을 지키고, 재고가 0 이하로 내려가거나 잔액보다 많이 출금되는 일을 막는다. 반대로 읽기 모델은 화면이 필요한 DTO나 materialized view를 미리 만들어 복잡한 JOIN 없이 바로 조회하도록 만든다. 쓰기는 PostgreSQL, 읽기는 Elasticsearch처럼 서로 다른 DB를 둘 수도 있다.
- 04
하지만 CQRS는 읽기와 쓰기를 나누면 빨라진다는 단순한 결론으로 도입하면 위험하다. 단순 관리자 CRUD처럼 목록과 상세 조회가 충분히 빠르고 권한이나 필터 조건도 단순하다면 분리 비용이 이득보다 커질 수 있다. 도입 판단은 분리하면 빨라지는가보다, 분리 후 생기는 Eventual Consistency와 운영 지표를 감당할 수 있는가를 먼저 본다. read-your-writes SLA가 강한 화면은 비동기 projection만으로 부족할 수 있다.
- 05
프론트엔드 관점에서는 React에서 useMutation으로 데이터를 변경한 뒤 invalidateQueries로 캐시를 무효화하고 다시 조회하는 흐름이 미니 CQRS처럼 보인다. useMutation은 Command 실행에 가깝고, invalidateQueries는 읽기 모델을 무효화한 뒤 최신 데이터를 다시 읽는 과정에 가깝다. 규모가 커지면 이 패턴이 서버 아키텍처 레벨에서 CommandBus, QueryBus, Query 모델 분리로 확장된다. 그래서 CQRS는 프론트엔드 캐시 운용 경험과도 연결된다.
- 06
Event Sourcing은 현재 상태만 저장하는 CRUD의 약점을 다른 방식으로 푼다. 전통적인 CRUD는 통장 잔액처럼 현재 값만 남기므로 언제, 왜, 어떻게 그 상태가 됐는지 알기 어렵다. Event Sourcing은 거래 내역 같은 이벤트를 모두 저장하고, 이 이벤트를 순서대로 재생해서 현재 상태나 특정 시점의 상태를 복원한다. Martin Fowler가 말하듯 현재 상태뿐 아니라 어떻게 여기까지 왔는지가 필요한 순간이 있다.
- 07
이벤트 소싱에서 이벤트는 이미 발생한 사실이므로 수정하거나 삭제하지 않는다. 잘못된 출금 이벤트가 있다면 지우는 대신 출금 취소 같은 보상 이벤트를 새로 append한다. 이 append-only 원칙 덕분에 완전한 감사 추적, 이벤트 시퀀스 재생, 과거 상태 재현, 버그 재현이 가능해진다. 이벤트가 너무 많아져 Aggregate 재수화 시간이 길어지면 특정 시점의 상태를 Snapshot으로 저장하고 그 이후 이벤트만 재생해 성능을 보완한다.
- 08
Event Sourcing도 아무 곳에나 쓰는 패턴은 아니다. GDPR이나 개인정보 삭제 요건이 있는 도메인에서는 append-only 원칙 때문에 삭제 요구와 충돌할 수 있고, Crypto Shredding이나 PII 분리 저장 같은 추가 설계가 필요하다. 외부 API 호출이나 결제 처리처럼 사이드 이펙트에 의존하는 이벤트 재생은 과거와 같은 결과를 보장하기 어렵다. 현재 상태만 필요한 단순 도메인이라면 Kurrent의 4C 프레임워크 기준으로도 도입을 재고해야 한다.
- 09
CQRS와 Event Sourcing을 함께 쓰면 핵심 질문은 쓰기 모델의 변경을 어떻게 읽기 모델에 전달하느냐가 된다. Command Handler에서 DB에 저장한 뒤 EventBus.publish를 호출하는 사이에 서버가 다운되면 이벤트가 유실될 수 있다. Outbox 패턴은 이벤트를 직접 발행하지 않고, 데이터와 이벤트를 같은 DB 트랜잭션에 저장한다. 이후 별도 relay나 Processor가 Outbox를 읽어 브로커로 발행하므로, 커밋된 변경만 메시지로 나가게 된다.
- 10
Outbox는 이벤트 유실을 줄이지만 중복 발행 가능성을 없애지는 않는다. 브로커 발행 직후 published_at을 갱신하기 전에 Processor가 크래시하면 재시작 후 같은 이벤트를 다시 발행할 수 있다. 그래서 Outbox는 구조적으로 at-least-once를 보장하고, 소비자는 멱등성을 구현해야 한다. 운영에서는 published_at이 없는 이벤트가 5분 이상 100건을 초과하면 Processor 중단으로 간주해 알람을 내는 식의 기준도 둘 수 있다.
- 11
Kafka와 Projection을 결합하면 한 번 쓰고 여러 곳에서 읽는 구조를 만들 수 있다. 이벤트가 Kafka의 append-only 토픽에 쌓이면 각 Consumer Group은 독립적으로 오프셋을 관리하고, Elasticsearch나 Redis 같은 읽기 모델은 자기 목적에 맞게 Projection을 갱신한다. 새 읽기 모델이 필요하면 기존 시스템을 멈추지 않고 Consumer를 추가한 뒤 처음부터 이벤트를 재생할 수 있다. 이때 Kafka는 Event Store와 경쟁한다기보다 스트리밍과 여러 Projection에 강한 역할을 맡는다.
- 12
또 다른 동기화 방식은 PostgreSQL WAL을 데이터 원천으로 삼는 CDC다. Debezium 같은 CDC 커넥터가 WAL을 읽어 변경 이벤트를 Kafka로 스트리밍하면, 읽기 모델은 그 이벤트를 구독해 자신의 뷰를 업데이트한다. 이렇게 하면 애플리케이션 Command Handler는 쓰기 DB에만 집중하고, 읽기 모델 동기화는 CDC 파이프라인이 담당한다. AWS 환경에서는 Debezium과 MSK, RDS, Aurora Read Replica, ElastiCache 같은 조합으로 이 구조를 물리적으로 구현할 수 있다.
- 13
CDC에서 특히 위험한 실패는 큰 에러로 드러나는 장애보다 읽기 모델이 조용히 뒤처지거나 일부 변경을 건너뛰는 silent failure다. Debezium 문서는 PostgreSQL 업그레이드 과정에서 replication slot이 제거되면 커넥터가 기대한 위치에서 재개하지 못하고 변경 이벤트를 건너뛸 수 있다고 경고한다. slot이 없거나 active=false가 오래 지속되면 커넥터만 재시작하지 말고, Kafka의 마지막 처리 offset과 PostgreSQL WAL 보존 범위를 먼저 대조해야 한다.
- 14
저장소 선택도 패턴의 일부다. PostgreSQL은 ACID와 익숙한 스키마, 트랜잭션 내 Outbox에 강하고, EventStoreDB와 KurrentDB는 이벤트 소싱에 최적화된 스트림 관리에 맞다. DynamoDB는 서버리스와 AWS 네이티브 운영에 유리하지만, 한 아이템 최대 400KB, 트랜잭션 최대 100개 unique item 또는 4MB 같은 제약을 고려해야 한다. 큰 payload는 S3에 두고 이벤트에는 포인터를 저장하거나, aggregateId와 version으로 이벤트를 쪼개야 한다.
- 15
정리하면 CQRS는 읽기와 쓰기의 책임을 나누어 각 모델을 다르게 최적화하는 구조이고, Event Sourcing은 상태 대신 이벤트 이력을 저장해 replay, Snapshot, Time Travel, Audit Log를 가능하게 하는 구조다. 두 패턴을 실제로 결합하려면 Outbox의 멱등성, Projection의 지연, CDC의 slot과 LSN, 이벤트 스키마 버전 관리까지 함께 설계해야 한다. 핵심은 패턴 이름이 아니라, 변경이 어떻게 기록되고 읽기 모델에 어떻게 안전하게 도달하는지를 끝까지 추적하는 것이다.
같은 레이어
L9에서 이어 듣기
- 설계 원칙을 운영 가능한 코드로 잇기 길이 미정
- Clean Architecture의 의존성 규칙 길이 미정
- DDD 기본기: 도메인 언어와 경계 설계 길이 미정
- Twelve-Factor App 운영 원칙 길이 미정
- CAP과 일관성으로 보는 분산 시스템 선택 길이 미정
- MSA 패턴, 분리의 이득과 운영 비용 길이 미정
- Saga Pattern: 로컬 커밋과 역순 보상 길이 미정
- TDD와 테스트 피라미드로 설계하는 테스트 전략 길이 미정
- 대규모 웹 크롤러의 큐, 정중함, 중복 제거 길이 미정
- API 계약으로 안전하게 서비스 경계를 진화시키기 길이 미정
- URL Shortener와 Rate Limiter로 보는 시스템 디자인 길이 미정