개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

CQRS — 조회와 명령을 분리하는 아키텍처 패턴

2025년 1월 7일
CQRSCQRSCommandQueryRead Model

대부분의 애플리케이션은 데이터를 읽는 빈도가 쓰는 빈도보다 훨씬 높다. 그런데도 Read와 Write를 동일한 모델에 묶어두면 두 작업이 서로의 성능과 복잡도를 끌어내린다. CQRS는 이 두 책임을 명확히 갈라 각자 최적화할 수 있게 해주는 아키텍처 패턴이다.

핵심 개념

CQRS는 Command와 Query를 완전히 다른 경로로 처리한다.

  • Command — 상태를 변경하는 요청. 반환값은 없거나 최소화한다. (CreateOrder, CancelShipment)
  • Query — 상태를 읽는 요청. 부작용이 없다. (GetOrderSummary, ListProducts)

이 분리 원칙은 Bertrand Meyer의 CQS(Command Query Separation)에서 출발했고, CQRS는 이를 아키텍처 수준으로 확장한다.

┌────────────┐     Command     ┌─────────────────┐
│   Client   │ ─────────────► │  Write Model     │
│            │                 │  (도메인 로직)    │
│            │     Query       └────────┬────────┘
│            │ ─────────────►           │ 이벤트/저장
│            │                 ┌────────▼────────┐
│            │ ◄────────────── │  Read Model      │
└────────────┘    결과 반환     │  (조회 최적화)   │
                               └─────────────────┘

기존 방식과의 차이

전통적인 CRUD 아키텍처는 하나의 모델이 읽기와 쓰기를 모두 담당한다.

구분CRUD (단일 모델)CQRS (분리 모델)
모델 수1개최소 2개
조회 최적화어려움Read Model에 집중
도메인 복잡도Write에 섞임Write Model에 격리
스케일링함께 확장독립 확장 가능

Read Model은 조회에 특화된 비정규화 뷰를 가질 수 있다. 복잡한 JOIN 없이 단순 조회가 가능하고, 별도 Read DB(예: Elasticsearch, Redis)로 분리하면 쓰기 부하와 완전히 독립적으로 확장된다.

Write Model 예시

// Command
interface CreateOrderCommand {
  userId: string;
  items: { productId: string; quantity: number }[];
}

// Command Handler
class CreateOrderHandler {
  constructor(private repo: OrderRepository) {}

  async execute(cmd: CreateOrderCommand): Promise<void> {
    const order = Order.create(cmd.userId, cmd.items);
    await this.repo.save(order);
  }
}

Read Model 예시

// Query
interface GetOrderSummaryQuery {
  orderId: string;
}

// Query Handler — 별도 Read DB 조회
class GetOrderSummaryHandler {
  constructor(private readDb: ReadDatabase) {}

  async execute(query: GetOrderSummaryQuery): Promise<OrderSummaryDto> {
    return this.readDb.findOrderSummary(query.orderId);
  }
}

Read Model은 도메인 규칙 없이 오직 DTO 반환에만 집중한다.

DTO(Data Transfer Object) — 레이어 간 데이터를 운반하기 위한 단순 객체. 비즈니스 로직을 포함하지 않는다.

Event Sourcing과의 관계

CQRS는 Event Sourcing과 자주 함께 쓰이지만 필수 조합은 아니다. Event Sourcing을 적용하면 Write Model이 상태 대신 이벤트 로그를 저장한다. 이 이벤트를 구독해 Read Model을 비동기로 갱신하는 구조가 자연스럽게 만들어진다.

Command → Write Model → OrderCreatedEvent
                              │
                    ┌─────────▼──────────┐
                    │  Event Handler      │
                    │  (Read Model 갱신)  │
                    └────────────────────┘

단, 이 경우 Read Model은 최종 일관성(Eventual Consistency) 을 가진다. 명령 직후 조회 시 아직 반영 전일 수 있음을 설계에서 명시해야 한다.

언제 써야 하나

CQRS는 복잡도를 동반한다. 모델이 둘로 늘고, 동기화 로직이 생기며, 팀이 패턴을 이해해야 한다. 다음 조건이 맞을 때 도입을 고려하라.

  • 읽기와 쓰기의 부하 비율이 극단적으로 다를 때
  • 도메인 Write 로직이 복잡해 단일 모델로 표현하기 어려울 때
  • Read 성능을 위해 비정규화된 뷰가 필요할 때

단순 CRUD 서비스에 CQRS를 적용하면 복잡도만 늘고 이점은 없다.

← 이전 글Domain Driven Design — 비즈니스 도메인을 중심으로 설계하는 방법
다음 글 →Saga Pattern — 분산 트랜잭션을 관리하는 방법