개발 역량 장착하기(6)-마이크로서비스 초간단 실습➄

Updated:

2차시 교육 ➄ : CQRS

오늘은 초간단 마크서비스의 실습에 대한 이야기 중 마지막 편으로 CQRS 패턴에 대해서 다루어 보려고 합니다. CQRS는 Command and Query Responsibility Segregation(명령과 조회의 책임 분리)의 약자로 이에 대한 설명은 많은 자료에서 살펴볼 수 있기 때문에 본 포스팅에서는 관련 설명은 생략합니다.
간단하게 구글링 해보셔도 되고 본 사이트의 마이크로서비스 관계 패턴-CQRS 포스팅에서 그 내용을 살펴볼 수 있습니다.

하지만 백문이 불여일견이라고 개념과 설명을 아무리 읽어보아도 어떤 식으로 동작하는지 직접 그 눈으로 보아야 직성이 풀리는 분들은 있게 마련이지요. (ㅎ 저만 그런가요? ) 뭐 암튼 저는 CQRS에 대한 궁금증을 직접 실습을 해보면서 비로소 해소하게 되었고, 하여 여기서 제가 했던 실습 내용을 공유하고자 합니다.


조회 전용 마이크로서비스 추가

CQRS 패턴에서 C에 해당하는 부분은 이미 지난 실습들을 통해 구현했던 것들입니다. 즉, 상품을 생성하거나 도는 주문 및 취소하는 명령어들에 대해서는 상품 서비스(Product Microservice), 주문 서비스(Order Microservice) 내에서 구현이 되었지요.
따라서, 남은 부분은 조회(Query) 부분에 대한 구현입니다.

그렇다면 조회는 어떤 식으로 해야 할까요? 단순히 상품 목록, 주문 목록만 보아도 되겠지만 이러한 정보는 상품이나 주문 서비스에서도 조회가 가능하긴 합니다. 물론 CQRS 사용 목적에 충실하여 조회는 무조건 별도의 조회전용 마이크로서비스에서만 해야 해라고 강제로 원칙을 정할 수도 있습니다만… 이는 고객의 시스템이 어떤 비즈니스 요건을 갖는지에 따라 고려를 해보아야 할 것 같습니다.

여기서 샘플로 삼는 비즈니스 요건은 그 정도로 엄격한 구분까지는 필요없을 것 같고, 그보다는 상품과 주문을 한꺼번에 보여주는 용도 정도로 제시할까 합니다. 즉, 어떤 특정 상품에 대한 주문목록과 이로 인한 재고의 변화를 보여주면 조회할 수 있는 서비스를 구현해보겠습니다.

이를 위해 아래와 같이 dashboard라고 명명하여 조회 전용 마이크로서비스 프로젝트를 추가해 줍니다.

pom.xml 파일은 기존에 다른 것에서 복사하여 대체시킨 후, 반드시 artificialId와 name을 dashboard로 수정해 줍니다.

클래스 복사하기

기본 클래스 복사

이미 만들어 두었던 마이크로서비스로부터 다음의 클래스를 dashboard 밑으로 복사하여 줍니다.

  • OxxxApplication.class (DashboardApplication으로 이름을 바꾸어준다.)
  • EventPublisher.class
  • Stream.Class

다른 서비스에 있는 클래스를 복사하기 할 때에는 패키지명에 유의해야 합니다.
여기서의 기본 패키지명은 com.example.dashboard이 되어야 합니다.
또한 EventPublisher 클래스 안에 OxxxApplication이라고 되어 있던 부분도 DashboardApplication라고 코드를 바꾸어 주세요.

  • EventPublisher
      public void publish(String json){
          if( json != null ){
    
              // DashboardApplication의 StreamProcessor 빈을 얻는다.
              StreamProcessor processor = DashboardApplication.applicationContext.getBean(StreamProcessor.class);
              MessageChannel outputChannel = processor.output();
    
              outputChannel.send(MessageBuilder
                      .withPayload(json)
                      .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON)
                      .build());
          }
      }
    

Enitity 클래스 생성(복사해오기)

dashboard 마이크로서비스는 product와 order 서비스를 대신하여 조회가 가능해야 하기 때문에 product, order와 동일하게 내부에 Entity를 생성해줍니다. 즉,

  • product 서비스의 Product, ProductReposity 클래스 복사
  • order 서비스의 Order, OrderRepository 클래스 복사

단, 각각의 엔티티의 key id 값은 productId, orderId가 아닌 generated id로 대체가 되기 때문에 ID를 키값으로 엔티티를 찾는 메소드가 추가되어야 합니다.

  • ProductReposity 클래스에 findByProductId 메소드 추가
    public interface ProductRepository extends CrudRepository<Product, Long> {
    
        Product findByProductId(Long productId);
    
    }
    
  • OrderRepository 클래스에 findByOrderId 메소드 추가
    public interface OrderRepository extends CrudRepository<Order, Long> {
    
      Order findByOrderId(Long orderId);
          
    }
    

dashboard에서는 상품ID에 따라 전체 주문 목록을 조회할 수 있어야 하기 때문에 다음과 같이 메소드를 하나 더 추가해 줍니다.

  • OrderRepository 클래스에 findListByProductId 메소드 추가
    public interface OrderRepository extends CrudRepository<Order, Long> {
    
      Order findByOrderId(Long orderId);
    
      List<Order> findListByProductId(Long productId);
          
    }
    


Event 클래스 생성(복사해오기)

그리고, Product가 생성되었을 때, Order가 생성되거나 취소될 때 이벤트를 수신하기 위하여 각각의 서비스에서 이벤트 클래스를 복사해옵니다.

  • product 서비스의 ProductCreated 클래스 복사
  • order 서비스의 OrderPlaced, OrderCancelled 클래스 복사

✋ 여기서 잠깐
기존에 product 서비스 내에는 ProductChanged 이벤트만 존재합니다. 상품이 생성되거나 재고 숫자가 변경될 때를 통틀어 모두 Changed 이벤트를 발생시키도록 하였는데요… 금번 실습을 위해 이 둘을 분리할 필요가 있습니다. 즉 @PostPersist에는 ProductCreated 이벤트를 발생시키도록 아래와 같이 수정하고, 해당 이벤트 클래스는 dashboard로 복사하여 줍니다.

  • product 서비스의 Product.java 클래스 내부
    
      @PostPersist
      public void publishProductCreated(){
          ProductCreated productCreated = new ProductCreated();
          ...
      }
    
      @PostUpdate
      public void publishProductChanged(){
          ProductChanged productChanged = new ProductChanged();
          ...
      }    
     
    


이벤트 수신하기

이제 dashboard는 product와 order 각각의 이벤트를 수신하여 동일한 Entity 정보를 생성합니다. 단, 기존에 order 서비스에서 주문취소가 발생하면 엔티티를 delete 시켰던 것과 다르게 여기서는 전체 주문/취소 현황을 이력으로 확인할 수 있도록 취소했다는 record 정보를 남기는 것으로 하겠습니다.

  • dashboard 서비스의 EventHandler.java

    @Service
    public class EventHandler {
      @Autowired ProductRepository productRepository;
      @Autowired OrderRepository orderRepository;
    
      @StreamListener(StreamProcessor.INPUT)
      public void onProductCreated_recordDashboard(@Payload ProductCreated productCreated){
    
          if(!productCreated.isMe()) return;
          System.out.println("##### ProductCreated in Dashboard : " + productCreated.toJson());
    
          Product product  = new Product();
    
          product.setProductId(productCreated.getProductId());
          product.setProductName(productCreated.getProductName());
          product.setProductStock(productCreated.getProductStock());
          productRepository.save(product);
    
      }
    
      @StreamListener(StreamProcessor.INPUT)
      public void onOrderPlaced_recordDashboard(@Payload OrderPlaced orderPlaced){
    
          if(!orderPlaced.isMe()) return;
          System.out.println("##### OrderPlaced in Dashboard: " + orderPlaced.toJson());
    
          Order order  = new Order();
    
          order.setProductId(orderPlaced.getProductId());
          order.setOrderId(orderPlaced.getId());
          order.setOrderQty(orderPlaced.getQty());
          order.setOrderPrice(orderPlaced.getPrice());
          order.setOrderStatus("** Ordered **");
          orderRepository.save(order);
    
          Product product  = productRepository.findByProductId(orderPlaced.getProductId());
          if (product != null) {
              product.setProductStock(product.getProductStock() - orderPlaced.getQty());
              productRepository.save(product);
          }
    
      }
    
      @StreamListener(StreamProcessor.INPUT)
      public void onOrderCancelled_recordDashboard(@Payload OrderCancelled orderCancelled){
    
          if(!orderCancelled.isMe()) return;
          System.out.println("##### OrderCancelled in Dashboard: " + orderCancelled.toJson());
    
          Order order  = new Order();
    
          order.setProductId(orderCancelled.getProductId());
          order.setOrderId(orderCancelled.getId());
          order.setOrderQty(orderCancelled.getQty());
          order.setOrderPrice(orderCancelled.getPrice());
          order.setOrderStatus("** Cancelled **");
          order.setTimestamp(orderCancelled.getTimestamp());
          orderRepository.save(order);
    
          Product product  = productRepository.findByProductId(orderCancelled.getProductId());
          if (product != null) {
              product.setProductStock(product.getProductStock() + orderCancelled.getQty());
              productRepository.save(product);
          }
      }
    }
    
    


dashboard API 만들기

이제, 거의 다 된 것 같습니다. 마지막으로 해야 할 일은 상품ID에 따라 상품의 재고 및 주문현황을 모두 조회할 수 있는 dashboard API를 만드는 것입니다.

REST API를 만들기 위해 다음과 같이 DashboardController를 생성해 줍니다.

  • DashboardController.java
    @RestController
    public class DashboardController {
    
      @Autowired ProductRepository productRepository;
      @Autowired OrderRepository orderRepository;
    
      @RequestMapping(value = "/dashboards/{productId}",
              method = RequestMethod.GET,
              produces = "application/json;charset=UTF-8")
      @ResponseBody
      public List<Object> getDashboard(@PathVariable long productId) throws Exception {
    
          ObjectMapper mapper = new ObjectMapper();
    
          Product product  = productRepository.findByProductId(productId);
          List<Order> orders = orderRepository.findListByProductId(productId);
    
          if (product == null && orders.isEmpty()) {
              return null;
          }
    
          List<Object> list = new ArrayList<>();
    
          if (product != null) list.add("\"product\" : " + product);
          if (!orders.isEmpty()) list.add("\"orders\" : " + orders);
    
          return list;
      }
    
    }
    

주의할 사항은 product, order에 대해서는 Repository 패턴 적용을 하면 자동으로 CRUD API가 생기기 때문에 메소드를 만들어 강제로 막아주어야 합니다. 아래와 같이 적용하면 dashboards 메소드를 통하지 않고는 외부에서 그 어떤 API도 사용할 수 없게 됩니다.

  • DashboardController.java
    
      @RequestMapping(value = {"/products","/products/{productId}"},
              method = RequestMethod.GET,
              produces = "application/json;charset=UTF-8")
      public ResponseEntity<Object> getProduct(@PathVariable long productId) throws Exception {
          return ResponseEntity.status(HttpStatus.FORBIDDEN).body("/products methods are NOT supported");
      }
    
      @RequestMapping(value = {"/orders","/orders/{orderId}"},
              method = RequestMethod.GET,
              produces = "application/json;charset=UTF-8")
      public ResponseEntity<Object> getOrder(@PathVariable long orderId) throws Exception {
          return ResponseEntity.status(HttpStatus.FORBIDDEN).body("/orders methods are NOT supported");
      }
    
    


검증 및 테스트

구현이 다 된 것 같으니 검증을 해보겠습니다.

  # 상품 추가
  ~ ❯❯ http POST localhost:8080/products name=BeSpoke-냉장고 stock=100
  ...
  # 상품 주문
  ~ ❯❯ http POST localhost:8081/orders productId=2 qty=10
  ...
  # 대시보드 확인
  ~ ❯❯ http DELETE localhost:8081/orders/1
  ...

  ...
  # 주문 취소
  ~ ❯❯ http DELETE localhost:8081/orders/1
  ...
  # 대시보드 확인
  ~ ❯❯ http DELETE localhost:8081/orders/1

위와 같이 CQRS 패턴을 적용한 마이크로서비스를 통해 조회 전용의 서비스를 구현해 보았습니다.
막상 구현해보니 그닥 어려운 개념이 아닌 것임을 알 수 있었습니다.
또한 CQRS 패턴이라는 게 일종의 데이터 복제 같기도 하고… 잘 생각해 보면 다양한 쓸모가 많아 보이는데요… 아주 Mission Critical한 업무에 대해서는 이런 식으로 다른 마이크로서비스에 데이터를 전달하여 다른 가공 처리 및 통계를 위한 조회 목적으로 사용할 수 있을 것입니다.

이번 글로써 아주 간단하게 마이크로서비스 구조를 탑재한 simplemall 코딩 실습을 마무리합니다.
코딩 내용에 대해 보다 자세하게 들여다보고 싶으신 분은 아래 소스를 다운받기 하여 참고하실 수 있습니다.

< EOF >