서비스를 운영하다 보면 데이터베이스에서 발생하는 작업은 크게 두 가지로 나뉜다.
- 데이터를 변경하는 쓰기 작업: 주문 생성, 결제 승인, 회원정보 수정
- 데이터를 보여주는 읽기 작업: 주문 목록 조회, 대시보드 표시, 상품 검색
일반적인 시스템은 하나의 데이터 모델로 두 작업을 모두 처리한다. 구조가 단순한 대신, 서비스가 커질수록 문제가 생길 수 있다. 쓰기에는 데이터 정합성과 트랜잭션이 중요하지만, 읽기에는 빠른 응답과 다양한 조회 형태가 더 중요하기 때문이다.
CQRS(Command Query Responsibility Segregation)는 이처럼 서로 다른 요구사항을 가진 쓰기와 읽기의 책임을 분리하는 아키텍처 패턴이다.
Command와 Query
CQRS의 핵심은 이름에 들어 있다.
- Command: 시스템의 상태를 변경한다.
- Query: 상태를 변경하지 않고 데이터를 조회한다.
예를 들어 주문 서비스라면 다음과 같이 나눌 수 있다.
Command
- 주문 생성
- 주문 취소
- 배송지 변경
Query
- 주문 상세 조회
- 최근 주문 목록 조회
- 월별 주문 통계 조회
쓰기 모델은 “이 주문을 취소할 수 있는가?” 같은 비즈니스 규칙과 데이터 정합성에 집중한다. 반면 읽기 모델은 화면에서 필요한 데이터를 얼마나 빠르고 편리하게 반환할 수 있는지에 집중한다.
사용자
├─ Command → 쓰기 모델 → 쓰기 저장소
└─ Query → 읽기 모델 → 읽기 저장소
여기서 중요한 점은 CQRS가 반드시 데이터베이스까지 두 개로 나눠야 한다는 뜻은 아니라는 것이다. 애플리케이션 코드의 모델만 분리할 수도 있고, 규모가 커졌을 때 읽기와 쓰기 저장소를 물리적으로 분리할 수도 있다.
왜 분리할까?
CQRS의 목적은 단순히 “조회 속도를 높이는 것”이 아니다. 읽기와 쓰기를 각각의 목적에 맞게 독립적으로 설계하고 확장하는 것이 핵심이다.
가령 읽기와 쓰기 비율이 9:1인 서비스가 있다고 하자. 하나의 데이터베이스가 모든 요청을 처리하면 대량의 조회 트래픽이 중요한 쓰기 트랜잭션에 영향을 줄 수 있다.
CQRS를 적용하면 다음과 같은 선택이 가능해진다.
- 읽기 서버만 여러 대로 확장한다.
- 조회 전용 데이터베이스나 캐시를 사용한다.
- 화면에 필요한 형태로 데이터를 미리 가공한다.
- 복잡한 쓰기 모델이 조회 성능에 미치는 영향을 줄인다.
- 읽기 장애와 쓰기 장애의 영향을 일정 부분 격리한다.
예를 들어 주문 목록 화면에 고객명, 상품명, 결제 상태가 필요하다고 해보자. 매번 여러 테이블을 조인하는 대신, 조회에 필요한 데이터를 하나의 읽기 모델로 미리 만들어 둘 수 있다.
{
"orderId": 1024,
"customerName": "김개발",
"productName": "기계식 키보드",
"paymentStatus": "PAID"
}
이처럼 조회에 최적화해 만들어진 데이터를 흔히 프로젝션(Projection)이라고 부른다.
최종 일관성이라는 대가
읽기와 쓰기 저장소를 물리적으로 분리하면 새로운 문제가 생긴다. 쓰기 저장소에서 변경된 내용을 읽기 저장소에 전달하는 데 시간이 필요하기 때문이다.
주문 생성
→ 쓰기 저장소 반영
→ 이벤트 발행
→ 프로젝션 갱신
→ 조회 결과에 노출
이 과정에 100ms가 걸린다면, 사용자가 주문을 생성한 직후 주문 목록을 조회했을 때 새 주문이 잠시 보이지 않을 수 있다. 즉, 두 저장소가 즉시 같은 상태가 아니라 일정 시간이 지난 후 같아지는 최종 일관성(Eventual Consistency)을 받아들여야 한다.
따라서 CQRS를 잘 설계한다는 것은 단순히 모델을 분리하는 데서 끝나지 않는다. 이 일관성 지연 시간을 비즈니스가 허용할 수 있는 범위로 관리해야 한다.
예를 들어 다음과 같은 전략을 사용할 수 있다.
- 쓰기 응답에 생성된 주문 정보를 포함해 즉시 화면에 표시한다.
- 프로젝션이 갱신될 때까지 화면에 ‘처리 중’ 상태를 보여준다.
- 중요한 조회는 일정 시간 동안 쓰기 저장소에서 직접 읽는다.
- 이벤트 처리 지연 시간을 측정하고 임계값을 넘으면 알림을 발생시킨다.
이벤트 처리는 반드시 재시도할 수 있어야 한다
프로젝션을 갱신하는 이벤트 소비자는 언제든 실패할 수 있다. 네트워크 오류가 발생하거나, 읽기 저장소가 잠시 중단되거나, 같은 이벤트가 중복 전달될 수도 있다.
그러므로 이벤트 처리에는 다음 장치가 필요하다.
- 이벤트에 고유 ID와 순서를 기록한다.
- 같은 이벤트를 여러 번 처리해도 결과가 달라지지 않도록 멱등성을 보장한다.
- 실패한 이벤트는 재시도한다.
- 계속 실패하면 DLQ(Dead Letter Queue) 등에 보관한다.
- 체크포인트나 이벤트 로그를 이용해 프로젝션을 다시 구축할 수 있게 한다.
- 이벤트 발행 누락을 막기 위해 필요하면 Transactional Outbox 패턴을 사용한다.
예를 들어 동일한 OrderCreated 이벤트를 두 번 받았을 때 주문 건수가 두 번 증가하면 안 된다. 이벤트 ID를 처리 이력에 저장하거나, 주문 ID를 기준으로 upsert하는 방식으로 중복 처리를 방지해야 한다.
프로젝션 로직에 버그가 발견된 경우에는 기존 읽기 데이터를 지우고 저장된 이벤트를 처음부터 다시 재생해 프로젝션을 복구할 수도 있다. 다만 이러한 이벤트 재처리를 활용하려면 이벤트가 유실되지 않고 보관되어야 하며, 재처리 과정 역시 멱등성을 가져야 한다.
Redis를 사용하면 CQRS일까?
쇼핑몰처럼 조회 요청이 많은 서비스에서는 데이터베이스의 부하를 줄이기 위해 Redis를 자주 사용한다. 그렇다면 쓰기는 데이터베이스에서 처리하고 읽기는 Redis에서 처리하는 구조도 CQRS라고 할 수 있을까?
결론부터 말하면 Redis를 사용했다는 사실만으로 CQRS가 되는 것은 아니다.
CQRS의 판단 기준은 Redis, Kafka 같은 특정 기술의 사용 여부가 아니다. Command와 Query가 서로 다른 책임과 모델을 갖도록 설계됐는지가 핵심이다.
DB 조회 결과를 Redis에 저장하면 캐싱이다
다음은 일반적인 Cache-Aside 패턴이다.
조회 요청
→ Redis 조회
→ 데이터가 있으면 바로 반환
→ 데이터가 없으면 DB 조회
→ 조회 결과를 Redis에 저장
→ 결과 반환
상품 정보가 변경되면 DB를 수정한 뒤 기존 캐시를 삭제하거나 새로운 값으로 갱신한다.
상품 수정
→ DB 반영
→ Redis 캐시 삭제 또는 갱신
이 구조에서 Redis는 DB 조회 결과를 일정 시간 보관하는 성능 최적화 계층이다. Redis에 데이터가 없어도 DB에서 같은 데이터를 조회해 다시 채울 수 있다.
읽기와 쓰기가 여전히 동일한 도메인 모델을 중심으로 동작하기 때문에, 이것은 일반적으로 CQRS가 아니라 캐싱 전략으로 분류한다.
Redis에 조회 전용 모델을 만들면 CQRS가 될 수 있다
반면 Redis에 쓰기 모델과 다른 형태의 조회 전용 모델을 만들어 관리한다면 CQRS라고 볼 수 있다.
예를 들어 쇼핑몰의 쓰기 데이터가 다음과 같이 여러 테이블에 나뉘어 있다고 가정해보자.
products
discounts
inventories
reviews
상품 상세 화면을 조회할 때마다 네 개의 테이블을 조인하고 할인 가격과 평균 평점을 계산하면 조회 비용이 커진다.
이때 화면에 필요한 정보를 미리 조합해 Redis에 저장할 수 있다.
{
"productId": 123,
"name": "기계식 키보드",
"price": 89000,
"discountPrice": 79000,
"stockAvailable": true,
"averageRating": 4.8
}
이 데이터는 단순히 SQL 조회 결과를 잠시 저장한 캐시가 아니다. 상품 상세 조회라는 목적에 맞게 별도로 설계한 Query 모델이자 프로젝션이다.
전체 구조는 다음과 같다.
Command
상품·할인·재고 변경
→ 쓰기 모델
→ MySQL
→ 이벤트 발행
→ Redis 프로젝션 갱신
Query
상품 상세 조회
→ Redis 조회 모델
→ 결과 반환
쓰기 요청은 MySQL의 정규화된 모델에서 비즈니스 규칙과 트랜잭션을 처리한다. 읽기 요청은 화면에 맞게 비정규화된 Redis 모델을 사용한다.
따라서 이 구조는 저장소가 두 개여서 CQRS인 것이 아니라, Command 모델과 Query 모델을 서로 다른 목적에 맞게 분리했기 때문에 CQRS인 것이다.
일반 캐시와 CQRS 읽기 모델의 차이
| 주요 목적 | 반복 조회 비용 감소 | 읽기와 쓰기의 책임 분리 |
| 데이터 구조 | 기존 DB 조회 결과와 유사 | 조회 목적에 맞게 별도 설계 |
| 데이터 생성 | Cache Miss가 발생하면 생성 | 이벤트 등을 받아 미리 갱신 |
| 조회 실패 시 | DB에서 직접 조회 가능 | 읽기 모델의 복구가 필요할 수 있음 |
| 정합성 관리 | TTL과 캐시 무효화 | 프로젝션 지연과 이벤트 재처리 |
| Redis의 역할 | 임시 성능 계층 | 독립적인 Query 저장소 |
다만 실제 시스템에서는 두 방식의 경계가 항상 명확하지는 않다. CQRS의 읽기 모델도 쓰기 DB를 기준으로 다시 구축할 수 있으며, Redis 캐시 역시 조회에 편리한 형태로 데이터를 가공할 수 있기 때문이다.
결국 중요한 것은 Redis의 존재가 아니라 다음 질문이다.
Redis가 기존 DB 조회를 빠르게 만들기 위한 임시 복사본인가, 아니면 쓰기 모델과 독립적으로 설계한 조회 전용 모델인가?
전자라면 일반적인 캐싱이고, 후자라면 CQRS에 가깝다.
CQRS와 캐시를 함께 사용할 수도 있다
CQRS와 캐시는 서로 대체하는 개념이 아니다. CQRS로 구성한 읽기 모델 앞에 별도의 캐시를 추가할 수도 있다.
Command
→ 쓰기 모델
→ MySQL
→ 이벤트
→ Elasticsearch 읽기 모델
Query
→ Redis 캐시
→ Cache Miss 시 Elasticsearch 조회
이 구조에서 각 저장소는 서로 다른 역할을 담당한다.
- MySQL은 비즈니스 규칙과 트랜잭션을 처리한다.
- Elasticsearch는 검색에 최적화된 Query 모델을 제공한다.
- Redis는 반복되는 조회 결과를 캐싱한다.
즉, CQRS는 읽기와 쓰기를 분리하는 아키텍처 패턴이고, Redis는 캐시나 읽기 저장소로 활용할 수 있는 기술적 선택지다.
한 문장으로 정리하면 다음과 같다.
DB 조회 결과를 Redis에 잠시 저장하면 캐싱이고, 쓰기 모델과 별개인 조회 전용 모델을 Redis에 구축했다면 CQRS라고 볼 수 있다.
CQRS와 이벤트 소싱은 같은 개념일까?
두 개념은 자주 함께 등장하지만 서로 다르다.
- CQRS는 읽기와 쓰기의 책임 및 모델을 분리하는 패턴이다.
- 이벤트 소싱은 현재 상태 대신 상태를 변경한 이벤트를 저장하는 방식이다.
CQRS는 일반적인 관계형 데이터베이스만으로도 구현할 수 있다. 반대로 이벤트 소싱을 적용하면서 CQRS를 함께 사용할 수도 있다.
CQRS만 사용
Command → 일반 DB 저장 → 이벤트 발행 → 읽기 모델 갱신
CQRS + 이벤트 소싱
Command → 이벤트 저장 → 이벤트 재생 → 현재 상태 및 읽기 모델 구성
따라서 “CQRS를 사용하면 반드시 이벤트 소싱과 메시지 브로커를 도입해야 한다”는 설명은 정확하지 않다.
언제 적용해야 할까?
CQRS는 다음과 같은 상황에서 효과적이다.
- 읽기와 쓰기의 트래픽 비율이 크게 다르다.
- 읽기와 쓰기에 필요한 데이터 모델이 매우 다르다.
- 복잡한 조회가 쓰기 트랜잭션의 성능을 방해한다.
- 도메인의 비즈니스 규칙이 복잡하다.
- 읽기와 쓰기를 독립적으로 확장해야 한다.
- 짧은 최종 일관성 지연을 비즈니스가 허용할 수 있다.
반면 단순한 CRUD 서비스에 처음부터 CQRS를 적용하면 얻는 이점보다 운영 복잡성이 더 커질 수 있다. 이벤트 중복과 순서 역전, 프로젝션 지연, 장애 복구, 모니터링 등 새롭게 관리해야 할 문제가 많아지기 때문이다.
CQRS는 “트래픽이 많으면 무조건 적용하는 구조”가 아니라, 읽기와 쓰기의 요구사항 차이가 분리 비용보다 커졌을 때 선택하는 패턴에 가깝다.
면접에서 한 호흡으로 설명한다면
CQRS는 상태를 변경하는 Command 모델과 데이터를 조회하는 Query 모델을 분리해 각각의 비즈니스 규칙과 성능 요구에 맞게 설계하는 패턴입니다. 읽기 비중이 높은 서비스에서는 조회 모델을 독립적으로 확장하고 부하를 격리할 수 있지만, 이벤트 기반으로 프로젝션을 갱신하면 약 100ms처럼 읽기 모델이 늦게 반영되는 최종 일관성 구간이 생깁니다. 그래서 쓰기 응답으로 변경 내용을 먼저 보여주거나 처리 중 상태를 제공하고, 이벤트에는 고유 ID를 부여해 멱등 처리와 재시도를 보장하며, 실패 이벤트는 DLQ에 저장하고 체크포인트부터 재처리해 프로젝션을 복구할 수 있도록 설계해야 합니다.
마무리
CQRS의 본질은 데이터베이스를 두 개 사용하는 것이 아니다. 서로 다른 목적을 가진 읽기와 쓰기를 억지로 하나의 모델에 맞추지 않는 데 있다.
다만 분리에는 항상 비용이 따른다. 조회 성능과 독립적인 확장성을 얻는 대신, 최종 일관성과 이벤트 처리 실패를 직접 관리해야 한다.
결국 좋은 CQRS 설계는 “읽기와 쓰기를 분리했다”에서 끝나지 않는다. 사용자가 데이터 반영 지연을 어떻게 경험하는지, 이벤트가 실패하거나 중복됐을 때 어떻게 복구하는지까지 설명할 수 있어야 완성된다.
'CS 정리' 카테고리의 다른 글
| Cache-Aside란? (0) | 2026.08.28 |
|---|---|
| 분산 트랜잭션이란? MSA에서 발생하는 문제와 Saga 패턴 (0) | 2026.08.24 |
| 서킷 브레이커(Circuit Breaker)란? 외부 API가 죽었을 때 우리 서버까지 같이 죽지 않게 하기 (0) | 2026.08.23 |
| Transactional Outbox에서 여러 워커가 이벤트를 안전하게 선점하는 방법 (0) | 2026.08.22 |
| Transactional Outbox Pattern - DB 트랜잭션과 이벤트 발행을 안전하게 연결하기 (0) | 2026.08.21 |