포스트

이벤트 소싱과 CQRS - 언제 비용이 이익을 넘는가

둘은 자주 한 묶음으로 소개되지만 별개의 패턴이고, 따로 쓸 수 있다. 그리고 둘 다 “더 나은 설계”가 아니라 특정 비용을 내고 특정 이익을 사는 거래다. 그 거래의 양쪽을 적어 두면 언제 쓸지가 정해진다.

CQRS: 읽기 모델과 쓰기 모델을 분리한다

명령(상태를 바꾸는 것)과 조회(상태를 읽는 것)의 모델을 나눈다. 그게 전부다. 같은 DB 안에서 읽기 전용 DTO를 따로 두는 것도 CQRS이고, 별도 읽기 전용 저장소를 두는 것도 CQRS다.

분리하는 이유는 두 요구가 반대 방향이기 때문이다. 쓰기는 정규화와 불변식 검증이 필요하고, 읽기는 화면에 맞게 미리 조인된 형태가 빠르다. 하나의 모델로 둘을 만족시키려면 어느 한쪽이 불편해진다.

비용은 저장소를 나눌 때부터 생긴다.

  • 읽기 모델이 쓰기보다 뒤처진다(최종 일관성). “저장했는데 목록에 없다”가 버그가 아니라 설계가 된다.
  • 읽기 모델을 채우는 경로가 새 장애 지점이 된다.
  • 두 모델의 스키마를 함께 진화시켜야 한다.

같은 DB 안에서의 분리는 이 비용이 거의 없다. 저장소를 나누기 전까지 CQRS는 싸다. 그래서 “CQRS를 도입한다”는 말이 어느 수준인지를 먼저 확인해야 한다.

이벤트 소싱: 상태 대신 사건을 저장한다

현재 상태를 저장하는 대신, 상태를 바꾼 사건의 목록을 저장한다. 현재 상태는 사건을 처음부터 적용해 재구성한다.

1
2
3
4
5
일반:  balance = 7000                      (덮어쓴다)
소싱:  Deposited(10000)                    (추가만 한다)
       Paid(2000)
       Paid(1000)
       → 재생하면 7000

얻는 것.

  • 모든 변경의 이유와 순서가 남는다. 감사와 분쟁 대응에 그대로 쓰인다.
  • 과거 시점의 상태를 재구성할 수 있다. “3월 1일의 잔액”이 계산 가능하다.
  • 새 읽기 모델을 나중에 만들 수 있다. 사건이 남아 있으므로 처음부터 다시 투영하면 된다.
  • 디버깅이 다르다. “왜 이 값이 됐는가”에 목록으로 답한다.

내는 것.

  • 조회가 비싸다. 재생 비용을 줄이려면 스냅샷이 필요하고, 스냅샷 주기와 무효화가 또 하나의 설계 대상이 된다.
  • 이벤트 스키마를 바꿀 수 없다. 이미 저장된 사건은 과거의 사실이다. 버전을 올리고 업캐스팅(읽을 때 변환)하거나, 새 이벤트 타입을 추가한다. 삭제와 수정이 불가능하다는 것이 장점이자 제약이다.
  • 개인정보 삭제 요구와 충돌한다. 지울 수 없는 로그에 개인정보가 들어가면 법적 요구를 만족하기 어렵다. 암호화 후 키 폐기(crypto-shredding) 같은 우회가 필요하다.
  • 팀의 사고방식을 바꿔야 한다. “행을 수정한다”가 아니라 “사건을 추가한다”로 모든 기능을 설계해야 한다.

둘의 관계

이벤트 소싱을 쓰면 조회가 어려워지므로 CQRS가 거의 강제된다. 사건에서 읽기 모델을 투영해 두고 조회는 거기서 한다. 반대는 성립하지 않는다. CQRS만 쓰는 것은 흔하고 자연스럽다.

도입 기준

다음이 여럿 해당할 때만 이벤트 소싱이 값을 한다.

  1. 도메인이 본래 사건 중심이다(금융 거래, 주문 상태 전이, 재고 이동).
  2. 감사 요구가 법적·업무적으로 실재한다.
  3. “왜 이 값이 됐는가”를 사후에 설명해야 하는 일이 잦다.
  4. 과거 시점 재구성이나 재투영이 필요하다.

해당하지 않는데 도입하면, 얻는 것 없이 스냅샷·업캐스팅·최종 일관성 비용만 낸다. CRUD로 충분한 도메인에 이벤트 소싱을 씌우는 것이 이 패턴의 가장 흔한 오용이다.

더 가벼운 대안: 이중부기 원장

금액을 다루는 도메인에서는 전면 이벤트 소싱보다 원장이 실용적인 경우가 많다. 확정된 기록은 수정하지 않고, 취소는 역분개로, 오류는 보정 분개로 표현한다. 추가만 하는 성질은 같고, 재생으로 상태를 만드는 대신 잔액 스냅샷을 따로 유지한다.

ParityPay 7편이 이 방식이다. 확정 원장을 UPDATE·DELETE하지 않는다는 규칙(INV-006)을 DB 제약과 트리거로 강제하고, 잔액 스냅샷이 같은 시점의 원장 계산값과 일치하는지(INV-010)를 불변조건으로 뒀다. 이벤트 소싱의 이점 중 감사와 설명 가능성을 가져오면서 재생 비용은 피하는 절충이다.

이 설명이 깨지는 곳

  • “이벤트를 카프카에 보낸다”는 이벤트 소싱이 아니다. 통합 이벤트를 발행하는 것과 사건을 진실의 원천으로 저장하는 것은 다르다. 전자는 Outbox의 영역이다.
  • 이벤트 스토어가 메시지 브로커는 아니다. 순서와 영속성 요구가 다르다.
  • 읽기 모델 재구축 시간이 운영 제약이 된다. 사건이 수억 개면 재투영에 몇 시간이 걸리고, 그 동안의 서비스 방식을 정해 둬야 한다.
  • CQRS가 곧 마이크로서비스는 아니다. 하나의 프로세스 안에서도 성립한다.

무엇을 재면 확인되는가

  1. 사건 수를 늘려 가며 스냅샷 없이 상태를 재생하는 시간을 잰다. 스냅샷 주기의 근거가 거기서 나온다.
  2. 읽기 모델의 지연(쓰기 커밋 → 조회 반영)을 분포로 본다. 그 값이 UX 요구를 넘는지.
  3. 읽기 모델 전체 재구축 시간을 실제로 재 본다. 재해 복구 계획의 입력값이다.

monticker에서 이벤트 중심 설계를 다뤘고, 원장은 이벤트 소싱 방식으로 만들었다. 위 세 가지 중 1번을 재지 않은 것이 그 구현의 빈칸이다.

실무와의 접점

JSP 기반 시스템의 구조 전환에서 조회를 별도 경로로 뺐다. 그것이 CQRS의 가장 가벼운 형태였고, 저장소는 하나였다. 그때 얻은 것은 조회 성능이었고 잃은 것은 거의 없었다. 저장소를 나누지 않는 CQRS는 싸다는 것이 그 경험의 결론이고, 비용은 저장소를 나누는 순간부터 시작된다.

정리

  • CQRS는 읽기와 쓰기 모델의 분리이고, 저장소를 나누기 전까지는 싸다.
  • 이벤트 소싱은 상태 대신 사건을 저장한다. 감사, 과거 재구성, 재투영을 얻는다.
  • 대가는 조회 비용, 스냅샷 설계, 이벤트 스키마의 불변성, 개인정보 삭제와의 충돌이다.
  • 이벤트 소싱은 CQRS를 사실상 강제하지만, 그 역은 아니다.
  • 사건 중심 도메인 + 감사 요구 + 설명 책임이 겹칠 때만 값을 한다.
  • 금액 도메인에서는 이중부기 원장이 더 가벼운 절충이 되는 경우가 많다.

참고

Kafka와 메시징 결제·정산 정합성 아키텍처와 마이그레이션
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다