사망자 큐(DLQ): 분산 시스템에서 독성 메시지 처리하기
메시지가 큐를 차단하지 않도록 방지하세요
데드 레터 큐(DLQ)는 소비자가 처리할 수 없는 메시지를 포착하는 안전망입니다. 이를 통해 한 가지 잘못된 페이로드가 큐에 있는 나머지 메시지들을 차단하거나 묵과하게 되는 것을 방지합니다.
메시지 기반 시스템은 결국 자신이 처리할 수 없는 메시지를 받게 됩니다. 형식이 잘못된 페이로드, 소비자 측에서 변경된 스키마, 혹은 재시도를 거듭해도 실패하는 하위 시스템 호출 등이 그 예입니다. 데드 레터 큐가 없으면 해당 메시지는 큐의 선두에 영구적으로 블록킹되거나 묵과되어 삭제될 수 있으며, 이 두 가지 결과는 모두 실패에 대한 인지보다 훨씬 나쁜 결과를 초래합니다.
데드 레터 큐는 보이지 않는 실패를 시각적이고 검사 가능한 상태로 전환합니다. 이를 통해 메시지를 격리하고, 알림을 발생하며, 실수로가 아닌 의도적으로 해당 메시지를 수정하여 다시 처리할지 아니면 완전히 폐기할지 결정할 수 있는 장소를 제공합니다.

구현 매커니즘은 브로커에 따라 다르지만, 그 underlying 패턴은 모든 곳에서 동일합니다. 즉, 전달 시도 카운터, 임계값, 그리고 이를 초과한 메시지가 이동하는 목적지입니다. 이 가이드에서는 데드 레터 큐가 실제로 수행하는 작업, 일시적 실패와 영구적 오류(포이즌 메시지)를 구분하는 방법, 언제 재시도하고 언제 폐기해야 하는지, 그리고 근본 원인을 해결한 후 안전하게 다시 처리(replay)하는 방법을 다룹니다. 이 패턴이 속한 더 넓은 통합 패턴 컨텍스트에 대해서는 App Architecture를 참조하십시오.
데드 레터 큐란 무엇인가
데드 레터 큐는 브로커나 소비자가 메시지를 여러 번 처리에 실패한 후 해당 메시지를 라우팅하는 별도의 일반 큐입니다. 이는 특별한 구조체가 아닙니다. RabbitMQ의 데드 레터 큐는 일반 교환(exchange)에 바인딩된 일반 큐이며, SQS DLQ는 일반 표준 큐 또는 FIFO 큐입니다. 큐를 ‘데드 레터 큐’로 만드는 것은 오직 다른 무언가가 실패한 메시지를 그곳으로 향하게 한다는 점뿐입니다.
각 브로커는 리디렉션을 다음과 같이 다르게 구현합니다:
- Amazon SQS는
maxReceiveCount가 포함된 리드라이브 정책(redrive policy)을 사용합니다. 메시지가 삭제되지 않고 해당 횟수만큼 수신되면, SQS는 구성된deadLetterTargetArn으로 메시지를 이동합니다. AWS는 원래 엔큐_timestamp(이동 시간이 아님)가 만료를 관리하므로 데드 레터 큐의 메시지 보존 기간을 소스 큐보다 길게 유지할 것을 명시적으로 권장합니다. - RabbitMQ는 메시지가
requeue=false로 거부되거나, 메시지별 TTL이 만료되거나, 큐가 길이 제한에 도달하거나, 쿠오럼 큐가delivery-limit를 초과할 때 메시지를 데드 레터 처리합니다. 이를x-dead-letter-exchange(및 선택적으로x-dead-letter-routing-key) 큐 인수를 사용하여 구성하며, RabbitMQ는 발생 이유, 원본 큐, 발생 횟수를 기록하는x-death헤더를 첨부합니다. - Apache Kafka에는 브로커 네이티브 DLQ가 없습니다. Kafka는 오프셋만 추적하며 ‘실패한’ 메시지의 개념이 없습니다. 데드 레터 토픽 패턴은 소비자, Kafka Streams 토폴로지, 또는 Kafka Connect 커넥터에서 구축해야 합니다. 일반적으로 Spring Kafka의
@RetryableTopic및DeadLetterPublishingRecoverer가 수행하듯, 최종 DLT 전에 리스토픽 계층과 함께 사용됩니다. - Azure Service Bus는 메시지의 전달 횟수가
MaxDeliveryCount(기본값 10)를 초과하거나,TTLExpiredException,HeaderSizeExceeded,MaxTransferHopCountExceeded등의 시스템 사유로 인해 자동으로 메시지를 데드 레터 처리하며, 각 사유는 메시지의DeadLetterReason속성에 기록됩니다.
브로커 및 스트리밍 플랫폼이 신뢰성 패턴이 아닌 운영상 어떻게 연결되는지에 대한 더 넓은 관점에서는 Apache Kafka Quickstart 및 RabbitMQ on AWS EKS vs SQS가 이러한 브로커를 실행하는 인프라 측면을 다룹니다.
포이즌 메시지(Poison Messages)
포이즌 메시지는 소비자가 몇 번을 재시도하더라도 절대 성공하지 않을 메시지를 의미합니다. 형식이 잘못된 JSON 페이로드, 생산자가 이름 변경한 스키마 필드, 비즈니스 규칙 위반, 혹은 특정 입력에서 매번 예외를 발생시키는 버그 등이 해당됩니다. 이는 메시지는 정상이지만 환경이 잠시 문제가 있는 경우(예: 하위 시스템 타임아웃, 데이터베이스 연결 단절, Rate Limit 응답)인 일시적 실패와는 다릅니다.
두 가지 실패 유형을 동일한 방식으로 처리하는 것이 가장 흔한 DLQ 오류입니다. 첫 번째 실패 시 즉시 데드 레터 처리하면 재시도 시 성공할 수 있는 일시적 오류를 처벌하게 됩니다. 반면 포이즌 메시지를 포기하기 전에 수십 번 재시도하면 컴퓨팅 자원을 낭비하고, 순서 보장 큐 및 파티션에서 그 뒤에 있는 관련 없는 메시지들을 지연시키며, 동일한 스택 트레이스로 로그를 폭풍우처럼 만들게 됩니다.
두 가지를 구분하는 몇 가지 감지 신호가 있습니다:
- 예외 유형. 역직렬화 오류, 유효성 검사 오류,
ClassCastException스타일의 실패는 거의 항상 영구적입니다. Spring Kafka의DefaultErrorHandler는 특정 예외를 치명적이라고 명시적으로 처리하여 재시도 예산을 먼저 소진하지 않고 재시도를 건너뜁니다. - 변동 없는 반복 횟수. RabbitMQ의
x-death헤더 배열을 통해 메시지가 몇 번 데드 레터 처리되었는지 그리고 그 이유를 정확히 확인할 수 있습니다. 매 사이클마다 동일한x-first-death-reason과 증가하는 카운터를 가진 메시지는 운이 나빠서가 아니라 포이즌 메시지입니다. - 복제본 간 일관된 실패. 모든 소비자 인스턴스가 주변의 다른 메시지들은 성공하면서 동일한 메시지에서는 실패한다면, 그것은 인프라가 아닌 메시지 자체가 문제입니다.
코드 수준에서 재시도 가능한 실패와 불가능한 실패를 구분하는 방법(즉, DLQ 정책의 기반이 되는 분류)에 대해서는 Go Error Handling Architecture: Boundaries and Patterns를 참조하십시오.
재시도 대 폐기
모든 DLQ의 핵심 정책 결정은 재시도 임계값입니다. 즉, 메시지를 격리하기 전에 몇 번의 전달 시도를 허용할지입니다. 이를 너무 낮게 설정하면 짧은 하위 시스템 장애 후 성공했을 메시지를 데드 레터 처리하게 됩니다. 너무 높게 설정하면 포이즌 메시지가 주 큐에서 오랫동안 머물러 작업자 용량을 소비하고(순서 보장 시스템의 경우) 그 뒤에 큐에 있는 모든 것을 차단하게 됩니다.
주요 브로커들에 대한 최신 가이드라인은 비슷한 수치를 수렴하고 있습니다:
| 브로커 | 메커니즘 | 일반적인 임계값 |
|---|---|---|
| Amazon SQS | 리드라이브 정책의 maxReceiveCount |
혼합된 일시적/영구적 작업 부하의 경우 3–5 |
| RabbitMQ (쿠오럼 큐) | delivery-limit 정책 인수 |
큐별로 조정된 3–5 |
| Azure Service Bus | MaxDeliveryCount |
기본값 10, 지연 시간에 민감한 큐에서는 종종 줄임 |
| Kafka (리스토픽을 통해) | 리스토픽 헤더 + 리스토픽 계층 | 최종 DLT 전에 3–4회의 리스토픽 홉 |
많은 팀들이 도달하는 실용적인 중간 지점은 다음과 같습니다: 보수적으로 시작(2–3회 시도), 실제 실패 혼합 패턴을 생산 환경에서 관찰, 그리고 대부분의 실패가 몇 번의 재시도 내에 해결되는 큐에 대해서만 임계값을 높입니다. 재시도 횟수와 함께 시도에 걸쳐 **지수 백오프 및 지터(exponential backoff and jitter)**를 사용하여 하위 시스템 장애가 재시도 폭풍우로 변하는 것을 방지합니다. 이는 백오프 및 서킷 브레이커 설계에서 다루는 것과 같은 규율입니다. 통합 경계에서의 서킷 브레이커가 이를 보완합니다: 이는 큐의 각 메시지가 개별적으로 장애를 발견하고 하나씩 데드 레터 처리하도록 방치하는 대신, 건강하지 않은 종속성으로 요청을 보내는 것을 차단합니다.
한 번 메시지가 DLQ에 들어가면, ‘폐기’ 또한 게으름이 아닌 의도적인 행동이어야 합니다. DLQ 자체에 보존 기간을 설정하십시오. 조사할 수 있을 만큼 충분히 길어야 합니다(AWS에서는 DLQ 보존 기간이 소스 큐보다 길 것을 권장하며, RabbitMQ DLQ의 경우 1주가 일반적인 하한선임). 실패가 묵과되어 만료되는 것이 아니라 삼각 분류되도록 DLQ 깊이와 나이에 대해 알림을 설정하십시오. DLQ에서 조사 없이 만료된 메시지는, 잃어버리기로 결정하지 않고서 잃어버린 메시지와 같습니다.
중복이 발생할 수 있는 곳이라면 어디에서나 **항등성(Idempotency)**이 중요합니다. DLQ에서 주 큐로 다시 처리(redrive)되는 메시지는 기능적으로 중복 전달입니다. 만약 소비자가 동일한 메시지를 두 번 실행해도 안전하지 않다면, DLQ에서 다시 처리하는 것은 피하려고 했던 바로 그 중복 부작용 버그를 생성할 수 있습니다. 다시 처리를 안전하게 만드는 소비자 측 패턴에 대해서는 Idempotency in Distributed Systems That Actually Works를 참조하십시오.
재처리(Replay) 전략
메시지를 DLQ에서 올바르게 꺼내는 것은 이를 넣는 것과 별개의 규율입니다.
- 근본 원인을 먼저 해결하십시오. 재처리 전에 소비자 수정 사항을 배포하는 것은 깨끗한 복구와 동일한 실패로 큐를 다시 오염시키는 것의 차이입니다.
- 자동이 아닌 의도적으로 다시 처리하십시오. SQS는 필요에 따라 메시지를 원래 큐(또는 다른 목적지)로 다시 이동하는 리드라이브-투-소스(redrive-to-source) 기능을 지원합니다. RabbitMQ와 Kafka는 해당 소비자 또는 도구를 직접 구축해야 합니다. 어쨌든 재처리는 무엇이고 언제 재처리되었는지에 대한 기록을 가진 운영자 트리거 액션으로 취급하십시오.
- 필요한 곳에서 순서를 보존하십시오. Kafka의 경우 데드 레터 토픽은 소스 토픽보다 적어도 동일한 수의 파티션을 가져야 하며 원래 메시지 키를 유지해야 합니다. 그래야 재처리된 메시지가 올바른 파티션에 도착하여 키별 순서를 보존합니다.
- 재처리 시도를 제한하십시오. 해결 및 재처리 사이클 후 다시 실패하는 메시지는 일시적이 아닙니다. 이를 DLQ에서 무한히 반복하지 말고 영구 아카이브(데이터베이스 테이블, 객체 저장소 버킷)로 라우팅하십시오. RabbitMQ 자체 문서는 데드 레터 처리된 메시지가 추가적인 TTL 기반 데드 레터링이 비활성화되기 전에 큐 간에만 제한된 횟수(16회)만큼 라우팅될 수 있다고 경고합니다.
- DLQ가 자신에게 데드 레터 처리하도록 절대 허용하지 마십시오. DLQ에 동일한 체인으로 다시 가리키는 자체
x-dead-letter-exchange(RabbitMQ) 또는 자체 리드라이브 정책(SQS)이 있다면, 재처리 실패가 무한 루프를 생성할 수 있습니다. DLQ의 자체 데드 레터 구성을 비워두거나 엄격히 최종 아카이브를 가리키십시오. - 존재뿐만 아니라 볼륨에 대해 알림을 설정하십시오. DLQ에 단일 메시지가 있는 것은 데이터 포인트입니다. 갑자기 증가하는 것은 사고입니다. DLQ 깊이와 메시지 나이를 다른 모든 것과 동일한 알림 파이프라인에 연결하십시오. DLQ 알림에 직접 적용되는 라우팅 및 노이즈 감소 관행에 대해서는 Modern Alerting Systems Design for Observability Teams를 참조하십시오.
여러 단계의 장기 프로세스가 단일 메시지보다 관련되어 있다면, 동일한 데드 레터 사고 방식이 워크플로우 계층에 적용됩니다. 사가의 보상 로직은 일시적이 아닌 영구적인 단계 실패 시 동일한 ‘격리, 검사, 결정’ 규율이 필요합니다. 그리고 이벤트 자체가 데이터베이스 쓰기에서 파생되는 경우, 트랜잭셔널 아웃박스 패턴은 이미 릴레이 작업자에 데드 레터 처리를 구축하므로 패턴이 브로커보다 한 레이어 더 일찍 나타납니다.
더 큰 그림에서 DLQ의 위치
데드 레터 큐는 실패를 없애지 않습니다. 대신 실패를 생존 가능하고 검토 가능하게 만들어 묵과되지 않게 합니다. 이는 일시적인 경우에 대해 백오프와 함께 재시도하고, 다시 처리가 안전하도록 항등성 소비자를 사용하며, 고통받는 종속성이 주 큐(및 결국 DLQ)를 동일한 실패로 수천 번씩 폭풍우처럼 만들지 않도록 서킷 브레이커를 사용하는 것과 함께 작동할 때 가장 좋습니다. DLQ 임계값, 보존 기간 및 알림을 손대지 않은 기본값이 아닌 첫-class 구성 결정으로 취급하고, 데드 레터가 데이터가 조용히 사라지는 곳이 아니라 진단 도구가 되게 하십시오.