CQRS · Architecture

CQRS 실전 적용기
Query가 Repository를 쓰면 안 되는 이유

CQRS는 처음에 한 번 정하면 끝나는 아키텍처 결정처럼 들린다. 막상 해 보면 계속 다시 지켜야 하는 경계에 가깝다. 새 읽기 요구사항이 생기면 가장 손쉬운 방법은 언제나 옆에 이미 있는 Command 쪽 Repository를 가져다 쓰는 것이기 때문이다.

CQRS(Command Query Responsibility Segregation)는 쓰기와 읽기의 책임을 나눈다. 원칙은 기본 아키텍처와 같다. Domain 계층은 독립적이고, Aggregate가 비즈니스 규칙을 감싸고, Repository 패턴도 그대로다. 달라지는 건 유스케이스가 Command 쪽과 Query 쪽으로 갈리고, 양쪽이 각자 모델을 갖는다는 점이다.

두 단계의 CQRS

Application Service를 Command Service와 Query Service로 나누기만 해도 이미 가벼운 CQRS다. 대부분의 도메인은 이걸로 충분하다.

Handler 기반 CQRS는 한 걸음 더 간다. 유스케이스마다 Handler struct를 따로 두고, 각 Handler가 자기 의존성을 직접 들고 Handle 메서드 하나만 노출한다. Service에 유스케이스가 너무 많이 몰려 비대해졌을 때, 또는 쓰기 모델과 읽기 모델을 정말 다른 저장소에 둬야 할 때 들일 만하다. 유스케이스가 적고 Service 클래스가 단순하게 유지된다면 가벼운 쪽으로 충분하다. 패턴에 이름이 붙어 있다고 굳이 Handler까지 갈 필요는 없다.

internal/
  domain/
    order/
      order.go                       # Aggregate — unchanged
      repository.go                  # the Query interface + Repository (adds the write method)
  application/
    command/
      cancel_order_handler.go        # CancelOrderCommand + CancelOrderHandler (the write logic)
    query/
      get_orders_handler.go          # GetOrdersQuery + GetOrdersHandler (the read logic)
  interface/
    http/
      order_handler.go               # holds the Command/Query Handlers, calls Handle(ctx, ...) directly

말하기는 쉽지만 어기기도 쉬운 규칙

QueryHandler는 order.Repository 대신 읽기 전용 인터페이스인 order.Query에 의존한다. DB를 직접 조회하고, Aggregate를 재구성하지 않는다.

// internal/domain/order/repository.go — the Query interface
type Query interface {
	FindOrders(ctx context.Context, q FindQuery) ([]*Order, int, error)
}

// Repository adds the write method on top of Query. Because Go interfaces
// use structural typing, one implementation satisfies both — there's no
// need for two separate implementations.
type Repository interface {
	Query
	SaveOrder(ctx context.Context, order *Order) error
}

// internal/infrastructure/persistence/order_repository.go — the implementation
func (r *OrderRepository) FindOrders(ctx context.Context, q order.FindQuery) ([]*order.Order, int, error) {
	// a query optimized for reading, with no Aggregate reconstitution
}
// internal/application/query/get_orders_handler.go
type GetOrdersQuery struct {
	Page int
	Take int
}

type GetOrdersHandler struct {
	orders order.Query
}

func NewGetOrdersHandler(orders order.Query) *GetOrdersHandler {
	return &GetOrdersHandler{orders: orders}
}

func (h *GetOrdersHandler) Handle(ctx context.Context, q GetOrdersQuery) (*GetOrdersResult, error) {
	orders, count, err := h.orders.FindOrders(ctx, order.FindQuery{Page: q.Page, Take: q.Take})
	if err != nil {
		return nil, err
	}
	return &GetOrdersResult{Orders: orders, Count: count}, nil
}

order.Query냐 order.Repository냐는 이름만 살짝 다른 것처럼 보인다. 그래서 아무도 모르게 어기기 쉽다. Repository는 Query를 임베드하므로 더 좁은 인터페이스도 만족한다. *OrderRepository는 이미 Command Handler에 연결돼 있고 테스트도 끝났다. 목록 화면에 필요한 값을 그대로 돌려주는 FindOrders 메서드도 있다. 이 구현체를 Query Handler의 필드에 넣어도 타입 체크는 문제없이 통과한다.

필드를 order.Query 대신 order.Repository로 선언해도 컴파일되고 리뷰도 통과한다. 그런데 그 순간 CQRS가 닫아 두려던 문이 아무 경고 없이 다시 열린다. 읽기 경로 옆에 쓰기 기능(SaveOrder)이 놓이고, 두 모델은 더 이상 분리돼 있지 않다.

문서까지 틀린 코드를 정답이라고 했던 사례

같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트에서, 구현끼리 서로 대조해 감사하다가 이 버그를 만난 적이 있다. 위의 일반론보다 배울 게 많아서 구체적으로 적어 둔다. FastAPI 구현에서는 Query Handler에 쓰기 기능이 있는 Repository가 그대로 주입돼 있었다. 읽기 인터페이스는 따로 없었다. 이것만이면 고치고 끝날 일이다. 한 번의 실수로 끝나지 않고 구조의 문제가 된 건 FastAPI의 cqrs-pattern.md 때문이었다. 이 문서가 바로 그 코드를 올바른 예시로 싣고 있었다. 문서와 코드가 서로 맞았고, 둘 다 틀렸다.

"코드가 자기 문서와 맞는가"를 보는 감사로는 이런 실패를 처음부터 잡을 수 없다. 그런 감사는 일치 여부만 보는데, 여기서는 문서와 코드가 틀린 내용으로 완벽하게 일치했다. 이걸 드러내려면 그 언어의 문서 대신 루트 원칙에 코드를 대 봐야 했다.

다른 세 언어에도 같은 드리프트가 조금 약하게 있었다. 한 곳은 Query Service 하나만 고치고, 같은 코드베이스에 구조가 똑같은 두 번째 Query Service는 옛 패턴으로 남겨 두었다. 다른 두 곳은 Command와 Query를 기능상으로는 이미 나눴는데, Query 인터페이스 이름을 XxxQueryRepository로 붙였다. 읽기 경로의 어휘에서 빼려던 단어를 이름에 도로 들여온 셈이다.

왜 계속 놓쳤나

코드가 문서의 설계 규칙을 따르는지 정적으로 검사하는 스크립트는 있었지만, application/query/ 안에 Repository 타입이 나오는 것을 콕 집어 잡는 규칙은 그때 없었다. domain 폴더가 있는지, interface 계층이 infrastructure를 import하지 않는지 같은 구조 검사로는 한 계층 아래의 잘못된 의존성 선택을 못 잡는다. 이 모양만 보는 규칙을 새로 쓰고 처음 돌렸더니, 다른 세 언어에서도 같은 위반이 따로따로 나왔다.

Interface 계층은 거의 바뀌지 않는다

HTTP Handler 입장에서 CQRS 도입은 대부분 라우팅만 바꾸는 일이다. Service 메서드를 부르던 자리에서 해당 Handler의 Handle 메서드를 부르면 된다. 코드는 다음과 같다.

func (h *OrderHandler) CancelOrder(w http.ResponseWriter, r *http.Request) {
	orderID := r.PathValue("orderId")
	if _, err := h.cancelOrder.Handle(r.Context(), command.CancelOrderCommand{OrderID: orderID}); err != nil {
		writeOrderError(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *OrderHandler) GetOrders(w http.ResponseWriter, r *http.Request) {
	page, take := parsePagination(r)
	result, err := h.getOrders.Handle(r.Context(), query.GetOrdersQuery{Page: page, Take: take})
	if err != nil {
		writeOrderError(w, r, err)
		return
	}
	writeJSON(w, r, result)
}

Domain Event도 그대로다. 여러 곳에 걸친(cross-cutting) 후속 작업을 프로세스 안 이벤트 버스로 처리하지 않고, 기본 아키텍처와 똑같이 Outbox → 메시지 큐 → EventConsumer 경로로 전달한다. CQRS가 바꾸는 건 요청 하나가 어느 Handler로 가느냐다. 이미 일어난 사실을 나중에 어떻게 알리는지는 바뀌지 않는다.

CQRS가 바꾸지 않는 것

기본 아키텍처든 Handler 기반 CQRS든 Domain 계층의 독립성, Aggregate의 캡슐화, Repository 패턴은 똑같이 유지된다. CQRS는 그 토대 위에 얹는 라우팅과 읽기 모델의 결정이고, 토대를 갈아엎지 않는다. 그래서 Query Handler가 Repository를 끌어다 쓰는 실수는 저지르기도 쉽고 놓치기도 쉽다. 그 아래는 전부 그대로 컴파일되고 유닛 테스트도 통과한다. 겉보기엔 같은 아키텍처를 따르는 것 같은데, 사실은 이미 벗어나 있다.

더 볼 자료

docs/architecture/cqrs-pattern.md(같은 백엔드 설계(DDD, CQRS, Outbox)를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트의 Command/Query/Handler 구조 전체) · docs/architecture/repository-pattern.md(Query 쪽이 일부러 피하는 Repository 패턴)