Post

Redis + Outbox 패턴을 활용한 랜덤 지수 백오프 재시도 구현

🎬 Intro

Redis + Outbox 패턴을 활용하여 랜덤 지수 백오프 재시도 로직 설계를 소개 합니다.

결론

Redis 메시지 큐와 Outbox 패턴을 도입하여 기존 재시도 메커니즘의 핵심 문제들을 개선했습니다.

  • 재시도 대기 시간 동안 최대 6초 스레드 블로킹0초로 개선

  • 랜덤 지수 백오프 방식으로 외부 API 서버 부하 가중 개선

  • Outbox 패턴과 DLQ로 메시지 유실 방지 및 장애 추적 체계 구축

기존 방식

img.png

기존 방식의 흐름은 위와 같으며 개선할 포인트들은 다음과 같습니다.

1. 스레드 블로킹

img.png

재시도시 Spring Retry를 사용하고 있습니다. Spring Retry의 재시도 대기 메커니즘은 위와 같이 대기 시간 만큼 스레드를 sleep 시킵니다. 따라서 현재 구조에서 최대 6초 동안 스레드가 블로킹 되어 비효율적으로 사용되게 됩니다.

2. 확장성의 부재

voyage api를 사용하는 클러스터링 기능이 중요해지면서 스케일 아웃을 고려하고 있습니다. 현재 구조는 분산환경에서 재시도시 DB락을 고려해야하기 때문에 확장이 유연하지 않은 상황입니다.

3. 재시도 방식

기존의 경우 고정 지수 백오프 방식이므로 분산 환경에서 요청이 겹칠 경우 외부 voyage api 서버에 부하가 가중될 위험이 있습니다. 따라서 고정 지수 백오프가 아닌 랜덤 지수 백오프 방식으로 수정이 필요합니다.

4. 재시도 작업간의 높은 결합도

voyage api 작업외에 재시도가 필요한 작업들을 1개의 테이블에서 관리하기 때문에 테이블 결합도가 높은 상황 입니다. 따라서 voyage api 재시도 작업을 위한 테이블을 따로 만들어 관심사를 분리할 필요가 있습니다.

개선 과정

✅ Redis + Outbox VS Resilence4j + ScheduleExecutorService

Resilence4j + ScheduleExecutorService은 재시도를 논블로킹 방식으로 처리하긴 하지만 매번 ScheduledFutureTask객체를 생성 하게됩니다. 따라서 재시도가 많아질 경우 OOM의 위험이 있고, 만약 서버가 죽을 경우 예약된 재시도가 모두 사라지게 됩니다.

img.png

그래서 최종적으로 위와 같이 Redis + Outbox 패턴을 이용하여 재시도 로직을 설계하기로 하였습니다. 해당 아키텍처의 흐름은 다음과 같습니다.

  • Voyage ai 호출 실패
  • Outbox에 실패 메세지 저장 (트랜잭션 A)
  • Redis로 메세지 발송 이벤트 발행
  • 트랜잭션 A 커밋 후 Redis로 메세지 발송
  • Redis 메세지 발송 성공 시 Outbox에 실패 메세지 삭제 (트랜잭션 B)
  • Redis에서 백오프 시간 만큼 지연 이후 재시도 처리
  • 재시도 처리 성공 시 Outbox에서 실패 메세지 삭제 (트랜잭션 C)
  • 재시도 5회 실패 시 DLQ로 메세지 이동

여기서 중요한 포인트는 메세지 지연 처리 입니다. 이를 위해 Redisson의 RDelayed Queue를 고려하였지만 해당 기능은 deprecated 되어 다른 라이브러리를 사용하였습니다. 사용한 라이브러리는 Rqueue 입니다. 해당 라이브러리는 Spring 친화적이고, 메세지 지연, 타임아웃, Dead Letter 처리 등 다양한 기능을 제공하기 때문에 현재 프로젝트에 적합하다고 판단하였습니다.

✅ Rqueue

Rqueue의 메세지 처리 메커니즘은 Delayed Queue(ZSET), Execution Queue(LIST), Processing Queue(ZSET), Dead Letter Queue(LIST) 이 4가지 자료구조를 활용합니다. 각 자료구조의 기능을 살펴보면 다음과 같습니다.

QueueRedis 타입역할
Delayed QueueZSET백오프 시간만큼 메세지 지연
Execution QueueLIST즉시 실행 가능한 메세지 보관 및 Consumer에게 전달
Processing QueueZSET처리 중 메세지 백업 (Consumer 장애 대비)
Dead Letter QueueLISTn회 재시도 실패 메세지 보관

Delayed Queue(ZSET)

이름 그대로 메세지 지연 기능을 위한 자료구조이며, Redis의 ZSET을 활용합니다. ZSET의 score에는 백오프 시간을 할당하고, member에는 메세지 내용을 할당 합니다. ZRANGE를 통해 score에 할당된 시간과 현재시간을 비교하여 Execution Queue(LIST)로 이동 시킵니다. 이러한 메커니즘으로 메세지를 백오프 시간만큼 지연시킬 수 있습니다.

Execution Queue(LIST)

즉시 실행 가능한 메세지를 보관하는 자료구조이며, Redis의 LIST를 활용합니다. Delayed Queue에서 백오프 시간이 지난 메세지가 RPUSH로 추가되면, Consumer 스레드가 LPOP으로 메세지를 꺼내어 처리합니다. LPOP은 원자적 연산으로, 여러 Consumer가 동시에 대기하고 있어도 하나의 메세지는 정확히 하나의 Consumer에게만 전달되어 동시성 문제를 방지할 수 있습니다.

Processing Queue(ZSET)

처리 중인 메세지를 백업하는 자료구조이며, Redis의 ZSET을 활용합니다. ZSET의 score에는 visibilityTimeout 만료 시각을 할당하고, member에는 메세지 내용을 할당합니다. Consumer가 LPOP으로 메세지를 꺼내는 즉시 이 Queue에 백업되며, 정상 처리 시 ZREM으로 제거됩니다. 만약 visibilityTimeout을 초과하면 ZRANGE로 감지하여 메세지를 회수하고 재시도를 진행합니다. 이를 통해 Consumer 장애 시에도 메세지 유실을 방지할 수 있습니다.

Dead Letter Queue(LIST)

5회 재시도가 모두 실패한 메세지를 보관하는 자료구조이며, Redis의 LIST를 활용합니다. 재시도 횟수를 초과한 메세지가 RPUSH로 추가됩니다. 이 Queue의 메세지는 자동으로 삭제되지 않으며, 관리자가 수동으로 확인하고 처리할 수 있습니다.

✅ Rqueue 적용하기

Rqueue Config

1
2
3
4
5
6
7
8
9
@Configuration
public class RqueueCustomConfig {
    @Bean
    public SimpleRqueueListenerContainerFactory simpleRqueueListenerContainerFactory() {
        SimpleRqueueListenerContainerFactory factory = new SimpleRqueueListenerContainerFactory();
        factory.setTaskExecutionBackOff(new RandomExponentialBackOff());
        return factory;
    }
}
  • Rqueue의 메시지 리스너 컨테이너를 생성하고 설정하는 클래스 입니다
  • 백오프를 랜덤 지수 백오프 방식으로 설정하기 위해 사용하였습니다.

RandomExponentialBackOff

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class RandomExponentialBackOff implements TaskExecutionBackOff {

    private static final long[] MIN_DELAYS = {0L, 1000L, 2000L, 4000L, 8000L};
    private static final long[] MAX_DELAYS = {1000L, 2000L, 4000L, 8000L, 16000L};
    private static final Random random = new Random();

    @Override
    public long nextBackOff(Object queue, RqueueMessage message, int failureCount) {
        if (failureCount >= MIN_DELAYS.length) {
            return STOP;
        }
        long minDelay = MIN_DELAYS[failureCount];
        long maxDelay = MAX_DELAYS[failureCount];

        return random.nextLong(minDelay, maxDelay + 1);
    }
}
  • 재시도 차수가 증가함에 따라 0~1초, 1~2초, 2~4초, 4~8초, 8~16초 형식으로 대기 시간이 지수적으로 증가합니다.
  • 이를 통해 각 워커 스레드가 voyage ai 호출이 겹치는 것을 방지할 수 있습니다.

Consumer 쪽 코드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RqueueListener(
  value = VOYAGE_RETRY_EXECUTION_QUEUE, // Execution Queue
  numRetries = "5", // 재시도 횟수
  visibilityTimeout = "60000", // 타임 아웃
  deadLetterQueue = VOYAGE_RETRY_DLQ, // DLQ
  deadLetterQueueListenerEnabled = "true", // DLQ 리스너 활성화
  concurrency = "10" // 워커 스레드
)
public void consumeRetryTask(final VoyageRetryTask task) {
  Long feedbackId = task.getFeedbackId();
  try {
    clusteringService.clusterForRetry(feedbackId);
    voyageRetryQueueService.deleteOutboxByFeedbackId(feedbackId);

  } catch (Exception e) {
    log.error("메세지 처리 실패: feedbackId={}", feedbackId, e);
    throw new VoyageRetryFailedException("Voyage AI 재시도 실패: feedbackId=" + feedbackId, e);
  }
}
  • 어노테이션 기반으로 컨슈머의 동작을 설정합니다.
  • value: Execution Queue(LIST)를 지정하여 BLPOP으로 메시지를 수신합니다.
  • numRetries: 5회 재시도를 설정하며, 각 재시도마다 RandomExponentialBackOff가 적용됩니다.
  • visibilityTimeout: 60초로 설정하여 Consumer 장애 시 메시지를 자동으로 회수합니다.
  • deadLetterQueue: 5회 재시도 실패 시 DLQ로 메시지를 이동시킵니다.
  • concurrency: 10개의 워커 스레드로 재시도를 병렬 처리하도록 설정 합니다.

✅ OutBox 적용하기

Outbox Table

img.png

  • 재시도가 필요한 작업의 식별자와 실패 맥락만 확인할 수 있게 테이블은 최대한 간단하게 구성하였습니다.

메세지 Outbox에 저장 및 Redis에 발송

1
2
3
4
5
6
7
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void retryTask(final Long feedbackId, final String errorMessage) {
  VoyageRetryOutbox outbox = VoyageRetryOutbox.create(feedbackId, errorMessage);
  outboxRepository.save(outbox);
  OutboxCreatedEvent event = OutboxCreatedEvent.of(feedbackId);
  eventPublisher.publishEvent(event);
}
  • 메세지를 DB에 저장하여 유실을 방지합니다.
  • 이벤트를 발행하여 Redis 전송을 처리합니다.

Redis 메세지 발송 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Async("retryExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOutboxCreated(final VoyageRetryOutboxCreatedEvent event) {
  final Long feedbackId = event.getFeedbackId();
  final VoyageRetryTask task = VoyageRetryTask.of(feedbackId);
  try {
    rqueueMessageEnqueuer.enqueue(VOYAGE_RETRY_EXECUTION_QUEUE, task);
    outboxRepository.deleteByFeedbackId(feedbackId);
  } catch (Exception e) {
    final VoyageRetryOutbox voyageRetryOutbox = outboxRepository.findByFeedbackId(feedbackId)
      .orElseThrow(() -> new ResourceNotFoundException("voyage_retry_outbox에 존재하지 않는 feedback 입니다."));
    voyageRetryOutbox.updateErrorMessage(e.getMessage());
  }
}
  • 비동기로 Redis 전송하여 메인 트랜잭션을 블로킹하지 않습니다.
  • Redis 전송 성공 시 Outbox에서 즉시 삭제하여 불필요한 데이터 축적을 방지합니다.

5분 주기로 발송 실패 메세지 재발송 처리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Scheduled(fixedRate = 300000)
@Transactional
public void pollOutboxAndRetry() {
  retryPendingOutboxes();
  recoverStuckOutboxes();
}

private void retryPendingOutboxes() {
  voyageRetryOutboxRepository.findAllByStatus(OutboxStatus.PENDING)
    .forEach(outbox -> {
      try {
        applicationEventPublisher.publishEvent(
          VoyageRetryOutboxCreatedEvent.of(outbox.getFeedbackId())
        );
      } catch (Exception e) {
        log.error("폴링 실패: feedbackId={}", outbox.getFeedbackId(), e);
      }
    });
}

private void recoverStuckOutboxes() {
  LocalDateTime threshold = LocalDateTime.now().minusMinutes(STUCK_THRESHOLD_MINUTES);
  voyageRetryOutboxRepository.findStuckOutboxes(threshold)
    .forEach(outbox -> outbox.updateStatus(OutboxStatus.PENDING));
}
  • 5분 주기로 Outbox를 폴링하여 Redis 발송 실패 메시지를 재발송 합니다.
  • 재발송 성공 시 Outbox의 해당 메세지를 삭제합니다.
  • 메세지 발송 처리(ENQUEUED) 후 10분 이상 ENQUEUED 상태로 있을 경우,stuck 메시지로 판단하고 이를 다시 PENDING 상태로 복구합니다.
  • 따라서 정상적인 경우라면 테이블이 비어있기 때문에 DB 폴링 부하가 적습니다.

✅ 테스트 해보기

1. Outbox 메세지 적재 확인

img.png 메세지 발송이 실패했을때 Outbox에 메세지가 적재되는것을 확인할 수 있습니다. 이를 통해 메세지 유실을 방지할 수 있습니다. 해당 메세지들은 5분 주기로 재발송 처리가 됩니다.

2. 스레드 활용 부분

Before

img.png

백오프 시간만큼 스레드가 대기하여 최대 6초의 시간 동안 스레드가 블로킹 되게 됩니다.

After

img.png

이전 방식과 동일한 시나리오로 테스트를 구성하였습니다. 개선된 방식은 백오프 시간 만큼 Delayed Queue에서 지연을 시키기 때문에, 스레드가 백오프 시간만큼 블로킹 되지 않습니다. 또한 스레드풀에 있는 각 워커 스레드가 병렬적으로 재시도 작업을 처리하고 있는것들 확인할 수 있습니다.

3. DLQ 동작 확인

img.png

img.png

재시도를 5회 이상 실패하면 DLQ에 메세지가 쌓이게 됩니다. 또한 Rqueue에서 제공하는 대시보드를 통해 DLQ의 메세지 적재 상태 및 작업 큐의 상태도 확인할 수 있습니다.

성과 내용

✅ 재시도시 최대 6초 스레드 블로킹 → 0초로 개선

  • Before: Spring Retry의 Thread.sleep으로 인해 재시도마다 스레드가 최대 6초 동안 블로킹
  • After: Redis Delayed Queue를 활용하여 백오프 시간 동안 스레드를 해제

✅ 도메인 관심사 분리

  • Before: 서로 다른 도메인의 재시도를 하나의 테이블에서 관리
  • After: voyage api 재시도를 전용 Outbox 테이블로 분리하여 도메인별 독립적 관리 및 확장 가능

✅ Outbox 패턴으로 메시지 유실 방지

  • Redis 발송 실패 시 DB Outbox에 메시지 백업
  • 5분 주기 폴링으로 재발송 처리

✅ DLQ를 통한 최종 실패 메시지 추적 가능

  • 5회 재시도 실패 메시지를 DLQ에 자동 격리
  • Rqueue 대시보드로 실시간 모니터링
This post is licensed under CC BY 4.0 by the author.