Loading...
Skip to Content

CQRS를 Spring Boot와 함께 구현하기 위한 포괄적 가이드

 소프트웨어 설계에서 CQRS(Command Query Responsibility Segregation)는 복잡한 애플리케이션의 요구사항을 해결하는 데 있어 유용한 기술입니다. 특히, 데이터를 읽고 쓰는 작업을 분리해서 성능을 높이고 관리하기 쉽게 만듭니다. 이번 글에서는 Spring Boot로 CQRS를 구현하는 방법을 알아보겠습니다.

CQRS 개념도 - 명령(Command)과 조회(Query) 책임을 분리한 구조

 CQRS란 무엇인가?

Command Query Responsibility Segregation(CQRS)는 데이터 저장소의 읽기 및 쓰기 작업을 분리하는 설계 패턴입니다. 데이터를 업데이트하고 쿼리하는 데 동일한 모델을 사용하는 대신, CQRS는 애플리케이션을 두 부분으로 나눕니다

  • Command 측: 쓰기(생성, 업데이트, 삭제)를 처리합니다.
  • Query 측: 읽기를 처리합니다.

이러한 분리를 통해 각 측면을 독립적으로 최적화하고 확장할 수 있습니다.

 CQRS의 이점

  • 확장성: 읽기 및 쓰기 작업을 해당 로드에 따라 독립적으로 확장할 수 있습니다.
  • 성능: 읽기와 쓰기를 위해 특화된 모델로 성능을 최적화할 수 있습니다.
  • 유지보수성: 관심사의 명확한 분리로 코드 가독성과 유지보수성이 향상됩니다.
  • 유연성: 다른 측면에 영향을 주지 않고 한쪽의 변경이나 최적화를 쉽게 도입할 수 있습니다.

 CQRS 사용 시기

  • 복잡한 도메인: 데이터에 대한 복잡하고 다양한 작업이 있는 경우
  • 높은 읽기/쓰기 부하: 읽기/쓰기 부하가 불균형한 시스템
  • 이벤트 기반 시스템: 이벤트 소싱의 이점을 누릴 수 있는 애플리케이션

 Command 측 설계하기

쓰기 작업 처리

Command 측은 모든 쓰기 작업을 처리하는 책임이 있습니다. 시스템 상태를 변경하려는 의도를 나타내는 명령을 처리합니다.

주요 구성 요소

  • Command 모델: 들어오는 명령에 대한 데이터 구조를 나타냅니다.
  • 컨트롤러: 클라이언트가 명령을 보낼 수 있는 엔드포인트를 노출합니다.
  • 서비스: 명령을 처리하기 위한 비즈니스 로직을 포함합니다.
  • 리포지토리: 변경 사항을 유지하기 위해 데이터 저장소와 상호 작용합니다.

"주문 생성" 예시

주문 관리 시스템을 만들어 보겠습니다.

1. Command 모델

주문을 생성하기 위해 필요한 데이터를 대표하는 CreateOrderCommand를 정의합니다.

// CreateOrderCommand.java @Data public class CreateOrderCommand { private String product; private int quantity; private BigDecimal price; }

2. 엔티티

데이터베이스 안에 유지되어야 하는 Order 엔티티를 정의합니다.

// Order.java @Entity @Table(name = "orders") @Data @NoArgsConstructor @AllArgsConstructor public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String product; private int quantity; private BigDecimal price; private LocalDateTime orderDate; }

3. 리포지토리

데이터 지속성을 위해 OrderRepository를 생성합니다.

// OrderRepository.java public interface OrderRepository extends JpaRepository<Order, Long> { }

4. 서비스

비즈니스 로직을 핸들링하기 위한 OrderCommandService를 구현합니다.

// OrderCommandService.java @Service @RequiredArgsConstructor public class OrderCommandService { private final OrderRepository orderRepository; public Order createOrder(CreateOrderCommand command) { Order order = new Order(); order.setProduct(command.getProduct()); order.setQuantity(command.getQuantity()); order.setPrice(command.getPrice()); order.setOrderDate(LocalDateTime.now()); return orderRepository.save(order); } }

5. 컨트롤러

OrderCommandController 내부에 엔드포인트를 선언합니다.

// OrderCommandController.java @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderCommandController { private final OrderCommandService orderCommandService; @PostMapping public ResponseEntity<Order> createOrder(@RequestBody CreateOrderCommand command) { Order order = orderCommandService.createOrder(command); return new ResponseEntity<>(order, HttpStatus.CREATED); } }

 Query 측 설계하기

읽기 작업 처리

Query 측은 데이터 읽기에 최적화되어 있습니다. 쿼리에 최적화된 고유한 모델과 데이터베이스를 가질 수 있습니다

주요 구성 요소

  • Query 모델: 읽기 작업에 최적화된 데이터 구조
  • 컨트롤러: 클라이언트가 데이터를 쿼리할 수 있는 엔드포인트 노출
  • 서비스: 데이터 검색을 위한 비즈니스 로직 포함
  • 리포지토리: 데이터 저장소와 상호 작용하여 데이터 가져오기

"주문 검색" 예시

1. Query 모델

응답을 위한 OrderView DTO(Data Transfer Object)를 정의합니다.

// OrderView.java @Data public class OrderView { private Long id; private String product; private int quantity; private BigDecimal price; private LocalDateTime orderDate; }

2. 리포지토리

이번 예제도 같은 데이터베이스를 사용하므로, OrderRepository를 재사용합니다.

3. 서비스

데이터 검색을 위해 OrderQueryService를 구현합니다.

// OrderQueryService.java @Service @RequiredArgsConstructor public class OrderQueryService { private final OrderRepository orderRepository; public List<OrderView> getAllOrders() { List<Order> orders = orderRepository.findAll(); return orders.stream() .map(this::convertToView) .collect(Collectors.toList()); } public OrderView getOrderById(Long id) { Order order = orderRepository.findById(id) .orElseThrow(() -> new ResourceNotFoundException("Order not found")); return convertToView(order); } private OrderView convertToView(Order order) { OrderView view = new OrderView(); view.setId(order.getId()); view.setProduct(order.getProduct()); view.setQuantity(order.getQuantity()); view.setPrice(order.getPrice()); view.setOrderDate(order.getOrderDate()); return view; } }

4. 컨트롤러

OrderQueryController 내부에 엔드포인트를 선언합니다.

// OrderQueryController.java @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderQueryController { private final OrderQueryService orderQueryService; @GetMapping public ResponseEntity<List<OrderView>> getAllOrders() { List<OrderView> orders = orderQueryService.getAllOrders(); return ResponseEntity.ok(orders); } @GetMapping("/{id}") public ResponseEntity<OrderView> getOrderById(@PathVariable Long id) { OrderView order = orderQueryService.getOrderById(id); return ResponseEntity.ok(order); } }

 이벤트 소싱 통합

이벤트 소싱은 CQRS와 함께 자주 사용되며, 애플리케이션 상태의 변경 사항을 이벤트 시퀀스로 저장하는 것을 포함합니다.

이벤트 소싱의 이점

  • 감사 추적: 변경 사항의 완전한 이력
  • 스냅샷: 임의의 시점에서 시스템 상태를 복원할 수 있는 능력
  • 비동기 처리: 이벤트 처리와 명령 처리의 분리

이벤트 소싱 구현

1. 이벤트 정의

변경 사항을 대표하는 이벤트 클래스들을 생성합니다.

// OrderCreatedEvent.java @Data public class OrderCreatedEvent { private Long orderId; private String product; private int quantity; private BigDecimal price; private LocalDateTime orderDate; }

2. 이벤트 스토어

이벤트 스토어를 위한 메커니즘을 구현합니다.

// EventStore.java public interface EventStore { void saveEvent(String aggregateId, Object event); List<Object> getEvents(String aggregateId); }

3. 이벤트 발행

이벤트를 발행하기 위해 OrderCommandService를 수정합니다.

@Service @RequiredArgsConstructor public class OrderCommandService { private final OrderRepository orderRepository; private final EventStore eventStore; public Order createOrder(CreateOrderCommand command) { Order order = new Order(); // ... set order properties orderRepository.save(order); // Publish event OrderCreatedEvent event = new OrderCreatedEvent(); // ... set event properties eventStore.saveEvent(order.getId().toString(), event); return order; } }

4. 이벤트 핸들러

이벤트를 실행하기 위한 핸들러를 생성합니다.

// OrderEventHandler.java @Component public class OrderEventHandler { @EventListener public void on(OrderCreatedEvent event) { // Handle the event (e.g., update read models, send notifications) } }

 과제와 고려사항

복잡성

  • 증가된 복잡성: CQRS는 시스템 아키텍처의 복잡성을 높일 수 있습니다.
  • 최종 일관성: 읽기 모델이 쓰기를 즉시 반영하지 못할 수 있습니다.

CQRS를 사용하지 말아야 할 경우

  • 단순한 애플리케이션: 직관적인 CRUD 애플리케이션의 경우 CQRS가 과도할 수 있습니다.
  • 소규모 팀: 증가된 복잡성을 작은 개발 팀에서 관리하기 어려울 수 있습니다.

모범 사례

  • 모듈식 설계: Command와 Query 측면을 모듈식으로 유지하세요.
  • 일관된 모델: 모델이 일관되고 잘 정의되도록 보장하세요.
  • 테스팅: 양쪽을 독립적으로 철저히 테스트하세요..

Java Spring Boot로 CQRS 패턴을 구현하면 애플리케이션의 확장성과 유지보수성 면에서 큰 이점을 가져오지만, 시스템 상에 추가적인 복잡성을 도입하는 것과 같으므로 구현 및 전환에 어려움을 겪을 수 있습니다.

 이렇게 까다로운 개념인 CQRS를 모델 기반으로 보다 직관적이면서 간단하게 스프링부트로 구현할 수 있는 방법을 소개합니다.

모델 기반으로 CQRS를 Spring Boot에 구현하는 전체 흐름

위 화면은 마이크로서비스 아키텍처 설계 도구 MSAEZ(www.msaez.io)의 기능 중 하나인 이벤트스토밍 모델 생성 기능을 활용해 간단한 상품 주문 도메인을 모델링한 것입니다.

이벤트스토밍은 도메인이벤트를 시각화하여 시스템을 설계하는 방법론으로, 위 모델의 경우 간단한 쇼핑몰 주문 도메인을 예시로 작성되었습니다.

사용자(user)는 상품을 주문 혹은 취소할 수 있고, 이에 따라 OrderPlaced 이벤트와 OrderCanceled 이벤트가 생성됩니다. 해당 이벤트가 발행되면 배송 서비스 측에서 이를 받아와서 배송을 시작할 수도 있고, 취소된 주문 건에 대한 배송 취소를 할 수 있습니다. 만약 배송이 시작된다면 상품 관리 담당 측에서 재고량을 감소시키고 StockDecreased 이벤트를 발송하는 것으로 프로세스가 완료됩니다.

 마이크로서비스에서 CQRS는 크게 2가지 관점에서 활용될 수 있습니다.

첫번째로 마이크로서비스 상에서 트랜잭션으로 관리되는 커맨드 모델(파란색 스티커)을 그대로 복제해서 쿼리 모델로 활용할 수 있습니다. 하나의 데이터베이스에 많은 조회 요청이 들어올 경우, 커맨드 모델의 성능을 트랜잭션에 그랜트해줄 수 있도록 해당 모델을 복제해서 내려줍니다.

두번째로 마이크로서비스 아키텍처라고 하는 분산 환경에서 각 서비스가 가지고 있는 자기만의 고유 데이터를 전부 모아서 프로젝션합니다. 예를 들어 order 바운디드 컨텍스트 내부의 주문 정보와 delivery 바운디드 컨텍스트의 배송 정보를 모두 받아와서 데이터의 변경사항을 저장할 수 있습니다.

도메인 이벤트, 어그리거트, 쿼리모델(커맨드)로 이루어진 이벤트스토밍 모델에서 CQRS 패턴을 시각화하는 방법은 초록색으로 표현된 ReadModel 스티커를 활용하는 것입니다.

이벤트스토밍 모델에서 초록색 ReadModel로 시각화한 CQRS 패턴

ReadModel 내부에는 CQRS를 비롯한 다양한 쿼리 형태를 지정할 수 있도록 되어 있고, 그 안에서 핸들링하고 저장 및 수정할 수 있는 데이터 목록을 작성할 수 있습니다. ReadModel의 이름을 MyPage로 두고 데이터를 입력한 모습입니다.

이 ReadModel을 통해 하나의 서비스에 대한 조회 건이 많을 때 이에 대한 쿼리 모델을 만들기도 하고, MSA라는 분산 플랫폼의 특수성으로 인해 사용자가 MyPage를 클릭할 때 필요한 데이터를 모아서 읽기 성능을 보장하는 모델을 대시보드 상에 제공합니다. 앞서 제시한 마이크로서비스에서 CQRS를 활용할 수 있는 방안 중 두번째에 해당되는 내용입니다.

MSA 분산 환경에서 서비스 간 조회를 담당하는 ReadModel 구성

데이터를 모두 입력했다면 우선 CREATE 절에 모델을 기반으로 수신되는 이벤트 중 OrderPlaced 라는 최초 발행 이벤트를 통해 MyPage 내부의 데이터 값을 해당 이벤트에 저장되어 넘어오는 데이터와 일치시키는 작업을 진행합니다.

OrderPlaced 이벤트를 받아 MyPage ReadModel을 생성하는 CREATE 절 설정

그리고 데이터 값에 변동이 생길 때는 UPDATE 절에 추가 및 변경되는 데이터에 대한 정보를 저장할 수 있도록 이벤트와 데이터 정보를 작성합니다. 또한 주문이 취소되는 OrderCanceled 이벤트가 발행되면 삭제 쿼리가 동작할 수 있도록 DELETE 절에 작성해줍니다.

이렇게 CQRS 내부 설정을 마친 뒤 MSAEZ의 메인 기능인 Code Generate 기능을 활용해 생성한 코드를 보면 다음과 같습니다.

package stmalldemo.infra; import java.io.IOException; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cloud.stream.annotation.StreamListener; import org.springframework.messaging.handler.annotation.Payload; import org.springframework.stereotype.Service; import stmalldemo.config.kafka.KafkaProcessor; import stmalldemo.domain.*; @Service public class MyPageViewHandler { //<<< DDD / CQRS @Autowired private MyPageRepository myPageRepository; @StreamListener(KafkaProcessor.INPUT) public void whenOrderPlaced_then_CREATE_1( @Payload OrderPlaced orderPlaced ) { try { if (!orderPlaced.validate()) return; // view 객체 생성 MyPage myPage = new MyPage(); // view 객체에 이벤트의 Value 를 set 함 myPage.setOrderId(String.valueOf(orderPlaced.getId())); myPage.setUserId(orderPlaced.getUserId()); myPage.setProductName(orderPlaced.getProductName()); myPage.setProductId(orderPlaced.getProductId()); myPage.setQty(String.valueOf(orderPlaced.getQty())); myPage.setAddress(orderPlaced.getAddress()); myPage.setStatus(orderPlaced.getStatus()); // view 레파지 토리에 save myPageRepository.save(myPage); } catch (Exception e) { e.printStackTrace(); } } @StreamListener(KafkaProcessor.INPUT) public void whenDeliveryStarted_then_UPDATE_1( @Payload DeliveryStarted deliveryStarted ) { try { if (!deliveryStarted.validate()) return; // view 객체 조회 List<MyPage> myPageList = myPageRepository.findByDeliveryId( String.valueOf(deliveryStarted.getId()) ); for (MyPage myPage : myPageList) { // view 객체에 이벤트의 eventDirectValue 를 set 함 myPage.setOrderId(String.valueOf(deliveryStarted.getOrderId())); myPage.setProductId(deliveryStarted.getProductId()); myPage.setProductName(deliveryStarted.getProductName()); myPage.setAddress(deliveryStarted.getAddress()); myPage.setStatus(deliveryStarted.getStatus()); // view 레파지 토리에 save myPageRepository.save(myPage); } } catch (Exception e) { e.printStackTrace(); } } @StreamListener(KafkaProcessor.INPUT) public void whenOrderCanceled_then_DELETE_1( @Payload OrderCanceled orderCanceled ) { try { if (!orderCanceled.validate()) return; // view 레파지 토리에 삭제 쿼리 myPageRepository.deleteByOrderId( String.valueOf(orderCanceled.getId()) ); } catch (Exception e) { e.printStackTrace(); } } //>>> DDD / CQRS }

이와 같이 MSAEZ는 모델링을 기반으로 CQRS 패턴을 스프링부트로 구현하는 코드를 자동으로 생성해줍니다. 객체와 리포지토리를 생성하고, 이벤트로부터 넘어오는 DirectValue를 리포지토리에 저장해 사용할 수 있는 형태를 볼 수 있습니다.

업데이트 및 삭제 이벤트가 발행되었을 때는 객체 내부를 조회하여 해당되는 데이터 값을 변경하거나 삭제 쿼리를 작동시키게 됩니다.

Java Spring Boot로 CQRS 패턴을 구현하면 애플리케이션의 확장성과 유지보수성을 크게 향상시키고, Command와 Query 책임을 분리함으로써 각 측면을 해당 요구사항에 따라 최적화할 수 있습니다.

대규모 또는 복잡한 시스템에서 CQRS를 채택하는 것은 많은 이점이 있지만 그와 동시에 시스템의 복잡성도 증가하므로, CQRS를 채택하기 전에 애플리케이션의 요구사항과 제약 조건을 평가하는 단계가 필요합니다.