본문 바로가기
백엔드 개발

CQRS + Facade 패턴 실무 적용: 구조적 장점과 SOLID 원칙을 지키는 개발 방식

by collenkim 2025. 12. 1.
반응형

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)을 조합하면
서비스 구조는 안정적으로 유지되고,
도메인이 커질수록 그 차이는 더욱 분명해진다.

중규모 이상 프로젝트에서 특히 강력한 설계 전략이다.

반응형