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
public class CreateOrderCommand {
private String product;
private int quantity;
private BigDecimal price;
}
2. 엔티티
데이터베이스 안에 유지되어야 하는 Order
엔티티를 정의합니다.
// Order.java
public class Order {
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
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
public class OrderCommandController {
private final OrderCommandService orderCommandService;
public ResponseEntity<Order> createOrder( {
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
public class OrderView {
private Long id;
private String product;
private int quantity;
private BigDecimal price;
private LocalDateTime orderDate;
}
2. 리포지토리
이번 예제도 같은 데이터베이스를 사용하므로, OrderRepository
를 재사용합니다.
3. 서비스
데이터 검색을 위해 OrderQueryService
를 구현합니다.
// OrderQueryService.java
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
public class OrderQueryController {
private final OrderQueryService orderQueryService;
public ResponseEntity<List<OrderView>> getAllOrders() {
List<OrderView> orders = orderQueryService.getAllOrders();
return ResponseEntity.ok(orders);
}
public ResponseEntity<OrderView> getOrderById( {
Long id)OrderView order = orderQueryService.getOrderById(id);
return ResponseEntity.ok(order);
}
}
이벤트 소싱 통합
이벤트 소싱은 CQRS와 함께 자주 사용되며, 애플리케이션 상태의 변경 사항을 이벤트 시퀀스로 저장하는 것을 포함합니다.
이벤트 소싱의 이점
- 감사 추적: 변경 사항의 완전한 이력
- 스냅샷: 임의의 시점에서 시스템 상태를 복원할 수 있는 능력
- 비동기 처리: 이벤트 처리와 명령 처리의 분리
이벤트 소싱 구현
1. 이벤트 정의
변경 사항을 대표하는 이벤트 클래스들을 생성합니다.
// OrderCreatedEvent.java
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
를 수정합니다.
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
public class OrderEventHandler {
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.*;
public class MyPageViewHandler {
//<<< DDD / CQRS
private MyPageRepository myPageRepository;
public void whenOrderPlaced_then_CREATE_1(
{
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();
}
}
public void whenDeliveryStarted_then_UPDATE_1(
{
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();
}
}
public void whenOrderCanceled_then_DELETE_1(
{
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를 채택하기 전에 애플리케이션의 요구사항과 제약 조건을 평가하는 단계가 필요합니다.