※ 일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 직역, 의역이 있을 수 있으며 틀린 부분은 지적해주시면 감사하겠습니다.
시작하기에 앞서
DDD로 설계된 프로젝트에 CQRS를 도입하려고 하면 다양한 의문이 생긴다. 「UseCase와 Command의 차이는 무엇인가」 「QueryService는 어느 층에 둘 것인가」 「Repository는 어떻게 되는 것인가」과 같은 의문들 말이다.
이 포스트에서는 DDD × CQRS를 도입할 때 오해하기 쉬운 부분을 정리하고, 이상적인 디렉토리 구성과 코드 샘플(Go 코드) 을 바탕으로 설명하도록 하겠다.
CQRS란 무엇인가(1분 설명)
Command Query Responsibility Segregation(명령 쿼리 책임 분리)의 약어이다. CQRS의 핵심은 "정보를 갱신하는 모델과 정보를 읽는 모델을 나눌 수 있다"는 개념이다. 그러나 CQRS는 불필요한 위험과 복잡성을 추가하는 것을 주의해야 한다.
| 종류 | 역할 | 상태 변화 |
| Command | 데이터 변경 처리 | 예 |
| Query | 데이터 읽는 처리 | 없음 |
위의 두 경로로 역할에 따라 명확하게 분리하는 아키텍처 패턴이다. 하지만, CQRS는 모든 시스템에 적용할 패턴은 아니다. 도입의 기준은 「Query측에서 집약을 넘는 JOIN이나, Command측과 다른 최적화가 필요하게 되었을 때」이다. 소규모 CRUD 애플리케이션에서는 반대로 프로그램의 복잡성을 높일 수 있으므로 도입하는 것을 추천하지 않는다.
CQRS의 근본: CQS 원칙
CQRS는 Bertrand Meyer가 제창한 CQS(Command Query Separation) 원칙 을 아키텍처 레벨로 확장한 것이다. CQS는 개별 메서드를 "값 반환(Query)"과 "상태 변경(Command)"으로 나누는 원칙이다. CQRS는 이 아이디어를 전체 모델 설계에 적용한다.
CQS의 기본 아이디어는 객체의 메소드를 두 가지 범주로 명확하게 분류하는 것이다.
- Query : 결과를 반환하고 시스템 상태를 변경하지 않음 (부작용 없음)
- Command : 시스템 상태를 변경하지만 값을 반환하지 않음
흔히하는 오해
오해 1 : "CQRS를 도입하면 UseCase가 없어진다"
흔한 첫 번째는 CQRS를 도입한다 → UseCase를Command와Query로 치환한다 → UseCase클래스는 불필요해진다라는 생이다.
하지만 UseCase의 개념은 사라지지 않는다. UseCase를 Command인가 Query인가로 배분할 뿐이다. 따라서 UseCase는 먼저 존재하고 CQRS는 그 구현에 대한 정책이라고 할 수 있다.
업무요건(UseCase)
↓ 분류한다
데이터를 바꾸는가?
┌──────┴──────┐
Yes No
↓ ↓
Command Query
(주문한다) (주문목록을보여준다)
오해 2 : "UseCase의 이름을 Command/Query로 바꿔야한다"
예를 들면 place_order_usecase.go → place_order_command.go로 이름을 변경해야한다는 오해를 하는 경우가 있다. 이름은 사실 어느쪽이든 상관없지만, 본질을 이름이 아니라 내부 구현 규칙이다. 따라서 명명 규칙을 팀에서 통일해야한다.
// 이름은 UseCase그대로이지만 CQRS로써 올바르다.
type GetOrderListUseCase struct {
queryService OrderQueryService
}
func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
// Domain모델을 경우하지 않는다(Query적인 구현)
return uc.queryService.FindAll(ctx)
}
✅ usecase로 통일
├── place_order_usecase.go
├── cancel_order_usecase.go
└── get_order_list_usecase.go
✅ command/query으로 통일
command/
├── place_order.go
└── cancel_order.go
query/
├── get_order_list.go
└── get_order_detail.go
오해 3 : "Query 쪽에서도 Repository를 사용한다"
DDD의 Repository는 집계 단위 로 데이터를 처리한다. 즉, Repository가 반환하는 데이터는 항상 해당 집계의 범위로 제한된다.
Repository와 QueryService의 구분은 다음 결정 흐름에 의해 결정하게 된다.
- 집계를 스킵한다? → 예 → QueryService 사용
- 집계 내에서 완료한다? → 예 → Repository에서도 가능 (단, DTO로의 변환이 복잡해지면 QueryService를 고려)
예를 들어, "주문 정보 + 고객 이름"과 같이 여러 집계에 걸친 데이터가 필요한 경우 Repository에서는 비효율적이며, 이러한 경우 QueryService를 사용하는 것이 적절하다.
아래는 일반적인 구현이다.
// Query이지만 Repository를 사용해 집약을 획득하고、DTO로 변환
func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
orders, err := uc.orderRepository.FindAll(ctx) // ← 집약을 모두 획득
if err != nil {
return nil, err
}
result := make([]OrderListDTO, 0, len(orders))
for _, order := range orders {
// 고객이름을 획득하기 위해 다른 Repository를 호출할 필요가 있다.
customer, err := uc.customerRepository.FindByID(ctx, order.CustomerID())
if err != nil {
return nil, err // ← N+1문제가 발생한다.
}
result = append(result, OrderListDTO{
OrderID: order.ID().String(),
CustomerName: customer.Name(), // ← 다른 집약의 정보를 개별로 획득...
TotalAmount: order.TotalAmount(),
})
}
return result, nil
}
집계 경계를 넘는 Query에서 Repository를 사용하면 다음과 같은 문제가 발생한다.
- N+1 문제 : 주문 수만큼 고객 테이블에 대한 쿼리가 발생한다.
- 집계의 경계를 넘는 비효율성 : SQL이라면 한 번의 JOIN으로 끝나는 데이터를 집계의 경계를 지켜 여러 번 나누어 취득하고 있다.
Repository(집계를 반환) 와 QueryService(DTO를 반환) 를 구분하여 작성하게 되면 다음과 같다.
// ✅ Command쪽:Repository를 사용(기존대로)
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, input PlaceOrderInput) error {
order, err := order.Place(input.CustomerID, input.Items)
if err != nil {
return err
}
return uc.orderRepository.Save(ctx, order) // ← 집약의 영속화
}
// ✅ Query쪽:QueryService를 사용(CQRS의 새로운 방법)
func (uc *GetOrderListUseCase) Execute(ctx context.Context) ([]OrderListDTO, error) {
return uc.queryService.FindAll(ctx) // ← DTO를 직접 반환
}
왜 Query 측은 도메인 계층을 통과하지 않는지가
| Repository | QueryService | |
| 반환하는 것 | 집계(DomainModel) | DTO(화면용 데이터) |
| 역할 | 집계 저장 및 복원 | 화면에 필요한 형태로 데이터 취득 |
| JOIN 제약 |
집계의 경계를 지킴 | 자유롭게 JOIN 가능 |
왜 Query 측은 도메인 계층을 통과하지 않는지가라는 의문이 들 수 있다. 모든 데이터 검색은 "도메인 모델을 통과해야 한다"고 생각하는 사람들도 있지만, 도메인 모델의 역할은 비즈니스 규칙의 강제성과 무결성을 보장한다.
읽기 처리는 데이터를 변경하지 않으므로 비즈니스 규칙을 통과할 필요가 없다. 집계의 경계도 Write 무결성을위한 메커니즘이며 읽기에는 제한이 있다. CQRS는 이 결정을 아키텍처 수준에서 명시하는 패턴이다.
오해 4: "QueryService는 인프라 계층에 배치해야한다"
다음은 오해로 인한 배치이다.
패턴A:QueryService는 SQL를 작성하므로 인프라층에 둔다.
→ 어플리케이션층에서 인프라층을 직접 참고하게 된다
→ 의존관계 붕괴 🚨
패턴B:Query쪽은 도메인을 경유하지 않으므로, Application층에 직접SQL을 작성한다.
→ 어플리케이션층이 DB 구현에 의존하게 된다.
→ DB의 변경이 어플리케이션층에 영향을 주게 됨 🚨
올바른 배치는 다음과 같다. 이것은 Repository 패턴과 정확히 동일한 구조이다.
【Repository】 【QueryService】
domain/order/ application/order/
└── repository.go └── query_service.go
(인터페이스) (인터페이스정의)
infrastructure/order/ infrastructure/order/
└── mysql_repository.go └── mysql_query_service.go
(구현:SQL을 작성) (구현:SQL을 작성)
인터페이스는 애플리케이션 계층에, 구현은 인프라 계층에. SQL을 작성하는 클래스는 반드시 인프라 계층이어야한다.
QueryService의 interface 배치의 기준
QueryService의 interface를 공유로 정의할지, 이용측에서 정의할까는 다음의 기준으로 판단한다.
| 하나의 UseCase에서만 사용하는 경우 | 해당 UseCase 내에서 private interface로 정의한다. |
| 여러 UseCase가 공유하는 경우 | query_service.go으로 공유 인터페이스로 설정한다. |
위의 코드 샘플에서는 FindAll을 FindByID여러 UseCase가 사용하기 때문에 공유 인터페이스로 정의한다.
왜 Repository는 domain 계층이고 QueryService는 application 계층인가?
- Repository 는 도메인 로직에 직접 의존한다. 집계의 복원 및 저장은 도메인의 관심사이며 도메인 계층의 코드가 Repository 인터페이스를 참조하기 때문에 도메인 계층에 배치한다.
- QueryService 는 도메인 모델을 거치지 않고 DTO를 직접 반환한다. 도메인 로직은 QueryService에 의존하지 않으므로 domain 계층에 둘 필요가 없다. 이용하는 것은 어플리케이션 레이어의 UseCase 뿐이므로, application 레이어에 배치한다.
이상적인 디렉토리 구성
internal/
├── presentation/ # 핸들 계층(Handler, Controller등)
│ └── order/
│ └── handler.go
│
├── application/ # 어플리케이션 계층
│ └── order/
│ ├── place_order.go # Command계(Domain를 통과)
│ ├── cancel_order.go # Command계
│ ├── get_order_list.go # Query계(Domain를 통과하지 않음)
│ ├── get_order_detail.go # Query계
│ ├── query_service.go # QueryService인터페이스 ← 여기에 둔다!
│ └── dto.go # DTO정의
│
├── domain/ # 도메인 계층(DDD의 핵심)
│ └── order/
│ ├── order.go # 집약 룰
│ ├── order_item.go # Entity
│ ├── order_id.go # ValueObject
│ └── repository.go # Repository I/F(Reader / Writer으로 분리)
│
└── infrastructure/ # 인프라 계층
└── order/
├── mysql_repository.go # Repository구현(집약의 영속화)
└── mysql_query_service.go # QueryService구현(SQL은 여기에서만)
종속성 요약
Command 및 Query 시스템에서는 종속 경로가 다르다.
【Command계】
Presentation
↓ 의존
Application(UseCase)
↓ 의존
Domain(집약 + Repository I/F)
↑ 의존(implements)
Infrastructure(Repository구현)
【Query계】
Presentation
↓ 의존
Application(UseCase + QueryService I/F + DTO)
↑ 의존(implements)
Infrastructure(QueryService구현)
단계적 도입을 권장
Step 1: UseCase를 Command계・Query계로 분류한다.
→ 각UseCase에 Command/Query의 분류 코맨트를 추가하여,
분류 결과를 ADR등의 문서에 기재한다.
Step 2: Query쪽에 Repository를 사용하지 않고, QueryService를 도입한다.
→ 타이밍:Query쪽에 JOIN이나 집약 횡단의 데이터 획득이 필요해졌을 경우
→ DB은 공유, Event Sourcing없음
💡 Query의 응답에 도메인 로직의 계산 결과가 필요한 경우:
(a) 비정규화: Command쪽에서 계싼 결과를 DB에 저장하고, Query쪽은 읽어들일 뿐
(b) 로직의 복제:Query쪽에 SQL등으로 계산 로직을 가짐(정합성 리크스 존재)
(c) Domain Service로의 의존:Query쪽에서 Domain Service를 호출
→ CQRS의 분리도가 떨어지지만, 로직의 일원관리를 우선하는 현실적인 선택지
Step 3: 필요해지면 Read모델용 DB를 분리(옵션)
→ 퍼포먼스 요건이 발생했을 때
참고자료
'IT > WEB' 카테고리의 다른 글
| html의 iframe 태그 (0) | 2026.05.31 |
|---|---|
| [Vue3] Vue 경험자를 위한 Vue3 스타트 가이드라인 (0) | 2023.09.06 |
| [Vue3] Vue3의 Composition API (0) | 2023.09.03 |
| [CSS] Scoped CSS에 있어서의 CSS 설계 방법 (0) | 2023.08.21 |
| Atomic Design을 그만두고 디렉토리 구조를 바꾼 이야기 (0) | 2023.07.30 |