S9 · REF제 4 장 · 원칙 1·7 대응

CQRS / 읽기 모델

Greg Young

쓰기 모델은 하나, 읽기 투영은 여럿. 데이터를 바꾸는 경로와 데이터를 보는 경로는 요구가 다르므로, 진실은 한 곳에 두고 화면마다 그 진실을 알맞게 접은 투영(read model)을 따로 둔다는 아키텍처 패턴이다. "중심 객체는 사건 하나, 렌즈는 필터+투영"이라는 우리 5장의 문장을 소프트웨어 공학은 이렇게 부른다.

무엇인가

뿌리는 Bertrand Meyer 의 CQS(Command-Query Separation) 다. Object-Oriented Software Construction(1988)에서 Meyer 는 메서드 수준의 규율을 제시했다 — 하나의 메서드는 상태를 바꾸거나(command) 값을 돌려주거나(query) 둘 중 하나만 해야 하며, "질문이 답을 바꿔서는 안 된다". Greg Young 은 2000년대 후반 이 규율을 메서드가 아니라 시스템 수준으로 끌어올려 CQRS(Command Query Responsibility Segregation)라 이름 붙였다. 쓰기와 읽기를 아예 별개의 모델로 분리하자는 것이다.

용어비고
쓰기 모델명령(command)을 받아 검증하고 상태를 바꾸는 단 하나의 모델불변식(invariant)이 사는 곳 — 진실의 원천
읽기 모델특정 화면·질의에 맞게 접어 둔 데이터 표현화면 수만큼 여럿이어도 된다
투영(projection)쓰기 쪽의 변경을 읽기 모델로 옮겨 적는 변환이벤트를 구독해 갱신하는 경우가 전형
최종 일관성읽기 모델이 쓰기 모델을 잠시 늦게 따라잡는 것을 허용분리의 대가이자, 분리를 가치 있게 하는 조건

왜 분리하는가. 쓰기는 정합성과 불변식이 중요하고, 읽기는 화면마다 다른 모양·다른 성능이 중요하다. 하나의 정규화된 모델로 둘을 다 시키면 양쪽 다 어정쩡해진다. 읽기 모델을 여럿 두면 새 화면 = 새 투영 하나 추가가 되고, 쓰기 모델은 건드리지 않는다.

Young 자신이 반복해서 단서를 달았다. 첫째, CQRS 는 이벤트 소싱이 아니다 — 둘은 궁합이 좋아 자주 함께 쓰일 뿐 별개 패턴이다(이벤트 소싱은 별도 항목). 둘째, CQRS 는 시스템 전체에 씌우는 최상위 아키텍처가 아니라 필요한 부분(bounded context)에만 국소 적용하는 패턴이다. Martin Fowler 도 bliki 의 CQRS 항목에서 "대부분의 시스템엔 과하다"고 같은 경고를 남겼다.

우리 모델과의 접점

개념 모델 5장과 원칙 1·7은 CQRS 의 어휘로 거의 그대로 번역된다. 지도의 접점 문구 — "단일 소스, 렌즈는 필터+투영"의 엔지니어링 대응물 — 를 표로 펴면:

CQRS우리 모델비고
쓰기 모델 하나사건(Event) — 단일 소스(원칙 1)"렌즈는 문(門)이지 창고가 아니다"
읽기 모델 여럿렌즈 = 필터+투영 — 여정·수첩·여비 + 2차 렌즈"렌즈는 셋이 정원이 아니라 축의 수만큼 열리는 문"
화면별 맞춤 투영세 선택 속성이 렌즈 노출을 결정 — 날짜→여정 · 상태→수첩 · 배정→여비투영마다 자기 필터 조건이 있다
투영은 파생물시제·롤업·순자산 곡선·미배정 자금 — 저장보다 파생(원칙 7)단, 물질화 여부는 어긋남 — 아래 참조
불변식은 쓰기 쪽에"일회성 이동은 반드시 사건에 앵커된다"(§6) 같은 제약렌즈가 아니라 데이터 규칙이 삼위일체를 보장

어긋남이 두 군데 있고, 둘 다 의도적이다.

  • 우리는 '분리'는 버리고 '투영'만 취했다. CQRS 의 R 은 책임 분리(segregation)인데, 우리 렌즈는 읽기 전용이 아니다 — "마스터 관리 화면은 따로 없고 렌즈가 곧 관리 화면"이며, 어디서 입력하든 같은 객체를 편집하고 즉시 상호 반영된다. 최종 일관성이라는 대가도 치르지 않는다. 개인 기록 도구의 데이터 규모에서는 분리의 비용이 이득을 넘어서기 때문이고, 이는 "필요한 곳에만 국소 적용하라"는 Young 의 단서를 따른 셈이다.
  • 읽기 모델을 물질화하지 않는다. 전형적 CQRS 는 투영을 별도 저장소(비정규화 테이블·머티리얼라이즈드 뷰)에 실제로 써 둔다. 우리는 원칙 7에 따라 계산으로 될 것은 저장하지 않는다 — 시제도, 곡선도, 렌즈의 목록도 조회 시점에 파생한다. 투영이라는 개념은 같되, 그것을 데이터로 두느냐 계산으로 두느냐가 다르다.
store-master 리팩터(D1)와 직결. 통합 앱 셸 proto 는 지금 타임라인 엔진이 이벤트 정본이고 store 가 그것을 반사한다 — 쓰기 모델이 투영 하나(여정) 안에 들어앉은, CQRS 관점에서 뒤집힌 구조다. D1 묶음의 store-master 역전은 이것을 바로 세우는 일이다: store 가 유일한 쓰기 모델이 되고, 엔진은 Event[] 를 props 로 받는 투영 컴포넌트로 내려간다. 그러면 여정·수첩·여비·프로필이 전부 store.emit() 을 구독하는 동렬의 읽기 모델이 된다 — 원칙 1을 문서가 아니라 코드 구조에 새기는 리팩터.

생각할 거리

  1. 편집하는 투영의 명령 어휘. CQRS 라면 렌즈는 읽기만 하고 변경은 명령으로 보내야 한다. 우리는 렌즈에서 직접 편집하지만, D1 이후엔 결국 각 렌즈의 편집이 store 로 보내는 명령의 집합(추가·이동·삭제·승격…)으로 정리된다. 이 명령 목록을 명시적으로 닫아 두는 것과 자유 변형을 허용하는 것 — "일회 Flow 는 사건에 앵커"(§6) 같은 불변식을 어느 층이 지키는가가 여기서 갈린다.
  2. 파생의 물질화는 언제 정당한가. 원칙 7은 저장보다 파생이지만, CQRS 가 투영을 저장하는 이유는 조회 시점 계산이 비싸지기 때문이다. 수십 년치 사건 위에서 시나리오 N+1벌의 곡선(B6 "모두 비교")을 매번 재계산하는 날이 오면, 캐시된 투영(store.emit() 으로 무효화)은 원칙 7 위반인가, 아니면 원칙이 금지한 것은 '저장'이 아니라 '정본화'일 뿐인가.
  3. 최종 일관성은 이미 와 있다. 한 기기 안에서는 즉시 반영이지만, 핸들+PIN 계정의 대리 기록처럼 두 기기가 같은 기록을 만지는 순간 다른 기기의 렌즈는 최종 일관적인 읽기 모델이 된다. 늦게 도착한 투영을 어떻게 보여 줄 것인가 — Local-first 항목의 동기화 질문과 같은 자리에서 만난다.
  4. 겹(시나리오)은 투영의 매개변수인가, 둘째 쓰기 모델인가. 원칙 5의 겹은 "같은 데이터를 다른 세계로 접는" 투영 매개변수처럼 굴지만, proto 의 B1·B2 구현은 copy-on-write 사본·removed 마커라는 쓰기를 낳았다. 겹이 읽기 쪽 개념(투영 파라미터)으로 남는지, 슬며시 두 번째 쓰기 모델이 되고 있는지 — 델타만 승격한다는 7장의 순환과 함께 검토할 것.

더 찾아보기

  • Greg Young, CQRS Documents(2010년경 배포된 정리 문서) — 검색: Greg Young CQRS Documents pdf. 강연 녹화도 여럿: Greg Young CQRS talk.
  • Martin Fowler, bliki "CQRS" — martinfowler.com/bliki/CQRS.html — 패턴의 요약과 "대부분엔 과하다"는 경고를 함께 담은 짧은 표준 참조.
  • Bertrand Meyer, Object-Oriented Software Construction, 1988 — CQS 의 원전. 검색: command query separation Meyer.
  • 검색: CQRS read model projection · azure architecture cqrs pattern — 클라우드 벤더 문서에 도해가 잘 정리돼 있다.
  • 같은 장의 이웃: 파셋 분류(여러 축의 투영이라는 같은 발상의 도서관학판), Zettelkasten(구조를 미리 정하지 않는 축적). 쓰기 쪽 원장의 계보는 이벤트 소싱 / 불변 로그, 회계판 "상태는 파생"은 플레인텍스트 회계.

내 생각

이 메모는 지도 페이지의 같은 항목과 공유되며, 이 기기(브라우저)에 저장됩니다.