LabHub
배우기 러닝패스 코스

Queues and Asynchronous APIs

What Infinite Retries Create

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

재시도에는 상한, 지수 백오프, 지터, 그리고 포기 후 갈 곳이 있어야 한다. 넷 중 하나라도 없으면 재시도는 장애 증폭기다.

Concept map: 같은 시각에 몰려 재시도 · 총 대기 시간 · 재시도해도 소용없는 실패는 즉시 포기 · DLQ 에 넣는 것으로 끝나면

왜 이게 필요했나

가장 나쁜 재시도는 즉시, 고정 간격으로, 무한히 하는 것입니다. 서버가 잠깐 과부하로 느려졌을 때 모든 클라이언트가 곧바로 다시 때리면 겨우 버티던 서버가 완전히 주저앉습니다. 재시도가 장애를 치료하는 대신 악화시킵니다.

첫 번째 개선은 지수 백오프입니다. 1초, 2초, 4초, 8초로 간격을 늘립니다. 실패가 계속될수록 재시도 압력이 지수적으로 줄어 서버에 숨 쉴 틈을 줍니다.

두 번째가 지터입니다. 지수 백오프만으로는 부족합니다. 서버가 잠시 죽으면 1만 개 클라이언트가 동시에 실패를 감지하고, 모두 같은 규칙을 따라 정확히 같은 순간에 재시도합니다. 서버가 회복하려는 찰나에 1만 개가 쏟아지고 다시 쓰러집니다. 그리고 이 파도는 2초, 4초, 8초 뒤에도 반복됩니다. 완전 지터(full jitter)는 0부터 계산된 백오프 값 사이의 무작위 시간을 기다려 이 동기화를 깹니다.

어떻게 동작하나

상한과 포기도 필요합니다. 간격이 무한정 커지지 않게 최대 대기 시간(예: 30초)을 두고, 일정 횟수 후에는 포기합니다. 포기한 메시지가 갈 곳이 DLQ(dead-letter queue)입니다.

DLQ 는 쓰레기통이 아니라 조사 대상 목록입니다. 그래서 메시지만 넣으면 쓸모가 없습니다. 함께 넣어야 할 것은 넷입니다 — 원래 메시지, 실패 원인(예외 메시지나 상태 코드), 시도 횟수, 그리고 최초 수신 시각입니다. 이것이 있어야 "이 메시지는 왜 여기 있는가"에 답할 수 있고, 원인을 고친 뒤 재처리할 수 있습니다.

재처리 스크립트도 미리 만들어 두어야 합니다. 새벽 3시에 DLQ 가 쌓였을 때 재처리 도구를 그 자리에서 만들게 되면 실수가 납니다.

그리고 아무 실패나 재시도해서는 안 됩니다. 재시도가 의미 있는 것은 일시적 오류뿐입니다. 메시지 자체가 잘못된 경우(스키마 위반, 존재하지 않는 참조)는 100번을 시도해도 같습니다. 이런 것은 첫 실패에서 바로 DLQ 로 보내는 편이 낫습니다. 이 구분을 하지 않으면 잘못된 메시지 하나가 큐 전체를 막습니다 — 이것을 독이 든 메시지(poison message)라고 부릅니다.

현장에서 만나는 모습

DLQ 에 알림을 걸지 않는 팀이 많습니다. DLQ 는 조용하기 때문에 몇 주 뒤에 발견됩니다. "DLQ 깊이 > 0" 은 페이지가 아니라도 최소한 하루 한 번은 누군가 봐야 하는 지표입니다.

또 하나. DLQ 자체의 소비자를 만들어 자동 재처리하게 두는 설계는 위험합니다. 원인이 그대로면 무한 루프가 됩니다. 재처리는 사람이 원인을 확인한 뒤 명시적으로 트리거하는 편이 안전합니다.

백오프에 지터가 없으면

실패한 작업들이 같은 시각에 몰려 재시도 합니다. 하류가 잠깐 죽었다 살아나는 순간 그 무리가 한꺼번에 들이닥쳐 다시 죽입니다(thundering herd).

# ❌ 모두가 정확히 1s, 2s, 4s 뒤에 재시도한다
delay = base * (2 ** attempt)

# ✅ 흩어진다 — full jitter
delay = random.uniform(0, base * (2 ** attempt))

# ✅ 최소 대기는 보장하면서 흩기 — decorrelated jitter
delay = min(cap, random.uniform(base, prev * 3))

full jitter 가 가장 단순하고 실측에서도 잘 흩어집니다. 상한(cap)을 두어 지수가 무한히 커지지 않게 합니다.

재시도 횟수는 무엇으로 정하나

"세 번" 은 관례일 뿐입니다. 총 대기 시간 으로 거꾸로 계산하는 편이 낫습니다.

하류가 보통 30초 안에 복구된다면 → 총 대기가 60초쯤 되게 잡는다
base=1s, 지수 2, 상한 20s → 1, 2, 4, 8, 16, 20 … 6번이면 51초

그리고 재시도해도 소용없는 실패는 즉시 포기 합니다. 400(잘못된 요청), 401·403(권한), 404 는 몇 번을 보내도 같습니다. 이것을 구분하지 않으면 영원히 실패할 작업이 큐를 막습니다.

class Permanent(Exception): pass      # 재시도하지 않는다

if 400 <= status < 500 and status not in (408, 429):
    raise Permanent(f"고칠 수 없는 실패: {status}")

죽은 편지 큐를 운영하는 법

재시도를 다 쓴 작업은 DLQ 로 보냅니다. 그런데 DLQ 에 넣는 것으로 끝나면 아무도 안 봅니다. 세 가지가 함께 있어야 합니다.

# 예: 원인을 고친 뒤 되돌리기
labhub-cli dlq replay --queue orders --since 2026-09-06T12:00 --limit 500

되돌릴 때 한꺼번에 넣지 않는 것 이 중요합니다. 5,000건을 한 번에 재투입하면 그것이 곧 스파이크가 되어 다시 무너집니다. 속도 제한을 걸어 흘려 넣습니다.

다음 실습에서 할 것

큐 소비자에 지수 백오프와 완전 지터를 붙이고, 상한을 걸고, 5회 실패 후 DLQ 로 보내고, DLQ 메시지에 원인과 시도 횟수를 담고, 마지막에 원인을 제거한 뒤 재처리 스크립트로 복구합니다.