05. Error Classification & DLQ — 일시적/영구적 분류, retry policy, 회수 패턴
이 문서가 답하는 질문: 실패를 어떻게 분류하고 retry하나? DLQ를 어떻게 보관소가 아니라 회수 자산으로 만드나?
한 줄 답
모든 실패는 transient(일시적) / permanent(영구적) / poison(메시지 자체 결함) 의 3분류로 환원된다. Retry는 transient에만 의미가 있고, permanent는 즉시 명시적 분류, poison은 DLQ로 격리. DLQ를 단순 보관소로 두면 사고가 박제된다 — funnel Lambda 패턴으로 DLQ 메시지조차 자동 status 갱신해야 운영 가시성이 살아난다.
Why — 왜 “FAILED” 한 글자로는 부족한가
운영 중 영상 transcode가 실패했다. 사용자에게 뭐라고 알리고, 운영자는 어디를 고쳐야 하는가?
FAILED 한 글자로는 5가지 시나리오가 구분 안 된다. 각각 액션이 완전히 다르다:
- Q1 → 사용자에게 재업로드 안내
- Q2 → 자동 retry로 해결, 메트릭만 모니터링
- Q3 → 코드 fix + 배포
- Q4 → IaC 점검 + redeploy
- Q5 → MC 콘솔에서 jobId 추적
→ 따라서 에러는 분류되어야 한다 — 분류된 에러가 (a) 알람 우선순위, (b) 사용자 메시지, (c) 자동 복구 가능성을 결정.
How — 3분류 분류법
1) Transient / Permanent / Poison
| 분류 | 정의 | 대응 | 예 |
|---|---|---|---|
| Transient | 시간이 흐르면 자가 회복 가능 | retry (지수 백오프) | network timeout, throttling, 503 |
| Permanent | 같은 입력으론 항상 실패 | 즉시 분류 + 사용자 알림 | invalid format, file not found, 4xx |
| Poison | 메시지 자체가 처리 불가능 | DLQ 격리 + 분석 | 파싱 실패, 코드 결함 노출 |
핵심: 분류는 catch 직후, 메시지 처리 안에서 일어난다. retry policy는 분류 결과에 따라 결정.
2) Retry policy — 3단 구조
Tier 1 — Application retry:
- Lambda 코드 안에서
retry()라이브러리 사용 (es-toolkit/function,p-retry등) - 짧은 작업의 일시 실패에 즉시 재시도
- 멱등성 전제 — retry는 멱등 작업에만 안전
- backoff:
min(base * 2^n, max)지수 백오프
Tier 2 — Queue retry:
- 람다 자체가 throw하거나 timeout/OOM
- SQS visibility 만료 후 재전송
maxReceiveCount초과하면 DLQ로- 큐별로 다른 횟수 (자주 일시 실패하는 큐는 5, 결정적인 큐는 3)
Tier 3 — DLQ funnel:
- DLQ에 들어간 메시지는 원본 그대로 (status 메시지 아님)
- 별도 회수 람다가 DLQ를 trigger source로 받아 status FAILED 갱신
- 무한 루프 방지: status-update DLQ는 회수 람다에 trigger로 안 달기
3) 에러 분류기 (classifyError)의 표준 골격
function classifyError(err: unknown): { code: ErrorCode; message: string; retriable: boolean } {
// 1. AWS SDK 에러 — 어느 단계에서나 발생 가능
if (isAwsSdkError(err)) {
if (err.name === 'ThrottlingException') return { code: 'ERR_AWS_THROTTLING', retriable: true, ... };
if (err.name === 'AccessDeniedException') return { code: 'ERR_AWS_ACCESS_DENIED', retriable: false, ... };
if (err.name === 'NetworkingError') return { code: 'ERR_AWS_NETWORK', retriable: true, ... };
if (err.name === 'ServiceUnavailableException') return { code: 'ERR_AWS_SERVICE_ERROR', retriable: true, ... };
}
// 2. 단계별 키워드 매칭
const stage = detectStage(err.message); // 'probe' | 'transcode' | 'invalid-input' | ...
switch (stage) {
case 'probe':
if (err.message.match(/no such|not found/i)) return { code: 'ERR_PROBE_FILE_NOT_FOUND', retriable: false };
if (err.message.match(/invalid data|format/i)) return { code: 'ERR_PROBE_INVALID_FORMAT', retriable: false };
// ...
case 'transcode':
if (err.message.match(/missing segment/i)) return { code: 'ERR_TRANSCODE_MISSING_SEGMENT', retriable: false };
return { code: 'ERR_TRANSCODE_FAIL', retriable: false };
case 'invalid-input':
return { code: 'ERR_INVALID_INPUT', retriable: false };
default:
return { code: 'ERR_UNKNOWN', retriable: false };
}
}분류 원칙:
- AWS 에러 먼저 — 단계 무관하게 발생
- 단계 식별 + sub-분류 — 같은 단계라도 사용자 액션이 다르면 sub-분류
ERR_UNKNOWN비율은 분류기 보강 신호 — 5% 넘으면 PR 필요
4) ErrorCode 카탈로그 설계 원칙
ERR_<STAGE>_<CONDITION>- STAGE — 단계 (probe, transcode, …) — 어느 람다에서 일어났는지
- CONDITION — 세부 조건 — 어떤 자체 점검 결과인지
좋은 카탈로그:
ERR_PROBE_FILE_NOT_FOUND
ERR_PROBE_INVALID_FORMAT
ERR_PROBE_NO_STREAM
ERR_PROBE_FAILED
ERR_TRANSCODE_FAIL
ERR_TRANSCODE_MISSING_SEGMENT
ERR_AWS_THROTTLING
ERR_INVALID_INPUT
ERR_UNKNOWN나쁜 카탈로그:
ERR_FAILED // 너무 광범위
ERR_PROCESSING_PROBLEM // 무엇? 어디?
ERR_UNKNOWN_ERROR // unknown만 100% (분류 안 함)→ stage + condition 2축이면 운영자가 즉시 액션을 알 수 있다.
What — DLQ 회수 패턴의 디테일
1) DLQ 회수 — funnel Lambda 패턴
핵심 디테일:
- funnel Lambda가 정상 status + 모든 DLQ를 trigger source로 받음
- DLQ 메시지는 원본 페이로드 (status 메시지가 아님) — funnel이 이걸 감지해
ERR_UNKNOWN으로 갱신 - funnel 자체의 DLQ는 trigger source로 달지 않음 — 무한 루프 방지
// funnel Lambda의 분류 로직
function parseMessage(record: SQSRecord): StatusUpdate | null {
const body = JSON.parse(record.body);
// 1. EventBridge MC ERROR (정상 status 형태 아님)
if (body['detail-type'] === 'MediaConvert Job State Change') {
return { fileId: extractFileId(body), status: 'FAILED', errorCode: 'ERR_TRANSCODE_FAIL' };
}
// 2. 정상 status 메시지
if (['PROCESSING', 'COMPLETE', 'FAILED'].includes(body.status)) {
return body;
}
// 3. DLQ 원본 메시지 (status 필드 없음) — Lambda가 죽었다는 신호
const source = dlqLabel(record.eventSourceARN); // 'analyze-lambda' or 'sprite-lambda'
return {
fileId: extractFileId(body),
status: 'FAILED',
errorCode: 'ERR_UNKNOWN',
errorMessage: `${source} Lambda failed after retries (timeout/OOM/unhandled)`,
};
}2) 무한 루프 방지 — 안전망의 안전망 단절
만약 funnel DLQ → funnel Lambda를 연결하면:
- funnel이 죽음 → DLQ로 메시지
- DLQ trigger로 funnel이 다시 호출됨
- 같은 문제로 다시 죽음 → 무한 루프 + 비용 폭발
→ 끝단의 안전망은 CloudWatch + 운영자 알람으로 끝낸다. 마지막 한 단계는 사람이 본다.
3) Retry 횟수 — 큐별 차이의 의미
| 큐 | 작업 특성 | 권장 maxReceiveCount |
|---|---|---|
| 결정적 작업 (probe, parse) | 일시 실패 거의 없음 | 3 |
| 외부 의존 많음 (S3 list, MC submit) | 일시 실패 잦음 | 5 |
| DB write | connection pool 일시 exhaustion | 5 |
| 단순 분기 | 외부 의존 적음 | 3 |
→ 횟수 선택 기준: “이 작업이 transient로 회복될 평균 확률”.
- 회복 잘 됨 → 횟수↑ (사용자에게 노출 없이 처리)
- 회복 잘 안 됨 → 횟수↓ (빨리 DLQ로 보내서 운영자가 봄)
4) Poison message 격리
// 메시지 파싱 실패 = poison
try {
const msg = JSON.parse(record.body);
validateSchema(msg); // throw if invalid
} catch (err) {
// poison — retry해도 무의미
return { batchItemFailures: [{ itemIdentifier: record.messageId }] };
// → SQS retry 카운트 채워서 빠르게 DLQ로
}또는:
- Lambda 응답에 즉시 DLQ로 보내는 옵션 활용 (배치 메서드)
batchItemFailures패턴으로 한 record 결함이 batch 전체를 막지 않게
5) DLQ 회수 자산화 — 메트릭 + 자동화
DLQ를 살아있는 자산으로 만드는 4가지:
- 자동 회수 람다 — DLQ 메시지를 funnel이 받아 status 갱신 (위 패턴)
- 메트릭 알람 —
ApproximateNumberOfMessages > 0일 때 알람 - Redrive Console — AWS는 SQS 콘솔에서 한 번에 main queue로 redrive 가능
- 분류 보강 PR —
ERR_UNKNOWN이 늘면 분류기 키워드 추가 PR
What-if — 잘못 쓰면 어떻게 깨지는가
1) “Retry 정책 없음 — 모든 throw가 DLQ 직행”
→ transient 에러도 DLQ로 빠짐, DLQ가 노이즈로 가득. 알람 신뢰도 하락.
2) “Permanent 에러도 retry”
invalid format 에러를 5회 retry해도 결과는 같다. 5배 시간 + 5배 비용 + 사용자 대기시간 5배.
→ 분류기에서 retriable: false로 판단, 즉시 명시적 분류해서 status 갱신.
3) “DLQ에 메시지 쌓이는데 모니터링 없음”
DLQ가 사고의 박물관이 됨. 같은 패턴이 반복되어도 모름. → DLQ depth는 0이 정상이라는 알람.
4) “funnel Lambda를 DLQ trigger로 무한 루프”
위에 본 사고 — 끝단 단절 필수.
5) “에러 메시지에 PII/secret 노출”
throw new Error(`DB error: ${connectionString}`); // 비밀번호 노출!→ 에러 메시지 sanitize. 외부 노출되는 errorMessage와 내부 로그용 message 분리.
6) “분류기 키워드가 코드와 분리”
분류기를 별도 파일에 두면 람다 코드가 throw하는 메시지와 어긋남. 신규 메시지 추가 시 분류 누락. → 분류기와 throw 코드를 같은 PR에서, 또는 throw하는 쪽에서 명시적 errorCode 같이 넘김.
7) “DLQ retention < 운영자 응답 시간”
기본 SQS retention 4일. 주말 + 휴일 + 휴가 4일 넘으면 사라짐. → DLQ retention은 max(14일).
Insight — 흥미로운 이야기
“Erlang의 ‘let it crash’ 철학”
Erlang 분산 시스템 설계의 핵심 — 작은 프로세스가 죽도록 두고, supervisor가 다시 시작하라. 한 프로세스의 결함이 다른 프로세스를 오염시키지 않게 격리. SQS DLQ + funnel Lambda 패턴은 이 철학의 클라우드 버전이다 — 람다 하나가 죽으면 격리하고, 별도 funnel이 사후 처리. 1986년 Erlang 설계가 2026년 서버리스에 그대로 살아있다.
“Netflix Chaos Engineering의 출발점은 retry 폭주”
2010년 Netflix 발표 — retry policy가 잘못 설정되면 한 서비스 장애가 의존 서비스에 retry 폭풍을 일으켜 cascading failure가 일어난다. 그래서 Hystrix(circuit breaker)가 만들어졌다. 미디어 파이프라인도 같다 — MC가 일시 다운됐을 때 람다가 무한 retry하면 정작 MC가 회복돼도 큐가 백로그로 가득 차 정상 처리가 안 됨. circuit breaker 패턴이 필요한 이유.
“DLQ는 Postal Service의 dead letter office에서 왔다”
“Dead Letter Queue”는 우체국 용어 — 받는 사람을 못 찾은 편지가 가는 곳. AWS가 SQS에 도입하면서 IT 용어로 정착. 흥미로운 점은, 우체국 dead letter office에서도 직원이 편지를 열어 단서를 찾아 다시 배달을 시도한다는 것. SQS DLQ도 단순 휴지통이 아니라 회수 가능한 자산으로 다뤄야 한다는 발상이 그 어원에 있다.
“좋은 에러 카탈로그는 운영의 사전이다”
AWS API의 에러 코드는 약 1500개. Stripe는 약 200개. 잘 분류된 코드는 운영자가 처음 보는 에러여도 즉시 액션을 안다. 작은 시스템도 처음부터 enum + naming convention으로 시작하면 쉽다.
ERR_<STAGE>_<CONDITION>같은 작은 규칙 하나가 6개월 뒤 신입에게 운영 매뉴얼이 된다.
요약 + Mermaid
요점 — 모든 실패는 transient/permanent/poison으로 환원된다. Retry는 transient에만, 분류기가 catch 직후 결정. DLQ는 보관소가 아니라 회수 자산 — funnel Lambda 패턴으로 DLQ 메시지조차 자동 status 갱신해야 운영 가시성이 살아난다. 끝단의 funnel DLQ는 자기 자신의 trigger로 달지 않아 무한 루프 방지. 다음 문서(
06)는 이 모든 분류와 흐름을 어떻게 시각화하는지 — 관측 가능성을 본다.