🔀 CQRS 패턴
패턴 개요
CQRS (Command Query Responsibility Segregation)는 명령(쓰기)과 조회(읽기)의 책임을 분리하는 패턴입니다. 데이터 변경과 데이터 조회를 서로 다른 모델로 처리하여 성능, 확장성, 보안을 최적화합니다.
중요도: ⭐⭐⭐⭐ 고급 패턴
복잡한 도메인, 높은 트래픽, 서로 다른 읽기/쓰기 요구사항이 있는 시스템에서 필수적입니다.
📑 목차
1. 핵심 개념
🎯 정의
CQRS는 Martin Fowler가 제안한 Command Query Separation (CQS) 원칙을 확장한 패턴입니다.
CQS 원칙:
- Command (명령): 상태를 변경하지만 값을 반환하지 않음
- Query (조회): 값을 반환하지만 상태를 변경하지 않음
CQRS 확장:
- Command와 Query를 서로 다른 모델로 분리
- 서로 다른 DB를 사용할 수도 있음
📊 전통적인 CRUD vs CQRS
전통적인 CRUD (단일 모델):
┌─────────────┐
│ Client │
└──────┬──────┘
│
▼
┌─────────────┐
│ Service │ ← 동일한 Entity 모델
└──────┬──────┘
│
▼
┌─────────────┐
│ Database │ ← 단일 DB
└─────────────┘
CQRS (명령/조회 분리):
┌─────────────┐
│ Client │
└──────┬──────┘
│
┌─────────┴─────────┐
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ Command │ │ Query │
│ Service │ │ Service │
└─────┬──────┘ └──────┬─────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│Write Model │ │ Read Model │ ← 서로 다른 모델
│ (정규화) │ │ (비정규화) │
└─────┬──────┘ └──────┬─────┘
│ │
▼ ▼
┌────────────┐ ┌────────────┐
│ Write DB │ │ Read DB │ ← 서로 다른 DB
│ (PostgreSQL) │ (Elasticsearch)
└────────────┘ └────────────┘
│ ▲
└──────────────────┘
동기화 (이벤트)
2. 문제와 해결
🚨 해결하려는 문제
문제 1: 읽기/쓰기 요구사항 불일치
E-Commerce 시스템
쓰기 요구사항:
- 주문 생성 (초당 100건)
- 강한 일관성 필요
- 정규화된 데이터 구조
- 트랜잭션 보장
읽기 요구사항:
- 상품 검색 (초당 10,000건)
- 최종 일관성 허용
- 비정규화된 데이터 (JOIN 최소화)
- 전문 검색, 필터링, 정렬
단일 모델의 문제:
-- 쓰기에 최적화 (정규화)
CREATE TABLE orders (
id UUID PRIMARY KEY,
user_id UUID,
status VARCHAR(20)
);
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID,
product_id UUID,
quantity INT
);
-- 읽기 시 복잡한 JOIN 필요 (느림!)
SELECT o.*, oi.*, p.*
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.user_id = ?;문제 2: 성능 병목
단일 DB 구조:
- 읽기 95%, 쓰기 5%
- 읽기 쿼리가 복잡 (다중 JOIN, 집계)
- 쓰기 트랜잭션이 읽기 블로킹
→ DB 성능 저하
문제 3: 확장성 제한
단일 모델:
- 읽기와 쓰기가 같이 증가
- Scale-up만 가능 (Vertical Scaling)
- 비용 급증
CQRS:
- 읽기와 쓰기 독립적으로 확장
- Scale-out 가능 (Horizontal Scaling)
- 비용 효율적
✅ CQRS의 해결 방법
해결 1: 모델 분리
// Write Model (정규화, DDD 적용)
@Entity
public class Order {
@Id
private UUID id;
private UUID userId;
private OrderStatus status;
@OneToMany
private List<OrderItem> items;
public void addItem(Product product, int quantity) {
// 비즈니스 로직
validateStock(product, quantity);
items.add(new OrderItem(product, quantity));
}
}
// Read Model (비정규화, DTO)
public class OrderSummaryDTO {
private UUID orderId;
private String userName;
private List<String> productNames; // JOIN 없이 바로 조회
private BigDecimal totalAmount;
private String status;
}해결 2: DB 분리
Write DB (PostgreSQL):
- ACID 트랜잭션
- 정규화
- 관계형 데이터
Read DB (Elasticsearch):
- 전문 검색
- 집계 쿼리
- 비정규화
- 캐시 친화적
해결 3: 독립적 확장
Write:
- Master DB 1대
- Slave DB 2대 (백업)
- 초당 100건 처리
Read:
- Elasticsearch 클러스터 10대
- 초당 10,000건 처리
- Redis 캐시 추가
3. CQRS 아키텍처
📐 기본 구조
┌───────────────────────────────────────────────────────┐
│ API Gateway │
└────────────┬──────────────────────┬───────────────────┘
│ │
Command (POST/PUT/DELETE) Query (GET)
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Command Handler │ │ Query Handler │
│ │ │ │
│ - Validation │ │ - Caching │
│ - Business Logic │ │ - Projection │
│ - Event Publishing │ │ - Filtering │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Write Model │ │ Read Model │
│ │ │ │
│ Order (Entity) │ │ OrderSummary (DTO) │
│ OrderItem (Entity) │ │ ProductView (DTO) │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ Write Database │ │ Read Database │
│ │ │ │
│ PostgreSQL │ │ Elasticsearch │
│ (정규화, ACID) │ │ (비정규화, 검색) │
└──────────┬──────────┘ └──────────▲──────────┘
│ │
└─────────────────────────┘
Event Bus
(Kafka/RabbitMQ)
🔄 데이터 흐름
Command 흐름 (쓰기):
1. Client → POST /orders
2. Command Handler → Validate
3. Command Handler → Business Logic
4. Write DB ← Save Order
5. Event Bus ← Publish OrderCreatedEvent
6. Client ← Response (201 Created)
Query 흐름 (읽기):
1. Client → GET /orders/123
2. Query Handler → Check Cache
3. Read DB ← Query (if cache miss)
4. Client ← Response (200 OK)
동기화 흐름:
1. Event Bus → OrderCreatedEvent
2. Event Handler → Listen
3. Read DB ← Update OrderSummary
4. 구현 패턴
1. Simple CQRS (단순 CQRS)
특징: 같은 DB, 다른 모델
// Command Side
@Service
public class OrderCommandService {
@Autowired
private OrderRepository orderRepository;
@Transactional
public UUID createOrder(CreateOrderCommand command) {
// Write Model 사용
Order order = new Order(command.getUserId());
command.getItems().forEach(item ->
order.addItem(item.getProductId(), item.getQuantity())
);
return orderRepository.save(order).getId();
}
}
// Query Side
@Service
public class OrderQueryService {
@Autowired
private OrderSummaryRepository orderSummaryRepository;
public OrderSummaryDTO getOrder(UUID orderId) {
// Read Model 사용 (비정규화)
return orderSummaryRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
}장점: 구현 간단, 마이그레이션 쉬움 단점: 성능 향상 제한적
2. CQRS with Separate DBs (DB 분리)
특징: 서로 다른 DB 사용
# application.yml
spring:
datasource:
write:
url: jdbc:postgresql://localhost:5432/orders_write
username: write_user
password: secret
read:
url: jdbc:postgresql://localhost:5432/orders_read
username: read_user
password: secret@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.write")
public DataSource writeDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.read")
public DataSource readDataSource() {
return DataSourceBuilder.create().build();
}
}3. CQRS + Event Sourcing (이벤트 소싱)
특징: Write Model을 이벤트로 저장
// Event Store (Write Side)
@Service
public class OrderEventStore {
public void save(UUID orderId, DomainEvent event) {
eventRepository.save(new EventEntry(
orderId,
event.getClass().getName(),
serialize(event),
Instant.now()
));
// Read Model 업데이트용 이벤트 발행
eventBus.publish(event);
}
public List<DomainEvent> getEvents(UUID orderId) {
return eventRepository.findByAggregateId(orderId)
.stream()
.map(this::deserialize)
.collect(Collectors.toList());
}
}
// Projection (Read Side)
@Component
public class OrderSummaryProjection {
@EventListener
public void on(OrderCreatedEvent event) {
OrderSummary summary = new OrderSummary();
summary.setOrderId(event.getOrderId());
summary.setUserName(event.getUserName());
summary.setStatus("CREATED");
orderSummaryRepository.save(summary);
}
@EventListener
public void on(OrderCompletedEvent event) {
OrderSummary summary = orderSummaryRepository.findById(event.getOrderId());
summary.setStatus("COMPLETED");
orderSummaryRepository.save(summary);
}
}5. 실제 구현
💻 Spring Boot + Axon Framework
의존성:
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.9.0</version>
</dependency>Command 정의:
public class CreateOrderCommand {
@TargetAggregateIdentifier
private final UUID orderId;
private final UUID userId;
private final List<OrderItemDTO> items;
}
public class CompleteOrderCommand {
@TargetAggregateIdentifier
private final UUID orderId;
}Aggregate (Write Model):
@Aggregate
public class OrderAggregate {
@AggregateIdentifier
private UUID orderId;
private OrderStatus status;
public OrderAggregate() {
// Axon requires no-arg constructor
}
@CommandHandler
public OrderAggregate(CreateOrderCommand command) {
// Validation
if (command.getItems().isEmpty()) {
throw new IllegalArgumentException("Order must have items");
}
// Apply event
apply(new OrderCreatedEvent(
command.getOrderId(),
command.getUserId(),
command.getItems()
));
}
@EventSourcingHandler
public void on(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.status = OrderStatus.CREATED;
}
@CommandHandler
public void handle(CompleteOrderCommand command) {
// Business logic
if (status != OrderStatus.PAID) {
throw new IllegalStateException("Order must be paid first");
}
apply(new OrderCompletedEvent(command.getOrderId()));
}
@EventSourcingHandler
public void on(OrderCompletedEvent event) {
this.status = OrderStatus.COMPLETED;
}
}Projection (Read Model):
@Component
public class OrderSummaryProjection {
@Autowired
private OrderSummaryRepository repository;
@EventHandler
public void on(OrderCreatedEvent event) {
OrderSummary summary = new OrderSummary();
summary.setOrderId(event.getOrderId());
summary.setUserId(event.getUserId());
summary.setItemCount(event.getItems().size());
summary.setStatus("CREATED");
summary.setCreatedAt(Instant.now());
repository.save(summary);
}
@EventHandler
public void on(OrderCompletedEvent event) {
repository.findById(event.getOrderId()).ifPresent(summary -> {
summary.setStatus("COMPLETED");
summary.setCompletedAt(Instant.now());
repository.save(summary);
});
}
}Query Handler:
@Component
public class OrderQueryHandler {
@Autowired
private OrderSummaryRepository repository;
@QueryHandler
public OrderSummary handle(FindOrderQuery query) {
return repository.findById(query.getOrderId())
.orElseThrow(() -> new OrderNotFoundException(query.getOrderId()));
}
@QueryHandler
public List<OrderSummary> handle(FindOrdersByUserQuery query) {
return repository.findByUserId(query.getUserId());
}
}🚀 Elasticsearch 읽기 모델
설정:
@Configuration
@EnableElasticsearchRepositories(basePackages = "com.example.read.repository")
public class ElasticsearchConfig {
@Bean
public RestHighLevelClient client() {
return new RestHighLevelClient(
RestClient.builder(
new HttpHost("localhost", 9200, "http")
)
);
}
}Read Model (Elasticsearch Document):
@Document(indexName = "order_summaries")
public class OrderSummaryDocument {
@Id
private String id;
@Field(type = FieldType.Keyword)
private String orderId;
@Field(type = FieldType.Keyword)
private String userId;
@Field(type = FieldType.Text)
private String userName; // 비정규화: User 정보 포함
@Field(type = FieldType.Nested)
private List<OrderItemSummary> items; // 비정규화: 아이템 정보 포함
@Field(type = FieldType.Double)
private BigDecimal totalAmount;
@Field(type = FieldType.Keyword)
private String status;
@Field(type = FieldType.Date)
private Instant createdAt;
}검색 쿼리:
@Service
public class OrderSearchService {
@Autowired
private ElasticsearchOperations elasticsearchOperations;
public List<OrderSummaryDocument> searchOrders(String keyword) {
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.multiMatchQuery(keyword, "userName", "items.productName"))
.withPageable(PageRequest.of(0, 10))
.build();
return elasticsearchOperations.search(searchQuery, OrderSummaryDocument.class)
.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
}
public List<OrderSummaryDocument> findByUserAndStatus(String userId, String status) {
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("userId", userId))
.must(QueryBuilders.termQuery("status", status)))
.build();
return elasticsearchOperations.search(searchQuery, OrderSummaryDocument.class)
.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
}
}6. 동기화 전략
1. 동기 업데이트 (Synchronous)
@Transactional
public UUID createOrder(CreateOrderCommand command) {
// 1. Write DB에 저장
Order order = orderRepository.save(new Order(command));
// 2. Read DB에 즉시 동기화
OrderSummary summary = toSummary(order);
orderSummaryRepository.save(summary);
return order.getId();
}장점: 즉시 일관성 단점: 성능 저하, 결합도 증가
2. 비동기 이벤트 (Asynchronous Event)
// Command Side
@Transactional
public UUID createOrder(CreateOrderCommand command) {
Order order = orderRepository.save(new Order(command));
// 이벤트 발행 (비동기)
eventPublisher.publish(new OrderCreatedEvent(order));
return order.getId();
}
// Event Handler (별도 스레드/서비스)
@Async
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
OrderSummary summary = toSummary(event);
orderSummaryRepository.save(summary);
}장점: 성능, 확장성 단점: 최종 일관성 (약간의 지연)
3. CDC (Change Data Capture)
Debezium 사용:
# Debezium Connector 설정
name: order-connector
connector.class: io.debezium.connector.postgresql.PostgresConnector
database.hostname: localhost
database.port: 5432
database.user: postgres
database.password: secret
database.dbname: orders
table.include.list: public.orders,public.order_items@Component
public class DebeziumEventListener {
@KafkaListener(topics = "dbserver1.public.orders")
public void handleOrderChange(ConsumerRecord<String, String> record) {
ChangeEvent event = parseChangeEvent(record.value());
if (event.getOperation() == Operation.CREATE) {
OrderSummary summary = buildSummary(event);
orderSummaryRepository.save(summary);
}
}
}장점:
- Write 모델 변경 불필요
- 신뢰성 높음 (DB 트랜잭션 로그 기반)
단점:
- 설정 복잡
- DB별 지원 상이
7. 장단점
✅ 장점
-
성능 최적화
- 읽기와 쓰기를 독립적으로 최적화
- 복잡한 JOIN 제거
-
확장성
- 읽기와 쓰기를 독립적으로 스케일링
- 비용 효율적
-
유연성
- 읽기 모델을 다양하게 구성 (검색, 분석, 보고서)
- 기술 스택 자유 (PostgreSQL + Elasticsearch)
-
보안
- 읽기와 쓰기 권한 분리
- 민감 정보 제어
❌ 단점
-
복잡도 증가
- 두 개의 모델 유지
- 동기화 로직 필요
-
최종 일관성
- 읽기 모델이 즉시 업데이트되지 않음
- 사용자 혼란 가능
-
중복 코드
- Command와 Query 코드 분리
- 유지보수 비용
-
학습 곡선
- 팀 교육 필요
- 디버깅 어려움
8. 사용 시기
✅ 적합한 경우
-
읽기/쓰기 비율 차이
- 읽기 95%, 쓰기 5%
- 읽기 쿼리가 복잡
-
성능 요구사항
- 높은 처리량 필요
- 낮은 지연시간 요구
-
복잡한 도메인
- DDD 적용
- 비즈니스 로직 복잡
-
다양한 뷰
- 검색, 분석, 보고서 등 다양한 읽기 패턴
❌ 부적합한 경우
-
단순한 CRUD
- 복잡도 대비 이점 없음
-
즉시 일관성 필수
- 금융 거래 등
-
소규모 시스템
- 오버 엔지니어링
9. 실전 사례
🏢 Microsoft Azure
사용 사례: Dynamics 365
Write: SQL Server (트랜잭션)
Read: Azure Search (검색), Cosmos DB (글로벌 읽기)
효과:
- 글로벌 읽기 지연 90% 감소
- 검색 성능 10배 향상
🏢 Netflix
사용 사례: 사용자 활동 추적
Write: Cassandra (쓰기 최적화)
Read: Elasticsearch (검색), Redshift (분석)
효과:
- 초당 100만 이벤트 처리
- 실시간 추천 가능
📚 참고 자료
🔗 관련 패턴
📖 추가 학습 자료
상위 문서: 데이터 관리 패턴 폴더 마지막 업데이트: 2026-01-05 다음 학습: Event Sourcing 패턴