ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MSA - Outbox Pattern? Saga Pattern?
    Back-end 2024. 3. 19. 09:58
    반응형

    개요

    Saga 와 Outbox 패턴에 대해 구현 및 실습을 하고 생각을 정리해보자.

    참고로 코드나 내용은 제가 생각한대로 구성하여 널리 알려진 개념들과 맞지 않을 수도 있습니다:)

     

    github:  https://github.com/ghkdwlgns612/msa-transaction-pattern

     

    계기

    Micro Service Architecture 라고 불리는 개념만 접해봤을 뿐 실제 적용 경험은 없다.

    MSA 의 핵심은 DB 를 분리하는 것인데 이것에 대한 트랜잭션을 어떤 방법으로 관리를 할까라는 의문점을 가졌다.

    실제 내가 맡은 제품은 Monolith in MSA(코드만 분리하고 DBMS 는 하나) 로 사용하고 있다.

    그래서 언젠가는 사용해볼수도? 있지 않을까라는 생각에 구현하고 의문점을 나만의 방식으로 해결해봤다.

     

    Outbox, Saga Pattern 개념

    1. Outbox Pattern

     

    일반적으로 Outbox 는 메세지 발행할 때 문제점을 해결하기 위한 패턴이다. 문제는 아래와 같다.

     

    * 발행해야 할 메세지가 나가지 않는 경우

    * 발행하면 안되는 메세지가 나가는 경우

     

    위 문제의 설명은 강남언니 블로그에서 자세하게 설명되어있다.

    위 문제들을 트랜잭션 도중 메세지를 발행하지 않고 Relay, Scheduler + Outbox Table 을 이용해 발행한다.

    그렇게 되면 위 문제들을 해결할 수 있다. 1초마다 한 번씩 메세지를 Batch 형식으로 발행하면 순서가 맞지 않을 수 있다.

    이건 나의 경우엔 outbox Table 에 메세지를 저장 시 saveAt 컬럼을 추가하고 그것을 기준으로 정렬하여 보내주었다.

     

    다른 문제는 하나의 Relay, Scheduler 가 있는 건 아니다.

    HA 를 위해 3개 인스턴스를 실행시키는데 만약 Schedule 이 겹치는 경우 메세지가 중복으로 발행 될 수 있다.

    이것은 LeaderElecetion 으로 하나의 Leader 만이 Schedule Job 을 실행시킬 수 있도록 하면 된다고 생각했다.

     

    예시 코드이다.

    @Component
    @RequiredArgsConstructor
    public class RelayForPaymentStock {
    
        private final KafkaTemplate<String, Object> kafkaTemplate;
        private final OrderRepository orderRepository;
        private final OrderOutboxRepository orderOutboxRepository;
    
        @Transactional
        @Scheduled(fixedRate = 1000L)
        public void relay() {
            List<Long> orderIds = orderOutboxRepository.findAll()
                    .stream()
                    .sorted(Comparator.comparing(OrderOutbox::getSavedAt))
                    .map(OrderOutbox::getOrderId)
                    .toList();
    
            orderIds
                    .forEach(orderId -> {
                        Order order = orderRepository.findById(orderId)
                                .orElseThrow(EntityNotFoundException::new);
                        OrderToPaymentRequest paymentRequest
                                = new OrderToPaymentRequest(orderId, order.getUserName(), order.getPrice());
                        OrderToStockRequest stockRequest
                                = new OrderToStockRequest(orderId, order.getItemName(), order.getQuantity());
                        kafkaTemplate.executeInTransaction(operations -> {
                            operations.send(PAYMENT_TOPIC_NAME, paymentRequest);
                            operations.send(STOCK_TOPIC_NAME, stockRequest);
                            order.linkOtherServices();
                            orderOutboxRepository.deleteById(order.getId());
                            orderRepository.save(order);
                            return true;
                        });
                    });
        }
    }

     

    2. Saga Pattern

     

    사가 패턴은 바운디드 컨텍스트(DBMS) 의 일관성을 보장해주는 패턴이다.

    종류는 choreography, orchestrator 2가지로 나뉜다.

    차이점은 아웃박스 패턴은 "메세지 발행", 사가 패턴은 "바운디드 컨텍스트 일관성 보장" 차이점이다.

     

    사가 패턴은 Orchestrator 가 모든 바운디드 컨텍스트의 트랜잭션들을 관리하고 실패 시 보상 트랜잭션을 실행시켜주는 패턴이다.  

    글로만 보면 이해가 힘들다.

    일반적인 경우 아래의 그림과 같이 순차적으로 실행되고 순차적으로 롤백되는 경우가 있다.

    이러한 경우는 따로 상태(PaymentStatus, StockStatus...)를 Orchestrator 에서 관리할 필요가 없고 동기적으로 동작시키고 순서에 따라 롤백하면 된다.

    또 하나의 경우는 Order 요청 동시에 여러 서비스로 메세지가 동시에 발행되는 경우가 있다. 

    이건 각 서비스 요청을 비동기적으로 수행하고 Orchestrator 는 상태를 직접 관리해야한다.

    후자의 경우를 아래에서 나중에 구현해보자. 

     

    간단한 Saga, Outbox 패턴의 개념을 알아봤다. 이제는 구현하면서 내가 느꼈던 문제들을 살펴보자.

     

     

    구현 및 의문점

    orchestrator, choreography 2가지로 나누어 구현해봤다.

     

    1. outbox-with-choreography

     

    첫 번째는 Orchestrator 없이 구현한 아키텍처이다. 각 Topic 의 용도는 아래와 같다.

     

    * {domain}: order 에서 보내는 메세지를 저장.

    * {domain.success}: 각 바운디드 컨텍스트의 서비스 로직이 성공했을 경우 메세지를 저장.

    * {domain.dlt}: 각 바운디드 컨텍스트의 서비스 로직이 실패했을 경우 메세지를 저장.

    * {domain.compensation}: 다른 바운디드 컨텍스트의 서비스 로직이 실패했을 경우 보상 메세지를 저장.

     

    조금 복잡해보인다..

    그리고 Order 에서 상태들(payment, stock)을 관리해야한다는 점을 볼 수 있다.

    예를 들어서 payment, stock 의 성공/실패 여부도 중요하지만 순서에 따라서도 다양한 케이스로 나뉜다.

    보상 메세지는 2가지로 나눌 수 있다.

     

    * 다른 서비스가 실패할 경우 발행하는 메세지(StockConsumer 에서 보내주는 보상 메세지)

    * 성공했는데 다른 서비스가 실패하여 Rollback 해야하는 경우 발행하는 메세지(PaymentConsumer 에서 보내주는 보상 메세지)

     

    지금은 3가지의 상태로만 코드를 구성했지만 복잡한 경우 관리가 힘들어보인다.

     

    위와 같이 상태에 따른 보상 메세지 문제도 있었지만 outbox 패턴의 Schedule Job 에서 메세지를 발행하는 순간에도 문제가 발생할 수 있다. 만약 순차적으로 메세지 발행하는 것이 아닌 동시에 보낸다고 생각해보자.

    아래와 같이 payment, stock 서비스에 메세지를 보낼 때 executeInTransaction 으로 묶어서 보내지 않는다고 가정해보자.

    payment 는 성공하고 stock 에 topic 이 존재하지 않아 실패했을 경우 outbox 패턴을 쓰는 의미가 없어진다는 생각이 들었다.

    다행인 건 카프카에서 executeInTransaction 이란 메세드를 지원해서 만약 stock 메세지 발행이 실패하는 경우 payment 메세지를 롤백할 수 있다는 장점이 있었다.

    kafkaTemplate.executeInTransaction(operations -> {
        operations.send(PAYMENT_TOPIC_NAME, paymentRequest);
        operations.send(STOCK_TOPIC_NAME, stockRequest);
        order.linkOtherServices();
        orderOutboxRepository.deleteById(order.getId());
        orderRepository.save(order);
        return true;
    });

     

    결론적으로 현재는 order <-> payment, stock 이지만 order - alarm, shipment 라는 기능이 생긴다면 order 도메인 수정도 필요하고 상태들을 지속적으로 추가줘야해서 order-service 가 Orchestrator 가 되면서 매우 큰 바운디드 컨텍스트가 만들어지지 않을까?? 생각한다. 

     

    2. outbox-with-orchestrator

    Orchestrator 가 존재하는 아키텍처는 1번 아키텍처에 비해서는 양반이다.

    물론 테이블, 토픽이 늘어났지만 Order 에서는 훨씬 작업이 수월해졌다.

    또한 Order 과 다른 서비스들의 연결을 중간 Orchestrator 가 담당하고 있기에 다른 바운디드 컨텍스트간의 결합을 완전하게 끊어낼 수 있어서 가장 좋았다.

     

     

    결론

    결론적으로는 간단한 트랜잭션 관리는 Orchestrator 없이도 가능하다.

    그러나 소프트웨어는 항상 변경이 일어나고 기능이 추가된다.

    이런 관점에서는 하나의 전체적인 트랜잭션을 관리하는 Orchestrator 가 꼭 필요하다.

    또한 Orchestrator 도 여러 개로 나누는 것이 좋은 방안이 아닐까?

    왜냐하면 Orchestrator 에도 Table 들이 생길 수 있기에 별도로 관리해주는게 좋다.

    이번 프로젝트를 진행하면서 많은 생각이들었고 헷갈리는 부분이 많았지만 추후 기억에 남는 지식이 될 수 있을거라 생각한다.

     

     

    참고 

    https://blog.gangnamunni.com/post/transactional-outbox/

    반응형

    댓글

Designed by Tistory.