CQRS는 명령(Command)와 조회(Query) 책임을 명확히 분리하는 아키텍처 패턴이다.
데이터를 변경하는 작업과 조회하는 작업을 서로 다른 모델, 더 나아가 다른 저장소로 분리하는 방식이다.
분리하는 모델의 타입은 두 가지로 우선 간략하게 요약하면 아래와 같다.
- Command Model (Write Model)
- 상태 변경 목적
- 트랜잭션 중심
- 복잡한 비즈니스 규칙과 도메인 로직 포함
- Event Sourcing과 결합되기도 함
- Query Model (Read Model)
- 조회 목적
- 고성능, 고속 응답
- 단순화된 읽기 전용 View / DTO
- DB를 별도로 두거나 Read Replica로 구성해 조회 부하 분산
Command와 Query를 왜 나눌까?
쓰기와 읽기의 서로 다른 성격 (서로 다른 관심사)
- Write는 도메인 규칙 준수가 핵심
- Read는 빠른 응답 속도가 핵심
대규모 트래픽 구조에서 Read의 양이 압도적
웹/모바일 환경에서 트래픽 비율은 보통 Read가 90%, Write가 10%인데, Read 때문에 전체 성능이 영향을 받는 상황이 올 수 있다. 이때 CQRS로 Read를 독립시킨다면 고성능 조회 시스템으로 구축이 가능하다.
서비스 확장성(Scalability) 문제 해결
CQRS를 이용할 경우, Read 서버는 수평 확장을 하고, Write 서버는 도메인 로직 중심으로 안정성 확보가 가능하므로 각각 독립된 스케일링 전략을 사용할 수 있다.
CQRS는 언제 쓰면 좋을까?
- 화면이 많고 조회 API가 많을 경우
- 도메인 규칙이 복잡하여 Write가 무거운 서비스
- Read 트래픽이 매우 많은 대규모 시스템
- MSA 구조에서 서비스단위를 분리하고 싶을 때
다른 모델과 저장소는 구체적으로 무엇을 말하는 걸까?
다른 모델?
전통적(레거시)인 CRUD
// 엔티티
class Order {
Long id;
Long userId;
OrderStatus status;
List<OrderItem> items;
// 기타 필드
}
// 레포지토리
interface OrderRepository {
Order findById(Long id);
List<Order> findByUserId(Long userId);
void save(Order order);
}
Order 라는 엔티티 하나로 모든 것을 한다.
- 주문 생성, 수정, 취소
- 주문 상세 조회
- 주문 목록 조회
하나의 엔티티와 레포지토리가 모든 책임을 떠안게 되면, 시간이 지날수록 몇 가지 문제가 드러난다.
- 복잡한 조회(통계, 필터 등)가 들어가면서 엔티티 구조가 조회 요구사항 위주로 구성
- 도메인 규칙까지 쌓이면, 조회 요구사항에는 맞지 않는 과도하게 무거운 엔티티가 되어버림
무거운 엔티티는 별도의 글로 작성할 예정이다.
CQRS에서 말하는 ‘모델’
- Command Model (Write Model)
- 도메인 규칙, 불변 조건, 상태 변경 책임
- 최대한 도메인 중심, 정규화된 구조
- 예: Order, OrderItem, OrderService 등
- Query Model (Read Model)
- 화면/리포트용 데이터 제공 책임
- 조회를 위해 최적화된 구조(덜 정규화, join 최소화, 바로 DTO 형태 등)
- 예: OrderSummaryView, OrderDetailView, UserOrderListView 등
예시로 소스 코드를 보면 아래와 같다.
// Command 모델
class Order {
Long id;
Long userId;
OrderStatus status;
List<OrderItem> items;
Money totalAmount;
void addItem(Product product, int quantity) { ... }
void cancel() {
if (!canCancel()) throw new IllegalStateException();
this.status = OrderStatus.CANCELED;
}
boolean canCancel() { ... }
}
// Query 모델
class OrderSummaryView {
Long orderId;
String userName;
LocalDateTime orderDate;
String status;
BigDecimal totalAmount;
}
즉, 다른 모델이라는 것은 클래스/객체 레벨에서 다른 것으로, 단순하게 말하면 Command 용으로 쓰는 모델과, Query 용으로 쓰는 모델을 클래스(객체) 단위로 분리한다는 것이다.
이렇게 하는 이유는 “설계”의 기준이 다르기 때문에 분리하는 것이다.
다른 저장소?
소스 코드의 기준의 분리보다 더 나아간 스토리지(데이터베이스) 자체의 분리를 말하는 것이다. 크게 나누는 방법은 3가지를 꼽을 수 있다.
- 동일 데이터베이스에 테이블과 뷰 분리
- 동일 데이터베이스 다른 스키마
- 다른 데이터베이스
여기서 마지막인 Command용 데이터베이스와 Query용 데이터베이스를 각자 운영하는 방식을 말하는 것으로, 예를 들면 아래와 같다.
- Write:
- MariaDB (정규화, 트랜잭션, 강한 일관성)
- Read:
- Elasticsearch: 복잡한 검색, 필터링, 정렬, 통계
- Redis: 초고속 캐시 조회
- MongoDB 등 문서형 스토리지
이렇게 할 경우 예를 들어서 주문의 생성과 변경은 MariaDB에 저장하고, 주문 또는 상품 검색 화면은 Elasticsearch에서 바로 검색하는 것이다.
Command DB와 Query DB의 동기화는?
Event 기반으로 한다거나, 배치 방식으로 동기화를 하기도 한다.
데이터베이스 서버 단위까지 분리할 경우, 조회 성능을 더 최적화 할 수 있고 도메인 모델을 깔끔하게 유지할 수 있으며 확장성이 증가하게 된다. 여기서 확장성은 Query DB만 수평 확장을 하여 확장성을 증가한다고 볼 수 있다.
다만, 장점만 있는 것은 절대 아니다. 데이터베이스 종류가 늘어나게 되고, 모니터링이나 백업, 장애 대응도 그만큼 비용이 증가되면서 운영의 복잡도가 올라가게 된다. 또한, 데이터베이스에서 Commit 될 때의 이벤트(동기화)가 파이프라인의 핵심 인프라가 된다.
즉, 해당 파이프라인이 깨지게 되면 Read Mode이 Stale 하게 되고, 무엇보다 최신 데이터를 보장하지 않는다는 비즈니스적 합의가 우선되어야 하는데 현실적으로 많은 서비스에서 받아들이기 쉽지 않다.
DB의 동기화로 인한 최종 일관성(Eventual Consistency) 이슈
위 섹션에서 언급한대로 조회 모델이 즉시 업데이트 되지 않는 것이 가장 먼저 부딪히는 현실적인 문제다. 예를 들어 사용자가 주문을 했는데, “내 주문 목록” 화면에 바로 나타나지 않을 수 있다. 또, 상품을 취소 했는데, “요약 정보”에는 아직 취소 상태가 반영되지 않을 수 있다.
이러한 현상을 Stale 데이터라고 하며, 완화하는 방법은 UI/UX로 보완할 수 있는 아래와 같이 몇 가지 방법이 있다.
- UI에서 “데이터가 최신이 아닐 수 있습니다” 안내 표시
- F5 갱신 안내 / 수동 Refresh 버튼 제공
다만, 중요한 도메인의 경우 동기식 처리를 유지해야 한다. 예를 들어 결제가 완료되면, 그 즉시 주문 상태 변경을 진행하는 것처럼 말이다.
CQRS가 좋기만 할까?
CQRS 도입 시 여러 부분에서 복잡성이 증가하게 된다.
- 운영 복잡도 증가
- 데이터베이스가 2개가 되면서 모니터링, 백업, 장애 대응이 이중화로 되어야 한다.
- 도메인 이벤트 파이프라인이 장애 포인트로 발생 가능
- 이벤트 브로커(Kafka, RabbitMQ 등)의 안정성이 전체 시스템 안정성에 직접 영향을 주게 된다.
- 개발자의 역량 요구 증가 (Event-driven 아키텍처 이해가 필수적)
현실적인 Light CQRS도 있다
조금 전 섹션에서 UI에서 보완할 수 있다고 했지만, 보통의 사용자라면 화면에 표시를 해주어도 인지를 하지 못하고 장애로 인식하는 경우도 꽤 많고, 위 UI/UX로 보완을 하는 비즈니스적 합의 자체를 이끌어내기가 쉽지 않다. (왜요? 라는 질문부터 받을 준비를 하자)
그래서 아래와 같이 Light CQRS 형태로 구현하는 경우가 많다.
- Write DB : MariaDB / PostgreSQL 같은 RDBMS
- Read DB : 동일 RDB의 Read Replica
데이터 동기화 방식도 복잡한 Event Pipeline이 아니라, DB Replication 기능만으로 Query 모델 동기화가 자동으로 이루어진다. 이러한 방식은 정석 CQRS에 비하면 비용이 적게 들고, 운영 난이도도 크게 높아지지 않으며, 대부분의 서비스에서 충분한 조회 성능을 제공한다.
또한, 이러한 방식을 일반적인 대기업과 금융권에서 가장 많이 사용하는 구조이다.
Spring Boot 기준 CQRS 구조 예시 (Command / Query Handler)
// Command Handler
@Service
public class CreateOrderHandler {
@Transactional
public void handle(CreateOrderCommand cmd) {
Order order = new Order(cmd.userId(), cmd.items());
orderRepository.save(order);
eventPublisher.publish(new OrderCreatedEvent(order.getId()));
}
}
// Query Handler
@Service
public class GetOrderListHandler {
public List<OrderSummaryView> handle(GetOrderListQuery query) {
return orderSummaryRepository.findByUserId(query.userId());
}
}
정리
CQRS의 1차적인 목적은 단순한 성능 튜닝이 아니라, 쓰기와 읽기의 책임을 분리해서 도메인 복잡도를 관리하고, 각각에 맞는 확장 전략을 쓸 수 있게 만드는 것이다. 성능 향상(특히 조회 성능과 확장성)은 이 구조적 분리의 결과로 따라오는 효과에 가깝다.