Loading...
Skip to Content

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

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

 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를 모델 기반으로 보다 직관적이면서 간단하게 스프링부트로 구현할 수 있는 방법을 소개합니다.

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

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

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

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

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

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

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

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

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

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

그리고 데이터 값에 변동이 생길 때는 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를 채택하기 전에 애플리케이션의 요구사항과 제약 조건을 평가하는 단계가 필요합니다.