포스트

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

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


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

명령(상태를 바꾸는 것)과 조회(상태를 읽는 것)의 모델을 나눈다. 정의는 여기까지다. Fowler의 CQRS 글도 두 모델이 같은 DB를 공유할 수도, 별도 DB를 쓸 수도 있다고 적는다. 같은 DB 안에서 읽기 전용 DTO를 따로 두는 것도 CQRS이고, 별도 읽기 전용 저장소를 두는 것도 CQRS다.

분리하는 이유는 두 요구가 반대 방향이기 때문이다. 쓰기는 정규화와 불변식 검증이 필요하고, 읽기는 화면에 맞게 미리 조인된 형태가 빠르다. 하나의 모델로 둘을 만족시키려면 어느 한쪽이 불편해진다. 그런데 Fowler는 같은 글에서 “for most systems CQRS adds risky complexity”(대부분의 시스템에서 CQRS는 위험한 복잡도를 더한다)라고 경고한다.

그 복잡도는 저장소를 나눌 때부터 생긴다.

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

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


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

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

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

얻는 것.

  • 모든 변경의 이유와 순서가 남는다. 감사와 분쟁 대응에 그대로 쓰인다.
  • 과거 시점의 상태를 재구성할 수 있다. “3월 1일의 잔액”이 계산 가능하다.
  • 새 읽기 모델을 나중에 만들 수 있다. 사건이 남아 있으므로 처음부터 다시 투영하면 된다. Fowler의 Event Sourcing 글은 이것을 Complete Rebuild라 부른다. 애플리케이션 상태를 버리고 빈 상태에서 이벤트 로그를 다시 실행해 만드는 것이다.
  • 디버깅이 다르다. “왜 이 값이 됐는가”에 목록으로 답한다.

내는 것.

  • 조회가 비싸다. 재생 비용을 줄이려면 스냅샷이 필요하고(Fowler의 예는 밤마다 스냅샷을 만들고 장애 시 그 뒤 이벤트만 재생하는 방식이다), 스냅샷 주기와 무효화가 또 하나의 설계 대상이 된다.
  • 이벤트 스키마를 바꿀 수 없다. 이미 저장된 사건은 과거의 사실이다. 버전을 올리고 업캐스팅(읽을 때 변환)하거나, 새 이벤트 타입을 추가한다. 감사에 쓰이는 바로 그 불변성이 여기서는 제약이 된다.
  • 개인정보 삭제 요구와 충돌한다. 지울 수 없는 로그에 개인정보가 들어가면 법적 요구를 만족하기 어렵다. 암호화 후 키 폐기(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를 도입한다”는 말을 들으면 저장소를 나누는지부터 확인한다. 비용은 거기서 갈린다.
  • 이벤트 소싱은 사건 중심 도메인, 감사 요구, 설명 책임이 겹칠 때만 값을 한다. CRUD 도메인에서는 비용만 남는다.
  • 금액 도메인에서는 이중부기 원장이 더 가벼운 절충이 되는 경우가 많다.
참고한 자료외부 출처 2 · 블로그 글 2

외부 출처

이 블로그의 관련 글

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

댓글

아직 댓글이 없습니다