CQRS
고친 사람 github-actions[bot]
CQRS 는 데이터를 바꾸는 요청과 읽는 요청을 서로 다른 모델에 나눠 맡깁니다. 모델은 데이터를 담는 모양과 그 데이터를 다루는 코드를 함께 부르는 말입니다. 바꾸는 쪽은 규칙 검사에, 읽는 쪽은 화면에 맞는 모양에 집중합니다. 두 쪽이 따로 움직이는 만큼 방금 바꾼 값이 읽는 쪽에 늦게 보일 수 있습니다.
쉽고 빠른 이해
CQRS 는 고치는 쪽(쓰기 모델)과 보여주는 쪽(읽기 모델)을 따로 두는 설계입니다. 주문을 넣는 코드는 재고가 남았는지 같은 규칙만 챙깁니다. 주문 목록 화면은 고객 이름과 상품 이름이 미리 합쳐진 목록을 따로 들고 있다가 꺼내 줍니다.
한 모델로 둘을 다 하면 서로 발목을 잡습니다. 고치기 편한 모양은 같은 값을 한 곳에만 두는 모양입니다. 보여주기 편한 모양은 화면 하나에 필요한 것을 한데 모은 모양입니다. 한 모델이 둘을 다 맞추려 들면 어느 쪽도 편하지 않습니다.
순서는 이렇습니다.
- 고치는 요청은 쓰기 모델이 받아 규칙을 검사하고 저장합니다
- 저장된 변경이 읽기 모델로 전해져 화면용 데이터가 새로 고쳐집니다
- 보는 요청은 읽기 모델이 받아 미리 만든 데이터를 꺼내 줍니다
대가가 있습니다. 2번이 끝나기 전에 보면 방금 넣은 주문이 목록에 없습니다. 코드와 데이터가 두 벌이 되어 맞춰야 할 것도 늘어납니다.
화면 모양이 저장 모양과 크게 다를 때 씁니다. 화면이 테이블을 그대로 보여주는 곳에는 안 씁니다.
상세
이 절은 먼저 두 요청인 명령과 조회가 무엇인지 봅니다. 그다음 둘을 왜 따로 맡기는지를 데이터 모양의 차이로 보입니다. 요청이 두 갈래로 흐르는 길은 그림으로 따라갑니다. 예로는 주문을 넣고 주문 목록을 보는 쇼핑몰 하나를 끝까지 씁니다.
명령과 조회
CQRS 는 Command Query Responsibility Segregation 의 줄임말입니다. 우리말로는 명령 조회 책임 분리라고 합니다. 명령을 맡는 부분과 조회를 맡는 부분을 가른다는 뜻입니다.
명령은 시스템의 상태를 바꾸는 요청입니다. 주문을 넣는다, 배송지를 바꾼다, 회원을 탈퇴시킨다가 명령입니다. 명령은 「바꿔라」라고 시킬 뿐 데이터를 돌려주지 않는 것이 원칙입니다.
조회는 상태를 바꾸지 않고 값만 돌려주는 요청입니다. 주문 목록을 본다, 상품을 이름으로 찾는다가 조회입니다. 데이터베이스 쪽에서는 조회를 질의라고도 부릅니다. 몇 번을 보내도 데이터가 바뀌지 않으므로 안심하고 다시 보낼 수 있습니다.
뿌리가 된 명령-질의 분리
CQRS 의 뿌리는 명령-질의 분리라는 원칙입니다. 영어로 Command-Query Separation 이라 CQS 라고 줄여 부릅니다. 메서드 하나는 명령이거나 조회이거나 둘 중 하나만 하라는 원칙입니다. 이 원칙이 CQRS 로 커지는 과정은 고객 서비스 인터페이스 하나를 둘로 쪼개며 봅니다.
CQS 를 따르면 값을 돌려주는 메서드는 상태를 바꾸지 않습니다. 상태를 바꾸는 메서드는 값을 돌려주지 않습니다. 이 가름은 메서드 한 개 단위에서 일어납니다.
CQRS 는 이 가름을 메서드에서 객체로 끌어올립니다. 명령과 조회를 한 서비스 안의 다른 메서드로 두는 데서 멈추지 않습니다. 명령만 받는 객체와 조회만 받는 객체를 따로 만듭니다.
고객을 다루는 서비스 하나로 보겠습니다. 나누기 전에는 명령과 조회가 한 인터페이스에 섞여 있습니다.
interface CustomerService {
void createCustomer(Customer c);
void changeLocale(CustomerId id, Locale l);
Customer getCustomer(CustomerId id);
List<Customer> findByName(String name);
}
위의 두 메서드는 상태를 바꾸고 값을 안 돌려줍니다. 아래 두 메서드는 값을 돌려주고 상태를 안 바꿉니다. CQRS 는 이 두 무리를 서로 다른 인터페이스로 떼어 냅니다.
interface CustomerWriteService {
void createCustomer(Customer c);
void changeLocale(CustomerId id, Locale l);
}
interface CustomerReadService {
CustomerView getCustomer(CustomerId id);
List<CustomerRow> findByName(String name);
}
읽기 쪽이 돌려주는 타입이 Customer 에서 CustomerView 와 CustomerRow 로 바뀌었습니다. 떼어 내고 나면 조회가 쓰기 쪽 객체 Customer 를 빌려 쓸 까닭이 없어집니다. 화면마다 필요한 모양을 따로 만들어 돌려줄 수 있게 됩니다.
쓰기 모델과 읽기 모델
명령을 받는 쪽을 쓰기 모델, 조회를 받는 쪽을 읽기 모델이라고 합니다. 둘을 가르는 까닭은 두 쪽이 원하는 데이터 모양이 다르기 때문입니다.
쓰기 모델이 챙길 것은 규칙입니다. 재고보다 많이 주문할 수 없다, 이미 발송된 주문은 취소할 수 없다 같은 규칙을 명령마다 검사합니다. 여러 값을 한꺼번에 바꿀 때는 트랜잭션으로 묶어 중간에 멈춘 상태가 남지 않게 합니다.
그래서 쓰기 모델은 정규화된 모양이 편합니다. 정규화는 같은 사실을 한 곳에만 두도록 테이블을 나누는 일입니다. 고객 이름이 한 곳에만 있으면 이름을 바꿀 때 그 한 곳만 고치면 됩니다.
읽기 모델이 챙길 것은 화면입니다. 주문 목록 화면 한 줄에 주문 번호, 고객 이름, 상품 이름, 배송 상태가 함께 나옵니다. 정규화된 테이블에서 이것을 보여주려면 테이블 여럿을 조인으로 합쳐야 합니다. 조인은 여러 테이블의 행을 키로 이어 붙여 한 결과로 만드는 연산입니다.
읽기 모델은 이 합친 결과를 미리 만들어 둡니다. 화면 한 줄이 테이블 한 행이 되도록 필요한 값을 옮겨 담습니다. 같은 값을 일부러 여러 곳에 두는 이 방식을 비정규화라고 합니다. 그러면 조회는 조인 없이 한 번 읽고 끝납니다.
한 모델이 두 일을 다 하면 어느 한쪽에 맞추고 다른 쪽이 참게 됩니다. 쓰기용 객체에 조회용 필드와 메서드가 붙어 객체가 불어납니다. 아니면 화면 하나를 그리려고 조인이 길어집니다. 두 모델이 원하는 것을 나란히 놓으면 이렇습니다.
| 쓰기 모델 | 읽기 모델 | |
|---|---|---|
| 받는 요청 | 명령 | 조회 |
| 챙기는 것 | 규칙 검사 · 트랜잭션 | 화면에 맞는 모양 |
| 데이터 모양 | 정규화 · 한 사실은 한 곳에 | 비정규화 · 화면 한 줄이 한 행 |
| 요청 수 | 흔히 적다 | 흔히 훨씬 많다 |
표의 마지막 줄도 나누는 까닭이 됩니다. 웹 서비스는 흔히 쓰기보다 읽기가 훨씬 많습니다. 두 모델이 따로 있으면 서버 대수를 늘려 부하를 나누는 수평 확장을 읽기 쪽에만 할 수 있습니다.
요청이 두 갈래로 흐르는 길
명령과 조회가 다른 모델로 가면 둘 사이를 잇는 단계가 하나 필요합니다. 쓰기 모델이 바꾼 내용을 읽기 모델에 옮겨 주는 단계입니다. 이 단계를 프로젝션이라고 부릅니다. 쓰기 쪽의 변경을 받아 읽기용 데이터를 새로 고쳐 씁니다.
아래 그림은 쓰기 저장소와 읽기 저장소까지 나눈 경우입니다. 저장소를 하나로 두는 경우는 「나누는 정도」에서 다시 봅니다.
flowchart TD
U["사용자"]
U -->|"명령 · 주문을 넣는다"| W["쓰기 모델"]
U -->|"조회 · 주문 목록을 본다"| R["읽기 모델"]
W --> WS[("쓰기 저장소")]
WS -->|"바뀐 내용"| P["프로젝션"]
P --> RS[("읽기 저장소")]
R --> RS
그림에서 명령과 조회는 서로 만나지 않습니다. 명령은 쓰기 모델을 지나 쓰기 저장소에서 끝납니다. 조회는 읽기 모델을 지나 읽기 저장소만 봅니다. 둘을 잇는 것은 프로젝션을 지나는 한 줄기뿐입니다.
바뀐 내용을 넘기는 방법은 대표적으로 둘입니다. 애플리케이션이 직접 알리는 방법과 데이터베이스의 기록을 읽어 가는 방법입니다.
첫째 방법에서는 쓰기 쪽 코드가 「주문이 들어왔다」 같은 이벤트를 내보냅니다. 이벤트는 이미 일어난 변경을 알리는 메시지입니다. 프로젝션은 이 메시지를 받아 읽기용 데이터를 고칩니다.
이벤트는 보통 메시지 브로커가 나릅니다. 메시지 브로커는 보내는 쪽과 받는 쪽 사이에서 메시지를 맡아 두는 서버입니다. 덕분에 쓰기 쪽은 프로젝션이 지금 떠 있는지 몰라도 이벤트를 내보낼 수 있습니다.
둘째 방법은 변경 데이터 캡처입니다. 데이터베이스는 바뀐 내용을 자기 변경 기록에 차례로 남깁니다. 변경 데이터 캡처는 이 기록을 읽어 바뀐 내용을 넘깁니다. 애플리케이션 코드가 이벤트를 따로 내보내지 않아도 된다는 점이 첫째 방법과 다릅니다.
나누는 정도
CQRS 가 요구하는 것은 모델을 나누는 것입니다. 저장소까지 나누라는 뜻은 아닙니다. 나누는 정도는 크게 세 단계로 가를 수 있습니다.
| 나누는 정도 | 따로 두는 것 | 읽은 값이 늦나 |
|---|---|---|
| 코드만 나눈다 | 쓰기용 클래스와 읽기용 클래스. 테이블은 같다 | 안 늦는다 |
| 읽기용 테이블을 둔다 | 같은 데이터베이스 안의 읽기용 테이블이나 구체화 뷰 | 새로 고치는 방식에 따라 늦는다 |
| 저장소를 나눈다 | 쓰기는 관계형 데이터베이스, 읽기는 검색 엔진 같은 다른 저장소 | 늦는다 |
구체화 뷰는 조회 결과를 미리 계산해 테이블처럼 저장해 두는 뷰입니다. 볼 때마다 조인을 다시 돌리지 않아도 됩니다.
둘째 단계가 늦는지는 읽기용 데이터를 언제 고치느냐에 달렸습니다. 쓰기와 같은 트랜잭션 안에서 함께 고치면 늦지 않습니다. 나중에 따로 고치면 그 사이만큼 늦습니다.
첫 단계만으로도 CQRS 입니다. CQRS 를 「관계형 데이터베이스에 쓰고 다른 저장소에서 읽는 구성」으로 좁혀 부르는 일이 흔합니다. 그 말은 셋째 단계 하나를 가리킵니다.
단계가 올라갈수록 읽기 쪽 저장소를 더 자유롭게 고를 수 있습니다. 그 대신 읽은 값이 늦는 틈과 움직이는 부품이 함께 늘어납니다.
읽기 복제본과 다른 점
데이터베이스 복제로 읽기를 나누는 방식과 헷갈리기 쉽습니다. 원본 데이터베이스의 사본인 읽기 복제본을 두고 조회만 그쪽으로 보내는 방식입니다. 이것도 읽기와 쓰기를 가르지만 CQRS 는 아닙니다.
복제본은 원본과 모양이 같습니다. 테이블도 같고 조회할 때 조인도 똑같이 해야 합니다. 같은 모양을 여러 벌 두어 부하를 나누는 것이 복제본입니다.
CQRS 의 읽기 모델은 화면에 맞춰 모양 자체를 바꿔 둡니다. 다른 모양을 한 벌 더 두는 것이 읽기 모델입니다. 둘을 함께 쓸 수도 있습니다.
복제본에도 원본을 뒤따라오는 틈이 있습니다. 이 틈을 복제 지연이라고 합니다. 읽기용 저장소를 따로 둔 읽기 모델에도 같은 틈이 있습니다. 방금 쓴 값이 안 보이는 문제는 복제본과 똑같이 따라옵니다.
쓰면 나빠지는 것
대가는 셋입니다. 읽은 값이 늦을 수 있습니다. 둘 사이를 잇는 부품이 고장 날 수 있습니다. 코드가 두 벌이 됩니다.
저장소를 나누면 쓰기가 끝난 뒤 프로젝션이 읽기 저장소를 고치기까지 틈이 생깁니다. 그 틈에 들어온 조회는 옛 값을 봅니다. 지금은 어긋나도 새 변경이 멈추면 결국 같아지는 이 성질을 결과적 일관성이라고 합니다.
sequenceDiagram
participant 사용자
participant 쓰기 as 쓰기 모델
participant 프로젝션
participant 읽기 as 읽기 모델
사용자->>쓰기: 주문을 넣는다
쓰기-->>사용자: 접수됐다
쓰기->>프로젝션: 주문이 들어왔다
사용자->>읽기: 주문 목록을 본다
읽기-->>사용자: 방금 넣은 주문이 없다
프로젝션->>읽기: 목록에 새 주문을 더한다
사용자->>읽기: 다시 본다
읽기-->>사용자: 새 주문이 보인다
사용자는 주문이 접수됐다는 답을 받고도 목록에서 그 주문을 못 찾습니다. 프로젝션이 반영을 마친 뒤에야 보입니다.
이 틈을 가리는 방법이 있습니다. 화면이 방금 보낸 명령의 내용을 들고 있다가 목록에 먼저 그려 둡니다. 방금 쓴 사용자의 조회만 잠시 쓰기 저장소로 보내기도 합니다.
둘째 대가는 잇는 부품의 고장입니다. 프로젝션이 멈추면 읽기 모델은 그 시점에 멈춘 채 낡아 갑니다. 같은 변경이 두 번 전달되면 목록에 같은 주문이 두 줄 들어갈 수 있습니다.
그래서 프로젝션은 같은 변경을 두 번 받아도 한 번 받은 것과 결과가 같게 만듭니다. 이 성질을 멱등성이라고 합니다. 읽기 모델이 틀어졌을 때 쓰기 저장소를 처음부터 다시 훑어 새로 채우는 길도 마련해 둡니다.
변경을 내보내는 일 자체도 빠질 수 있습니다. 쓰기 저장소에 저장하는 일과 이벤트를 내보내는 일은 한 트랜잭션으로 묶이지 않습니다. 데이터베이스와 메시지 브로커가 서로 다른 시스템이기 때문입니다.
저장은 됐는데 이벤트가 빠지면 읽기 모델은 그 변경을 영영 모릅니다. 두 곳에 따로 쓰다 한쪽만 성공하는 이 문제를 이중 쓰기 문제라고 부릅니다.
이 문제를 막는 방법으로 트랜잭셔널 아웃박스가 있습니다. 내보낼 이벤트를 같은 트랜잭션 안에서 별도 테이블에 적어 둡니다. 주문과 이벤트가 한 데이터베이스 안에 있으니 함께 저장되거나 함께 버려집니다. 그 테이블은 나중에 읽어 내보냅니다.
셋째 대가는 코드가 두 벌이라는 점입니다. 필드 하나를 더하면 쓰기 모델, 읽기 모델, 프로젝션 세 곳을 고칩니다. 화면이 테이블 모양 그대로인 곳에서는 이 수고가 버는 것 없이 늘기만 합니다.
이벤트 소싱과 함께 나오는 까닭
CQRS 는 이벤트 소싱과 짝지어 자주 나옵니다. 이벤트 소싱은 현재 상태 대신 일어난 일을 차례로 쌓아 두는 저장 방식입니다. 계좌라면 잔액을 적어 두지 않고 입금과 출금 기록만 쌓습니다.
이렇게 쌓은 기록은 조회에 불편합니다. 잔액 하나를 알려면 기록을 처음부터 더해야 합니다. 「잔액이 백만 원 넘는 계좌」 같은 조회는 기록만으로는 못 합니다.
그래서 이벤트 소싱에는 기록을 받아 조회용 모양을 만드는 읽기 모델이 거의 늘 붙습니다. 기록을 쌓는 쪽이 쓰기 모델이 됩니다. 프로젝션이 그 기록을 받아 읽기 모델을 채웁니다.
반대 방향은 성립하지 않습니다. CQRS 는 이벤트 소싱 없이도 씁니다. 「나누는 정도」의 첫 단계는 평범한 테이블 하나로도 됩니다.
쓰는 곳과 안 쓰는 곳
읽기와 쓰기가 원하는 모양이 크게 다를 때 맞습니다. 주문은 한 건씩 규칙을 따지며 들어옵니다. 그런데 화면은 여러 테이블을 합친 목록과 통계를 보여줍니다. 이 거리가 클수록 모델을 나눠서 버는 것이 큽니다.
흩어진 데이터를 한 화면에 모을 때도 씁니다. 서비스마다 자기 데이터베이스를 따로 가진 마이크로서비스에서는 여러 서비스의 테이블을 조인할 수 없습니다. 각 서비스가 내보내는 이벤트를 받아 읽기 모델 하나에 모아 두면 화면은 한 곳만 조회하면 됩니다.
반대로 화면이 테이블을 그대로 보여주는 기능에는 안 맞습니다. 만들기·읽기·고치기·지우기 네 동작으로 끝나는 이런 기능을 CRUD(Create·Read·Update·Delete)라고 부릅니다. 두 모델이 똑같은 모양이 되어 두 벌을 맞추는 수고만 남습니다.
방금 쓴 값을 바로 읽어야 하는 데이터도 조심합니다. 잔액이나 재고처럼 늦은 값이 곧 사고가 되는 데이터입니다. 이런 데이터는 읽기 모델을 따로 두지 않거나, 쓰기와 같은 트랜잭션 안에서 읽기 모델까지 함께 고칩니다.
CQRS 는 흔히 시스템 전체가 아니라 필요한 한 구역에만 씁니다. 읽기와 쓰기가 크게 다른 구역은 대개 시스템의 일부이기 때문입니다. 나머지 구역은 한 모델로 두는 편이 고칠 곳이 적습니다.
관련 항목
CQRS 가 뿌리를 둔 설계 원칙
명령-질의 분리 · 도메인 주도 설계 · 경계 컨텍스트 · 애그리거트 · 도메인 모델
CQRS 와 짝지어 쓰는 설계 패턴
이벤트 소싱 · 이벤트 주도 · 발행-구독 · 사가 · 트랜잭셔널 아웃박스 · 태스크 기반 UI
읽기 모델을 만들고 채우는 수단
프로젝션 · 구체화 뷰 · 비정규화 · 변경 데이터 캡처 · 메시지 브로커 · 이벤트 · 검색 엔진 · Elasticsearch
두 모델이 어긋날 때 드러나는 일관성 문제
결과적 일관성 · 강한 일관성 · 복제 지연 · 이중 쓰기 · 멱등성 · 쓰기 후 읽기
읽기와 쓰기를 다르게 다루는 다른 수단
읽기 복제본 · 복제 · 읽기 쓰기 분리 · 캐싱 · 샤딩 · 수평 확장
CQRS 가 가르는 요청과 데이터 모양의 기초 개념
질의 · 갱신 · 트랜잭션 · 정규화 · 조인 · CRUD · 낙관적 잠금
CQRS 가 속하는 아키텍처 양식
다른 이름: Command Query Responsibility Segregation · 명령 조회 책임 분리 · 명령과 조회의 책임 분리 · 명령 질의 책임 분리 · CQRS 패턴