Software Architecture/Architectural Styles

Architectural Styles - 08. CQRS

CQRS — 명령과 질의를 가르는 선택

한 전자상거래 시스템의 접근 패턴을 보자. 주문 생성(쓰기)은 하루 10만 건, 주문 조회(읽기)는 하루 1억 건이다. 1000 대 1의 비율이다. 그런데도 같은 Order 모델, 같은 OrderRepository, 같은 DB를 쓴다. 읽기 최적화(인덱스, 캐싱, 복제본)를 하려 해도 쓰기 모델에 영향을 줘서 함부로 못 건드린다. 쓰기 트랜잭션의 무결성을 지키려면 읽기 성능을 희생해야 하고, 읽기 성능을 올리려면 쓰기 무결성이 흔들린다. 이 교착에서 빠져나오는 한 방법이 CQRS(Command Query Responsibility Segregation)다. 명령(쓰기)과 질의(읽기)를 아예 다른 모델로 가르는 것.

이 글이 다루는 질문: 같은 모델로 읽고 쓰는 게 항상 자연스러운가. 답부터 말하면 — 읽기/쓰기 비율이 비슷하고 단순한 시스템에선 자연스럽다. 하지만 비율이 극단적으로 다르거나 읽기 패턴이 다양하면, 가르는 쪽이 단순해진다.

비유로 감 잡기 — 도서관의 두 데스크

도서관에 책 등록 데스크와 책 검색 데스크가 따로 있다고 상상하자. 등록 데스크에서는 사서가 새 책을 분류하고, 메타데이터를 입력하고, 위치를 지정한다. 철저한 절차를 따라야 하고, 실수하면 안 된다 (쓰기, 정확성 중시).

검색 데스크에서는 방문객이 "프로그래밍 책 어디 있어?", "올해 들어온 소설 알려줘", "저자별로 봬야 해" 같은 질문을 한다. 사서가 빠르게 답하려면 미리 분류된 카드철(색인), 베스트셀러 코너, 신간 코너 같은 '읽기에 최적화된 구조'가 필요하다. 등록 절차의 정확성과는 다른 최적화.

한 사서가 두 데스크를 다 담당하면 어느 쪽도 최적이 안 된다. 등록 절차에 집중하면 검색이 느려지고, 검색 친화적으로 책을 펼쳐놓으면 등록 절차가 엉망이 된다. 그래서 두 데스크를 가르고, 두 사서가 각자 자기 데스크에 맞게 최적화한다. 이게 CQRS의 직관이다 — 쓰기 모델(등록 데스크)과 읽기 모델(검색 데스크)을 가르고, 각자 자기 목적에 최적화한다.

비유의 한계 — 도서관 비유는 '물리적 분리'를 강조하지만, CQRS의 분리는 모델과 저장소 수준이다. 같은 DB 안에서 다른 스키마를 쓸 수도 있고, 아예 다른 DB를 쓸 수도 있다. 또한 비유는 '같은 데이터를 다르게 인덱싱'에 그치지만, CQRS는 종종 비정규화된 읽기 모델(쿼리에 맞춘 뷰)을 만든다. 비유는 '비정규화'에서 무너진다.

CQRS의 핵심 — Bertrand Meyer의 CQS를 시스템 수준으로

CQRS의 원형은 Bertrand Meyer가 (1988)에서 제안한 CQS(Command-Query Separation) 원칙이다. 한 메서드는 명령(상태를 변경, 반환 없음)이거나 질의(상태를 반환, 변경 없음) 둘 중 하나만 해야 한다는 원칙. 메서드 수준의 분리.

Greg Young이 2010년에 이를 아키텍처 수준으로 밀어 올린 것이 CQRS다. 한 시스템 안에서 명령(쓰기) 모델과 질의(읽기) 모델을 아예 다른 구조로 유지하자는 것. Martin Fowler가 자신의 사이트에서 정리한 바에 따르면, CQRS는 "단순한 패턴이 아니라 하나의 스타일"이다 — 시스템 구조 자체를 가르는 결정.

flowchart LR
    subgraph Write["쓰기 모델 (Command)"]
        WC[Order Command Handler]
        WE[Order Entity<br/>정규화된 도메인 모델]
        WD[(Write DB<br/>정규화 스키마)]
        WC --> WE
        WE --> WD
    end
    subgraph Read["읽기 모델 (Query)"]
        RQ[Order Query Handler]
        RV[Order View<br/>비정규화된 읽기 뷰]
        RD[(Read DB<br/>쿼리 최적화 스키마)]
        RQ --> RV
        RV --> RD
    end
    WE -.->|이벤트 발행| PROJ[Projection]
    PROJ -.->|읽기 모델 갱신| RV

핵심은 두 모델이 독립적이라는 점이다. 쓰기 모델은 정규화된 도메인(비즈니스 규칙에 충실), 읽기 모델은 비정규화된 뷰(쿼리 성능에 충실). 두 모델은 이벤트(또는 동기 복제)로 동기화된다.

왜 가르는가 — 읽기/쓰기 비대칭

CQRS가 가치를 발휘하는 조건은 읽기와 쓰기가 비대칭일 때다. 비대칭의 형태는 세 가지.

  • 비율 비대칭 — 읽기가 쓰기보다 압도적으로 많을 때 (1000:1 등). 읽기에만 독립 확장을 적용하려면 모델이 분리돼야 한다.
  • 패턴 비대칭 — 쓰기는 단순하지만 읽기가 복잡한 조인·집계를 요구할 때. 쓰기 모델(정규화)과 읽기 모델(비정규화)을 다르게 가져가야 둘 다 만족.
  • 확장 비대칭 — 읽기는 선형 확장(읽기 복제본)이 쉽지만, 쓰기는 마스터 하나에 국한되는 경우. 읽기를 별도 인프라로 빼면 확장이 자유롭다.

CQRS 적합/부적합 판단표

조건 CQRS 추천 여부 이유
읽기/쓰기 비율 비슷 부적합 가르는 비용만 증가
도메인 모델이 단순 (CRUD) 부적합 분리할 복잡성이 없음
읽기 패턴 단순 (PK 조회 위주) 부적합 굳이 읽기 모델 분리 필요 없음
읽기 90%+, 다양한 쿼리 패턴 적합 읽기 모델 독자적 최적화 효과 큼
복잡한 도메인 + 다양한 읽기 뷰 적합 쓰기는 정규화, 읽기는 비정규화로 둘 다 만족
팀이 작고 단순함이 중요 부적합 CQRS의 복잡성이 팀에 과함
이벤트 소싱 도입 적합 (사실상 필수) 읽기 모델을 별도로 구축해야 쿼리 가능

이 표에서 핵심 — CQRS는 '기본'이 아니라 '비대칭이 뚜렷할 때' 선택하는 특수 패턴이다. Fowler가 "CQRS는 고도로 특수한 상황에서만 가치가 있고, 기본 선택지가 아니다"라고 경고하는 이유다. 단순 CRUD 도메인에 CQRS를 끼우면 복잡성만 배로 늘고 이득은 없다.

구체화 뷰 (Materialized View) — 읽기 모델의 형태

읽기 모델은 보통 '구체화 뷰' 형태로 만들어진다. 쿼리 결과를 미리 계산해 저장해두는 것. 사용자가 "내 주문 목록"을 요청할 때마다 5개 테이블을 조인하는 대신, 미리 조인한 결과를 테이블에 저장해두고 단순 SELECT로 가져온다.

-- 정규화된 쓰기 모델 (5개 테이블 조인 필요)
SELECT o.id, o.total, o.status, c.name, p.method, s.tracking_no
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN payments p ON o.id = p.order_id
JOIN shipments s ON o.id = s.order_id
WHERE c.id = ?;

-- 비정규화된 읽기 모델 (단일 테이블)
SELECT * FROM customer_order_view WHERE customer_id = ?;

이 뷰는 누가 언제 갱신하나. 두 방식이 있다.

  • 동기식 — 쓰기 트랜잭션 안에서 읽기 모델도 갱신. 강한 일관성 유지, 하지만 쓰기가 느려짐.
  • 비동기식 — 쓰기가 이벤트를 발행하면, 별도 프로젝션(projection)이 읽기 모델을 갱신. 쓰기는 빠르지만 최종 일관성.

CQRS의 대부분은 비동기식을 택한다. 그래야 쓰기와 읽기가 독립적으로 확장되고, 쓰기 성능이 읽기 모델 갱신 비용에 영향받지 않는다. 이 경우 읽기 모델은 쓰기보다 약간 늦게 반영된다 — 보통 밀리초에서 수 초의 지연. 사용자에게 "방금 주문했는데 목록에 안 떠요" 같은 현상이 나타날 수 있다. 이게 CQRS의 비용이다.

설계 사례 — 단일 모델에서 CQRS로

1단계 — 단일 모델 (CRUD)

@Entity
class Order {
    @Id Long id;
    Long customerId;
    BigDecimal total;
    String status;
    // 정규화된 도메인 모델 — 비즈니스 규칙에 충실
}

interface OrderRepository extends JpaRepository<Order, Long> {
    // 동일한 저장소로 읽기/쓰기 모두 처리
}

// 읽기 시 복잡한 조인 필요
@Service
class OrderQueryService {
    public List<OrderSummary> findByCustomer(Long customerId) {
        // 5개 테이블 조인 — 느림
        return orderRepo.findSummariesByCustomerJoinEverything(customerId);
    }
}

읽기와 쓰기가 같은 모델, 같은 저장소. 단순하지만 읽기 패턴이 다양해지면 조인이 늘어나 성능이 떨어진다.

2단계 — 읽기 전용 DTO + 뷰 (부분적 분리)

// 읽기 전용 뷰 테이블
@Entity
@Table(name = "customer_order_view")
class CustomerOrderView {
    @Id Long id;
    Long customerId;
    String customerName;     // 조인 결과 저장
    BigDecimal total;
    String status;
    String paymentMethod;    // 조인 결과
    String trackingNumber;   // 조인 결과
}

// 읽기 전용 리포지토리
interface OrderViewRepository extends JpaRepository<CustomerOrderView, Long> {
    List<CustomerOrderView> findByCustomerId(Long customerId);
}

// 트리거 또는 애플리케이션에서 뷰 갱신
@EventListener
void on(OrderPlacedEvent e) {
    CustomerOrderView view = new CustomerOrderView();
    view.id = e.orderId();
    view.customerId = e.customerId();
    view.total = e.total();
    view.status = "PENDING";
    orderViewRepo.save(view);
}

읽기 모델(뷰)이 분리됐다. 읽기는 단순 SELECT, 쓰기는 여전히 정규화된 모델로. 아직은 같은 DB 인스턴스.

3단계 — 완전한 CQRS (별도 저장소 + 비동기 동기화)

// 쓰기 모델 — JPA, 정규화
@Entity
class Order {
    // 비즈니스 규칙에 충실한 정규화된 도메인
}

// 읽기 모델 — Elasticsearch, 비정규화
@Document(indexName = "customer-orders")
class CustomerOrderDoc {
    @Id String id;
    Long customerId;
    String customerName;
    BigDecimal total;
    String status;
    List<ItemInfo> items;     // 중첩 구조 — 읽기 편의를 위해 비정규화
    Instant createdAt;
}

// 쓰기 → 읽기 동기화 (비동기 프로젝션)
@Component
class OrderProjection {
    @KafkaListener(topics = "order-events")
    void project(OrderEvent event) {
        if (event instanceof OrderPlaced e) {
            CustomerOrderDoc doc = new CustomerOrderDoc();
            doc.id = e.orderId().toString();
            doc.customerId = e.customerId();
            doc.total = e.total();
            doc.status = "PENDING";
            doc.items = e.items().stream().map(this::toItemInfo).toList();
            orderDocRepo.save(doc);  // Elasticsearch에 저장
        } else if (event instanceof OrderShipped e) {
            orderDocRepo.findById(e.orderId().toString())
                .ifPresent(doc -> { doc.status = "SHIPPED"; orderDocRepo.save(doc); });
        }
    }
}

// 읽기 API — Elasticsearch 직접 조회
@RestController
class OrderQueryController {
    @GetMapping("/customers/{id}/orders")
    List<CustomerOrderDoc> orders(@PathVariable Long id) {
        return orderDocRepo.findByCustomerId(id);  // 빠른 전문 검색
    }

    @GetMapping("/orders/search")
    List<CustomerOrderDoc> search(@RequestParam String q) {
        return orderDocRepo.searchByKeyword(q);  // 전문 검색 — RDB로는 비싼 연산
    }
}

쓰기는 JPA(RDB), 읽기는 Elasticsearch. 두 모델이 완전히 분리됐다. 읽기 모델은 비정규화된 구조로, 다양한 쿼리(고객별 조회, 키워드 검색, 기간 집계)에 각각 최적화된다. 동기화는 Kafka 이벤트를 통한 비동기 프로젝션. 약간의 지연(최종 일관성)을 감수하는 대신, 읽기와 쓰기가 독립적으로 확장된다.

CQRS 위반/오용 감지

신호 의미
단순 CRUD에 CQRS 적용 오버엔지니어링 — 비용만 증가
읽기 모델이 쓰기 모델을 동기 대기 CQRS의 장점(독립 확장) 상실
읽기/쓰기 비율 비슷한데 CQRS 가르는 비용만 남음
동기화 메커니즘(outbox 등) 없음 이벤트 유실로 읽기 모델 불일치
한 팀이 읽기·쓰기 모두 담당 경계가 명확하지 않으면 혼란
프로젝션 실패 시 복구 불가 읽기 모델 재구축 불가 — 재생(replay) 설계 필요

마지막 행이 중요하다. CQRS에선 프로젝션이 실패하거나 읽기 모델이 손상되면, "이벤트 로그를 재생(replay)해 읽기 모델을 다시 구축"하는 능력이 필수다. 이게 안 되면 읽기 모델을 신뢰할 수 없다.

CQRS의 비용

CQRS는 강력하지만 비용이 큰 패턴이다. Fowler가 경고하듯, "CQRS를 도입하면 코드 복잡성이 크게 증가한다".

  • 복잡성 증가 — 두 모델, 두 저장소, 동기화 메커니즘. 단순 CRUD에 비해 코드량·인프라가 배로.
  • 최종 일관성 — 읽기 모델이 쓰기보다 늦다. 사용자에게 "방금 변경했는데 안 보여요" 현상.
  • 운영 부담 — 두 시스템(RDB + Elasticsearch 등)을 운영. 백업·모니터링·장애 대응이 배로.
  • 재생(replay) 인프라 — 읽기 모델을 재구축하려면 이벤트 로그 보존·재생 도구가 필요. 이벤트 소싱(09편)과 짝할 때 자연스럽지만, 단독 CQRS에선 추가 비용.

이 비용을 감당할 만큼의 이득(읽기 성능, 독립 확장, 다양한 쿼리 패턴)이 있을 때만 CQRS가 정당화된다.

CQRS와 이벤트 소싱의 관계

CQRS는 이벤트 소싱(09편)과 자주 짝하지만, 둘은 별개의 패턴이다.

  • CQRS만 — 쓰기 모델은 현재 상태를 저장(일반적인 RDB), 읽기 모델만 분리. 동기화는 트리거, CDC, 이벤트 발행 등으로.
  • 이벤트 소싱만 — 상태를 이벤트 로그로 저장, 읽기는 이벤트를 재생해 현재 상태 계산. 쓰기와 읽기가 같은 모델.
  • CQRS + 이벤트 소싱 — 쓰기 모델은 이벤트 로그, 읽기 모델은 별도 비정규화 뷰. 둘의 강력한 조합.

CQRS를 도입하겠다고 해서 무조건 이벤트 소싱까지 가야 하는 건 아니다. 읽기/쓰기 분리의 이득만 필요하면 CQRS만, 상태를 이벤트로 저장하는 이득이 추가로 필요하면 이벤트 소싱까지. 두 결정은 별개다.

다음으로 — 이벤트 소싱

CQRS의 읽기 모델을 "이벤트를 재생해 구축"하는 형태로 밀면 자연스럽게 이벤트 소싱(09편)에 닿는다. 상태를 현재 값이 아니라 '이벤트의 누적'으로 저장하는 패턴. 감사 로그, 시점 복원, 도메인 이벤트의 풍부한 활용이 가능한 대신, 스키마 진화·버전 관리 같은 새로운 복잡성을 가져온다.

CQRS의 핵심 통찰은 "읽기와 쓰기가 같은 모델을 써야 할 이유가 없다"는 것이다. 이 통찰이 들어맞는 비대칭적인 상황에서만, 가르는 비용을 감수할 가치가 있다.


참고

  • Young, Greg — "CQRS Documents" (gregoryyoung.com, 2010~), CQRS 원정의 — 접근 2026-07-20
  • Fowler — "CQRS" (martinfowler.com/bliki/CQRS.html), 경고와 함께 정리 — 접근 2026-07-20
  • Fowler — "What do you mean by Event-Driven?" (martinfowler.com, 2017) — 접근 2026-07-20
  • Richards, Ford — (O'Reilly, 2020), Ch.7 (CQRS)
  • Meyer — (Prentice Hall, 1988), CQS 원칙