Transactional Outbox 패턴을 이용한 메시지 발행의 신뢰성 보장
Programming

Transactional Outbox 패턴을 이용한 메시지 발행의 신뢰성 보장

부제: 메시지 발행의 신뢰성을 보장하기 위해선 어떻게 해야할까?

 

 

Transactional Outbox 패턴이란 비즈니스 엔터티를 업데이트하는 행위와 메시지를 발행하는 행위 사이에 메시지를 저장하는 로직을 추가한 방법입니다.

 

Transactional Outbox 패턴을 구현하는 방법은 다음과 같습니다.

  1. 메시지를 저장하는 Outbox 테이블을 만든다.
  2. 비즈니스 엔터티를 업데이트하는 로직과 메시지를 저장하는 로직을 하나의 트랜잭션으로 묶는다.
  3. Outbox 테이블을 풀링하는 별도의 프로세스를 통해 메시지를 발행한다.

 

Transactional Outbox 패턴을 통해 트랜잭션이 커밋될 때만 메시지를 전송하도록 보장할 수 있습니다. 또한. 메시지를 발행하는 로직을 트랜잭션에서 완전히 분리하여 외부 메시지 브로커에 대한 의존성을 최대한 제거할 수 있습니다.

 

 

문제 상황

Transactional Outbox 패턴이 나온 이유는 무엇이며, 어떤 상황에서 사용해야 할까요?

상품 서버와 전시 서버가 분리된 환경에서 새로운 상품을 등록하는 상황을 예로 들어보겠습니다.

public class ProductBusinessService {
    
    private ProductRepository repository;
    private ProductEventProducer producer;
    
    public void create(Product newProduct) {
        repository.save(newProduct);
        producer.sendEvent(toEvent(newProduct));
    }
}

 

새로운 상품이 등록되면 서버 단에서는 상품 엔터티를 저장하고 메시지 브로커로 이벤트를 전송하게 됩니다. 전시 서버는 메시지 브로커로부터 메시지를 컨슘하여 새 상품을 전시에 노출하게 됩니다.

 

만약 상품 엔터티를 저장하는 로직이 정상적으로 수행되었음에도 불구하고 메시지 브로커의 일시적인 장애가 발생한다면 어떻게 될까요? 메시지 발행에 실패하여 발행되어야 할 메시지가 발행되지 않는 상황이 발생할 것입니다. 전시에 노출되어야 할 상품이 노출되지 않는 문제로 이어지고, 이는 곧 셀러의 불이익으로 연결됩니다.

 

상품 등록이 정상적으로 완료되면 메시지 발행이 반드시 이루어지도록 보장할 필요가 있습니다. 그래서 탄생한 것이 바로 Transactional Outbox 패턴입니다.

 

 

Transactional Outbox 패턴 적용 후

Transactional Outbox 패턴을 적용하게 되면 어떤 점들이 바뀌는지 살펴봅시다.

public class ProductBusinessService {
    
    private ProductRepository repository;
    private ProductOutboxRepository outBoxRepository;
    
    @Transactional
    public void create(Product newProduct) {
        repository.save(newProduct);
        outBoxRepository.save(toOutbox(newProduct));
    }
}

 

상품을 등록하는 로직 내 메시지를 발행하는 부분이 outbox 테이블에 메시지를 저장하는 것으로 바뀌었습니다. 또한, 상품 엔터티를 저장하는 로직과 메시지를 저장하는 로직이 하나의 트랜잭션에 묶이게 된 것을 볼 수 있습니다. 메시지 Sender는 Outbox 테이블을 풀링하여 메시지가 저장된 순서대로 브로커로 메시지를 전송하게 됩니다.

 

Outbox 패턴을 통해 상품 엔터티가 정상적으로 커밋되면 메시지를 저장도 이루어지고, 메시지 저장이 실패한다면 등록된 상품 엔터티도 롤백되게 됩니다. 또한, 커밋된 순서대로 메시지를 발행함으로써 메시지의 순서도 보장할 수 있습니다.

 

 

Retry 정책을 통해 메시지 발행을 보장한다면

메시지 발행에 실패할 경우 재시도하도록 만든다면 문제를 해결할 수 있지 않을까요?

일시적인 네트워크 오류는 해결할 수 있겠지만 메시지 브로커에 장애가 발생한 경우 단순히 재시도 정책으로 문제를 해결할 수는 없습니다.

 

메시지 브로커 장애가 해결될 때까지 메시지를 어딘가에 저장할 필요가 생기고, 자연스레 메시지 저장소인 Outbox 테이블이 만들어지게 됩니다.

 

 

메시지를 발행하는 행위를 트랜잭션으로 묶는다면

물론 아래 코드로도 메시지 발행이 실패했을 때 상품 저장도 롤백되도록 만들 수 있습니다.

public class ProductBusinessService {
    
    private ProductRepository repository;
    private ProductEventProducer producer;
    
    @Transactional
    public void create(Product newProduct) {
        repository.save(newProduct);
        producer.sendEvent(toEvent(newProduct));
    }
}

 

위 코드는 단순히 상품 엔터티를 저장하는 로직과 메시지 브로커로 메시지를 전송하는 로직을 트랜잭션으로 묶은 것입니다. 메시지 발행이 트랜잭션 내에서 이루어지기 때문에 메시지 발행이 실패하면 상품 등록도 실패하게 될 것입니다.

 

이 코드의 문제점은 셀러가 상품 정보를 정상적으로 입력하여 등록을 시도했음에도 불구하고, 단지 메시지 전송에 실패했다는 이유만으로 상품 등록까지 실패한다는 점입니다. 셀러가 상품 등록에 있어 허들이 높아질 것이고, 메시지 브로커의 장애가 발생하는 경우 상품 등록을 전혀 할 수 없는 상황이 벌어질 것입니다.

 

더 큰 문제는 트랜잭션 내에서 메시징 시스템과 같은 외부 시스템에 직접적으로 의존하게 된다는 것입니다. 만약 네트워크 오류나 메시지 브로커의 장애가 발생하면 데이터베이스의 커넥션과 요청을 위한 스레드는 외부 시스템의 응답이 올 때까지 대기하게 됩니다. 외부 시스템의 장애는 서버 단의 장애로까지 이어질 위험이 있습니다. 따라서 트랜잭션 내에서 외부 시스템을 직접적으로 의존하는 것은 피해야 합니다.

 

 

@EventListenr, @TransactionalEventListenr가 해결하려는 문제와의 유사성

이벤트를 발행하는 로직의 의존성을 분리한다는 측면에서 이벤트 리스너가 해결하려는 문제와 비슷한 측면이 있습니다. 그러나 이벤트 리스너를 사용해 의존성을 분리하더라도 메시지 브로커의 장애에 대비하기 위해서는 메시지를 어딘가에 저장할 필요성은 여전히 존재합니다. 이를 해결하기 위해서는 Outbox 테이블과 같은 메시지 저장소가 만들어질 수밖에 없습니다.

 

 

결론

메시징 시스템을 사용할 때 어려운 점 중 하나는 메시지를 발행하는 시스템, 메시지를 중개하는 시스템, 메시지를 소비하는 시스템이 모두 분산되어 있다는 것입니다. 이번 글에서는 메시징 시스템을 사용할 때 발생하는 문제와 이를 Transactional Outbox 패턴을 통해 어떻게 해결하는 지에 대해 알아보았습니다. 메시징 방식을 이용하는 모든 시스템에서 Outbox 패턴을 사용할 필요는 없지만, 메시지 발행의 신뢰성을 보장할 필요가 있다면 Outbox 패턴을 고려해 보면 좋을 것 같습니다.

 

 

참고 자료