본 포스팅은 CQRS 패턴을 학습하면서 정리한 내용이다.
개념 자체는 단순해 보이지만, 왜 이런 설계가 등장했는지, 어떤 문제를 풀기 위한 것인지를 함께 이해하는 것이 핵심이다.
CQRS의 단순 개념만으로는 서비스 레이어를 조회용, 명령용으로 나누는 것 정도로 이해할 수 있다.
근데 실제로 파고들수록 단순한 코드 분리가 아니라, 읽기와 쓰기의 데이터 모델 자체를 분리하는 아키텍처적 결정이라는 걸 알게 되었다. 그 차이를 이해하는 게 이 패턴을 이해하는 출발점이다.

C = Command (명령 - 데이터를 변경하는 작업)
Q = Query (조회 - 데이터를 읽는 작업)
R = Responsibility (책임)
S = Segregation (분리)
풀어 쓰면, "명령과 조회의 책임을 서로 다른 모델로 분리한다" 는 아키텍처 패턴이다.
CQRS의 핵심 원칙 중 하나는, 하나의 메서드는 데이터를 읽거나 변경하거나 둘 중 하나만 해야 한다는 것이다. 조회(Query)는 시스템의 상태를 절대 변경해서는 안 된다.
DDD(도메인 주도 설계), 이벤트 기반 시스템, 고성능 조회 API 설계 등에서 자주 등장하는 패턴이다.
단일 DB에서 5개 테이블을 JOIN해서 데이터를 가져와야 하는 상황을 생각해보자.
SELECT
o.id AS order_id,
u.name AS customer_name,
oi.product_name,
p.payment_status
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN order_items oi ON o.id = oi.order_id
LEFT JOIN payments p ON o.id = p.order_id
WHERE o.id = :orderId;
조회 요구사항이 단순할 때는 문제가 없다. 하지만 조회 조건이 복잡해지고, 정렬·필터·집계가 추가될수록 이야기가 달라진다.
즉, 모놀리식 환경에서도 조회 요구사항이 복잡해지면 읽기 모델을 별도로 최적화할 필요성이 생긴다.
MSA(마이크로서비스 아키텍처)에서는 Company, User, Hub가 각각 다른 서비스에 분산되어 있다. DB JOIN은 불가능하고, 서비스 간 API 호출로 데이터를 조합해야 한다.
// 단건 호출 방식 - 네트워크 호출이 2N번 발생
public List<CompanyResponse> getCompanies() {
List<Company> companies = companyRepository.findAll();
return companies.stream().map(company -> {
// 매 company마다 외부 서비스를 각각 호출 → N개면 2N번의 네트워크 비용 발생
User user = userClient.getUser(company.getUserId());
Hub hub = hubClient.getHub(company.getHubId());
return CompanyResponse.from(company, user, hub);
}).toList();
}
벌크 호출(Bulk Call)로 개선해도 결국 네트워크 비용과 응답 지연은 피할 수 없다.
CQRS를 적용하면, 조회 전용 모델(Read Model)에 필요한 데이터를 미리 조합해서 저장해둔다. 조회 시점에는 단순 조회만 하면 된다.
// CQRS 적용 후 - 조회 전용 모델에서 바로 가져옴
public List<CompanyResponse> getCompanies() {
return companyQueryService.findAll(); // 네트워크 호출 없음
}
| 상황 | CQRS 적합도 |
|---|---|
| 조회 트래픽이 쓰기보다 압도적으로 높은 경우 | ✅ 적합 |
| 조회 요구사항이 복잡하고 다양한 경우 | ✅ 적합 |
| 여러 서비스의 데이터를 조합해야 하는 MSA 환경 | ✅ 적합 |
| 단순한 CRUD 중심의 소규모 서비스 | ❌ 과도한 복잡도 |
| 실시간 강한 일관성이 필수인 경우 | ⚠️ 신중히 검토 |



두 사례 모두 CQRS만 단독으로 적용한 게 아니라, 이벤트 기반 동기화(Kafka)와 함께 조합하여 사용했다는 점이 중요하다.

com.example.order
├── application
│ ├── command
│ │ └── OrderCommandService.java // 쓰기 전담 서비스
│ ├── query
│ │ ├── OrderQueryService.java // 읽기 전담 서비스
│ │ └── dto
│ │ └── SearchOrder.java // 조회 전용 DTO (Application 계층에 위치)
│ └── OrderService.java // 컨트롤러가 의존하는 인터페이스
├── controller
│ └── OrderController.java
├── domain
│ ├── model
│ │ └── Order.java // 도메인 규칙을 가진 엔티티
│ └── repository
│ └── OrderRepository.java // 인터페이스
└── infrastructure
├── jpa
│ └── OrderJpaRepository.java // 쓰기용 JPA 레포지토리
└── elasticsearch
└── OrderElasticSearchRepositoryImpl.java // 읽기용 ES 레포지토리
1. OrderService를 인터페이스로 분리
컨트롤러가 OrderCommandService와 OrderQueryService 둘 다 알아야 할 필요는 없다. 파사드(Facade) 역할의 인터페이스를 두면 계층 간 결합도를 낮출 수 있다.
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final OrderCommandService orderCommandService; // 쓰기 전담
private final OrderQueryService orderQueryService; // 읽기 전담
@Override
public void createOrder(CreateOrderRequest request) {
orderCommandService.create(request); // 명령 위임
}
@Override
public SearchOrder getOrder(Long id) {
return orderQueryService.findById(id); // 조회 위임
}
}
2. 조회 DTO(SearchOrder)는 Application 계층에 위치
DDD 관점에서 도메인 모델은 도메인 규칙을 가진 객체다. 단순 조회 결과를 담는 DTO는 도메인 규칙이 없으므로, Domain 계층이 아닌 Application 계층에 두는 것이 원칙에 맞다.
3. 읽기/쓰기 Repository 분리
조회 성능을 위해 Elasticsearch나 MongoDB 같은 별도 저장소를 도입할 경우, Repository를 미리 분리해두면 유연하게 대응할 수 있다.
CQRS의 핵심 과제 중 하나는 쓰기 모델과 읽기 모델을 어떻게 동기화할 것인가다.
| 방식 | 설명 | 장점 | 단점 |
|---|---|---|---|
| 엔티티 변경 감지 | JPA @EntityListener, Hibernate EventListener, Spring AOP 등을 통해 변경 감지 | 구현 간단, 트랜잭션 보장 | 외부 시스템 연동 불가 |
| 이벤트 기반 (Event-Driven) | Kafka, RabbitMQ 등으로 이벤트 발행 후 구독자가 읽기 모델 업데이트 | 느슨한 결합, 확장성 높음 | 메시지 유실/중복 처리(Retry, DLQ) 필요 |
| CDC (Change Data Capture) | DB 변경 로그(binlog 등)를 읽어 외부 시스템에 전달. Debezium + Kafka가 대표적 | 비즈니스 코드 수정 없이 연동 가능 | 설정 복잡, 운영 부담 높음 |
이벤트 기반 방식에서 한 가지 중요한 포인트가 있다.
// 트랜잭션 커밋 이후에만 이벤트를 발행해야 한다.
// 트랜잭션 내부에서 발행하면, 롤백 시 이벤트가 이미 나간 상태가 되어 데이터 불일치 발생
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(OrderCreatedEvent event) {
kafkaTemplate.send("order-created", event);
}
@TransactionalEventListener의 AFTER_COMMIT 옵션을 사용하면, 트랜잭션이 성공적으로 커밋된 이후에만 이벤트가 발행된다. 쓰기 실패 시 이벤트가 나가는 상황을 방지할 수 있다.
CQRS에서 읽기 모델은 쓰기 모델과 즉시 동기화되지 않는다. 이벤트 기반으로 동기화하는 경우, 짧은 시간 동안 쓰기 모델에는 반영되었지만 읽기 모델에는 아직 반영되지 않은 상태가 존재한다.
읽기 DB와 쓰기 DB가 분리되면, 읽기 측 데이터가 최신 변경 사항을 즉시 반영하지 못할 수 있다. 이 지연은 스테일(stale) 데이터로 이어진다.
따라서 CQRS를 도입하기 전에 "우리 서비스가 이 정도 지연을 허용할 수 있는가"를 먼저 따져야 한다.
조회 성능을 높이기 위해 Company 조회 모델에 User 이름, Hub 이름을 함께 저장하는 방식은 흔하다. 덕분에 JOIN이나 외부 API 호출 없이 빠르게 데이터를 제공할 수 있지만, 동일 데이터가 여러 곳에 존재하게 된다.
CQRS는 정규화된 구조를 일부 포기하고 성능을 얻는 방식이라고 볼 수 있다.
CQRS의 핵심 개념 자체는 단순하지만, 특히 이벤트 소싱(Event Sourcing)과 결합될 때 애플리케이션 설계에 상당한 복잡도를 추가할 수 있다.
단순 CRUD 중심의 서비스에 CQRS를 적용하면 오히려 구조 복잡도와 운영 부담만 늘어날 수 있다.
자주 같이 언급되는 패턴이니 간단히 짚고 넘어간다.
Event Sourcing은 저장된 이벤트를 기반으로 데이터 관리 작업을 수행하고, 이벤트로부터 뷰(View)를 구체화함으로써 CQRS 패턴과 함께 사용되는 경우가 많다.
둘은 독립적인 패턴이지만 궁합이 좋다. 다만 두 패턴을 동시에 적용하면 복잡도가 상당히 높아지므로, 필요성을 충분히 검토하고 도입하는 것을 권장한다.