검색과 집계 — 하나의 스트림, 여러 읽기 모델
2025-09-22
7편에서 CQRS 읽기 모델을 세워 "내 주문 목록"을 싸게 답했습니다. 하지만 손님만 질문하는 게 아닙니다. 운영자는 이런 걸 묻죠 — "배송중인 주문만 보여줘", "취소된 주문은?", "지금까지 매출은 얼마야?" 이런 검색·집계는 per-order 뷰만으론 매번 전부 훑어야 합니다.
이 편에서 같은 주문 이벤트 스트림에서 상태 필터 검색 과 상태별 건수·매출 집계 뷰를 더합니다. 그리고 여기서 CQRS의 진짜 힘을 봅니다 — 하나의 이벤트 흐름, 여러 개의 읽기 모델.
💻 이 편의 코드는 github.com/kahnco/go-ddd-shop 의
part-18태그에 있습니다.
집계는 또 하나의 프로젝션
"상태별 주문 수", "총 매출" 같은 집계를 어떻게 만들까요? 매 질의마다 모든 주문을 훑어 세는 방법도 있지만, 주문이 많아지면 느려집니다. 더 나은 길은 집계 자체를 하나의 프로젝션으로 유지하는 것입니다 — 이벤트가 흐를 때마다 숫자를 증분으로 갱신해 두면, 질의 때는 그냥 읽기만 하면 됩니다.
읽기 모델 저장소가 per-order 뷰와 나란히 집계 숫자를 들고 있게 합니다. 주문이 생기면 그 상태를 세고 매출에 더하고, 상태가 바뀌면 옛 상태에서 빼고 새 상태에 더합니다.
func (s *MemoryStore) Upsert(v OrderView) {
if _, ok := s.orders[v.OrderID]; ok {
return // 이미 아는 주문(재전송) — 두 번 세지 않는다(멱등)
}
s.orders[v.OrderID] = v
s.counts[v.Status]++ // 상태별 건수 +1
s.revenue += v.Total // 매출에 더함
}
func (s *MemoryStore) SetStatus(orderID, status string) {
v, ok := s.orders[orderID]
if !ok || v.Status == status {
return
}
s.counts[v.Status]-- // 옛 상태 -1
s.counts[status]++ // 새 상태 +1
if status == "CANCELLED" {
s.revenue -= v.Total // 취소는 매출에서 뺀다
}
v.Status = status
s.orders[orderID] = v
}
주문이 PLACED → PAID → CONFIRMED → SHIPPED 로 흐르면, 그 count가 상태를 따라 이동해 결국 SHIPPED 에 남습니다. 취소되면 매출에서 빠지고요. 집계가 이벤트를 따라 살아 있는 겁니다.
한 가지 눈여겨볼 점 — Upsert 는 이미 아는 주문이면 그냥 넘어갑니다. order.placed 가 재전송돼도 두 번 세지 않기 위해서죠. 9편에서 다진 발행측 신뢰성이 있어도, 읽기 모델은 스스로도 멱등해야 안전합니다.
검색 — 상태·회원으로 거르기
검색은 per-order 뷰를 필요한 축으로 거르는 일입니다. 상태와 회원, 둘 다(혹은 하나만)로 거릅니다.
// /orders?status=SHIPPED&customer=cust-1
func (s *MemoryStore) Query(customerID, status string) []OrderView {
var out []OrderView
for _, v := range s.orders {
if customerID != "" && v.CustomerID != customerID { continue }
if status != "" && v.Status != status { continue }
out = append(out, v)
}
// 주문 ID 순 정렬 …
return out
}
(데모라 맵을 훑지만, 요점은 읽기 모델이 조회 축을 안다 는 겁니다. 실서비스라면 상태·회원에 인덱스를 건 저장소나 검색 엔진에 이 뷰를 두어, 데이터가 커도 필터가 싸게 유지되게 합니다.)
조회 API
새 엔드포인트 둘을 엽니다 — 검색과 집계.
mux.HandleFunc("GET /orders", h.query) // ?status=&customer=
mux.HandleFunc("GET /stats/orders", h.stats) // 상태별 건수·매출
운영자의 질문에 답하기
주문을 섞어 넣고(정상 3건은 배송까지, 재고 부족 2건은 취소) 물어봅니다.
$ curl localhost:8087/stats/orders
{
"counts": { "SHIPPED": 3, "CANCELLED": 2 },
"order_count": 5,
"total_revenue": 3000 ← 취소 2건은 매출에서 빠짐(배송 3건 × 1,000)
}
$ curl 'localhost:8087/orders?status=SHIPPED'
[ 3건 ]
$ curl 'localhost:8087/orders?status=CANCELLED'
[ 2건 ]
"배송중 몇 건, 취소 몇 건, 매출 얼마"가 한 번의 질의 로 즉답됩니다. 이 숫자들은 질의 순간 센 게 아니라, 주문 이벤트가 흐를 때마다 미리 갱신해 둔 것입니다. 그리고 쓰기 쪽(주문 서비스)은 이 집계의 존재조차 모릅니다.
하나의 스트림, 여러 읽기 모델
이게 CQRS가 여는 진짜 문입니다. 우리에겐 이제 같은 주문 이벤트 스트림 에서 자라는 뷰가 여럿입니다.
- 주문 상세 / 회원별 목록 (7편)
- 상태 필터 검색 (이번 편)
- 상태별 건수·매출 집계 (이번 편)
새로운 질문이 생기면 새 읽기 모델을 하나 더 만들어 같은 스트림을 구독시키면 됩니다 — 일별 매출 추이, 상품별 판매량, 지역별 통계… 각기 다른 저장소(검색엔진·시계열 DB·캐시)에 특화해서요. 쓰기 모델은 한 번도 안 바뀝니다. 이벤트를 진실의 원천 으로 두면, 읽기 모델은 얼마든지, 원하는 모양으로 파생시킬 수 있습니다.
정직하게 — 집계의 함정들
- 증분 집계는 규율이 필요 합니다. 상태 전이를 빠뜨리거나 이벤트를 두 번 세면 숫자가 틀어집니다. 그래서 멱등(재전송 방어)이 중요하고, 가끔은 스트림을 재생해 집계를 처음부터 다시 계산(재구축)하는 안전장치를 둡니다.
- "매출"의 정의는 도메인이 정합니다. 우리는 "취소를 뺀 총 주문액"으로 정의했지만, 결제 완료 기준·환불 반영 등 도메인에 따라 달라집니다. 집계는 늘 그 정의를 명시적으로 코드에 담아야 합니다.
- 결과적 일관성 은 여기서도 유효합니다 — 방금 취소한 주문이 매출에서 빠지기까지 짧은 지연이 있습니다.
정리 — 읽기 모델이 자란다
- 집계를 프로젝션으로 — 상태별 건수·매출을 이벤트로 증분 유지해, 질의 때 훑지 않고 즉답합니다.
- 검색을 조회 축으로 — 상태·회원 필터로 per-order 뷰를 거릅니다.
- 멱등한 집계 로 재전송에도 숫자가 틀어지지 않게 했습니다.
- 무엇보다, 하나의 이벤트 스트림에서 여러 읽기 모델 을 뽑을 수 있음을 보였습니다 — 새 질문엔 새 뷰로, 쓰기 모델은 그대로.
이제 우리 쇼핑몰은 손님에게도(내 주문), 운영자에게도(검색·집계) 조회를 싸게 답합니다. 계획했던 고도화가 여기서 마무리됩니다 — 사가로 완주하고, 아웃박스·JetStream·멱등성·다중 replica로 신뢰할 수 있고, 회원·장바구니로 손님을 맞고, 분산추적으로 들여다보고, CQRS로 다양한 질의에 답하는 시스템. 빈 폴더에서 시작해 참 멀리 왔습니다. 그리고 그 먼 길 내내, 도메인은 포트와 어댑터 뒤에서 놀랍도록 담담했습니다.
이번 편 전체 코드는 리포의
part-18태그에 있습니다.