1. 하나의 모델로 모든 것을 처리할 수 있을까? (The Myth of the Single Model)
우리가 처음 웹 개발을 배울 때, 그리고 대부분의 프레임워크 튜토리얼에서 가장 먼저 접하는 패턴은 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):
- 쓰기(Command): 데이터 무결성, 정규화(Normalization), 트랜잭션 보장이 최우선입니다. "중복 데이터는 절대 안 돼!"
- 읽기(Query): 화면에 뿌려줄 데이터의 조합(Join), 비정규화(Denormalization), 캐싱이 중요합니다. "화면에 보여줄 건데 조인을 5번이나 해야 해?"
- 하나의
User객체에 검증 로직(쓰기용)과 화면 출력용 포맷팅 로직(읽기용)이 뒤섞여서 거대한 "God Object"가 되어버립니다. 객체의 책임이 모호해지고, 유지보수가 악몽으로 변합니다.
-
보안 및 권한 관리의 복잡성: 쓰기 작업은 관리자나 본인만 가능해야 하지만, 읽기 작업은 공개되어야 할 수도 있습니다. 하나의 엔티티에
@JsonIgnore같은 어노테이션을 덕지덕지 붙이다 보면, 실수로 민감한 정보가 API 응답에 노출되는 사고가 터지기 쉽습니다.
이 문제를 해결하기 위해 등장한 개념이 바로 CQRS (Command Query Responsibility Segregation), 즉 명령과 조회의 책임 분리입니다.
2. CQRS의 핵심 - "책임을 나누자"
CQRS의 아이디어는 아주 단순합니다. 버트런드 마이어(Bertrand Meyer)가 제안한 CQS (Command Query Separation) 원칙에서 유래했습니다. "데이터를 변경하는 놈(Command)과 데이터를 가져오는 놈(Query)을 완전히 나누자!"
Command (명령)
- 역할: 시스템의 상태를 변경하는 작업. (Create, Update, Delete)
- 특징: 반환값이 없어야 합니다(Void). 그냥 "해!"라고 시키는 것이니까요. (단, 실용적인 이유로 성공/실패 여부나 생성된 리소스의 ID 정도는 반환하기도 합니다.)
- 관심사: 데이터의 정합성, 트랜잭션, 도메인 규칙, 비즈니스 로직.
- 예:
CreateOrder,ChangePassword,ShipItem
Query (조회)
- 역할: 시스템의 상태를 반환하는 작업. (Read)
- 특징: 시스템의 상태를 절대 변경하지 않아야 합니다(Side-effect Free). 멱등성(Idempotency)을 가집니다.
- 관심사: 화면에 보여주기 위한 데이터 포맷팅, DTO 매핑, 캐싱, 조회 성능.
- 예:
GetOrderById,SearchProducts,GetUserProfile
3. CQRS 구현의 단계 (Levels of CQRS)
CQRS는 0 아니면 1이 아닙니다. 프로젝트의 규모와 복잡도에 따라 단계적으로 적용할 수 있습니다.
2.1. 가장 단순한 형태의 CQRS (Logical Separation)
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);
}
}
이것만 해도 코드가 훨씬 깔끔해집니다.
- 쓰기 로직은 복잡한 조인이나 화면 출력 로직에 신경 쓸 필요 없이, 오직 비즈니스 규칙(invariant)에만 집중합니다.
- 읽기 로직은 도메인 모델의 제약(캡슐화)을 무시하고, 화면에 필요한 데이터를 가장 빠르게 가져오는 쿼리 튜닝에 집중할 수 있습니다.
2.2. 물리적인 분리 (Database Split) - 진정한 CQRS
더 나아가면 아예 데이터 저장소를 나눌 수도 있습니다. "Polyglot Persistence(다양한 저장소 혼용)" 전략을 쓸 수 있게 됩니다.
- 쓰기 DB (Command Store): MySQL, PostgreSQL 같은 RDBMS. 데이터 무결성과 ACID 트랜잭션 보장이 최우선입니다. 정규화된 스키마를 사용합니다.
- 읽기 DB (Query Store): Redis(캐시), MongoDB(문서형), Elasticsearch(검색엔진), 또는 역정규화된 RDBMS 테이블. 조회 성능 최적화가 최우선입니다. 미리 조인된(Pre-joined) 데이터를 저장합니다.
"어? 그럼 두 DB 사이의 데이터 싱크(Sync)는 어떻게 맞춰요?" 여기서 이벤트(Event) 개념이 등장합니다.
- 사용자가 글을 씁니다. -> Command DB에 저장됩니다. (동기)
- "글 작성됨(PostCreated)" 이벤트를 메시지 큐(Kafka, RabbitMQ)에 발행합니다.
- 조회 쪽 서비스(Consumer)가 이벤트를 받아서 Query DB를 업데이트합니다. (비동기)
이 과정에서 최종적 일관성(Eventual Consistency)을 허용하게 됩니다. 사용자가 글을 쓰고 나서 목록에 반영되기까지 0.1초 ~ 수 초의 딜레이가 생길 수 있다는 것을 비즈니스적으로 받아들이는 것입니다.
4. CQRS와 단짝 친구 - 이벤트 소싱 (Event Sourcing)
CQRS를 이야기할 때 빠지지 않는 것이 이벤트 소싱입니다. 둘은 패턴의 유래가 다르지만, 궁합이 너무 좋아서 마치 세트 메뉴처럼 다닙니다.
상태(State) vs 사건(Event)
기존 방식은 "현재 상태(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(커밋 로그), 블록체인 원장이 바로 이벤트 소싱의 완벽한 예시입니다.
CQRS와의 시너지
이벤트 소싱을 쓰면 "쓰기 모델"은 단순히 이벤트를 저장(Append)하는 역할만 합니다. 매우 빠르죠. (Insert Only). 하지만 "현재 잔액 조회"를 할 때마다 이벤트를 다 계산할 수는 없습니다. 너무 느리겠죠? 그래서 읽기 모델(Projection)이 필요합니다. 이벤트를 구독해서 미리 계산된 "현재 상태 테이블"을 만들어두는 것입니다.
이것은 엄청난 유연성을 줍니다. 만약 기획자가 "지난 달 거래 내역을 보여주는 새로운 화면이 필요해"라고 하면? 기존 RDBMS라면 마이그레이션을 하고 난리가 나겠지만, 이벤트 소싱에서는 과거 이벤트를 다시 돌려서(Replay) 새로운 읽기 전용 테이블을 뚝딱 만들어내면 됩니다. 타임머신을 가진 셈이죠.
5. 언제 CQRS를 써야 할까? (The Complexity Tax)
CQRS는 "은탄환(Silver Bullet)"이 아닙니다. 오히려 시스템의 복잡도를 급격하게 높이는 "복잡성 폭탄"이 될 수도 있습니다.
도입하면 좋은 경우 👍
- 읽기와 쓰기의 빈도 차이가 극심할 때: 티켓 예매 사이트, SNS 피드, 뉴스 포털 (조회 >>> 생성). 읽기 전용 DB(Replica)를 스케일 아웃(Scale-out)하기 좋습니다.
- 도메인 규칙이 매우 복잡할 때: 여러 애그리거트(Aggregate)가 얽혀 있는 복잡한 엔터프라이즈 시스템. 쓰기 모델은 도메인 로직 검증에만 집중하게 해줍니다.
- 이력 추적이 중요할 때: 금융, 회계, 법률 시스템 (이벤트 소싱과 함께 사용 시). 누가, 언제, 왜 변경했는지 완벽하게 추적(Audit Trail) 가능합니다.
- 팀이 나뉠 때: 읽기 모델(프론트엔드 친화적, 화면 중심) 담당 팀과 도메인 로직(백엔드 코어, 비즈니스 중심) 담당 팀이 서로 간섭 없이 일할 수 있습니다.
도입하면 망하는 경우 👎
- 단순한 CRUD 앱: 관리자 페이지, 개인 블로그, 토이 프로젝트. 그냥
SELECT * FROM하세요. 제발. CQRS 도입하면 코드 양이 3배로 늘어납니다. (Command, Event, Handler, Query, Projection...) - 일관성(Consistency)이 즉각적이어야 할 때: 주식 트레이딩 시스템처럼 0.001초의 데이터 불일치도 허용하지 않는 경우, 이벤트 전파 딜레이(Eventual Consistency)가 치명적일 수 있습니다. 물론 해결책은 있겠지만 난이도가 헬입니다.
6. 마무리 - 기술적 허세에 속지 말자
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를 쪼개고 메시지 큐를 도입하는 건, 정말로 그게 필요할 정도로 트래픽이 터질 때 해도 늦지 않습니다.