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

Updated:

2차시 교육 ④ : Correlation and Compensation

개발 역량 장착 이야기가 벌써 5회째이군요. 😅
원래 기획 의도는 5회만에 포스팅을 모두 마칠 생각이었는데, 쓰다보니 주구장창 길어지고 있습니다. ㅜㅜ
1회 포스팅 내용을 더듬어 기억해 보니 Cloud Application Modernization Developer 과정은 총 13일동안 4과목 & 2번의 평가가 진행되는 과정이어서, 4개 과목에 대해 각각 1회차씩 포스팅을 하고 마지막 포스팅은 기타 못다한 멘트를 더하면 되지 않을까 하는 생각에 5회 분량으로 연재할 거다라고 하였었네요… ^^;;
그런데 배웠던 내용들을 복기하면서 논리적으로 연결시키려다 보니 수업에서 배우지 않았던 내용까지도 포함하여 작성을 하게 되었네요.. 코딩까지도 새로 하면서요… (물론 줄거리의 긴박한 전개를 위해 생략한 내용도 있습니다 ^^)
모쪼록 이런 삽질 작업이 독자 한분에게만이라도 유용한 팁이 되기를 바라 마지 않습니다. 😙


Correlation Key

지난 시간까지 마이크로서비스간 동기/비동기 통신에 대해 학습한 내용을 공유하여 드렸습니다. 이번에는 Compensation and Correlation 패턴이라는 것을 배워보도록 하겠습니다.

사실, 마이크로서비스간 동기/비동기 통신만 알면 핵심적인 지식은 다 터득한 셈입니다. 서로 다른 시스템간 데이터를 어떻게 주고 받아야 하고, 데이터 일관성과 무결성 유지를 위해 어떻게 동기화해야 할지는 동기/비동기 통신을 바탕으로 응용이 되는 것이니깐요…

제가 사용한 simplemall 시나리오가 많이 빈약하긴 하지만, “주문취소”라는 비지니스 상황을 가정하여 보겠습니다.

order 서비스에서 생성된 상품 주문이 delivery 서비스에 전달되면 단순히 해당 상품 정보를 가지고 새로운 배송정보를 생성해주는 것과 달리 주문을 취소해달라는 요청이 delivery 서비스에 전달되면 기존의 배송 정보를 찾아서 삭제 or 취소 상태로 만들어주어야 합니다.
즉, order 서비스가 발행한 취소 주문이 delivery 서비스의 어떤 배송정보와 매칭되어야 할지 key값이 필요한 것이지요. 마치 관계형 데이터베이스 시스템의 foreign key처럼 말이지요.
암튼 이렇게 서로 다른 마이크로서비스간 데이터 일관성 처리를 위해 전달하는 key를 corrleation key라고 부릅니다.

주문 취소 구현

구현해 보겠습니다.
simplemall에서는 고객이 취소를 요청할 경우 해당 주문정보를 아예 삭제하고 배송중이던 서비스도 아예 없던 것으로 하기로 했습니다.

  • OrderCancelled.java (주문취소 이벤트 클래스 생성)
    public class OrderCancelled extends EventPublisher {
      private Long id;
      private Long productId;
      private String productName;
      private Long userId;
      private int qty;
      private int price;
    
      public Long getId() {
          return id;
      }
      public void setId(Long id) {
          this.id = id;
      }
    
      public Long getProductId() {
          return productId;
      }
      public void setProductId(Long productId) {
          this.productId = productId;
      }
    
      public String getProductName() {
          return productName;
      }
      public void setProductName(String productName) {
          this.productName = productName;
      }
    
      public Long getUserId() {return userId;}
      public void setUserId(Long userId) {this.userId = userId;}
    
      public int getQty() { return qty; }
      public void setQty(int qty) {
          this.qty = qty;
      }
    
      public int getPrice() {return price;}
      public void setPrice(int price) {this.price = price;}
    }
    
  • Order.java (주문삭제시 OrderCancelled 이벤트 발행)
        ...
    
        @PostRemove
        public void onRemoved() {
          OrderCancelled orderCancelled = new OrderCancelled();
          BeanUtils.copyProperties(this, orderCancelled);
          orderCancelled.publishAfterCommit();
      }
    

delivery 서비스에서 orderCancelled 이벤트 메시지를 수신하면 관련 배송정보를 찾아 삭제하는 로직을 의 EventHandler 안에 추가 구현합니다. 여기서 OrderCancelled의 getId()는 orderId를 말하는데 delivery 엔티티와 연결해주는 Correlation Key의 역할을 합니다.

  • EventHandler.java ()
    @Service
    public class EventHandler {
      @Autowired DeliveryRepository deliveryRepository;
    
      ...
    
      @StreamListener(StreamProcessor.INPUT)
      public void onOrderCancelled_deleteDelivery(@Payload OrderCancelled orderCancelled){
    
          if(!orderCancelled.isMe()) return;
          System.out.println("##### listener deleteDelivery : " + orderCancelled.toJson());
    
          Delivery delivery = deliveryRepository.findByOrderId(orderCancelled.getId());
          if(delivery != null) deliveryRepository.delete(delivery);
    
      }
    }
    
    

위에서 주어진 orderyId를 가지고 있는 해당 delivery 엔티티를 찾기 위해서는 리포지토리 안에 다음과 같이 findByOrderId() 메소드가 있어야 합니다.

  • DeliveryRepository.java
    public interface DeliveryRepository extends CrudRepository<Delivery, Long> {
      // orderyId로 delivery엔티티 찾아옴 
      Delivery findByOrderId(Long orderId);
    }
    

테스트해볼까요?

  • 상품 주문 후 배송 확인
    # 상품 재고 생성
    ~ ❯❯ http POST localhost:8080/products name="socks" stock=15
    ...
    # 주문 생성
    ~ ❯❯ http POST localhost:8081/orders productId=1 qty=5
    ...
    # 배송 확인
    ~ ❯❯ http localhost:8082/deliveies
    ...
    

  • 상품 주문 취소 후 배송 확인
    # 주문 취소
    ~ ❯❯ http DELETE localhost:8081/orders/1
      
    # 배송 확인
    ~ ❯❯ http localhost:8082/deliveies
    ...
    

취소 주문이 잘 전달되어 배송정보가 삭제되는 것을 보실 수 있습니다. 😙

그런데 말입니다… 여기서 끝이 난 게 아닙니다.
상품을 취소했기 때문에 상품재고도 다시 원복되어야 합니다.

지난번 포스팅에서 상품 주문시 재고를 감소시켰던 것은 동기식 통신으로 처리했는데, 취소 처리시 재고 복원은 어떻게 하는 것이 좋을까요? 주문할 때에는 상품재고를 먼저 확인하여 재고 수량이 주문 수량 이상 있어야만 주문을 완료할 수 있는 것이기 때문에 동기식으로 처리하는 것이 비교적 안전하다고 할 수 있지만, 취소를 처리할 경우에는 그렇게 할 필요가 없습니다. 즉, 고객이 원하면 일단 단일 취소를 처리해주고, 독립적인 product에서 취소 메시지를 확인한 후 재고를 복원시켜줘도 비즈니스적으로 충분히 문제될 것이 없습니다.

구현을 해 보겠습니다. order 서비스에서 주문취소라는 이벤트는 한번만 발생시키면 되기 때문에, product서비스에서 이 메시지를 수신하여 상품재고를 복원하는 로직만 구현하면 됩니다. 아래와 같이요.

  • product 서비스의 EventHandler.java
    @Service
    public class EventHandler {
      @Autowired ProductRepository productRepository;
    
      @StreamListener(StreamProcessor.INPUT)
      public void onOrderCancelled_restoreStock(@Payload OrderCancelled orderCancelled){
    
          if(!orderCancelled.isMe()) return;
          System.out.println("##### listener restoreStock : " + orderCancelled.toJson());
    
          Optional<Product> optProduct = productRepository.findById(orderCancelled.getProductId());
          if (optProduct.isEmpty()) return;
    
          Product product = optProduct.get();
          product.setStock(product.getStock() + orderCancelled.getQty());
          productRepository.save(product);
    
      }
    }
    

주문취소 후 재고 확인 테스트

테스트 해봅니다.

  • 현재 재고 상태 확인
    # 재고 확인
    ~ ❯❯ http localhost:8080/products
    

  • 상품 주문 취소 후 재고 확인
    # 주문 취소
    ~ ❯❯ http DELETE localhost:8081/orders/1
    ...
    # 재고 확인
    ~ ❯❯ http localhost:8080/products
    ...
    

주문취소 후 상품 재고가 다시 늘어난 것을 확인할 수 있습니다.

Compensation 패턴

이상과 같이 주문 취소 이벤트를 수신할 경우 관련 상품 재고를 복원하는 로직을 구현함으로써, 보통 모놀리스 시스템에서 트랜잭션이 실패할 경우 데이터 일관성 유지를 위해 Rollback 처리하는 것을 대신할 수 있는데 이러한 것을 Compensation 패턴이라고 합니다.

예를 들어, 앞서 상품을 주문 처리할 때 동기식 방식에 의해 상품 재고를 먼저 감소시킨 후, 주문을 완료하려고 하는 데 예기치 않은 에러가 발생했다고 가정해 봅시다. 오류가 발생했지만 서로 떨어져 있는 시스템간에는 Rollback할 방법이 없기 때문에 재고는 주문 수량만큼 감소한 채로 남게 됩니다.
따라서, 상품의 재고 처리를 위한 동기 호출 후에 주문이 완료되지 않을 경우에는 다시 상품 재고를 원복시키도록 하는 이벤트를 발생시켜 주어야 합니다.

이벤트 다이어그램 수정

이벤트 다이어그램을 수정해보겠습니다. (사용하지 않는 주문정보 변경 이벤트는 삭제하고, 대신에 “주문실패됨”이라는 이벤트 추가)

주문실패시 보상패턴 구현

수정된 이벤트를 반영하여 주문실패 로직을 구현해 보겠습니다. 주문 로직을 수행하다가 내부에서 실패시 OrderCancelled 이벤트를 발생시키려면 비즈니스 로직이 복잡해지기 때문에 Order.java의 내에 구현되었던 로직을 OrderController로 옮기는 것이 좋습니다.

  • OrderController.java
    public class OrderController {
    
      @Autowired OrderRepository orderRepository;
    
      @RequestMapping(value = "/orders",
              method = RequestMethod.POST,
              produces = "application/json;charset=UTF-8")
      public void placeOrder(@RequestBody HashMap<String, String> map) throws Exception {
          Long productId = Util.getParam(Long.class, map,"productId", true);
          Long userId    = Util.getParam(Long.class, map,"userId", false);
          String productName = Util.getParam(String.class , map,"productName", true);
          int  price = Util.getParam(Integer.class, map,"price", false);
          int  qty   = Util.getParam(Integer.class, map,"qty", false);
    
          OrderApplication.applicationContext.getBean(ProductService.class)
                  .decreaseStock(productId, qty);
    
          Order order = new Order();
    
          try {
              order.setProductId(productId);
              order.setQty(qty);
              if (!productName.equals("")) order.setProductName(productName);
              if (userId != 0) order.setUserId(userId);
              if (price != 0) order.setPrice(price);
    
              // for Test
              if (price <0) throw new RuntimeException("DUMMY EXCEPTION for Compensation Test");
              
              orderRepository.save(order);
    
          } catch (Exception e) {
              System.out.println("\n $$$ Exception occurred in creating order ==> " + e.getMessage());
    
              // 에러 발생시, 상품 재고를 다시 원복시켜 주어야 함
              OrderCancelled orderCancelled = new OrderCancelled();
              BeanUtils.copyProperties(order, orderCancelled);
              orderCancelled.publish();
          }
      }
    }
    

    ※ Util.getParam()메소드는 아래 샘플소스를 다운받아 참고하세요

  • Order.java
        @PrePersist
        public void onPrePersist() {
          // 기존 로직은 모두 삭제한다.
      }
    

테스트해보겠습니다. 에러를 일으키기 위해 price값을 -1로 세팅해줍니다.

  • 상품 주문 후 재고 확인
    # 재고 확인
    ~ ❯❯ http localhost:8080/products
    ...
    # 주문 
    ~ ❯❯ http POST localhost:8081/orders productId=1 qty=5 price=-1
    ...
    # 다시 재고 확인
    ~ ❯❯ http localhost을:8080/products
    ...
    

주문시 에러가 발생될 경우 OrderCancelled 이벤트가 발생되면서 product 재고를 복원된 것을 확인할 수 있습니다. 기존의 모놀리스 시스템이 가지는 장점인 rollback 대신에 이렇게 구현하는 것이 약간 번거로울 수 있지만, 깔끔하게 보상 처리가 된 것이지요.

보상 패턴은 일종의 워크플로우처럼 작동되는 모습을 가지며, 또한 독립된 각각의 마이크로서비스가 갖는 데이터에 대하여 비즈니스적으로 통합된 일관성과 무결성을 유지하도록 하는 기술적 전략이기도 합니다.

이제 주요한 개발은 다 마친 것 같습니다. 다음번에는 CQRS 패턴을 구현해보도록 하겠습니다.

< EOF >