RabbitMQ 실전 운영: Dead Letter Exchange·Retry Queue·Priority Queue로 메시지 유실 없이 처리하기

메시지가 사라진 순간, 브로커를 탓하기 전에 설계를 봐야 한다
결제 완료 이벤트를 발행했는데 알림이 가지 않고, 재고 차감도 되지 않으며, 고객은 결제 화면에서 로딩만 보는 상황을 경험한 적이 있을 것입니다. 우리 팀이 운영하던 이커머스 플랫폼에서 배송 이벤트 처리를 RabbitMQ로 전환하고 두 달이 지났을 때, 업스트림 물류 API가 30초짜리 타임아웃을 내뿜기 시작했습니다. Consumer는 메시지를 가져갔지만 처리에 실패했고, nack 설정이 없던 큐에서 메시지들은 그냥 버려졌습니다. 5,000건이 넘는 배송 이벤트가 유실됐습니다.
Dead Letter Exchange, 지수 백오프 Retry Queue, Priority Queue, Quorum Queue까지, 메시지 유실 없이 운영 가능한 RabbitMQ 아키텍처를 설계 결정 순서대로 정리합니다.
1. RabbitMQ 아키텍처 복습
RabbitMQ의 메시지 흐름은 Producer → Exchange → Binding → Queue → Consumer 순서입니다. Producer는 Exchange에 메시지를 발행하고, Exchange는 Binding 규칙에 따라 하나 이상의 Queue로 메시지를 라우팅합니다.
Channel은 하나의 TCP Connection 위에서 동작하는 가상 연결입니다. Channel은 스레드 안전하지 않으므로 스레드당 하나의 Channel을 쓰는 것이 기본 원칙입니다.
메시지의 내구성은 두 레이어에서 설정합니다. 첫째, 큐를 durable: true로 선언해야 브로커가 재시작되어도 큐가 사라지지 않습니다. 둘째, 메시지 자체에 delivery_mode: 2(persistent)를 설정해야 큐에 들어온 메시지가 디스크에 기록됩니다.
2. Exchange 타입 선택
| Exchange 타입 | 라우팅 기준 | 적합한 사용 사례 |
|---|---|---|
| Direct | Routing key 완전 일치 | 단일 큐 라우팅, 작업 분산 |
| Fanout | 무조건 모든 바인딩 큐 | 이벤트 브로드캐스트 |
| Topic | Routing key 패턴 매칭 (*, #) | 도메인·서비스별 이벤트 라우팅 |
| Headers | 메시지 헤더 속성 매칭 | 복잡한 조건 |
Topic Exchange는 가장 실용적입니다. order.payment.completed, order.shipment.dispatched처럼 도메인.서브도메인.이벤트 패턴을 쓰면 소비자가 order.#으로 구독해 모든 주문 이벤트를 받을 수 있습니다.
Fanout Exchange는 여러 서비스가 같은 이벤트를 각자의 방식으로 처리할 때 유용합니다. 주문 완료 이벤트를 알림 서비스, 정산 서비스, 추천 서비스가 모두 소비해야 한다면, 각 서비스가 자신만의 큐를 Fanout Exchange에 바인딩합니다.
3. Dead Letter Exchange 설정
Dead Letter Exchange(DLX)는 큐에서 "죽은" 메시지를 받는 Exchange입니다. 메시지가 다음 세 가지 중 하나에 해당하면 DLX로 라우팅됩니다. 첫째, Consumer가 basic.nack 또는 basic.reject을 requeue: false로 보낸 경우. 둘째, 메시지 TTL이 만료된 경우. 셋째, 큐가 x-max-length를 초과한 경우입니다. (RabbitMQ 공식 DLX 문서)
const amqp = require('amqplib');
async function setupQueuesWithDLX() {
const conn = await amqp.connect(process.env.RABBITMQ_URL);
const channel = await conn.createChannel();
await channel.assertExchange('dlx.order', 'direct', { durable: true });
await channel.assertQueue('dlq.order.events', {
durable: true,
arguments: {
'x-message-ttl': 7 * 24 * 60 * 60 * 1000,
},
});
await channel.bindQueue('dlq.order.events', 'dlx.order', 'order.events');
await channel.assertQueue('queue.order.events', {
durable: true,
arguments: {
'x-dead-letter-exchange': 'dlx.order',
'x-dead-letter-routing-key': 'order.events',
'x-message-ttl': 30000,
},
});
}
DLQ에 들어온 메시지에는 원본 메시지 헤더 외에 RabbitMQ가 자동으로 x-death 헤더를 추가합니다. 이 헤더에는 원본 큐 이름, 사망 이유, 발생 시각, 시도 횟수가 담깁니다.
4. 지수 백오프 Retry Queue 구현
일시적 오류는 재시도로 해결됩니다. 그러나 즉시 재시도는 이미 과부하 상태인 downstream에 더 많은 요청을 쏟아 상황을 악화시킵니다.
RabbitMQ에서 백오프 재시도를 구현하는 방법은 "TTL이 있는 중간 큐"를 만드는 것입니다. 메시지가 실패하면 TTL이 설정된 대기 큐로 보냅니다. TTL이 만료되면 그 메시지가 다시 작업 큐로 돌아옵니다.
async function setupExponentialBackoffRetry(channel) {
const WORK_EXCHANGE = 'ex.order';
const WORK_QUEUE = 'queue.order.payment';
const RETRY_DELAYS_MS = [5000, 15000, 60000, 300000];
await channel.assertExchange(WORK_EXCHANGE, 'direct', { durable: true });
await channel.assertExchange('ex.order.dead', 'direct', { durable: true });
await channel.assertQueue('dlq.order.payment', { durable: true });
await channel.bindQueue('dlq.order.payment', 'ex.order.dead', 'payment');
for (const delayMs of RETRY_DELAYS_MS) {
await channel.assertQueue(`queue.order.payment.retry.${delayMs}ms`, {
durable: true,
arguments: {
'x-dead-letter-exchange': WORK_EXCHANGE,
'x-dead-letter-routing-key': 'payment',
'x-message-ttl': delayMs,
'x-max-length': 10000,
},
});
}
await channel.assertQueue(WORK_QUEUE, {
durable: true,
arguments: {
'x-dead-letter-exchange': 'ex.order.dead',
'x-dead-letter-routing-key': 'payment',
},
});
}
이 패턴의 핵심은 재시도 큐 자체에 Consumer가 없다는 점입니다. 메시지는 TTL이 만료될 때까지 큐에서 대기하고, 만료되면 DLX 메커니즘에 의해 자동으로 작업 큐로 복귀합니다.
5. 메시지 TTL과 큐 TTL
TTL은 메시지 단위와 큐 단위, 두 레벨에서 설정할 수 있습니다. (RabbitMQ TTL 공식 문서)
메시지 TTL은 큐의 head(가장 앞에 있는 메시지)에서만 적용된다는 점이 중요합니다. 큐 안에 오래된 메시지가 있어도 앞에 있는 메시지가 살아있으면 뒤의 만료된 메시지는 DLX로 가지 않습니다.
알림 이벤트처럼 "늦으면 의미 없는" 메시지는 짧은 TTL(수분 단위)을 설정해 큐가 밀려도 오래된 알림이 뒤늦게 발송되는 사태를 막을 수 있습니다. 반면 결제·정산 이벤트처럼 반드시 처리해야 하는 메시지는 TTL 없이 DLX와 재시도 큐 조합으로만 관리합니다.
6. Priority Queue와 소비자 설계
RabbitMQ는 x-max-priority 인수로 우선순위 큐를 지원합니다. 0255 사이의 값을 설정할 수 있으며, 숫자가 높을수록 우선 처리됩니다. 실무에서는 보통 35단계면 충분합니다.
| 전략 | 구현 복잡도 | 기아 위험 | 운영 관찰 용이성 |
|---|---|---|---|
단일 큐 + x-max-priority | 낮음 | 낮은 우선순위 기아 가능 | 우선순위별 lag 분리 불가 |
| 우선순위별 큐 분리 | 중간 | 낮음 | 큐별 lag 개별 모니터링 가능 |
| 우선순위별 큐 + Consumer 가중치 배분 | 높음 | 없음 | 세밀한 처리량 제어 가능 |
7. Consumer Prefetch와 처리량 튜닝
basic.qos의 prefetch_count는 Consumer가 ack를 보내기 전에 브로커에서 가져올 수 있는 최대 메시지 수입니다. (RabbitMQ Consumer 공식 문서)
import pika
import threading
def worker_consumer(channel, worker_count=4):
semaphore = threading.Semaphore(worker_count)
def on_message(ch, method, properties, body):
semaphore.acquire()
def process():
try:
handle_event(body)
ch.basic_ack(delivery_tag=method.delivery_tag)
except RecoverableError:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=True)
except PermanentError:
ch.basic_nack(delivery_tag=method.delivery_tag, requeue=False)
finally:
semaphore.release()
thread = threading.Thread(target=process, daemon=True)
thread.start()
channel.basic_qos(prefetch_count=8, global_qos=False)
channel.basic_consume(
queue='queue.order.payment',
on_message_callback=on_message,
auto_ack=False
)
channel.start_consuming()
auto_ack=True는 절대 사용하지 않습니다. 브로커가 메시지를 Consumer에게 전달한 순간 ack를 처리한 것으로 간주하므로, 처리 중 Consumer가 죽으면 메시지가 사라집니다.
prefetch_count 튜닝 지침: CPU 바운드 작업은 worker_count == CPU 코어 수, prefetch는 worker_count × 2 정도에서 시작합니다. I/O 바운드 작업은 worker_count를 크게 잡고 prefetch도 이에 맞춥니다.
8. Poison Message 격리
Poison Message는 어떤 Consumer가 받아도 처리에 실패하는 메시지입니다. 페이로드가 깨졌거나, 필수 참조 엔티티가 삭제됐거나, 비즈니스 규칙상 영구적으로 처리 불가능한 경우입니다.
일시 오류와 영구 오류를 구분하는 기준을 코드에 명시해야 합니다. 네트워크 타임아웃, DB 데드락, 외부 API 5xx는 재시도 대상입니다. JSON 파싱 실패, 필수 필드 없음, 비즈니스 규칙 위반은 재시도해도 결과가 같으므로 즉시 DLQ로 보내야 합니다.
DLQ 운영 정책도 함께 설계해야 합니다. DLQ 메시지가 하루 임계값을 초과하면 알림을 발생시킵니다. RabbitMQ의 트랜잭셔널 아웃박스 패턴과 함께 쓰면 재처리 시 중복 처리 방어가 더 탄탄해집니다.
9. Classic vs Quorum Queue
RabbitMQ 3.8부터 Quorum Queue가 안정 버전으로 제공되고, 2026년 현재 RabbitMQ 4.x 시리즈에서 Quorum Queue는 고가용성 워크로드의 기본 선택지로 자리 잡았습니다. (RabbitMQ Quorum Queue 공식 문서)
| 항목 | Classic Queue | Quorum Queue |
|---|---|---|
| 복제 방식 | 선택적 미러링 | Raft 합의 기반 강제 복제 |
| 내구성 | 미러 수 설정에 따라 다름 | 과반수 노드 살아있으면 데이터 보존 |
| Poison Message 방어 | 기본 없음 | x-delivery-limit으로 자동 DLX |
| 권장 버전 | 레거시 | RabbitMQ 3.8 이상 |
Quorum Queue의 큰 장점 중 하나는 x-delivery-limit 인수입니다. 메시지를 지정한 횟수 이상 requeue하면 자동으로 DLX로 보냅니다.
Classic Queue의 Mirrored Queue는 RabbitMQ 3.11부터 공식 Deprecated로 선언됐습니다. Kafka와 RabbitMQ의 선택 기준이 궁금하다면 Kafka Consumer Lag 운영 가이드에서 두 브로커의 설계 철학 차이를 비교해볼 수 있습니다.
10. Prometheus 모니터링
| 메트릭 | 의미 | 알림 기준 |
|---|---|---|
rabbitmq_queue_messages_ready | 큐에서 Consumer를 기다리는 메시지 수 | 임계값 초과 시 Consumer 증설 검토 |
rabbitmq_queue_messages_unacknowledged | Consumer가 가져갔지만 ack 안 한 메시지 수 | 처리 시간 이상 지속 시 Consumer 장애 의심 |
rabbitmq_queue_consumer_utilisation | Consumer가 메시지를 받을 준비된 비율 | 낮으면 prefetch 또는 Consumer 수 조정 |
rabbitmq_queue_messages (DLQ) | DLQ에 쌓인 메시지 수 | 0보다 크면 즉시 알림 |
결론
- DLX는 모든 작업 큐에 설정되어 있는가: 신규 큐 생성 시 DLX 연결을 체크리스트 필수 항목으로 만드세요.
- 재시도는 지수 백오프로 구현되어 있는가: 5초 → 15초 → 1분 → 5분처럼 단계적 대기 시간을 두고, 최대 재시도 횟수를 초과한 메시지는 DLQ로 격리합니다.
- Consumer는
auto_ack=false로 수동 ack를 사용하는가. - Quorum Queue로 전환 또는 전환 계획이 있는가.
- DLQ 모니터링과 재처리 절차가 문서화되어 있는가.