Dead Letter / Retry 패턴
메시지 큐 기반 시스템에서 메시지 처리가 실패하면 어떻게 해야 할까? 가장 단순한 방법은 실패한 메시지를 버리는 것이다. 하지만 이메일 발송, 결제 처리 같은 작업은 한 번 실패했다고 포기할 수 없다. 네트워크 일시 장애나 외부 서비스 순간 다운처럼, 잠시 후 다시 시도하면 성공할 가능성이 높은 에러가 많기 때문이다.
반대로 무한정 재시도하는 것도 문제다. 존재하지 않는 이메일 주소로 보내는 것처럼, 아무리 재시도해도 절대 성공하지 않는 에러도 있다. 이런 메시지를 계속 재시도하면 큐가 막히고 정상 메시지까지 처리가 지연된다.
Dead Letter / Retry 패턴은 이 두 가지 문제를 동시에 해결한다. 재시도 가능한 에러는 일정 횟수까지 재시도하고, 영구적인 에러나 재시도 횟수를 초과한 메시지는 Dead Letter Queue(DLQ)로 격리하는 것이다.
핵심 개념
Dead Letter Queue (DLQ)
Dead Letter Queue는 "죽은 편지함"이라는 이름 그대로, 정상적으로 처리할 수 없는 메시지가 보내지는 특수한 큐다. 우체국에서 수취인 불명 편지를 따로 모아두는 것과 같다.
DLQ에 들어간 메시지는 자동으로 처리되지 않는다. 개발자가 나중에 확인하고, 원인을 분석한 뒤, 필요하면 수동으로 재처리하거나 폐기한다. 이렇게 하면 문제가 있는 메시지가 정상 처리 흐름을 방해하지 않으면서도, 데이터를 완전히 잃어버리지 않는다.
Retry (재시도)
재시도의 핵심은 "모든 에러를 동일하게 취급하지 않는다"는 것이다. 에러를 크게 두 가지로 분류한다.
| 에러 유형 | 설명 | 예시 | 처리 방식 |
|---|---|---|---|
| 일시적 에러 (Transient) | 시간이 지나면 해결될 가능성이 높음 | 네트워크 타임아웃, 연결 거부, 서비스 일시 다운 | 재시도 |
| 영구적 에러 (Permanent) | 재시도해도 결과가 같음 | 잘못된 주소, 인증 실패, 잘못된 데이터 형식 | 즉시 DLQ |
이 분류를 기반으로 재시도 전략을 세운다.
Wait Queue를 이용한 지연 재시도
실패한 메시지를 즉시 재시도하면 같은 에러가 반복될 확률이 높다. 외부 서비스가 다운됐다면 0.1초 뒤에 다시 보내봤자 여전히 다운 상태일 것이다. 그래서 점진적으로 대기 시간을 늘리면서 재시도하는 방식을 사용한다.
RabbitMQ에서는 TTL(Time-To-Live)이 설정된 Wait Queue를 활용해서 이를 구현할 수 있다.
처리 실패 → Wait Queue (5초 TTL) → 만료 → 메인 큐로 복귀 → 재처리
Wait Queue는 메시지를 일정 시간 동안 보관했다가, TTL이 만료되면 설정된 Dead Letter Exchange를 통해 원래 큐로 돌려보내는 큐다. 이 메커니즘을 "지연 재시도"에 활용하는 것이다.
재시도 횟수에 따라 대기 시간을 늘리는 구조:
1차 실패 → wait-5s 큐 (5초 대기) → 메인 큐
2차 실패 → wait-10s 큐 (10초 대기) → 메인 큐
3차 실패 → wait-20s 큐 (20초 대기) → 메인 큐
4차 실패 → Dead Letter Queue (포기)
이런 식으로 대기 시간을 점점 늘리는 패턴을 Exponential Backoff(지수 백오프)라고 한다. 외부 서비스가 복구될 시간을 충분히 주면서, 불필요한 요청으로 부하를 가중시키지 않는다.
RabbitMQ에서 Wait Queue 설정
Wait Queue를 만들 때 핵심은 두 가지 설정이다.
처리 실패 → Wait Queue (5초 TTL) → 만료 → 메인 큐로 복귀 → 재처리
x-message-ttl: 큐에 들어온 메시지가 머무는 시간(ms). 이 시간이 지나면 메시지가 "만료"된다.x-dead-letter-exchange: 만료된 메시지가 전달될 Exchange. 여기서는 원래 처리 큐가 바인딩된 Exchange를 지정한다.x-dead-letter-routing-key: 만료된 메시지의 라우팅 키. 원래 처리 큐로 정확히 돌아가도록 설정한다.
이름이 "dead letter"이지만, 여기서는 DLQ 용도가 아니라 지연 전달 메커니즘으로 활용하는 것이다. RabbitMQ에서 "dead letter"는 "큐에서 빠져나가는 메시지의 목적지"라는 의미에 가깝다.
재시도 횟수 추적
메시지가 몇 번째 재시도인지 알아야 적절한 Wait Queue로 보낼 수 있다. 이를 위해 메시지 헤더에 재시도 횟수를 기록한다.
1차 실패 → wait-5s 큐 (5초 대기) → 메인 큐
2차 실패 → wait-10s 큐 (10초 대기) → 메인 큐
3차 실패 → wait-20s 큐 (20초 대기) → 메인 큐
4차 실패 → Dead Letter Queue (포기)
메시지를 소비할 때는 헤더에서 재시도 횟수를 꺼낸다.
// Wait Queue 선언
await channel.assertQueue('task.wait.5s', {
durable: true,
arguments: {
'x-message-ttl': 5000, // 메시지가 5초간 머무름
'x-dead-letter-exchange': 'MainExchange', // TTL 만료 시 이 Exchange로 전달
'x-dead-letter-routing-key': 'task.process' // 라우팅 키 지정
}
});
여기서 주의할 점은 실패한 메시지도 ack 처리한다는 것이다. nack으로 원래 큐에 되돌리면 즉시 재처리되어 Wait Queue의 지연 효과를 얻을 수 없다. 대신 원본을 ack으로 제거하고, 별도로 Wait Queue에 새 메시지를 발행하는 방식을 쓴다.
에러 분류에 따른 처리 흐름
실제 구현에서는 에러 타입을 분류하고, 각각 다른 전략을 적용한다. SMTP를 통한 이메일 발송을 예로 들어보자.
// 재시도 시 헤더에 카운트 증가
const retryOptions = {
headers: {
'x-retry-count': currentRetryCount + 1,
},
};
channel.publish(exchange, routingKey, Buffer.from(message), retryOptions);
에러 처리의 핵심 원칙:
- 네트워크 에러: 재시도 가능. 서버가 잠시 다운됐거나 연결이 불안정한 경우가 대부분이다.
- 5xx 응답: 영구적 실패로 간주. "해당 사서함이 없다" 같은 에러는 재시도해도 소용없다.
- 4xx 응답: 일시적 실패 가능성. "사서함이 꽉 찼다" 같은 경우 시간이 지나면 해결될 수 있다.
- 알 수 없는 에러: 안전하게 DLQ로. 분류할 수 없는 에러를 재시도하면 예상치 못한 부작용이 생길 수 있다.
DLQ 메시지에 메타데이터 남기기
DLQ에 들어간 메시지는 나중에 디버깅해야 한다. 그래서 왜 실패했는지, 몇 번 재시도했는지, 언제 실패했는지 정보를 헤더에 함께 기록한다.
// Consumer에서 재시도 횟수 확인
async consumeMessage<T>(
queue: string,
onMessage: (payload: T, retryCount: number) => Promise<void>,
) {
const { consumerTag } = await this.channel.consume(queue, async (msg) => {
if (!msg) return;
const payload = JSON.parse(msg.content.toString());
const retryCount = msg.properties.headers?.['x-retry-count'] || 0;
try {
await onMessage(payload, retryCount);
this.channel.ack(msg);
} catch (error) {
this.channel.ack(msg); // 원본 메시지는 ack 처리
throw error; // 에러 핸들러에서 재시도/DLQ 결정
}
});
return consumerTag;
}
이 메타데이터가 있으면 DLQ 모니터링 대시보드에서 실패 원인을 빠르게 파악할 수 있다. x-failure-type으로 실패 유형별 통계를 내고, x-failed-at으로 시간대별 실패 패턴을 분석할 수도 있다.
전체 아키텍처
지금까지 설명한 내용을 종합하면, 메시지 처리 흐름은 이렇게 된다.
┌─────────────┐
│ Producer │
└──────┬──────┘
│ publish
▼
┌─────────────┐
┌──────│ Main Queue │──────┐
│ └─────────────┘ │
│ │
성공│ 실패│
│ │
▼ ▼
┌────────┐ ┌──────────────┐
│ 완료 │ │ 에러 분류기 │
└────────┘ └──────┬───────┘
│
┌───────────────────┼───────────────────┐
│ │ │
일시적 에러 영구적 에러 알 수 없는 에러
(재시도 가능) (재시도 무의미)
│ │ │
▼ │ │
┌──────────────┐ │ │
│ retryCount │ │ │
│ < MAX? │ │ │
└──────┬───────┘ │ │
Yes │ No │ │
│ │ │ │
▼ └─────────────────────┴───────────────────┘
┌──────────┐ │
│Wait Queue│ ▼
│(TTL 지연) │ ┌─────────────┐
└────┬─────┘ │Dead Letter Q │
│ TTL 만료 │ (격리 보관) │
│ └─────────────┘
└──→ Main Queue (재처리)
설계 시 고려사항
재시도 횟수와 간격
재시도 횟수와 간격은 서비스 특성에 따라 다르게 설정해야 한다.
- 이메일 발송: 3~5회, 5초→10초→20초 (SMTP 서버 복구 시간 고려)
- 외부 API 호출: 3회, 1초→3초→9초 (rate limit 복구 시간)
- 결제 처리: 1~2회, 30초→60초 (결제 게이트웨이는 신중하게)
무조건 많이 재시도한다고 좋은 게 아니다. 재시도가 많아지면 그만큼 처리 지연이 길어지고, 실패하는 메시지가 시스템 리소스를 차지한다.
멱등성 (Idempotency)
재시도 패턴을 도입하면 같은 작업이 두 번 이상 실행될 수 있다. 예를 들어 이메일을 보낸 뒤 ack 전에 워커가 크래시되면, 같은 이메일이 다시 발송될 수 있다. 이런 상황에서도 문제가 없으려면 작업이 멱등(idempotent)해야 한다.
멱등성을 보장하는 방법:
- 처리 전에 고유 ID로 이미 처리된 메시지인지 확인
- DB에 처리 기록을 남기고 중복 체크
- 자연적으로 멱등한 작업 설계 (같은 이메일을 두 번 보내도 큰 문제가 없다면 OK)
DLQ 모니터링
DLQ에 메시지가 쌓이는 건 어딘가에 문제가 있다는 신호다. DLQ를 방치하면 의미가 없다.
- DLQ 메시지 수가 임계값을 넘으면 알림 발송 (Slack, Discord 등)
- 주기적으로 DLQ를 확인하고, 원인이 해결된 메시지는 재처리
- 완전히 폐기할 메시지는 로그를 남기고 삭제
대안 패턴과 비교
단순 재시도 (즉시 nack)
async handleError(
error: any,
payload: TaskPayload,
retryCount: number,
): Promise<void> {
const message = JSON.stringify(payload);
const maxRetry = 3;
// 1. 네트워크 에러 → 재시도 가능
const isNetworkError =
error.code === 'ESOCKET' ||
error.message?.includes('ECONNREFUSED') ||
error.message?.includes('ETIMEDOUT');
if (isNetworkError) {
if (retryCount >= maxRetry) {
// 재시도 횟수 초과 → DLQ
await this.sendToDLQ(message, error, retryCount, 'MAX_RETRIES_EXCEEDED');
return;
}
// Wait Queue로 재시도
await this.sendToWaitQueue(message, retryCount);
return;
}
// 2. SMTP 에러 → 응답 코드로 판단
if (error.responseCode) {
if (error.responseCode >= 500) {
// 5xx: 영구적 실패 (잘못된 주소 등) → 즉시 DLQ
await this.sendToDLQ(message, error, retryCount, 'SMTP_PERMANENT_FAILURE');
return;
}
if (error.responseCode >= 400) {
// 4xx: 일시적 실패 (사서함 꽉 참 등) → 재시도
if (retryCount >= maxRetry) {
await this.sendToDLQ(message, error, retryCount, 'MAX_RETRIES_EXCEEDED');
return;
}
await this.sendToWaitQueue(message, retryCount);
return;
}
}
// 3. 알 수 없는 에러 → 즉시 DLQ (안전하게 격리)
await this.sendToDLQ(message, error, retryCount, 'UNKNOWN_ERROR');
}
간단하지만 문제가 많다. 실패한 메시지가 즉시 다시 처리되면서 같은 에러가 빠르게 반복되고, CPU와 네트워크를 낭비한다. 에러 분류도 없어서 영구적 에러도 무한 재시도한다.
Circuit Breaker 패턴
재시도 패턴과 함께 사용하면 효과적이다. 연속 실패가 일정 횟수를 넘으면 "회로를 차단"해서 일정 시간 동안 요청 자체를 보내지 않는다. 외부 서비스가 완전히 다운됐을 때 불필요한 재시도를 줄여준다.
Exponential Backoff + Jitter
여러 워커가 동시에 실패하면 같은 시간에 재시도가 몰릴 수 있다(Thundering Herd). 이를 방지하기 위해 대기 시간에 랜덤한 지터(jitter)를 추가한다.
function createDLQHeaders(
error: any,
retryCount: number,
failureType: 'SMTP_PERMANENT_FAILURE' | 'MAX_RETRIES_EXCEEDED' | 'UNKNOWN_ERROR',
) {
const headers: Record<string, any> = {
'x-retry-count': retryCount,
'x-error-code': error.code || 'UNKNOWN',
'x-error-message': error.message || 'Unknown error',
'x-failed-at': new Date().toISOString(),
'x-failure-type': failureType,
};
if (error.responseCode !== undefined) {
headers['x-response-code'] = error.responseCode;
}
if (error.stack) {
headers['x-error-stack'] = error.stack;
}
return headers;
}
고정된 Wait Queue TTL 대신 메시지별로 TTL을 다르게 설정하면 RabbitMQ에서도 이 패턴을 구현할 수 있다.
정리
Dead Letter / Retry 패턴의 핵심:
- 에러를 분류하라: 일시적 에러와 영구적 에러를 구분해서 재시도 여부를 결정
- 점진적으로 재시도하라: Wait Queue + TTL로 대기 시간을 늘려가며 재시도
- 포기할 줄 알아라: 최대 재시도 횟수를 넘기면 DLQ로 격리
- 기록을 남겨라: DLQ 메시지에 실패 원인과 메타데이터를 꼼꼼히 기록
- DLQ를 모니터링하라: 쌓인 메시지를 방치하면 패턴의 의미가 없다
이 패턴은 메시지 큐뿐 아니라 HTTP 요청 재시도, 배치 작업 실패 처리 등 다양한 곳에 응용할 수 있다. 핵심은 항상 같다. 실패를 인정하되, 복구 가능한 실패는 다시 시도하고, 불가능한 실패는 격리해서 나중에 처리한다.