비동기 작업 큐의 재시도 폭풍을 방어하는 데드 레터 큐(DLQ) 격리와 지수 백오프 설계

비동기 작업 큐의 재시도 폭풍을 방어하는 데드 레터 큐(DLQ) 격리와 지수 백오프 설계
비동기 작업 큐의 재시도 폭풍을 방어하는 데드 레터 큐(DLQ) 격리와 지수 백오프 설계

대용량 알림 발송, 외부 API 배치 연동, 이미지 리사이징 등 실시간 응답이 필요 없는 백엔드 작업들은 흔히 Celery, Redis Streams, RabbitMQ 등의 비동기 작업 큐(Task Queue)를 통해 처리됩니다.

그러나 작업 실행 중 외부 서비스 장애나 파싱 불가능한 데이터(Poison Pill)가 유입될 때 적절한 재시도 한계와 격리 장치가 없으면, 수만 건의 실패 작업이 동시에 큐로 되돌아가며 재시도 폭풍(Retry Storm)을 일으키게 됩니다. 이로 인해 브로커 메모리가 고갈되고 정상적인 후속 작업까지 연쇄적으로 지연되는 장애로 번지기 쉽습니다.

이번 글에서는 Celery 공식 태스크 가이드와 Redis Streams, RabbitMQ의 DLX(Dead Letter Exchange) 표준 아키텍처를 바탕으로, 비동기 작업 큐의 안정성을 보장하는 최대 재시도 임계치, 지수 백오프, 그리고 데드 레터 큐(DLQ, Dead Letter Queue) 격리 파이프라인을 체계적으로 정리합니다.

1. 무한 재시도와 독약(Poison Pill) 메시지가 유발하는 위험

작업 실패 시 단순 retry를 무제한 호출하거나 기본 예외 처리에 의존할 경우 다음과 같은 엔진 레벨의 문제가 발생합니다:

  • 워커 프로세스 점유 및 큐 적체: 복구 불가능한 에러(데이터 포맷 불일치, 존재하지 않는 리소스 ID 등)를 가진 태스크가 무한 루프를 돌며 가용한 워커 슬롯을 독점하여, 정상 처리 가능한 대기 작업들이 타임아웃에 빠집니다.
  • 브로커 메모리 고갈과 OOM: 실패한 작업 메타데이터가 Redis 메모리에 누적되거나 브로커의 Unacknowledged 메시지 버퍼를 잠식하여 브로커 인스턴스 전체의 강제 종료(OOM Killer)를 유발합니다.
  • 다운스트림 서비스 2차 충격: 외부 API 일시 순단 시 수천 개의 태스크가 동시 다발적으로 즉시 재시도를 감행하면, 복구 중이던 대상 서버에 순간적인 트래픽 폭증을 유발하여 영구 차단(429/403)으로 이어집니다.

2. 재시도 제어의 2대 원칙: Max Retries와 지수 백오프

작업 실패가 발생했을 때 시스템이 취해야 할 첫 번째 방어선은 엄격한 횟수 제한(Max Retries)과 점진적 대기 시간 증가(Exponential Backoff)입니다.

  1. 재시도 가능한 예외와 불가능한 예외 분리: 일시적 네트워크 순단(Timeout, 503)은 재시도 대상(Retryable)으로 분류하고, 스키마 유효성 실패(ValueError, KeyError)는 즉시 실패(Non-retryable)로 처리하여 무의미한 재시도를 원천 차단합니다.
  2. 지수 백오프와 지터(Jitter) 적용: 재시도 대기 시간을 countdown = min(max_backoff, base_backoff * (2 ** attempt)) + random_jitter 형태로 점진 분산시켜 대상 시스템의 부하를 완화합니다.

3. 파이썬 Celery 기반 DLQ 라우팅 구현 패턴

최대 재시도 횟수를 초과한 실패 메시지는 메인 큐에서 완전히 제거하고, 별도의 데드 레터 큐(DLQ)로 안전하게 격리해야 합니다.

import logging
from celery import Celery
from celery.exceptions import MaxRetriesExceededError

app = Celery("tasks", broker="redis://localhost:6379/0")
logger = logging.getLogger(__name__)

@app.task(bind=True, max_retries=3, default_retry_delay=5)
def process_external_webhook(self, payload: dict):
    try:
        # 비즈니스 로직: 유효성 검증 및 외부 API 호출
        validate_payload(payload)
        call_partner_api(payload)
    except ValueError as val_err:
        # 복구 불가능한 에러: 즉시 DLQ로 격리 (재시도 생략)
        logger.warning(f"Poison Pill 감지: {val_err}")
        route_to_dlq(payload, error_reason=str(val_err))
        return
    except (ConnectionError, TimeoutError) as net_err:
        try:
            # 일시적 에러: 지수 백오프로 재시도 (5s, 10s, 20s...)
            backoff_delay = min(60, 5 * (2 ** self.request.retries))
            raise self.retry(exc=net_err, countdown=backoff_delay)
        except MaxRetriesExceededError:
            # 최대 재시도 초과: 메인 큐에서 제거하고 DLQ로 격리
            logger.error(f"최대 재시도 초과. DLQ로 전송합니다. task_id={self.request.id}")
            route_to_dlq(payload, error_reason="MAX_RETRIES_EXCEEDED", task_id=self.request.id)

def route_to_dlq(payload: dict, error_reason: str, task_id: str = None):
    """실패한 메시지를 사후 분석용 DLQ로 라우팅하고 모니터링 알림 발송"""
    dlq_record = {
        "task_id": task_id,
        "payload": payload,
        "error_reason": error_reason,
        "failed_at": "NOW()"
    }
    # 별도의 DLQ 전용 큐 또는 영구 스토리지에 적재
    app.send_task("tasks.save_dead_letter", args=[dlq_record], queue="dead_letter_queue")

4. 프로덕션 DLQ 운영 및 재생(Replay) 체크리스트

  • DLQ 임계치 알림 연동: DLQ에 메시지가 인입되는 즉시 Slack이나 PagerDuty로 알림을 전송하여, 외부 파트너사 API 장애나 잘못된 클라이언트 요청을 실시간으로 감지해야 합니다.
  • 멱등성(Idempotency) 보장: 장애 복구 후 DLQ에 보관된 메시지를 메인 큐로 재주입(Replay)할 때 중복 실행 부작용이 없도록, 각 태스크는 고유 키(Unique Key) 기반의 멱등성 검사를 내장해야 합니다.
  • TTL(Time-To-Live)과 보존 주기 설정: DLQ 자체도 무한정 커지지 않도록 7일~14일 단위의 보존 기간(TTL)을 설정하고, 초과된 미처리 데이터는 콜드 스토리지(S3, GCS)로 자동 아카이빙하십시오.

출처 및 공식 기술 문서

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다