CQRS + Facade 패턴 조합
실무에서 구조가 무너지지 않는 이유
서비스의 복잡도가 올라가면 가장 먼저 무너지는 것이 코드 구조다.
- 읽기·쓰기 로직이 뒤섞이고
- Controller가 비대해지며
- 수정 한 번에 여러 군데를 건드려야 하고
- 테스트는 점점 어려워진다
이 문제를 해결하기 위해 실무에서 가장 효과적이었던 조합이 바로:
CQRS + Facade 패턴
이 글은 단순한 패턴 설명이 아니다.
실제 서비스에서 적용하며 느낀
✔ 구조적 장점
✔ 유지보수성 개선 효과
✔ 도메인이 커질수록 나타나는 차이
를 기준으로 정리했다.
1. CQRS란?
CQRS (Command Query Responsibility Segregation) 는
쓰기와 읽기 책임을 명확히 분리하는 아키텍처 패턴이다.
- Command
- 상태 변경 중심 로직
- 등록 / 수정 / 삭제
- 비즈니스 규칙 수행
- Query
- 조회 중심 로직
- 리스트 / 상세 보기
- 성능 최적화 중심
- 읽기와 쓰기를 분리하면 자연스럽게:
- 단일 책임 원칙(SRP) 충족
- 조회 성능과 도메인 로직을 각각 최적화 가능
- 코드 충돌 감소
Facade 패턴이란?
Facade는 여러 서비스의 복잡한 흐름을
하나의 진입점으로 감싸는 구조다.
Controller → Facade → (Command / Query)
Controller는 내부 구조를 전혀 모른다.
Facade가 내부 서비스들을 조합하고 오케스트레이션한다.
- 외부는 단순해지고
- 내부는 캡슐화된다
CQRS + Facade 조합의 핵심 장점
명확한 책임 분리 (SRP 충족)
Command는 쓰기만,
Query는 읽기만 담당한다.
읽기/쓰기 로직이 뒤섞이지 않기 때문에
코드 구조가 쉽게 무너지지 않는다.
단일 책임 원칙을 가장 자연스럽게 만족시키는 구조다.
Controller가 극단적으로 단순해진다
Controller → OrderFacade → 내부 CQRS 처리
Controller는:
- 여러 서비스 직접 호출하지 않는다
- 복잡한 비즈니스 흐름을 처리하지 않는다
- 트랜잭션 로직을 가지지 않는다
단지 Facade만 호출한다.
테스트와 유지보수 난이도가 크게 낮아진다.
확장 시 기존 코드 변경 최소화 (OCP 충족)
예를 들어:
- 새로운 주문 취소 Command 추가
- 새로운 조회 조건 Query 추가
기존 코드를 거의 수정하지 않고
새 서비스 추가와 Facade 연결만으로 확장 가능하다.
변경은 확장으로 해결되고,
기존 구조는 안정적으로 유지된다.
테스트 난이도가 급격히 낮아진다
구조가 분리되어 있기 때문에:
- CommandService 단위 테스트
- QueryService 단위 테스트
- Facade는 전체 흐름 테스트
Mock 주입도 단순하다.
테스트 커버리지를 높이기 쉬운 구조다.
도메인이 복잡해질수록 효과가 커진다
다음과 같은 흐름이 있는 경우:
- 주문 → 결제 → 포인트 → 알림톡 → 배송
- 결제 취소 → 재고 복구 → 정산 반영
- 이벤트 기반 읽기 모델 갱신
Facade는 오케스트레이터 역할을 수행한다.
복잡한 비즈니스 흐름을 한 곳에서 관리하므로
구조적 붕괴를 막을 수 있다.
SOLID 원칙과의 관계
CQRS + Facade 조합은
SOLID 원칙을 억지로 지키지 않아도
구조 자체가 원칙을 강제한다.
| 원칙 | 어떻게 충족되는가 |
| S (단일 책임) | Command vs Query 분리, Facade는 진입점만 담당 |
| O (개방-폐쇄) | 새 기능 추가 시 기존 코드를 건드릴 필요가 거의 없음 |
| L (리스코프 대체) | 인터페이스 기반 Service 확장 가능 |
| I (인터페이스 분리) | 읽기/쓰기용 Repository 인터페이스 분리 |
| D (의존 역전) | Controller는 Facade 추상화에만 의존 |
패턴이 원칙을 자연스럽게 유도하는 구조다.
실전 코드 구조 예시
application
├─ facade
│ └─ OrderFacade.java
├─ service
│ ├─ OrderCommandService.java
│ └─ OrderQueryService.java
└─ repository
├─ OrderRepository.java
└─ OrderReadRepository.java
CommandService (쓰기)
@Service
public class OrderCommandService {
private final OrderRepository orderRepository;
public OrderCommandService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public Long createOrder(String productId, int quantity) {
Order order = new Order(productId, quantity);
orderRepository.save(order);
return order.getId();
}
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new RuntimeException("Order not found"));
order.cancel();
orderRepository.save(order);
}
}
QueryService (읽기)
@Service
public class OrderQueryService {
private final OrderReadRepository orderReadRepository;
public OrderQueryService(OrderReadRepository orderReadRepository) {
this.orderReadRepository = orderReadRepository;
}
public OrderDto getOrder(Long orderId) {
return orderReadRepository.findOrderInfo(orderId);
}
public List<OrderDto> getOrderList(String productId) {
return orderReadRepository.findOrdersByProduct(productId);
}
}
Facade (오케스트레이션 + 단일 진입점)
@Service
public class OrderFacade {
private final OrderCommandService commandService;
private final OrderQueryService queryService;
public OrderFacade(OrderCommandService commandService,
OrderQueryService queryService) {
this.commandService = commandService;
this.queryService = queryService;
}
public Long createOrder(String productId, int quantity) {
return commandService.createOrder(productId, quantity);
}
public void cancelOrder(Long orderId) {
commandService.cancelOrder(orderId);
}
public OrderDto getOrder(Long orderId) {
return queryService.getOrder(orderId);
}
public List<OrderDto> getOrderList(String productId) {
return queryService.getOrderList(productId);
}
}
Controller (Facade만 호출)
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderFacade orderFacade;
public OrderController(OrderFacade orderFacade) {
this.orderFacade = orderFacade;
}
@PostMapping
public Long create(@RequestBody CreateOrderRequest req) {
return orderFacade.createOrder(req.productId(), req.quantity());
}
@PostMapping("/{id}/cancel")
public void cancel(@PathVariable Long id) {
orderFacade.cancelOrder(id);
}
@GetMapping("/{id}")
public OrderDto get(@PathVariable Long id) {
return orderFacade.getOrder(id);
}
@GetMapping
public List<OrderDto> list(@RequestParam String productId) {
return orderFacade.getOrderList(productId);
}
}
정리
CQRS + Facade 패턴은 다음 철학을 가진다:
- 도메인의 책임을 명확히 나눈다
- 외부에는 단일 진입점을 제공한다
- 내부 복잡성은 캡슐화한다
- 확장성과 유지보수성을 극대화한다
읽기·쓰기 분리(CQRS)와 단일 진입점(Facade)을 조합하면
서비스 구조는 안정적으로 유지되고,
도메인이 커질수록 그 차이는 더욱 분명해진다.
중규모 이상 프로젝트에서 특히 강력한 설계 전략이다.
'백엔드 개발' 카테고리의 다른 글
| Spring Boot Redis 실무 활용: 클러스터/싱글 + 동기/비동기 + TTL + LocalDateTime (0) | 2026.02.13 |
|---|---|
| Builder 패턴으로 SDK 모듈 래핑하기 (실무 적용 사례) (0) | 2026.02.09 |
| AWS SQS SDK 기반 Consumer 실무 적용기 (0) | 2026.02.06 |
| JPA vs MyBatis, 실무에서 왜 이렇게 고민하게 될까? (0) | 2026.02.05 |
| 안전하게 API 서버를 종료하는 방법 (Graceful Shutdown) (0) | 2025.12.11 |