이 글은 EDA(Event-Driven Architecture) 시리즈의 3편입니다.
- 1편: EDA란 무엇인가 — 개념과 왜 필요한가 (작성 예정)
- 2편: 도메인 이벤트 — DDD에서의 이벤트 설계 (작성 예정)
- 3편: CQRS — 읽기와 쓰기를 분리하는 이유 ← 현재 글
- 4편: 이벤트 소싱 — 상태가 아닌 변화를 저장하다 (작성 예정)
- 5편: 이벤트 소싱 + CQRS 결합 실전 (작성 예정)
- 6편: 사가 패턴 — 장기 실행 프로세스를 다루는 방법 (작성 예정)
1. 왜 CQRS가 필요한가
단일 모델의 한계
전통적인 CRUD 방식에서는 하나의 도메인 모델이 읽기와 쓰기를 모두 담당합니다.
처음에는 단순하고 편리해 보이지만, 시스템이 복잡해질수록 다음과 같은 문제가 드러납니다.
복잡한 조회 쿼리와 도메인 모델의 충돌
도메인 모델은 비즈니스 규칙과 상태 변경에 최적화되어 있습니다.
그런데 화면에 보여줄 데이터는 여러 애그리게잇을 조합하거나, 집계 연산을 포함하거나, 특정 뷰에 맞게 가공된 형태인 경우가 많습니다.
이때 도메인 모델을 억지로 조회에 맞게 변형하다 보면 모델이 점점 오염됩니다.
읽기 성능 최적화가 쓰기 로직을 오염시키는 현상
읽기 성능을 높이기 위해 도메인 모델에 캐싱 로직이나 조회용 필드가 추가되기 시작합니다.
쓰기 로직에는 필요 없는 것들이 모델 안에 뒤섞이면서, 응집도가 떨어지고 유지보수가 어려워집니다.
2. CQRS란 무엇인가
CQRS(Command Query Responsibility Segregation)는 명령(Command)과 조회(Query)의 책임을 분리하는 아키텍처 패턴입니다.
| 구분 | 설명 |
| Command | 상태를 변경하지만 값을 반환하지 않는다 |
| Query | 값을 반환하지만 상태를 변경하지 않는다 |
CQS와 CQRS의 차이
CQRS는 Bertrand Meyer의 CQS(Command Query Separation) 원칙에서 출발합니다.
그러나 두 개념은 적용 레벨이 다릅니다.
- CQS: 메서드 레벨에서의 분리 — 하나의 메서드는 명령이거나 조회이어야 한다
- CQRS: 모델/아키텍처 레벨에서의 분리 — Command와 Query가 각각 독립적인 모델과 저장소를 가진다
3. CQRS의 구조
CQRS를 적용하면 시스템은 크게 두 개의 흐름으로 나뉩니다.
Command Side (쓰기)
Command → Command Handler → Domain Model → Write DB
Command Side는 비즈니스 규칙을 검증하고 상태를 변경하는 책임을 집니다.
도메인 모델이 이 흐름의 핵심이며, 트랜잭션 일관성을 보장합니다.
Query Side (읽기)
Query → Query Handler → Read Model → Read DB
Query Side는 데이터를 조회해서 화면에 맞는 형태로 반환하는 책임을 집니다.
도메인 로직이 개입할 필요가 없으므로, 조회에 최적화된 Read Model(DTO)을 직접 반환합니다.
전체 흐름 다이어그램

4. Command Handler 스타일
Command Handler를 구현하는 방식은 크게 세 가지로 나눌 수 있습니다.
각 스타일은 트레이드오프가 다르므로, 팀의 상황과 시스템 규모에 맞게 선택하는 것이 중요합니다.
① 카테고리 스타일 (Category Style)
같은 도메인과 관련된 Command를 하나의 Handler 클래스에 모아서 처리하는 방식입니다.
@Service
public class OrderCommandHandler {
public void handleCreate(CreateOrderCommand command) { ... }
public void handleCancel(CancelOrderCommand command) { ... }
public void handleComplete(CompleteOrderCommand command) { ... }
}
같은 도메인의 Command가 한 곳에 모여 있어 찾기 쉽고 초기 구성이 단순합니다.
다만 Command가 늘어날수록 Handler가 비대해지고 응집도가 낮아지는 단점이 있습니다.
② 전용 스타일 (Dedicated Style)
Command 하나당 Handler 클래스 하나를 만드는 방식입니다.
@Service
public class CreateOrderCommandHandler {
public void handle(CreateOrderCommand command) { ... }
}
@Service
public class CancelOrderCommandHandler {
public void handle(CancelOrderCommand command) { ... }
}
단일 책임 원칙에 충실하며, 각 Handler의 응집도가 높습니다.
클래스 수가 많아지는 단점이 있지만, Command별로 독립적으로 변경할 수 있어 유지보수에 유리합니다.
③ 메시지 스타일 (Message / Bus Style)
Command를 메시지처럼 디스패치하고, 공통 인터페이스로 처리하는 방식입니다.
public interface CommandHandler<C extends Command> {
void handle(C command);
}
@Service
public class CreateOrderCommandHandler
implements CommandHandler<CreateOrderCommand> {
@Override
public void handle(CreateOrderCommand command) { ... }
}
// CommandBus가 Command 타입에 맞는 Handler를 찾아 위임
commandBus.dispatch(new CreateOrderCommand(...));
CommandBus/Dispatcher를 통해 Handler를 동적으로 연결합니다.
확장성이 높고 로깅, 트랜잭션, 검증 등의 공통 관심사를 미들웨어로 분리하기 용이합니다.
Axon Framework 등이 이 방식을 채택하고 있습니다.
스타일 비교
| 구분 | 카테고리 스타일 | 전용 스타일 | 메시지 스타일 |
| 응집도 | 낮음 (Command가 늘수록) | 높음 | 높음 |
| 클래스 수 | 적음 | 많음 | 많음 |
| 확장성 | 낮음 | 중간 | 높음 |
| 미들웨어 적용 | 어려움 | 어려움 | 용이함 |
| 적합한 상황 | 소규모, 초기 단계 | 중간 규모 | 대규모, 프레임워크 활용 |
이 글의 예시 코드는 전용 스타일을 기준으로 작성합니다.
5. Command 처리의 불확실성
Command를 발행한다고 해서 항상 그 결과가 발행자에게 돌아오는 것은 아닙니다.
CQRS에서 Command는 다음 세 가지 이유로 처리되지 않거나, 결과를 알 수 없는 상황이 발생할 수 있습니다.
결과를 즉시 알 수 없는 경우 — 비동기 이벤트 발행
Command Handler가 도메인 이벤트를 발행하고 처리가 비동기로 이루어질 경우,
Command를 보낸 쪽은 처리가 완료되었는지 즉시 알 수 없습니다.

이를 fire-and-forget 방식이라고 합니다.
처리 결과가 필요하다면 별도의 Query를 통해 상태를 조회하거나, 콜백/폴링 방식을 사용해야 합니다.
중복 Command가 무시되는 경우 — 멱등성 (Idempotency)
네트워크 재전송 등으로 인해 동일한 Command가 두 번 이상 전달될 수 있습니다.
at-least-once 전달 보장 환경에서는 이런 중복 전달이 자연스러운 현상입니다.
이 경우 애플리케이션이 멱등한 오퍼레이션(Idempotent Operation)을 보장하고 있다면,
재전달된 Command는 중복 처리 없이 조용히 무시됩니다.
즉, Command는 전달되었지만 실제로 아무 일도 일어나지 않는 것입니다.
public void handle(CreateOrderCommand command) {
// 이미 처리된 Command라면 무시
if (orderRepository.existsById(command.orderId())) {
return;
}
Order order = Order.create(command.userId(), command.itemIds());
orderRepository.save(order);
}
권한 검사 실패로 인한 거부
Command Handler에서 권한 검사를 수행할 경우,
유효하지 않은 Command는 도메인 로직에 도달하기 전에 거부됩니다.
public void handle(CancelOrderCommand command) {
if (!authorizationService.canCancel(command.userId(), command.orderId())) {
throw new UnauthorizedException("주문 취소 권한이 없습니다.");
}
// 이후 도메인 로직 실행
}
정리
| 케이스 | 원인 | Command 처리 여부 |
| 비동기 이벤트 발행 | fire-and-forget | 처리됨, 결과를 알 수 없음 |
| 중복 Command | at-least-once + 멱등성 | 무시됨 |
| 권한 검사 실패 | 인가(Authorization) | 거부됨 |
6. Java/Spring 예시
Command Side
Command 정의
public record CreateOrderCommand(String userId, List<String> itemIds) {}
Command Handler
@Service
@RequiredArgsConstructor
public class CreateOrderCommandHandler {
private final OrderRepository orderRepository;
public void handle(CreateOrderCommand command) {
Order order = Order.create(command.userId(), command.itemIds());
orderRepository.save(order);
}
}
Command Repository — 쓰기 전용
Command Repository는 도메인 모델(Order)을 다룹니다.
저장과 삭제 등 상태 변경에 필요한 메서드만 노출합니다.
public interface OrderRepository {
void save(Order order);
void delete(OrderId orderId);
Optional<Order> findById(OrderId orderId);
}
Query Side
Query 정의
public record OrderSummaryQuery(String orderId) {}
Query Handler
@Service
@RequiredArgsConstructor
public class OrderSummaryQueryHandler {
private final OrderReadRepository orderReadRepository;
public OrderSummaryDto handle(OrderSummaryQuery query) {
return orderReadRepository.findSummaryById(query.orderId());
}
}
Query Repository — 읽기 전용
Query Repository는 도메인 모델이 아닌 Read Model(DTO)을 직접 반환합니다.
도메인 로직이 필요 없으므로, JPA 대신 MyBatis나 Native Query를 활용하면 조회 성능을 더욱 높일 수 있습니다.
public interface OrderReadRepository {
OrderSummaryDto findSummaryById(String orderId);
List<OrderSummaryDto> findAllByUserId(String userId);
}
Read Model (DTO)
public record OrderSummaryDto(
String orderId,
String userId,
List<String> itemIds,
String status,
LocalDateTime createdAt
) {}
Repository를 나누는 이유
| 구분 | Command Repository | Query Repository |
| 반환 타입 | 도메인 모델 (Order) | DTO (OrderSummaryDto) |
| 책임 | 상태 변경, 트랜잭션 | 데이터 조회, 뷰 최적화 |
| 기술 선택 | JPA 권장 | JPA / MyBatis / Native Query |
| 도메인 로직 | 포함 | 없음 |
Command Repository와 Query Repository를 분리하면 각 저장소가 자신의 책임에 집중할 수 있습니다.
특히 Query Repository는 도메인 로직이 개입할 여지가 없기 때문에, 복잡한 JOIN이나 집계 연산을 ORM 없이 직접 SQL로 작성해 성능을 최적화하는 것이 자연스러운 선택이 됩니다.
7. CQRS 적용 시 고려사항
Read Model 동기화 방식
CQRS에서 Write DB의 변경을 Read DB에 어떻게 반영할 것인가는 중요한 설계 결정입니다.
크게 두 가지 방식이 있으며, 각각 트레이드오프가 다릅니다.

① 동기 방식 — 강한 일관성
Command 처리와 Read Model 업데이트를 같은 트랜잭션 안에서 수행합니다.
Command가 성공하면 Read Model도 즉시 반영되므로, Query 결과가 항상 최신 상태를 보장합니다.
다만 Write DB와 Read DB가 강하게 결합되고, 트랜잭션 범위가 넓어져 성능에 영향을 줄 수 있습니다.
② 비동기 방식 — Eventual Consistency
Command 처리 후 도메인 이벤트를 발행하고, 별도의 Event Handler가 Read Model을 업데이트합니다.
Write DB와 Read DB가 느슨하게 결합되어 확장성이 높습니다.
그러나 이벤트 처리 지연으로 인해 Read Model이 일시적으로 최신 상태를 반영하지 못할 수 있습니다.
이것이 Eventual Consistency(결과적 일관성)입니다.
방식 비교
| 구분 | 동기 방식 | 비동기 방식 |
| 일관성 | 강한 일관성 | Eventual Consistency |
| 성능 | 트랜잭션 범위 증가 | 높은 처리량 |
| 결합도 | Write/Read DB 강결합 | 느슨한 결합 |
| 적합한 상황 | 즉각적 일관성이 필요할 때 | 높은 확장성이 필요할 때 |
언제 CQRS를 적용해야 하는가
CQRS는 모든 상황에 적합한 패턴이 아닙니다. 다음 조건에 해당할 때 적용을 고려하세요.
- 도메인이 복잡하고 비즈니스 규칙이 많을 때
- 읽기와 쓰기의 부하가 극단적으로 다를 때 (예: 읽기 요청이 쓰기의 100배 이상)
- 다양한 뷰(화면)마다 요구하는 데이터 형태가 크게 다를 때
언제 적용하지 말아야 하는가
반대로, 다음과 같은 상황에서는 오히려 복잡도만 높아집니다.
- 단순한 CRUD 위주의 애플리케이션
- 팀의 규모가 작고 유지보수 비용을 최소화해야 할 때
- 읽기/쓰기 패턴이 단순하고 성능 이슈가 없을 때
8. 마치며
CQRS의 핵심을 정리하면 다음과 같습니다.
- Command와 Query는 서로 다른 책임을 가지며, 이를 분리함으로써 각 모델이 자신의 역할에 집중할 수 있다
- Command Handler 스타일은 세 가지로 나뉜다 — 카테고리 / 전용 / 메시지 스타일, 시스템 규모와 확장성 요구에 따라 선택한다
- Command는 항상 처리되지 않을 수 있다 — 비동기 발행으로 결과를 알 수 없거나, 멱등성에 의해 무시되거나, 권한 검사로 거부될 수 있다
- Repository도 Command / Query로 나뉜다 — Command Repository는 도메인 모델을, Query Repository는 Read Model(DTO)을 다룬다
- Read Model 동기화는 동기/비동기 중 선택한다 — 강한 일관성이 필요하면 동기, 높은 확장성이 필요하면 비동기(Eventual Consistency)
- 모든 상황에 적용하는 패턴이 아니다 — 복잡한 도메인, 읽기/쓰기 부하 불균형이 있을 때 효과적이다
다음 편에서는 CQRS와 자주 함께 언급되는 이벤트 소싱(Event Sourcing)을 다룹니다.
이벤트 소싱을 적용하면 Write DB의 데이터 구조가 어떻게 바뀌는지, 그리고 CQRS와 결합했을 때 어떤 시너지가 생기는지 살펴보겠습니다.
그리고 시리즈 후반부에서는 장기 실행 프로세스를 다루는 사가 패턴(Saga Pattern)도 별도로 다룰 예정입니다.