"글을 쓰는(Write) 횟수보다 글을 읽는(Read) 횟수가 1,000배는 더 많습니다."
대부분의 시스템은 읽기 요청 트래픽이 쓰기 요청보다 압도적으로 많습니다. 그런데 우리는 보통 하나의 Member 객체로 회원 가입도 하고(Write), 회원 조회도 합니다(Read).
이렇게 되면 조회 성능을 높이려다 데이터 무결성이 깨지거나, 쓰기 모델을 복잡하게 만들다 조회 성능이 떨어지는 딜레마에 빠집니다.
이를 해결하기 위해 "명령(Create, Update, Delete)"과 "조회(Read)"를 아예 분리하는 것이 CQRS의 핵심입니다.

1. 방식 1: 논리적 CQRS (Logical Separation) - "코드만 분리"
데이터베이스는 하나(Single DB)를 쓰지만, 애플리케이션 내부의 모델(Model)과 서비스(Service)를 분리하는 방식입니다. 가장 현실적이고 도입하기 쉬운 1단계입니다.
특징
- Command Model: 데이터의 변경을 다룹니다. 도메인 로직에 충실합니다.
- Query Model: 데이터의 조회를 다룹니다. 화면(UI)에 최적화된 DTO를 반환합니다.
코드 예시
Java
// 1. 명령 서비스 (Command Service) - 상태 변경, 리턴값 없음(void/id)
@Service
@Transactional
public class MemberCommandService {
private final MemberRepository memberRepository;
public void createMember(CreateMemberCommand command) {
// 도메인 로직 수행 (유효성 검사, 암호화 등)
Member member = new Member(command);
memberRepository.save(member);
}
public void changePassword(Long id, String newPassword) {
Member member = memberRepository.findById(id).orElseThrow();
member.changePassword(newPassword); // 도메인 메서드 호출
}
}
// 2. 조회 서비스 (Query Service) - 화면용 DTO 반환
@Service
@Transactional(readOnly = true) // 읽기 전용 최적화
public class MemberQueryService {
private final MemberDAO memberDAO; // 조회 전용 DAO (MyBatis나 JDBC Template 사용 가능)
public MemberProfileDto getMemberProfile(Long id) {
// 도메인 로직 없이, 화면에 필요한 데이터만 빠르게 뽑아서 매핑
return memberDAO.findProfileById(id);
}
}
장단점
- 장점: 복잡한 도메인 로직(Command) 때문에 단순 조회(Query) 성능이 희생되는 것을 막을 수 있습니다. 코드의 가독성이 높아집니다.
- 단점: 결국 DB가 하나라서, DB 부하가 심해지면 한계가 옵니다.
2. 방식 2: 물리적 CQRS (Physical Separation) - "DB까지 분리"
읽기 전용 DB와 쓰기 전용 DB를 물리적으로 나누는 방식입니다. 보통 **쓰기 DB(RDBMS)**와 **읽기 DB(NoSQL, Cache, Search Engine)**를 따로 둡니다.
특징
- 이종 데이터베이스: 쓰기는 MySQL에 하고, 조회는 검색 속도가 빠른 ElasticSearch나 Redis에서 합니다.
- 데이터 동기화: 쓰기 DB에 변경이 일어나면, 이벤트(Kafka, RabbitMQ)를 통해 읽기 DB로 데이터를 전파합니다.
구조 예시 (쇼핑몰 상품)
- Command: 관리자가 상품 정보를 MySQL에 저장 (INSERT)
- Sync: '상품 생성됨' 이벤트를 Kafka로 발행 -> 컨슈머가 받아서 ElasticSearch에 저장
- Query: 사용자는 ElasticSearch에서 상품 검색 (SELECT)
코드/구조 개념
Java
// Command 쪽 (MySQL)
public void updateProduct(ProductCommand cmd) {
productRepository.save(cmd.toEntity()); // MySQL 저장
eventPublisher.publish(new ProductUpdatedEvent(cmd)); // 이벤트 발행
}
// Query 쪽 (ElasticSearch)
@KafkaListener(topics = "product-updates")
public void syncProduct(ProductUpdatedEvent event) {
// 검색에 최적화된 형태로 변환하여 ES 저장
productSearchRepository.save(convertToDocument(event));
}
장단점
- 장점:
- 조회 성능 극대화: 조회 쿼리에 최적화된 NoSQL을 사용할 수 있어 속도가 비약적으로 상승합니다.
- 스케일 아웃: 읽기 트래픽이 몰리면 읽기 DB만 늘리면 됩니다.
- 단점:
- 데이터 불일치 (Eventual Consistency): MySQL에는 저장되었는데, 아직 ElasticSearch로 안 넘어가서 검색이 안 되는 시간 차(Lag)가 발생합니다.
- 구축 비용: 관리해야 할 DB와 인프라(메시지 큐)가 늘어납니다.
3. 실무 비교: 언제 무엇을 쓰는가?
| 구분 | Logical CQRS (Code 분리) | Physical CQRS (DB 분리) |
| 데이터베이스 | 1개 (공유) | 2개 이상 (분리) |
| 목적 | 복잡한 도메인 로직 정리, 유지보수성 | 조회 성능 극한 최적화, 트래픽 분산 |
| 동기화 이슈 | 없음 (트랜잭션으로 보장) | 있음 (이벤트 전파 지연 발생) |
| 난이도 | 낮음 (바로 적용 가능) | 높음 (인프라 구축 필요) |
| 추천 상황 | 대부분의 일반적인 웹 애플리케이션 | 검색 엔진 도입, 대용량 트래픽 서비스 |
결론
CQRS를 한다고 해서 처음부터 DB를 쪼개고 Kafka를 도입할 필요는 없습니다. 그건 **과잉 엔지니어링(Over-engineering)**입니다.
- 처음에는 논리적 CQRS로 시작하세요. Service와 DTO를 Command용과 Query용으로 나누는 것만으로도 코드가 훨씬 깔끔해집니다.
- 그러다 조회 트래픽이 감당 안 되거나 복잡한 검색 조건이 필요해질 때, 그때 **물리적 CQRS(예: ElasticSearch 도입)**로 넘어가도 늦지 않습니다.
'프로그래밍 > 디자인패턴' 카테고리의 다른 글
| 처리율 제한 패턴(Rate Limiter Pattern) 완벽 정리 (0) | 2025.12.07 |
|---|---|
| 트랜잭셔널 아웃박스 패턴(Transactional Outbox Pattern) 완벽 정리 (0) | 2025.12.07 |
| API 게이트웨이 패턴(API Gateway) 완벽 정리 (0) | 2025.12.07 |
| 캐싱 패턴(Caching Pattern) 완벽 정리 (0) | 2025.12.07 |
| 서킷 브레이커 패턴(Circuit Breaker Pattern) 완벽 정리 (1) | 2025.12.07 |