-
CQRS: Command와 Query의 책임 분리이코에코(Eco²)/Foundations 2025. 12. 21. 18:40

원문: CQRS - Martin Fowler (2011)
들어가며
2011년, Martin Fowler가 Greg Young의 아이디어를 정리하여 발표한 CQRS(Command Query Responsibility Segregation)는 읽기와 쓰기를 별도의 모델로 분리하는 패턴이다.
흥미로운 점은 Fowler 본인이 이 글에서 CQRS의 위험성을 강하게 경고한다는 것이다. 그는 CQRS가 유용한 경우도 있지만, 대부분의 시스템에는 불필요한 복잡성을 추가한다고 강조한다.
"CQRS is a significant mental leap for all concerned, so shouldn't be tackled unless the benefit is worth the jump."
이 문서에서는 CQRS가 해결하는 문제, 패턴의 핵심 구조, 그리고 언제 사용해야 하는지(사용하지 말아야 하는지)를 살펴본다.
CRUD의 한계
단일 모델의 문제
전통적인 시스템은 하나의 모델로 모든 작업을 처리한다:
┌─────────────────────────────────────────────────────────────┐ │ CRUD 단일 모델 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Record: { id, name, email, balance, ... } │ │ │ │ 모든 작업이 같은 모델 사용: │ │ • Create: 새 레코드 생성 │ │ • Read: 레코드 조회 │ │ • Update: 레코드 수정 │ │ • Delete: 레코드 삭제 │ │ │ └─────────────────────────────────────────────────────────────┘단순한 시스템에서는 이 방식이 잘 작동한다. 하지만 요구사항이 복잡해지면 문제가 생긴다.
읽기와 쓰기의 불일치
실제 시스템에서는 읽기와 쓰기의 요구사항이 매우 다르다:
┌─────────────────────────────────────────────────────────────┐ │ 읽기 vs 쓰기 요구사항 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 쓰기 (Command) 측면: │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ • 복잡한 비즈니스 규칙 검증 │ │ │ │ • 데이터 정합성 보장 │ │ │ │ • 트랜잭션 처리 │ │ │ │ • 예: "잔액이 충분해야만 이체 가능" │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ 읽기 (Query) 측면: │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ • 여러 테이블 조인 │ │ │ │ • 집계, 통계, 대시보드 │ │ │ │ • 다양한 형태의 조회 (리스트, 상세, 검색) │ │ │ │ • 예: "최근 30일 거래 내역 + 카테고리별 합계" │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ 하나의 모델로 양쪽을 만족시키면? │ │ → 모델이 비대해지고, 양쪽 모두 최적화 불가 │ │ │ └─────────────────────────────────────────────────────────────┘
CQRS의 핵심: 모델 분리
Command와 Query 분리
CQRS는 이 문제를 모델 자체를 분리하여 해결한다:
┌─────────────────────────────────────────────────────────────┐ │ CQRS 구조 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────┐ │ │ │ Client │ │ │ └──────┬──────┘ │ │ │ │ │ ┌─────────────┴─────────────┐ │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────────┐ ┌─────────────────────┐ │ │ │ Command Side │ │ Query Side │ │ │ │ │ │ │ │ │ │ • 상태 변경 │ │ • 데이터 조회 │ │ │ │ • 비즈니스 규칙 │ │ • 최적화된 뷰 │ │ │ │ • 트랜잭션 │ │ • 집계/통계 │ │ │ │ │ │ │ │ │ │ void execute() │ │ Data query() │ │ │ └─────────────────────┘ └─────────────────────┘ │ │ │ │ 핵심: 완전히 다른 모델, 다른 최적화 전략 │ │ │ └─────────────────────────────────────────────────────────────┘CQS 원칙
CQRS는 Bertrand Meyer의 CQS(Command Query Separation) 원칙에서 확장되었다:
구분 Command Query 목적 상태 변경 데이터 반환 부작용 있음 없음 반환값 void (또는 생성된 ID) 데이터 멱등성 일반적으로 아님 항상 멱등
분리의 수준
CQRS는 다양한 수준으로 적용할 수 있다.
레벨 1: 같은 DB, 다른 모델
가장 단순한 형태로, 같은 데이터베이스를 사용하되 접근하는 모델만 분리한다:
┌─────────────────────────────────────────────────────────────┐ │ 같은 DB, 다른 모델 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────┐ ┌────────────────┐ │ │ │ Command Model │ │ Query Model │ │ │ │ (ORM Entity) │ │ (DTO/View) │ │ │ └───────┬────────┘ └───────┬────────┘ │ │ │ │ │ │ └──────────┬───────────────┘ │ │ ▼ │ │ ┌─────────────────────┐ │ │ │ Shared Database │ │ │ │ │ │ │ │ Tables + Views │ │ │ └─────────────────────┘ │ │ │ │ 장점: 단순함, 즉시 일관성 │ │ 단점: 읽기 성능 최적화 제한 │ │ │ └─────────────────────────────────────────────────────────────┘레벨 2: 분리된 DB
읽기와 쓰기가 별도의 데이터베이스를 사용한다:
┌─────────────────────────────────────────────────────────────┐ │ 분리된 DB (Read Replica) │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌────────────────┐ ┌────────────────┐ │ │ │ Command Model │ │ Query Model │ │ │ └───────┬────────┘ └───────┬────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌────────────────┐ ┌────────────────┐ │ │ │ Write DB │ ──────▶ │ Read DB │ │ │ │ (Primary) │ 동기화 │ (Replica) │ │ │ │ │ │ │ │ │ │ 정규화된 구조 │ │ 비정규화/캐시 │ │ │ └────────────────┘ └────────────────┘ │ │ │ │ 장점: 읽기/쓰기 독립적 확장, 읽기 최적화 │ │ 단점: 복잡성 증가, 데이터 지연(Eventual Consistency) │ │ │ └─────────────────────────────────────────────────────────────┘레벨 3: Event Sourcing + Projection
가장 강력한 형태로, Event Sourcing과 결합한다:
┌─────────────────────────────────────────────────────────────┐ │ Event Sourcing + CQRS │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Command Side Query Side │ │ ───────────── ────────── │ │ │ │ POST /orders GET /orders │ │ │ │ │ │ ▼ ▼ │ │ ┌────────────┐ ┌────────────┐ │ │ │ Aggregate │ │ Read Model │ │ │ │ Order │ │ (View) │ │ │ └─────┬──────┘ └─────┬──────┘ │ │ │ ▲ │ │ │ 이벤트 발행 │ Projection │ │ ▼ │ │ │ ┌─────────────────────────────────┴───────────┐ │ │ │ Event Store │ │ │ │ │ │ │ │ OrderCreated → ItemAdded → OrderShipped │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ Write: Event를 저장 │ │ Read: Event를 재생하여 Projection 생성 │ │ │ └─────────────────────────────────────────────────────────────┘
CQRS와 함께 사용되는 패턴
CQRS는 단독으로 사용되기보다 다른 패턴과 자주 조합된다:
패턴 조합 방식 Event Sourcing Command가 이벤트를 저장, Query가 Projection을 읽음 Eventual Consistency 읽기 모델이 비동기로 업데이트됨 Event-Driven 서비스 간 이벤트로 데이터 동기화 Task-based UI CRUD 대신 의도를 표현하는 명령
언제 사용해야 하는가?
Martin Fowler의 경고
Fowler는 CQRS의 위험성을 강조한다:
┌─────────────────────────────────────────────────────────────┐ │ ⚠️ CQRS 사용 시 주의 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ❌ 피해야 할 경우: │ │ • 단순한 CRUD 시스템 │ │ • 읽기/쓰기 요구사항이 비슷한 경우 │ │ • 팀이 CQRS 경험이 없는 경우 │ │ • 복잡성을 감당할 수 없는 프로젝트 │ │ │ │ ✅ 적합한 경우: │ │ • 읽기/쓰기 비율이 극단적으로 다름 (예: 읽기 90%) │ │ • 읽기와 쓰기의 확장 요구가 다름 │ │ • 복잡한 도메인 로직 + 다양한 조회 요구 │ │ • Event Sourcing을 이미 사용 중 │ │ │ │ "대부분의 시스템에서 CQRS는 위험한 복잡성을 추가한다." │ │ │ └─────────────────────────────────────────────────────────────┘BoundedContext 단위로만 적용
CQRS는 시스템 전체가 아닌 특정 Bounded Context에만 적용해야 한다:
┌─────────────────────────────────────────────────────────────┐ │ CQRS 적용 범위 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ System: │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ Context A │ │ Context B │ │ Context C │ │ │ │ │ │ (CRUD) │ │ (CQRS) │ │ (CRUD) │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ❌ 시스템 전체에 CQRS 강제 │ │ ✅ 필요한 Context에만 CQRS 적용 │ │ │ │ 각 Context는 자신에게 맞는 패턴을 선택해야 한다. │ │ │ └─────────────────────────────────────────────────────────────┘대안: Reporting Database
CQRS가 과하다면 Reporting Database 패턴을 고려하라:
┌─────────────────────────────────────────────────────────────┐ │ Reporting Database (더 단순한 대안) │ ├─────────────────────────────────────────────────────────────┤ │ │ │ CQRS: │ │ → 모든 읽기가 분리된 모델 사용 │ │ → 높은 복잡성 │ │ │ │ Reporting Database: │ │ → 대부분 읽기는 기존 시스템 사용 │ │ → 복잡한 쿼리만 별도 DB로 오프로드 │ │ → 낮은 복잡성 │ │ │ │ ┌────────────┐ │ │ │ Main App │──── 일반 쿼리 ────▶ Main DB │ │ │ │ │ │ │ │──── 복잡한 쿼리 ──▶ Reporting DB │ │ └────────────┘ (동기화) │ │ │ └─────────────────────────────────────────────────────────────┘
핵심 정리
개념 설명 CQRS Command(쓰기)와 Query(읽기) 모델을 분리하는 패턴 기원 Greg Young 제안, Martin Fowler가 정리 (CQS에서 확장) 적합 읽기/쓰기 불균형이 큰 고성능 시스템, 복잡한 도메인 경고 대부분의 시스템에는 불필요한 복잡성 추가 범위 시스템 전체가 아닌 BoundedContext 단위로만 적용 대안 Reporting Database로 복잡한 쿼리만 분리
더 읽을 자료
- CQRS, Task Based UIs, Event Sourcing agh! - Greg Young
- Clarified CQRS - Udi Dahan
부록: Eco² 적용 포인트
전환 계획: gRPC → Command-Event Separation
Eco²는 Command-Event Separation 아키텍처를 채택한다. CQRS의 핵심 원칙을 Event-Driven 방식으로 적용한다.
┌─────────────────────────────────────────────────────────────┐ │ Eco² CQRS via Command-Event Separation │ ├─────────────────────────────────────────────────────────────┤ │ │ │ Command Side (쓰기) Query Side (읽기) │ │ ───────────────────── ───────────────── │ │ │ │ POST /scan GET /my/profile │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ Scan API │ │ My API │ │ │ │ (Write) │ │ (Read) │ │ │ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ ├──────────────┐ │ │ │ ▼ ▼ ▼ │ │ RabbitMQ Event Store Read Model │ │ (Task) + Outbox (Projection) │ │ │ │ ▲ │ │ │ │ CDC │ │ │ ▼ ▼ │ │ │ Celery Kafka ──────────────►│ │ │ Workers (Event) Kafka Consumer │ │ (Projection 업데이트) │ │ │ │ 핵심: Write와 Read가 완전히 분리, Kafka로 동기화 │ │ │ └─────────────────────────────────────────────────────────────┘Martin Fowler 경고 준수
Eco²는 CQRS를 필요한 BoundedContext에만 선택적으로 적용한다:
┌─────────────────────────────────────────────────────────────┐ │ Eco² CQRS 적용 범위 (BoundedContext) │ ├─────────────────────────────────────────────────────────────┤ │ │ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌─────────┐ │ │ │ Auth │ │ Scan │ │ Character │ │ My │ │ │ │ (CRUD) │ │ (CQRS) │ │ (CQRS) │ │ (Read) │ │ │ └───────────┘ └───────────┘ └───────────┘ └─────────┘ │ │ │ │ Auth: 단순 CRUD │ │ → CQRS 불필요, 기존 패턴 유지 │ │ │ │ Scan: Command + Event │ │ → ProcessImage(Command) → ScanCompleted(Event) │ │ → CQRS 적용 ✅ │ │ │ │ Character: Command + Event │ │ → Event Sourcing + Projection │ │ → CQRS 적용 ✅ │ │ │ │ My: Query Only (Read Model) │ │ → 다른 도메인 Event를 Projection으로 수집 │ │ → Query-side CQRS ✅ │ │ │ └─────────────────────────────────────────────────────────────┘My Service: Kafka Consumer Projection
# domains/my/consumers/projection_consumer.py class MyProjectionConsumer: """Kafka Consumer - 모든 도메인 이벤트를 Projection으로 수집""" def __init__(self): self.consumer = Consumer({ 'bootstrap.servers': settings.KAFKA_SERVERS, 'group.id': 'my-service-projection', }) # 여러 토픽 구독 self.consumer.subscribe([ 'eco2.events.scan', 'eco2.events.character', 'eco2.events.auth', ]) async def handle_scan_completed(self, event: ScanCompleted): """Scan 이벤트 → My Read Model 업데이트""" await self.db.execute(""" UPDATE user_profiles SET total_scans = total_scans + 1, last_scan_at = :completed_at WHERE user_id = :user_id """, {"user_id": event.user_id, "completed_at": event.completed_at}) async def handle_character_granted(self, event: CharacterGranted): """Character 이벤트 → My Read Model 업데이트""" await self.db.execute(""" INSERT INTO user_characters (user_id, character_id, acquired_at) VALUES (:user_id, :char_id, :acquired_at) ON CONFLICT DO NOTHING """, {"user_id": event.user_id, "char_id": event.character_id, ...})AS-IS vs TO-BE
원칙 AS-IS (gRPC) TO-BE (Command-Event Separation) Command gRPC 직접 호출 RabbitMQ + Celery Task Query gRPC 또는 DB 직접 Kafka Projection (Read Model) 동기화 즉시 일관성 Eventual Consistency 분리 수준 논리적 분리 물리적 분리 (별도 DB) 확장성 제한적 Read/Write 독립 스케일링 BoundedContext 서비스 경계 + Event Schema Contract '이코에코(Eco²) > Foundations' 카테고리의 다른 글
Life Beyond Distributed Transactions: 분산 트랜잭션 없이 살아가기 (0) 2025.12.21 Domain-Driven Design: Aggregate와 트랜잭션 경계 (0) 2025.12.21 Enterprise Integration Patterns: 메시징 시스템의 설계 원칙 (2) 2025.12.21 Uber DOMA: 마이크로서비스 관리 방법론 (1) 2025.12.21 Event Sourcing: 상태(state)에서 이벤트로 (1) 2025.12.21