pandaterry's 개발로그

[이벤트소싱] SAGA Orchestration 패턴 + 트랜잭션 아웃박스 패턴 본문

개발/대규모

[이벤트소싱] SAGA Orchestration 패턴 + 트랜잭션 아웃박스 패턴

pandaterry 2025. 3. 13. 15:46

사가 패턴 : 오케스트레이션

여러 마이크로서비스에 걸친 분산 트랜잭션을 관리하기 위해 사용한다.

일련의 로컬 트랜젝션으로 만들어서 비즈니스 프로세스를 구성하고, 각 단계별로 실패시 보상 트랜젝션을 통해 이전 상태로 롤백한다.

사가 오케스트레이션 + 아웃박스 패턴

Command는 즉시성때문에 아웃박스 X

  • Orchestrator가 각 서비스에 명령(Command)를 보낼 때 아웃박스 패턴을 사용하지 않음. (바로 카프카로 전송. 그리고 아웃박스 패턴을 사용하려면 Command도 EventStore에 저장해야하는데, Relay하지도 않는 메시지를 굳이 이벤트 스토어에 저장하여 부하를 발생시킬 이유도 없음.)

이벤트로 비동기 응답처리는 아웃박스 O

  • 각 서비스의 Response(성공/실패)도 아웃박스 패턴으로 발행
      1. 타 마이크로서비스로부터의 이벤트를 Consumer에서 리스닝
          // Kafka 리스너가 InventoryCheckedEvent를 수신하면 호출
          @KafkaListener(topics = "event.InventoryChecked", groupId = "saga-orchestrator")
          public void onInventoryChecked(String message) {
              try {
                  // JSON 메시지를 이벤트 객체로 변환
                  ObjectMapper mapper = new ObjectMapper();
                  InventoryCheckedEvent event = mapper.readValue(message, InventoryCheckedEvent.class);
                  
                  // 오케스트레이터의 핸들러 메서드 호출
                  orchestrator.handleInventoryChecked(event);
                  
              } catch (Exception e) {
                  // 오류 처리 로직
                  log.error("Error processing InventoryChecked event: {}", e.getMessage(), e);
              }
          }
      
      1. 리스닝한 결과를 바탕으로 Orchestrator 메서드에서 처리
          // 재고 확인 결과 처리 (이벤트 핸들러)
          @Transactional
          public void handleInventoryChecked(InventoryCheckedEvent event) {
              SagaInstance saga = sagaRepository.findById(event.getSagaId())
                  .orElseThrow();
                  
              if (event.isAvailable()) {
                  // 재고 있음 - 다음 단계: 결제 진행
                  saga.setCurrentStep("PAYMENT_PROCESSING");
                  sagaRepository.save(saga);
                  
                  CreateOrderRequest request = parsePayload(saga.getPayload());
                  ProcessPaymentCommand command = new ProcessPaymentCommand(
                      saga.getId(), request.getUserId(), calculateAmount(request));
                      
                  messagingGateway.sendCommand(command);
              } else {
                  // 재고 없음 - 사가 종료 (실패)
                  saga.setStatus(SagaStatus.FAILED);
                  saga.setErrorMessage("Out of stock");
                  sagaRepository.save(saga);
              }
          }
      

보상 트랜잭션(Compensation Transaction) 구현

EventStore 에 저장된 이벤트 데이터를 기반으로 오류가 발생하기 전단계로 돌리는 것을 목표로 함.

  1. 하나의 묶음인 트랜젝션에 대해 상관관계 토큰을 고유값(traceId)으로 이벤트를 묶어줄 수 있는 수단에 이벤트에 하나의 컬럼으로서 저장한다.
  2. 오류가 발생하는 경우 해당 상관관계 토큰값을 where문으로 필터를 걸고 timestamp기반으로 정렬하면 해당 트랜젝션에 이루어졌던 모든 이벤트를 확인가능
  3. 해당 트랙잭션에 이루어졌던 모든 변화를 되돌려야하므로 이벤트를 역순으로 돌려서 실행
  4. 각 도메인마다 보상 트랜젝션을 하나하나 구현했어야 가능한데, 일단 보상이 가능한지 파악을 하고, 정말 일일히 로직을 되돌리는 로직을 추가로 작성하는 것. 여기서도 문제가 발생하면 내부 백오피스에 알림하는 식으로해서 처리

트랜젝션 아웃박스(이벤트가 메세지 브로커로 전송되는 순간)

방식1 : 실시간 비동기 카프카 통신 X

위에서 얘기했듯이 이벤트는 저장만 된다고 했다. produceEvent라는 메서드는 전통적인 아웃박스 패턴과는 어긋난다. 아웃박스 패턴의 본질은 이벤트 저장과 이벤트 발행을 분리하여 데이터 일관성을 보장하는 것이다.

방식2 : 주기적 벌크 비동기 카프카 통신 O

아웃박스 패턴은 이벤트가 저장되도록만 하고, 해당 이벤트별로 replayed 플래그를 저장하여 replayed 안된 애들만 다시 주기적으로 카프카로 비동기 전송을 날리는 것이다.

이걸 위해서는 당연히 MessageRelayService가 따로 있어야한다. 왜냐면 주기적으로 EventStore를 바라보고 스케쥴링 로직을 돌려야하니 말이다.

방식3(추천) : 하이브리드 비동기 카프카 통신?

실시간으로도 비동기 통신을 보내고 주기적으로도 보내는 방식을 잘못되었다고는 말할 수 없다. 비즈니스 로직에 따라서 그렇게 할 수도 있다.

  • 중요한 비즈니스 이벤트(주문생성, 결제 처리 등) : 아웃박스 패턴을 통해 데이터의 일관성을 보장
  • 덜 중요한 이벤트(알림, 로깅, 분석) : 즉시 발행 방식으로 실시간성을 유지