[말모] 트랜잭션 아웃박스 패턴으로 메시지 발행 보장하기 (AI 채팅 3편)

Choi Wontak·2025년 9월 16일

말모

목록 보기
8/9
post-thumbnail

난이도 ⭐️⭐️⭐️
작성 날짜 2025.09.17

고민 내용

지난번 OpenAI API 요청을 Message Queue로 처리하여 다음과 같은 장점을 얻을 수 있었다.

  1. 요청 실패 시 최대 3번의 자동 재요청
  2. DLQ로 실패 요청 관리 가능
  3. 트래픽 완화
  4. 실시간성 보장
  5. 확장성

그러나 아직 해결되지 못한 문제가 있다!

  1. Redis Stream 메시지 발행 시 오류가 발생한 경우 처리가 안된다

    실제로 오류가 발생한 경우,

    DB는 커밋되었지만 예외가 터져서 500 응답이 발생한다.
    이렇게 되면 사용자는 오류를 확인하고 비동기 요청도 발생하지 않지만,
    DB는 커밋되는 바람에 이후의 조회에서도 오류가 발생할 수 있다.
    또한 운영에서도 문제의 발생을 로그로만 확인할 수 있다.

  2. 컨슈머가 요청 처리 도중 죽어버린 경우

    컨슈머가 요청을 처리하던 도중 오류가 발생한 경우에는 자동으로 큐에 메시지를 발급한다.
    하지만 예외도 처리하기 전에 알 수 없는 이유로 서버가 죽으면,
    메시지는 PEL에 Pending 상태로 남고 재처리 되지는 않는다.

  3. DLQ에 박혀있는 실패 메시지들
    DLQ에서 직접 확인해보면 실패한 메시지들에 대한 확인이 가능하지만,
    이 메시지들을 하나하나 직접 재요청해야만 처리가 가능하다.

🤔 실패한 메시지들에 대해 데이터 정합성을 지키면서 자동 재시도 처리할 수는 없을까?


찾아보기

처음에는 실패한 메시지들을 어떻게 처리하지? 라는 의문이 생겼고,

그럼 DLQ를 주기적으로 조회해서 실패 메시지를 재발행 하면 되는 거 아닌가?

이렇게 생각했다.
하지만 메시지 발행조차 안되거나 컨슈머가 죽는 경우에는 메시지를 처리할 수가 없다. (처리할 메시지조차 없다ㅠㅠ)

그래서 찾아본 결과 메시지 발행을 보장하고 실패 메시지에 대한 일괄 처리를 보장하는 트랜잭션 아웃박스 패턴이라는 것을 알게 되었다.


트랜잭션 아웃박스 패턴 (transactional outbox pattern)


출처 : 대규모 트랜잭션을 처리하는 배민 주문시스템 규모에 따른 진화 (우아콘2023)

트랜잭션 아웃박스 패턴은 메시지 큐 환경에서 메시지 유실을 근본적으로 방지하는 전략이다.
비즈니스 데이터와 메시지를 하나의 트랜잭션으로 묶어 원자성을 확보하고,
별도의 안전한 경로를 통해 MQ로 발행함으로써 데이터 정합성을 보장한다.

트랜잭션 아웃박스 패턴의 원리

  1. 아웃박스 테이블 생성
    비즈니스 데이터와 같은 DB에 outbox 테이블을 둔다.

  2. 비즈니스 트랜잭션과 함께 메시지 저장
    애플리케이션은 비즈니스 데이터를 저장할 때 동시에 아웃박스 테이블에 메시지를 기록한다.
    이 과정은 하나의 DB 트랜잭션 안에서 처리되므로 원자성이 보장된다.

  3. 별도 프로세스(Outbox Poller)에서 메시지 발행
    아웃박스 테이블에 쌓인 메시지는 전용 프로세스(스케줄러)가 주기적으로 읽어 MQ(Kafka, RabbitMQ, Redis Stream 등)에 발행한다.

  4. 발행 성공 시 상태 업데이트
    MQ 발행이 성공하면 해당 메시지를 SENT 상태로 업데이트하거나 삭제한다.
    실패하면 재시도 로직을 수행한다.

상태 관리 전략

아웃박스 패턴에서는 메시지 상태를 관리해야 한다.
나의 경우에는 요런 식으로 하려고 한다.

  • PENDING : 발행 대기 상태
  • SENT : MQ로 정상 발행 완료
  • FAILED : 컨슈머가 메시지를 처리했지만, 실패한 상태
  • DONE : 메시지 처리가 완전히 완료

상태를 두는 이유는 메시지가 정상적으로 처리되었는지 추적하고, 장애 발생 시 재처리를 가능하게 하기 위함이다.

문제가 되는 상황은 PENDING 상태의 지속과 FAILED 상태

스케줄러는 두 가지 방식으로 체크한다.

  • 메시지 발행 보장(3초 마다 체크)
    PENDING 상태가 지속된지 5초가 지난 경우 다시 publish를 시도
    retry_count를 두고, 임곗값(3회) 초과 시 FAILED 처리

    이 상황은 MQ에 문제가 있거나 알 수 없는 이유로 메시지가 발행되지 않은 경우이다.
    이 과정은 빠르게 처리되는 것이 정상이기 때문에, 5초 이상 처리되지 않는 경우 처리가 되지 않았음으로 가정, 최대 8초까지의 지연을 허용한다.

  • API 호출 실패 및 메시지 발행 관리(5분 마다 체크)
    메시지가 FAILED 상태인 경우 다시 publish를 시도
    (컨슈머 로직 실패 또는 메시지 발행 반복된 실패인 경우)

    외부 API의 서버 문제 또는 메시지 큐의 문제상황으로 간주,
    5분 간격으로 API의 상태를 health check하고 FAILED 상태인 메시지를 재시도 처리한다.

SENT 상태의 지속은 어떻게 처리할까?
SENT 상태: 브로커는 메시지를 받았지만, 컨슈머가 아직 로직을 처리하지 않은 상황

이 상황이 지속된다는 것은 컨슈머가 로직 처리를 못 했거나, 컨슈머는 로직 처리를 완료했으나 OUTBOX 상태를 DB에 반영을 못한 경우다.

이 상황에 대해서는 따로 처리하지 않기로 결정했는데, 이유는 다음과 같다.

  1. 이 상황은 컨슈머의 문제이다.
    트랜잭션 아웃박스 패턴은 Producer를 위한 패턴이다.
    Producer의 트랜잭션은 커밋되었는데, 메시지는 처리되지 않은 경우를 위한 해결책이다.
    FAILED 상황을 따로 처리하는 경우는 부가적으로 외부 API 요청의 안정성을 보강하기 위한 방안이다.

  2. 추가적인 처리를 할 경우 중복 메시지 문제가 발생할 수 있다.
    컨슈머가 이미 처리했는데 DB에 반영만 안된 경우라면, 중복 메시지 발행으로 의도치 않은 데이터 처리가 발생한다.

  3. 2번의 문제를 피하려면 너무 상황이 복잡해진다.
    정리해보면 이렇다.

    • PEL에 존재 → 컨슈머 로직 도중 조용히 실패
      => 메시지 재발급 필요
    • PEL에 존재 X & DLQ에 존재 → 컨슈머 로직 도중 예외, DB 반영 실패
      => DONE으로 상태 변경
    • PEL에 존재 X & DLQ에도 존재 X → 컨슈머가 메시지를 못 가져옴
      => 컨슈머의 상태 변경 필요 (재시작 등)

따라서 이를 재시도 로직에 넣지 않고, DB를 모니터링하여 문제 발생 시 원인을 찾도록 할 예정이다.


장점

  • 메시지 유실 방지: DB와 MQ 발행 사이의 불일치를 제거한다.
  • 재시도 가능: 발행 실패 시 아웃박스 테이블을 기반으로 재시도할 수 있다.
  • 추적성 확보: 메시지의 상태와 이력을 관리할 수 있다.

단점

  • 추가 테이블 관리 필요: 아웃박스 테이블이 커질 수 있어 아카이빙 전략이 필요하다.
  • 지연(latency): 폴링 주기에 따라 메시지 발행이 지연될 수 있다.
  • 운영 복잡도 증가: 별도 메시지 발행 프로세스 관리가 필요하다.

그럼 Redis Stream 이제 필요 없는 거 아닌가..?

추가적인 인프라 비용이 발생하는 상황에서 굳이 메시지 큐가 필요한가?
그냥 스프링 애플리케이션 내부의 이벤트나 비동기 요청을 이용해서 처리하면 안 되나?
어차피 스케줄러가 재시도를 처리하는데, 3회 재시도 전략도 스케줄러를 통해 처리하면 되지 않을까?

그럼에도 Redis Stream을 사용하는 이유

  • 일단, 비동기 처리가 필요하다
    동기로 처리하면 클라이언트는 OpenAI의 응답이 완료될 때까지 요청을 기다려야 한다.
    쓰레드 풀 고갈로 인한 응답 지연이 발생할 수 있다.

  • 비동기 처리, 외부 아키텍처가 필요한가?
    AI 채팅은 서버에서 가장 부하가 많은 부분이다.
    스프링 어플리케이션 내부에서 단순 비동기 요청하는 것은 작업 큐에 작업을 제출하는 방식인데,
    해당 방식은 JVM 메모리 위에서 동작하기 때문에 요청이 몰려 서버가 죽는 경우 재부팅 시 모두 유실된다.

    반면에 Redis Stream은 외부 인프라를 사용하기 때문에 스프링의 장애에서 분리되며, 재부팅 시에도 영속화 설정을 통해 유지 가능하다.

    병렬 처리에 있어서도 장점이 있는데, MQ는 자동으로 여러 컨슈머로 작업을 뿌려주지만,
    그냥 내부 비동기를 사용할 경우 여러 개의 스레드 풀을 등록하여 직접 분산시켜 주어야 여러 개의 작업 큐를 사용할 수 있다.
    그렇지 않으면 그냥 하나의 작업 큐만 사용하니 병목 문제가 발생할 수 있다.
    나아가 MQ에서는 컨슈머가 느리더라도 버퍼링을 통해 컨슈머 속도에 맞춰 처리가 가능하다.

  • 그리고 확장성...
    컨슈머 그룹 기준으로 스케일 아웃이 가능하다.
    요청이 많아지는 경우 IO 작업을 처리하는 컨슈머만 분리하여 여러 개로 증설할 수 있다.

    현재도 논블로킹 IO 작업을 통한 효율적인 스레드 관리를 위해 WebClient를 사용 중이다.
    이후 IO 작업이 많은 컨슈머 작업은 WebFlux로 변경할 수 있기 때문에 컨슈머를 분리하는 것은 확장성을 고려한 선택이다.

Redis Stream 컨슈머의 재시도 처리 vs 스케줄러의 재시도 처리

채팅은 실시간성이 보장되어야 하기 때문에 최대한 빠른 재시도가 필요하고,
3번 이상 실패한 경우에는 API 서버에 문제가 있다고 판단하여 나중에 일괄 재시도해야 한다.

이 경우에는 너무 자주 DB를 조회해 부하가 발생하지 않도록 Scheduler의 시간을 일정 시간 이상 두어야 한다.

따라서 일정 주기로 재시도 하게 되며, 실시간성을 보장하지 않는다.

컨슈머가 실패 즉시 2회 재시도(총 3회 시도)하여 API의 성공 확률을 높이고,
스케줄러는 API 서버의 상태를 확인하고 10분 단위로 재시도하여 안정적인 메시지의 발행과 일괄적인 재시도를 담당한다.

Outbox는 “DB와 이벤트 발행 사이 일관성 보장”
Redis Stream은 “실시간 분산 전달 및 처리”

각각의 역할 분담 정도로 생각하면 좋을 것 같다.


아직 남은 메시지 중복 처리 문제

만약 beforeCommit에서 Outbox 메시지를 저장하고, afterCommit에서 Redis Stream을 발행했을 때

Producer가 메시지를 발행하는 것보다 Scheduler가 빠르게 아웃박스를 조회해 메시지를 발행하면 어떻게 될까?

스케줄러의 조회 시간을 충분하게 둔다고 하더라도 알 수 없는 이유로 메시지 발행이 지연된 경우에는 중복 메시지 문제가 발생할 수 있다.

해결 방법으로 생각해 본 것은 다음과 같다.

  1. 메시지 발행의 주체는 Only 스케줄러만
    Producer의 역할은 아웃박스에 메시지를 저장하는 것 까지만.
    스트림으로 발행하는 것은 Scheduler가 Pending 상태의 메시지를 넣어주는 것으로 책임을 완전히 분리한다.

    이렇게 하면 책임이 명확해지고, 구조가 단순해진다는 장점이 있다.
    그러나 스케줄러가 동작하는 주기에 의해 딜레이 되기 때문에 실시간성을 유지할 수 없다.

  2. CDC(Change Data Capture)
    DB에서 데이터의 변화를 실시간으로 감지하고 Stream으로 발행한다.

    실제 AWS에서 지원하는 DynamoDB의 추가 설정을 통해 구현이 가능하다.
    DynamoDB에 변화를 캡쳐할 테이블을 설정하고, 변화가 감지되면 Stream으로 메시지를 발행하는 구조이다.

    이렇게 하면 실시간성을 확보할 수 있다! 그러나 추가적인 인프라 구성에 대한 부담이 존재한다. 단일 인스턴스 환경에서 이러한
    실제 MSA 구성에서는 이러한 방식이 도움이 될 것 같아서 일단 알아만 두고 넘어가려고 한다!

  3. beforeCommit에서 아웃박스에 저장, afterCommit에서 저장된 아웃박스를 활용
    단순하게 메시지를 발행은 무조건 아웃박스를 거치는 방법이다.
    만약 스케줄러가 메시지를 발행한 상태라면 메시지의 상태가 SENT로 변경되었을 것이며,
    아웃박스 ID와 함께 PENDING 상태인 메시지를 조건으로 조회해 발행한다면 중복 메시지 문제를 피할 수 있을 것이다.

이 방식 역시 완전히 스레드 경합 문제에서 자유롭지는 않다.

  • 스케줄러의 스레드에서 메시지 조회
  • Producer(afterCommit)의 메시지 조회
  • 스케줄러의 메시지 발행
  • Producer의 메시지 발행

이 순서의 문제가 존재하기 때문이다.
이 문제는 비관적 락으로 해결 가능할 것으로 보이지만,
트래픽이 많지 않은 현재 상황에서 적용 시 성능 저하로 인한 실시간성 문제가 생긴다는 트레이드 오프를 고려하여 적용하지 않기로 하였다.

3번 방식만 적용하더라도 그냥 발행부터 하던 과거 방식과는 달리 일차적인 검증 단계가 들어가기 때문에 어느 정도 이슈에서 자유로울 것으로 예상한다.


적용하기

OutboxEntity

아웃박스 패턴에서 메시지 저장을 위한 엔티티이다.
json 형태의 payload를 저장하는데, 마찬가지로 암호화 대상이다.(이전 포스팅 참고)

PENDING 상태로 무한 재시도 하는 것을 막기 위해 retryCount를 추가했고, 해당 카운트가 임곗값을 넘으면 FAILED 상태로 변경한다.

상태값은 다음과 같다.

OutboxHelper

OpenAI API를 이용해야 하는 메서드들이 호출하는 publish 메서드이다.
Outbox를 통해 메시지를 저장하는 것까지만 진행하고,
책임과 트랜잭션의 완전 분리를 위해 ApplicationEvent를 발행한다.

OutboxMessageSavedEventListener

OutboxMessageSavedEvent를 감지하는 Listener 클래스이다.
이전 포스팅 확인했던 경합 문제를 해결하기 위해 트랜잭션 종료 후(AFTER_COMMIT) 상황에 실행되도록 하였고,
REQUIRES_NEW를 통해 새로운 트랜잭션으로 분리를 명시하였다.

OutboxService (publish)

Outbox 테이블에서 앞서 저장한 Outbox의 ID를 통해 Outbox를 조회하고, payload 값을 담아 Stream에 실질적으로 발행한다.
이전과 다른 점은 Outbox의 ID 값을 메시지에 담는다는 것인데,
메시지를 처리하면 Outbox의 상태를 FAILED 또는 DONE으로 바꾸기 위한 장치이다.
messageId를 이용하지 않은 이유는 재시도(재발행) 로직으로 인해 메시지의 ID 값은 달라질 수 있지만, Outbox의 ID는 동일하기 때문이다.

Consumer

소비자 로직은 이전과 거의 동일하다.
메시지 처리 성공 시 Outbox 메시지를 조회해 DONE 상태로 마킹한다.

만약 메시지 처리에 실패하면 DLQ에 넣으면서 Outbox 메시지를 FAILED 처리한다.
이렇게 마킹 처리된 메시지들은 Scheduler가 처리한다.

OutboxScheduler

3초마다 5초 이상 PENDING이 지속되는 메시지를 조회하여 처리한다.
PENDING 상태에서 SENT가 되지 않은 메시지는, 초기 로직이 실행되어 DB에 반영되었으나, 발행되지 않은 경우로 Scheduler가 이를 처리한다.

5분마다 FAILED 상태인 메시지들을 발행 시도한다.
FAILED 상태는 지속적으로 메시지 발행에 실패하거나 API 호출에 실패한 메시지들로, Scheduler가 이를 재발행하여 처리한다.

OutboxService (scheduled)

Scheduler가 호출하는 재시도 로직이다.
PENDING 상태이면서 해당 상태가 5초 이상 지속된 경우, 메시지를 스트림에 발행한다.

발행 성공한 메시지만 Outbox에 기록한다.
messageId를 등록하고 상태를 SENT로 변경한다.
만약 실패한 메시지의 처리 횟수가 3회 이상이 된 경우 FAILED 상태로 변경한다.

FAILED 상태를 가진 메시지를 처리한다.
FAILED 상태인 메시지들은 OpenAI API의 상태가 불안정한 경우일 수 있기 때문에 Health Check를 먼저 진행한다.

이전과 달리 지속 시간에 대한 조건이 없는 이유는 PENDING 상태인 경우 현재 Publishing이 진행 중일 수 있기 때문에 필요한 조건이었으며,
여기에서는 API 상태에 문제가 생긴 경우이기 때문에 API가 회복되면 FAILED 상태인 메시지들을 모두 처리해야 한다.

너무 많은 메시지 처리로 인한 문제가 생길 경우에는 Chunk 단위로 나누어 처리할 필요도 있을 것 같다!


RedisStreamAdapter

많은 양의 Outbox를 Batch 처리하기 위한 메서드이다.
개념 상 List를 받아 publish 처리하는 메서드이기 때문에 batch라는 키워드를 사용하였지만, 실제 레디스 내부적으론 Stream으로 처리한다.

왜냐하면 Redis Stream의 XADD가 단일 메시지만 처리할 수 있기 때문이다ㅠㅠ
조금이라도 처리를 효율적으로 하기 위해 Pipeline을 이용하였다.

그냥 단순히 XADD를 N번 처리할 수도 있지만 이 방식은 네트워크 왕복 시간(RTT)도 N배가 된다.
Pipeline을 이용하면 XADD 명령을 명령어 큐에 차곡차곡 쌓아두고, 한 번에 전달하기 때문에 네트워크 왕복이 한 번만 발생한다.


컨슈머가 요청 처리 도중 죽어버린 경우 해결하기

위의 코드로 거의 모든 조건을 만족했다.
그런데 2번인 "컨슈머가 요청 처리 도중 죽어버린 경우"를 아직 해결하지 못 했다.

계획 단계에서 SENT로 처리된 아웃박스 메시지를 처리하지 않기로 하면서 해당 부분을 어떻게 해결할 수 있을지 고민했는데, 다음과 같이 결론내렸다.

PEL에 너무 오래 남아있으면서, 아웃박스 상태가 DONE이 아닌 메시지를 재처리하자.

일단 이 컨슈머 그룹에서 PEL에 너무 오래 남아있다는 것은 컨슈머가 죽어서 제대로 처리되지 않았음을 의미한다.
그리고 이미 처리되었지만 ACK 처리되지 않아 PEL에 남아있는 경우를 재처리를 피하기 위해 DONE 상태 조건을 넣었다.

이걸 그냥 메시지 재발행하는 것은 PEL에 메시지가 쌓여 후에 메모리 이슈가 될 수 있으며, 메시지 중복에 대한 문제도 될 수 있다.

그래서 XPENDINGXCLAIM을 사용하였다.
XPENDING을 통해서 해당 컨슈머 그룹의 PENDING 상태의 메시지를 조회할 수 있다.
그리고 XCLAIM은 컨슈머에게 할당 된 메시지를 다른 컨슈머에게 재할당하는 명령이다.

컨슈머가 죽어있는 상태이기 때문에, 컨슈머 그룹 내의 모든 PEL 메시지들을 별도의 Consumer로 옮기고, 해당 컨슈머를 이용해 ACK 처리!
하는 게 내 계획이다.

그리고 메시지에 담긴 outboxId를 조회해 해당 Outbox가 DONE 상태가 아니라면 FAILED 상태로 변경한다. (이건 이제 FAILED를 처리하는 스케줄러가 처리한다.)

OutboxScheduler

10분마다 스케줄러가 job을 실행한다.

retryPendingMessages

PENDING 상태에서 5분 이상 지난 모든 PEL 메시지를 조회한다.

해당 메시지의 outboxId를 조회해 DONE 상태가 아닌 메시지를 FAILED 처리하고, ACK 명령을 보내 PEL에서 제거한다.

loadPendingMessages

컨슈머 그룹의 Pending 메시지를 모두 조회한다.

이제 각 컨슈머의 메시지를 가져와서 minIdleTime (5분)에 해당하는 메시지가 있다면 Claim 명령을 보내어 별도의 컨슈머로 옮긴다.

그리고 allClaimedMessages로 모아서 한 번에 return 한다.

이제 PEL에 쌓인 메시지는 ACK 되어 제거되기 때문에 메모리를 지킬 수 있으며,
아웃박스에서 메시지는 FAILED 처리되어 스케줄러에 의해 재처리된다.


결론

'데이터 정합성'이라는 키워드에 꽂혀 결점이 없는 서버를 만들자는 목표로 트랜잭션 아웃박스 패턴을 도입해보았다.
스스로 정말 오랜 시간 고민하고, 또 고민한 문제였다.
단순하게 적용하지 않고 상황에 맞는 방식으로 도입하다 보니 설계에만 꽤 많은 시간을 쏟은 것 같다.

물론 이게 진짜 최선의 선택인지도 정확히 잘 모르겠고 시간도 오래 걸렸지만,
Trade-off를 고민해보며 다양한 관점으로 문제 해결을 위해 고민했던 과정이 큰 도움이 된 것 같다!

이후에 확장된다면, 이런 것들을 고려해볼 수 있을 것 같다.

  • 트래픽이 많아지는 경우 Outbox Chunk 단위로 조회
    현재는 트래픽이 많지 않기 때문에 전체를 조회한다.

  • Outbox 상태 변경을 위한 효율적인 쿼리 도입
    현재는 빠른 개발을 위해 JPA를 최대한 활용하고 있다.

  • 분산 시스템으로 확장된 경우 CDC 도입

결론
안정적인 서버 구축의 방법이 무궁무진하다는 것을 알게 되었다.
항상 외부 시스템을 의심하자!


참고한 자료들

우아한테크 - 대규모 트랜잭션을 처리하는 배민 주문시스템 규모에 따른 진화
https://youtu.be/704qQs6KoUk?si=AJ3tFOeWbVihLYOu

분산 시스템에서 메시지 안전하게 다루기 - 강남언니 공식블로그
https://blog.gangnamunni.com/post/transactional-outbox

트랜잭션 아웃박스 패턴 - AWS 권장 가이드
https://docs.aws.amazon.com/ko_kr/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html

profile
백엔드 주니어 주니어 개발자

0개의 댓글