CQRS와 이벤트 소싱으로 대규모 트래픽 견디는 아키텍처 설계 2026
읽기와 쓰기를 분리하는 CQRS와 상태 변화를 이벤트로 저장하는 이벤트 소싱을 결합해 확장성 높은 시스템을 설계하는 방법을 정리했습니다. 실전 트레이드오프와 적용 기준을 다룹니다.
CQRS와 이벤트 소싱으로 대규모 트래픽 견디는 아키텍처 설계 2026
트래픽이 폭증하는 서비스에서 읽기와 쓰기는 성격이 전혀 다르다. 상품 상세 페이지는 초당 수만 번 읽히지만 실제 주문은 그보다 훨씬 적게 발생한다. 그런데 하나의 데이터 모델과 하나의 데이터베이스로 둘을 모두 처리하려 하면, 읽기 최적화와 쓰기 정합성이 충돌한다. CQRS(Command Query Responsibility Segregation)는 이 충돌을 명령과 조회의 책임을 분리해 해결하고, 이벤트 소싱은 상태의 현재 값 대신 상태를 만든 모든 사건을 기록해 완전한 이력을 남긴다. 2026년의 대규모 이커머스와 핀테크 시스템에서 이 조합은 검증된 패턴으로 자리 잡았다.
CQRS의 핵심 개념과 이점
CQRS의 출발점은 단순하다. 데이터를 바꾸는 쓰기 경로와 데이터를 읽는 조회 경로를 서로 다른 모델로 다루자는 것이다. 쓰기 모델은 비즈니스 규칙과 불변식을 엄격히 지키는 데 집중하고, 읽기 모델은 화면에 필요한 형태로 미리 가공된 데이터를 빠르게 반환하는 데 집중한다. 두 모델은 각자에게 최적화된 저장소를 쓸 수 있다. 쓰기는 관계형 데이터베이스, 읽기는 검색에 특화된 엘라스틱서치나 인메모리 캐시를 사용하는 식이다.
이 분리가 주는 이점은 확장성이다. 읽기 부하가 늘면 읽기 저장소만 복제를 늘려 수평 확장하면 되고, 쓰기 저장소는 정합성에 집중해 별도로 관리한다. 서로 독립적으로 스케일하므로 자원 배분이 효율적이다. 다만 읽기 모델은 쓰기 모델의 변경을 비동기로 반영하므로 짧은 지연 동안 데이터가 일치하지 않는 최종 일관성(eventual consistency)을 받아들여야 한다. 주문 완료 직후 목록에 즉시 안 보일 수 있다는 뜻이며, 이 지연을 UX로 어떻게 감출지가 설계의 관건이다.
이벤트 소싱으로 상태 관리하기
전통적인 방식은 현재 상태만 저장한다. 계좌 잔액이 5만 원이면 데이터베이스에는 5만 원이라는 값만 남는다. 이벤트 소싱은 반대로 잔액을 만든 모든 사건, 즉 입금 3만 원, 입금 4만 원, 출금 2만 원을 순서대로 기록한다. 현재 상태는 이 이벤트들을 처음부터 재생해 계산한다. 모든 변화의 원인이 남으므로 감사 추적이 완벽하고, 과거 어느 시점의 상태든 그때까지의 이벤트를 재생해 복원할 수 있다.
type AccountEvent =
| { type: 'Deposited'; amount: number; at: string }
| { type: 'Withdrawn'; amount: number; at: string };
function replay(events: AccountEvent[]): number {
return events.reduce((balance, e) => {
if (e.type === 'Deposited') return balance + e.amount;
if (e.type === 'Withdrawn') return balance - e.amount;
return balance;
}, 0);
}
이벤트가 수천 건 쌓이면 매번 전체 재생은 느려진다. 그래서 실무에서는 스냅숏을 주기적으로 저장한다. 예를 들어 100번째 이벤트마다 그 시점의 상태를 저장해두고, 이후에는 스냅숏에서 시작해 남은 이벤트만 재생한다. 또 이벤트는 절대 수정하거나 삭제하지 않는다. 잘못된 이벤트가 있으면 이를 상쇄하는 보상 이벤트를 새로 추가해 바로잡는다. 이 불변성이 이벤트 소싱의 신뢰성을 지탱하는 원칙이다.
두 패턴을 결합할 때의 실전 고려사항
CQRS와 이벤트 소싱은 함께 쓰일 때 자연스럽다. 쓰기 측에서 이벤트를 저장하면, 그 이벤트를 구독하는 프로젝션이 읽기 모델을 갱신한다. 이벤트가 곧 두 모델을 잇는 다리가 되는 구조다. 카프카 같은 이벤트 스트리밍 플랫폼이 이 흐름을 안정적으로 전달하며, 소비자가 실패해도 오프셋을 되돌려 재처리할 수 있어 복원력이 높다.
하지만 이 조합은 복잡성이라는 대가를 치른다. 개발자는 최종 일관성, 이벤트 버전 관리, 스키마 진화, 중복 처리 같은 분산 시스템 특유의 문제를 다뤄야 한다. 같은 이벤트가 두 번 전달돼도 결과가 같도록 멱등성을 보장하는 설계가 필수다. 따라서 모든 시스템에 이 패턴을 적용하는 것은 과도하다. 감사 요구가 강하고, 읽기와 쓰기 부하 차이가 크며, 도메인 로직이 복잡한 핵심 영역에 선별적으로 도입하는 것이 현명하다. 단순한 CRUD로 충분한 부분까지 이벤트 소싱을 밀어붙이면 얻는 것보다 잃는 것이 많다. 아키텍처 결정은 항상 문제의 성격에 맞춰야 하며, 화려한 패턴 자체가 목적이 되어서는 안 된다.
프로젝션과 읽기 모델 갱신 전략
이벤트 소싱과 CQRS를 함께 쓸 때 실무의 핵심은 프로젝션 관리다. 프로젝션은 이벤트 스트림을 구독해 읽기 모델을 만드는 컴포넌트인데, 하나의 이벤트 스트림에서 여러 프로젝션을 파생시킬 수 있다는 점이 강력하다. 같은 주문 이벤트로부터 주문 목록용 읽기 모델, 매출 집계용 읽기 모델, 배송 추적용 읽기 모델을 각각 독립적으로 만들 수 있다. 새로운 화면이 필요해지면 기존 이벤트를 처음부터 재생해 그에 맞는 읽기 모델을 새로 구축하면 된다. 원본 데이터가 이벤트라는 사실 기록으로 남아 있으므로, 읽기 모델은 언제든 버리고 다시 만들 수 있는 파생물로 취급된다.
프로젝션을 재구축해야 하는 상황은 생각보다 자주 온다. 읽기 모델에 버그가 있어 데이터가 어긋났거나, 화면 요구사항이 바뀌어 스키마를 변경해야 할 때다. 이때 새 프로젝션을 만들어 이벤트를 재생하고, 완성되면 기존 것과 교체하는 무중단 전환 기법을 쓴다. 다만 이벤트가 수억 건이면 재생에도 시간이 걸리므로, 재생 진행 상황을 모니터링하고 완료 시점을 예측하는 장치가 필요하다.
이벤트 스키마 진화도 반드시 대비해야 한다. 시스템이 오래 운영되면 이벤트 구조가 바뀌기 마련인데, 이미 저장된 과거 이벤트는 옛 구조 그대로 남아 있다. 그래서 프로젝션은 여러 버전의 이벤트를 모두 해석할 수 있어야 한다. 흔한 해법은 저장된 옛 이벤트를 읽는 시점에 최신 구조로 변환하는 업캐스팅 계층을 두는 것이다. 또 이벤트 이름과 필드에는 비즈니스 의미가 분명한 이름을 붙여, 몇 년 뒤에 봐도 그 이벤트가 무엇을 뜻하는지 알 수 있게 해야 한다. 이벤트는 한 번 저장하면 영원히 남는 시스템의 역사이므로, 그 설계에는 처음부터 신중을 기해야 한다.