백엔드 시스템을 개발하다 보면 하나의 도메인 모델이 점점 비대해지는 경험을 하게 됩니다. 비즈니스 로직을 처리하기 위한 복잡한 객체 그래프와, 단순히 화면에 뿌려주기 위한 조회용 데이터가 뒤섞이면서 유지보수가 어려워지죠.
오늘은 이러한 복잡성을 해결하고 시스템을 유연하게 만드는 아키텍처 패턴인 CQRS (Command Query Responsibility Segregation)에 대해 알아보겠습니다.
CQRS는 Command Query Responsibility Segregation의 약자로, 단어 그대로 '명령(Command)과 조회(Query)의 책임을 분리하는 패턴'을 의미합니다.
우리가 만드는 시스템의 기능은 크게 두 가지로 나뉩니다.

보통은 하나의 모델(Entity)로 이 두 가지 기능을 모두 처리하려 합니다. 하지만 CQRS는 "상태를 변경하는 모델과 상태를 조회하는 모델을 분리하자"는 것이 핵심입니다.
CQRS를 도입하면 다음과 같은 이점을 얻을 수 있습니다.
비즈니스 로직(명령)과 단순 조회(쿼리)가 분리되므로 코드가 깔끔해집니다.
명령과 조회의 목적이 다르기 때문에, 각 모델에 맞는 최적의 기술을 자유롭게 선택할 수 있습니다. 꼭 특정 기술을 써야 하는 것은 아니며, 프로젝트 상황에 맞춰 조합할 수 있습니다.
심화 단계에서는 기술뿐만 아니라 저장소(DB) 자체를 분리하기도 합니다. (예: 명령은 MySQL, 조회는 Redis나 ElasticSearch 사용)
가장 현실적이고 많이 사용되는 '단일 DB 내에서 논리적으로 모델을 분리하는 방식'을 가정해보겠습니다.
명령 모델은 데이터의 일관성을 지키고 비즈니스 로직을 수행하는 데 집중합니다.
단순한 상태 변경뿐만 아니라, 결제 금액 검증, 재고 수량 체크, 배송 상태 확인 등 도메인의 규칙(Invariant)을 강제하는 역할을 수행합니다.
// [Command] Order Entity (도메인 로직 포함)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
@Id @GeneratedValue
private Long id;
private String status;
// ... 기타 필드들
// 비즈니스 로직: 주문 취소
public void cancel() {
if (this.status.equals("SHIPPED")) {
throw new IllegalStateException("이미 배송된 상품은 취소가 불가능합니다.");
}
this.status = "CANCELLED";
}
}
// [Command] Service
@Service
@Transactional
@RequiredArgsConstructor
public class OrderCommandService {
private final OrderRepository orderRepository; // JPA Repository
public void cancelOrder(Long orderId) {
// 1. 도메인 모델 조회
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
// 2. 상태 변경 (도메인 로직 수행)
order.cancel();
// 3. Dirty Checking으로 자동 업데이트
}
}
조회 모델은 도메인 로직 없이, 쿼리 최적화 및 화면 표현(View)에 집중합니다.
단순히 데이터를 가져오는 것을 넘어, 여러 테이블을 조인해서 미리 계산된 결과를 반환하거나(Aggregation), 화면에 딱 맞는 DTO 형태로 데이터를 제공하여 조회 성능을 높입니다.
// [Query] OrderData (단순 조회용 DTO)
@Getter
@Setter
public class OrderData {
private Long orderId;
private String customerName;
private String productName;
private int totalPrice;
private String status;
}
// [Query] Service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderQueryService {
private final OrderMapper orderMapper; // MyBatis Mapper
public OrderData getOrderDetails(Long orderId) {
// 복잡한 조인 쿼리 등을 최적화된 SQL로 직접 실행하여 DTO로 반환
return orderMapper.findOrderDataById(orderId);
}
}
CQRS는 강력하지만 모든 곳에 무조건 적용해야 하는 것은 아닙니다.
단순한 CRUD 시스템에 CQRS를 적용하면, 오히려 파일 수가 늘어나고 구조가 불필요하게 복잡해질 수 있습니다. 얻을 수 있는 이점과 구현 비용을 잘 비교해야 합니다.
만약 성능을 극대화하기 위해 명령 DB와 조회 DB를 물리적으로 분리한다면, 데이터 동기화 이슈가 발생합니다.
단순히 "좋아 보여서" 도입하기보다는, 시스템이 다음과 같은 신호를 보낼 때 도입을 고려해야 합니다.
CQRS라고 해서 반드시 DB를 쪼개거나 메시지 큐를 도입해야 하는 것은 아닙니다. 코드 레벨에서 명령과 조회의 책임을 나누는 것(논리적 CQRS)만으로도 복잡도를 낮추는 훌륭한 시작이 될 수 있습니다.