
CQRS 패턴: 읽기와 쓰기를 분리해야 하는 진짜 이유 (Command Query Responsibility Segregation)
대규모 트래픽을 처리하는 시스템에서 데이터베이스의 병목 현상은 피할 수 없는 숙명입니다. 읽기(Query)와 쓰기(Command)의 책임을 물리적으로, 논리적으로 분리하여 성능과 확장성을 극대화하는 CQRS 패턴의 핵심 개념과 적용 전략을 정리합니다.

대규모 트래픽을 처리하는 시스템에서 데이터베이스의 병목 현상은 피할 수 없는 숙명입니다. 읽기(Query)와 쓰기(Command)의 책임을 물리적으로, 논리적으로 분리하여 성능과 확장성을 극대화하는 CQRS 패턴의 핵심 개념과 적용 전략을 정리합니다.
로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

우리가 처음 웹 개발을 배울 때, 그리고 대부분의 프레임워크 튜토리얼에서 가장 먼저 접하는 패턴은 CRUD (Create, Read, Update, Delete)입니다.
보통 User라는 하나의 클래스(또는 테이블)가 있고, 이 User 엔티티가 회원가입(C)도 하고, 프로필 조회(R)도 하고, 비밀번호 변경(U)도 합니다.
// 전형적인 만능 Entity (The God Class)
class User {
Long id;
String username;
String password; // 쓰기(로그인/변경) 때만 필요함, 읽을 땐 노출되면 안 됨
String email; // 개인정보라 마스킹 필요할 수도?
int loginCount; // 통계용, 비즈니스 로직과는 무관
Date lastLogin;
List<Order> orders; // 연관관계... 조회할 때 필요할까?
// ... 수많은 필드들
}
서비스 초기, 사용자 수 100명, 동시 접속자 10명 시절에는 이 방식이 너무나 효율적이고 직관적입니다. "데이터베이스에 있는 테이블 그대로 객체로 만들어서 쓰면 되잖아?" 아주 자연스러운 사고방식이죠. 하지만 서비스가 성장하고 트래픽이 폭발하면, 이 '단일 모델' 전략은 곧바로 한계에 부딪칩니다.
읽기 성능과 쓰기 성능의 불균형 (Asymmetry): 대부분의 웹 서비스는 읽기(Read) 요청이 쓰기(Write) 요청보다 압도적으로 많습니다. 비율로 따지면 1000:1 또는 그 이상일 수도 있습니다. 여러분이 인스타그램이나 트위터를 볼 때, 글을 쓰는 횟수보다 피드를 내리는(조회하는) 횟수가 얼마나 많은지 생각해보세요. 그런데 똑같은 DB, 똑같은 모델을 쓰느라 읽기 최적화를 하려니 쓰기 성능이 떨어지고, 쓰기 정합성을 맞추려니 읽기 속도가 느려집니다. 딜레마죠.
데이터 표현의 불일치 (Impedance Mismatch):
User 객체에 검증 로직(쓰기용)과 화면 출력용 포맷팅 로직(읽기용)이 뒤섞여서 거대한 "God Object"가 되어버립니다. 객체의 책임이 모호해지고, 유지보수가 악몽으로 변합니다.보안 및 권한 관리의 복잡성:
쓰기 작업은 관리자나 본인만 가능해야 하지만, 읽기 작업은 공개되어야 할 수도 있습니다. 하나의 엔티티에 @JsonIgnore 같은 어노테이션을 덕지덕지 붙이다 보면, 실수로 민감한 정보가 API 응답에 노출되는 사고가 터지기 쉽습니다.
이 문제를 해결하기 위해 등장한 개념이 바로 CQRS (Command Query Responsibility Segregation), 즉 명령과 조회의 책임 분리입니다.
CQRS의 아이디어는 아주 단순합니다. 버트런드 마이어(Bertrand Meyer)가 제안한 CQS (Command Query Separation) 원칙에서 유래했습니다. "데이터를 변경하는 놈(Command)과 데이터를 가져오는 놈(Query)을 완전히 나누자!"
CreateOrder, ChangePassword, ShipItemGetOrderById, SearchProducts, GetUserProfileCQRS는 0 아니면 1이 아닙니다. 프로젝트의 규모와 복잡도에 따라 단계적으로 적용할 수 있습니다.
DB는 하나를 쓰되, 코드 레벨에서 모델을 분리하는 것입니다. 가장 현실적이고 도입하기 쉬운 단계이며, 사실 대부분의 프로젝트는 이 정도만 해도 충분합니다.
// 쓰기 모델 (Domain Model) - JPA Entity 사용
@Service
@Transactional
class UserCommandService {
void changePassword(UserId id, String newPassword) {
User user = userRepository.findById(id); // 도메인 모델 로드
user.changePassword(newPassword); // 도메인 로직 수행 (상태 변경)
// 영속성 컨텍스트가 변경 감지(Dirty Checking) 후 업데이트
}
}
// 읽기 모델 (Read Model / DTO) - MyBatis, JOOQ, or Native SQL 사용
@Service
@Transactional(readOnly = true)
class UserQueryService {
UserDto getUserProfile(UserId id) {
// 복잡한 ORM 없이 가벼운 SQL로 필요한 컬럼만 쏙 뽑아서 DTO로 반환
// 엔티티 로드 안 함 (성능 최적화, N+1 문제 회피)
return queryDao.selectNameAndEmail(id);
}
}
이것만 해도 코드가 훨씬 깔끔해집니다.
더 나아가면 아예 데이터 저장소를 나눌 수도 있습니다. "Polyglot Persistence(다양한 저장소 혼용)" 전략을 쓸 수 있게 됩니다.
"어? 그럼 두 DB 사이의 데이터 싱크(Sync)는 어떻게 맞춰요?" 여기서 이벤트(Event) 개념이 등장합니다.
이 과정에서 최종적 일관성(Eventual Consistency)을 허용하게 됩니다. 사용자가 글을 쓰고 나서 목록에 반영되기까지 0.1초 ~ 수 초의 딜레이가 생길 수 있다는 것을 비즈니스적으로 받아들이는 것입니다.
CQRS를 이야기할 때 빠지지 않는 것이 이벤트 소싱입니다. 둘은 패턴의 유래가 다르지만, 궁합이 너무 좋아서 마치 세트 메뉴처럼 다닙니다.
기존 방식은 "현재 상태(Current State)"만 DB에 저장합니다.
User { balance: 1000 }
여기서 500원을 쓰면 UPDATE user SET balance = 500으로 덮어씁니다. 과거 데이터는 사라지죠. "잔액이 왜 500원이지? 언제 썼지?"라는 질문에, 별도의 로그가 없다면 답할 수 없습니다.
이벤트 소싱은 "상태를 변경하는 모든 사건(Event)"을 순서대로 저장합니다.
AccountCreated { balance: 0 }Deposited { amount: 1000 }Withdrawn { amount: 500 }현재 잔액을 알고 싶으면? 이벤트를 처음부터 쭉 재생(Replay)하면 됩니다. 0 + 1000 - 500 = 500. 은행 통장 거래내역, Git(커밋 로그), 블록체인 원장이 바로 이벤트 소싱의 완벽한 예시입니다.
이벤트 소싱을 쓰면 "쓰기 모델"은 단순히 이벤트를 저장(Append)하는 역할만 합니다. 매우 빠르죠. (Insert Only). 하지만 "현재 잔액 조회"를 할 때마다 이벤트를 다 계산할 수는 없습니다. 너무 느리겠죠? 그래서 읽기 모델(Projection)이 필요합니다. 이벤트를 구독해서 미리 계산된 "현재 상태 테이블"을 만들어두는 것입니다.
이것은 엄청난 유연성을 줍니다. 만약 기획자가 "지난 달 거래 내역을 보여주는 새로운 화면이 필요해"라고 하면? 기존 RDBMS라면 마이그레이션을 하고 난리가 나겠지만, 이벤트 소싱에서는 과거 이벤트를 다시 돌려서(Replay) 새로운 읽기 전용 테이블을 뚝딱 만들어내면 됩니다. 타임머신을 가진 셈이죠.
CQRS는 "은탄환(Silver Bullet)"이 아닙니다. 오히려 시스템의 복잡도를 급격하게 높이는 "복잡성 폭탄"이 될 수도 있습니다.
SELECT * FROM 하세요. 제발. CQRS 도입하면 코드 양이 3배로 늘어납니다. (Command, Event, Handler, Query, Projection...)CQRS와 이벤트 소싱은 개발자로서 한 번쯤 정복해보고 싶은 멋진 기술입니다. "우리 시스템은 MSA에 CQRS에 Kafka에 Event Sourcing까지 써"라고 말하면 왠지 고수가 된 기분이 들죠. 이를 "이력서 주도 개발(Resume Driven Development)"이라고도 합니다.
하지만 마틴 파울러(Martin Fowler) 옹께서도 말씀하셨습니다. "CQRS는 복잡성을 다루기 위한 도구이지, 복잡성을 만드는 도구가 되어서는 안 된다." (Use CQRS only when you have to)
대부분의 웹 애플리케이션에서는, 코드를 CommandService와 QueryService로 논리적으로 나누는 것(Level 1)만으로도 CQRS의 이점인 '관심사의 분리'를 충분히 누릴 수 있습니다.
DB를 쪼개고 메시지 큐를 도입하는 건, 정말로 그게 필요할 정도로 트래픽이 터질 때 해도 늦지 않습니다.