Architecture

[MSA] Spring Boot를 사용하여 CQRS 패턴 알아보기

아윤_ 2024. 6. 30. 01:08

 

 

Command And Query Segregation
명령과 조회의 책임을 분리한다.

 

 

읽기 모델과 쓰기 모델

CQRS 패턴은 말 그대로 읽기와 쓰기 작업을 분리하여 독립적으로 확장할 수 있도록 하는 마이크로서비스 디자인 패턴이다. 대부분의 애플리케이션은 CRUD 성격을 띠며, 애플리케이션을 설계할 때 CRUD 작업을 위한 엔티티 클래스와 해당 레포지토리 클래스를 만든다. 하지만, 이러한 애플리케이션은 읽기와 쓰기 요구 사항이 완전히 다를 수 있다.

 

예를 들어, user, product, purchase_order을 관리하는 애플리케이션을 고려해 보자.

  • user
  • product
  • purchase_order

 

모든 테이블이 정규화되어있을 때, 새로운 사용자, 제품, 주문을 생성하면 적절한 테이블로 빠르게 추가된다. 하지만, 읽기 요구 사항을 고려하면 단순히 모든 사용자, 제품, 주문을 원하지 않고 특정 사용자에 대한 모든 주문 세부 정보, 주별 총 판매량, 주별 및 제품별 판매량 등 여러 테이블을 조인한 많은 집계 정보를 원할 수 있다. 이러한 모든 조인 읽기 작업은 대응되는 DTO 매핑이 필요할 수 있다.

 

 

즉, 정규화를 많이 할수록 쓰기는 더 쉬워지지만, 읽기는 더 어려워져 전체 읽기 성능에 영향을 미치게 된다. 또한, 쓰기 작업 수행 시 데이터베이스에 레코드를 삽입하기 전에 비즈니스 검증을 수행할 수도 있는데, 이러한 모든 로직이 모델 클래스에 존재할 수 있어 읽기와 쓰기를 모두 지원하려면 매우 복잡한 모델을 만들 수 있다.

 

이처럼 애플리케이션은 완전히 다른 읽기, 쓰기 요구 사항을 가질 수 있다. 따라서 이 문제를 해결하기 위해 읽기와 쓰기를 위한 별도의 모델을 사용해야 한다.

 

 

읽기, 쓰기 트래픽

대부분의 웹 기반 애플리케이션은 읽기 요청이 많다. 예를 들어, 페이스북의 경우 우리가 새로운 게시물을 게시하는 빈도는 낮지만, 업데이트된 게시글을 확인하는 빈도는 매우 높다. 즉 애플리케이션은 읽기 요청보다 쓰기 요청이 많다. 따라서 이 문제를 해결하기 위해 읽기와 쓰기 작업을 별도의 마이크로서비스로 나누어 독립적으로 확장할 수 있다.

 

이러한 패턴을 CQRS 패턴이라고 한다.

  • Command: 데이터를 수정하고 아무것도 반환하지 않는다. (쓰기)
  • Query: 데이터를 수정하지 않고 데이터를 반환한다. (읽기)

 

 

애플리케이션 예시

아래와 같이 3개의 서비스가 있는 간단한 애플리케이션 예제를 살펴보자. 예시에서는 주문 서비스 관련 기능만 다룬다.

 

 

DB 테이블은 다음과 같다.

  • user
  • product
  • proudct_order

읽기와 쓰기 작업을 위한 OrderService 인터페이스를 정의해 보면, 다음과 같다.

public interface OrderService {
    void placeOrder(int userIndex, int productIndex);
    void cancelOrder(long orderId);
    List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState();
    PurchaseOrderSummaryDto getSaleSummaryByState(String state);
    double getTotalSale();
}
  • OrderService 인터페이스는 주문하기, 주문 취소하기, 다양한 쿼리 결과를 생성하는 등의 여러 책임을 가진다.
  • 주문을 취소하려면 주문 날짜가 30일 이내여야 하며, 부분 환불, 계산 등의 추가 비즈니스 로직이 포함될 수 있다.

 

 

CQRS 패턴 - 읽기, 쓰기 인터페이스

CQRS 패턴을 적용하기 위해 하나의 인터페이스를 읽기, 쓰기 인터페이스로 나누어 보자.

  • OrderQueryService: 모든 읽기 요구사항을 처리한다.
  • OrderCommandService: 데이터를 수정하는 모든 요구사항을 처리한다.

 

 

 

 

OrderQueryService

public interface OrderQueryService {
    List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState();
    PurchaseOrderSummaryDto getSaleSummaryByState(String state);
    double getTotalSale();
}

 

OrderCommandService

public interface OrderCommandService {
    void createOrder(int userIndex, int productIndex);
    void cancelOrder(long orderId);
}

 

 

CQRS 패턴 - 읽기, 쓰기 인터페이스 구현

 

OrderQueryServiceImpl

@Service
public class OrderQueryServiceImpl implements OrderQueryService {

    @Autowired
    private PurchaseOrderSummaryRepository purchaseOrderSummaryRepository;

    @Override
    public List<PurchaseOrderSummaryDto> getSaleSummaryGroupByState() {
        return this.purchaseOrderSummaryRepository.findAll()
                .stream()
                .map(this::entityToDto)
                .collect(Collectors.toList());
    }

    @Override
    public PurchaseOrderSummaryDto getSaleSummaryByState(String state) {
        return this.purchaseOrderSummaryRepository.findByState(state)
                        .map(this::entityToDto)
                        .orElseGet(() -> new PurchaseOrderSummaryDto(state, 0));
    }

    @Override
    public double getTotalSale() {
        return this.purchaseOrderSummaryRepository.findAll()
                        .stream()
                        .mapToDouble(PurchaseOrderSummary::getTotalSale)
                        .sum();
    }

    private PurchaseOrderSummaryDto entityToDto(PurchaseOrderSummary purchaseOrderSummary){
        PurchaseOrderSummaryDto dto = new PurchaseOrderSummaryDto();
        dto.setState(purchaseOrderSummary.getState());
        dto.setTotalSale(purchaseOrderSummary.getTotalSale());
        return dto;
    }
}

 

OrderCommandServiceImpl

: purchase_order 테이블에 단순 삽입하는 비즈니스 로직을 포함하며, 데이터 수정만 수행하고 아무것도 반환하지 않는다.

 

 

Command vs Query - 컨트롤러 

 

 

 

OrderQueryController

@RestController
@RequestMapping("po")
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "false")
public class OrderQueryController {

    @Autowired
    private OrderQueryService orderQueryService;

    @GetMapping("/summary")
    public List<PurchaseOrderSummaryDto> getSummary(){
        return this.orderQueryService.getSaleSummaryGroupByState();
    }

    @GetMapping("/summary/{state}")
    public PurchaseOrderSummaryDto getStateSummary(@PathVariable String state){
        return this.orderQueryService.getSaleSummaryByState(state);
    }

    @GetMapping("/total-sale")
    public Double getTotalSale(){
        return this.orderQueryService.getTotalSale();
    }

}

 

OrderQueryController의 경우 GET 요청만 존재하며, 데이터를 수정하는 작업은 수행되지 않는다.

 

OrderCommandController

@RestController
@RequestMapping("po")
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "true")
public class OrderCommandController {

    @Autowired
    private OrderCommandService orderCommandService;

    @PostMapping("/sale")
    public void placeOrder(@RequestBody OrderCommandDto dto){
        this.orderCommandService.createOrder(dto.getUserIndex(), dto.getProductIndex());
    }

    @PutMapping("/cancel-order/{orderId}")
    public void cancelOrder(@PathVariable long orderId){
        this.orderCommandService.cancelOrder(orderId);
    }
}

 

 

CQRS 패턴 - 확장

읽기와 쓰기 모델을 분리했으므로, 시스템을 독립적으로 확장할 수 있는 능력이 필요하다. 이 방법에 대해 알아보자.

 

런타임에 스프링 빈 생성

Query와 Command 컨트롤러에서 Spring Boot가 이 컨트롤러를 생성할지 여부를 결정하는 조건을 추가했다. @ConditionalOnProperty 어노테이션을 활용해서 app.write.enabled 설정에 따라 컨트롤러 빈을 생성한다.

  • 쓰기 컨트롤러
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "true")
  • 읽기 컨트롤러
@ConditionalOnProperty(name = "app.write.enabled", havingValue = "false")

 

이렇게 하면 애플리케이션을 읽기 전용 노드 또는 쓰기 전용 노드로 실행할 수 있다. 이를 통해 애플리케이션의 여러 인스턴스를 다른 모드로 실행하여 독립적으로 확장할 수 있다. 읽기 요청을 처리하는 인스턴스를 여러 개 두고, 쓰기 요청을 처리하는 인스턴스는 하나만 두는 식으로 운영할 수 있다. 이 인스턴스들은 로드 밸런서나 프록시(Nginx) 뒤에 두어 경로 기반 라우팅 등을 통해 적절한 인스턴스로 요청을 전달한다.

 

 

 

Command And Query DB

위의 예에서 우리는 동일한 DB를 사용했지만, 읽기와 쓰기 작업을 별도의 데이터베이스로 분리할 수 있다. 쓰기 작업은 이벤트 소싱을 통해 읽기 데이터베이스로 변경 사항을 푸시한다.

 

 

이처럼 CQRS 패턴은 동일한 애플리케이션에 대해 최적화된 데이터 스키마를 사용하는 서로 다른 데이터베이스를 사용할 수 있는 추가 이점을 제공한다.

 

 

요약

CQRS 패턴은 유지 보수와 확장성 외에도 병렬 개발을 용이하게 한다. 두 명의 개발자 또는 팀이 마이크로서비스를 통해 동시에 작업할 수 있다. 예를 들어 한 사람은 Query 측을, 다른 사람은 Command 측을 작업할 수 있다. 이는 개발 속도를 높이고, 코드의 명확성과 효율성을 증가시킨다.

 

참고

https://www.vinsguru.com/cqrs-pattern/

 

CQRS Pattern With Spring Boot | Vinsguru

Learn CQRS Pattern with Spring Boot, one of the Microservice Design Patterns to independently scale read and write workloads of an application.

www.vinsguru.com