배치 자동화 시스템이나 비동기 워커(Celery, RQ, BullMQ 등) 파이프라인을 운영할 때 외부 서드파티 API(결제 게이트웨이, 공공데이터 API, SMS 알림 등) 호출 실패는 불가피한 일상적 현상입니다. 하지만 일시적인 네트워크 순단에 대응하기 위해 단순 재시도(Fixed-interval Retry)를 적용하면 시스템에 치명적인 재시도 폭풍(Retry Storm)과 눈사태 붕괴(Thundering Herd Problem)가 발생합니다.
모든 워커가 고정된 주기(예: 1초 뒤)로 일제히 재요청을 보내면 회복 중이던 외부 서비스가 다시 다운되고, 복구 불가능한 영구 결함 데이터(Poison Pill)가 큐 맨 앞을 계속 점유하면서 정상 작업들의 처리가 지연되는 헤드 오브 라인 블로킹(Head-of-Line Blocking)이 발생합니다. 본 글에서는 무작위 지연을 결합한 풀 지터 지수 백오프(Exponential Backoff with Full Jitter)와 실패 메시지를 안전하게 분리 격리하는 데드 레터 큐(Dead Letter Queue, DLQ) 아키텍처를 분석합니다.
1. 재시도 폭풍 방지: 지수 백오프와 풀 지터(Full Jitter)의 수학적 원리
단순 지수 백오프(base * 2^attempt)는 재시도 간격을 점진적으로 늘려주지만, 동일 시점에 실패한 수천 개의 워커가 정확히 같은 타이밍에 다음 요청을 재전송하므로 주기적인 트래픽 피크(Spike)를 유발합니다. AWS 아키텍처 연구(Marc Brooker)에 따르면 균일한 트래픽 분산을 달성하기 위해서는 풀 지터(Full Jitter) 방식을 적용해야 합니다.
import random
import time
def calculate_full_jitter_backoff(
attempt: int,
base_delay: float = 1.0,
max_delay: float = 60.0
) -> float:
"""
Full Jitter 기반 지수 백오프 지연 시간 계산
formula: sleep = random_between(0, min(max_delay, base_delay * 2^attempt))
"""
# 1. 지수 증가 상한선 계산 (Truncated Exponential Backoff)
ceiling = min(max_delay, base_delay * (2 ** attempt))
# 2. 0부터 상한선 사이의 균등 분포 무작위 지연 선택
sleep_duration = random.uniform(0.0, ceiling)
return sleep_duration
풀 지터 알고리즘은 재시도 간격의 상한(Ceiling)만을 지수적으로 증가시키고, 실제 대기 시간은 0부터 상한 사이의 무작위 값으로 분산시킵니다. 이를 통해 수천 개의 워커가 동시에 실패하더라도 다운스트림 서버에 가해지는 요청 부하가 시간 축 전체에 걸쳐 균일하게 평탄화(Flattening)됩니다.
2. 영구 결함 격리: 데드 레터 큐(DLQ) 라우팅 상태 머신
네트워크 타임아웃이나 HTTP 503과 같은 일시적 결함(Transient Error)은 백오프 재시도로 해결되지만, 잘못된 페이로드 포맷(HTTP 400), 인증 만료(HTTP 401), 엔드포인트 미존재(HTTP 404)와 같은 영구적 결함(Permanent Error)은 재시도 횟수를 소진하는 즉시 데드 레터 큐(Dead Letter Queue)로 격리해야 합니다.
import json
import logging
from typing import Dict, Any
logger = logging.getLogger("AsyncQueueWorker")
class ResilientWorkerPipeline:
def __init__(self, main_queue, dead_letter_queue, max_retries: int = 5):
self.main_queue = main_queue
self.dlq = dead_letter_queue
self.max_retries = max_retries
def process_message(self, message: Dict[str, Any]):
headers = message.get("headers", {})
retry_count = headers.get("x-retry-count", 0)
task_id = message.get("task_id")
try:
# 비즈니스 태스크 실행
self.execute_external_call(message.get("payload"))
logger.info("작업 정상 처리 완료: task_id=%s", task_id)
self.main_queue.ack(message)
except TransientNetworkException as exc:
# 1. 일시적 결함: 최대 재시도 한도 검사
if retry_count < self.max_retries:
backoff_sec = calculate_full_jitter_backoff(retry_count)
headers["x-retry-count"] = retry_count + 1
headers["x-last-error"] = str(exc)
logger.warning(
"일시적 장애 발생, %0.2f초 후 재시도 (시도 %d/%d): task_id=%s",
backoff_sec, retry_count + 1, self.max_retries, task_id
)
# 지연 큐(Delay Queue)로 재인입
self.main_queue.requeue_with_delay(message, delay_sec=backoff_sec)
else:
# 2. 재시도 한도 초과: DLQ로 라우팅
logger.error("최대 재시도 횟수 초과. DLQ로 격리: task_id=%s", task_id)
self.route_to_dlq(message, reason="MAX_RETRIES_EXCEEDED", error=str(exc))
except PermanentBusinessException as exc:
# 3. 영구적 결함(Poison Pill): 재시도 없이 즉시 DLQ로 격리
logger.error("영구적 비즈니스 결함 감지. 즉시 DLQ 격리: task_id=%s, err=%s", task_id, exc)
self.route_to_dlq(message, reason="PERMANENT_ERROR", error=str(exc))
def route_to_dlq(self, message: Dict[str, Any], reason: str, error: str):
message["headers"]["x-dlq-reason"] = reason
message["headers"]["x-dlq-error"] = error
message["headers"]["x-dlq-timestamp"] = time.time()
self.dlq.publish(message)
self.main_queue.ack(message) # 메인 큐에서는 확인 처리하여 큐 정체 해소
1. 포이즌 필(Poison Pill) 즉시 분류: 입력 JSON 파싱 오류나 스키마 불일치는 아무리 재시도해도 성공할 수 없습니다.
try-except 단계에서 복구 불가능한 예외는 재시도 횟수 소진을 기다리지 않고 즉시 DLQ로 직행시켜 큐 리소스를 절약해야 합니다.<br>2. DLQ 적재 알림 및 모니터링 연동: DLQ는 데이터의 무덤이 되어서는 안 됩니다. DLQ 적재율이 임계치를 넘으면 슬랙/웹훅 알림을 발송하고, 원인 분석 후 버그를 수정하여 메인 큐로 재주입(Redrive/Replay)할 수 있는 관리 도구를 갖추어야 합니다.<br>
3. 지연 큐(Delay Queue) 구현 분리: Redis ZSET이나 RabbitMQ Dead Letter Exchange의 TTL을 활용하여, 백오프 대기 시간 동안 워커 스레드가 블로킹(
time.sleep)되지 않도록 메시지 브로커 레벨에서 지연을 제어해야 합니다.
4. 결론 및 실무 권장사항
분산 비동기 큐의 안정성은 시스템이 정상 작동할 때가 아니라 외부 서비스가 붕괴했을 때 어떻게 대응하느냐에 달려 있습니다. 풀 지터 지수 백오프로 재시도 폭풍을 억제하고, 엄격한 DLQ 라우팅으로 결함 메시지를 격리하는 아키텍처를 구축함으로써 외부 환경의 변동 속에서도 견고한 무중단 자동화 파이프라인을 유지할 수 있습니다.