정의
호출이 일시적인 이유(상대가 잠깐 바쁨, 네트워크 흔들림)로 실패했을 때 다시 시도하는 일이 재시도(retry) 예요. 그런데 단순히 "1초 뒤에 다시"로 두면 문제가 커질 수 있어서, 두 가지를 더해요.
- 지수 백오프(exponential backoff): 실패할 때마다 기다리는 시간을 1초, 2초, 4초처럼 두 배씩 늘려요.
- 지터(jitter): 기다리는 시간에 무작위 값을 섞어서 호출자마다 조금씩 다르게 만들어요.
왜 필요한가
열 명의 호출자가 같은 순간에 실패했다고 해 볼게요. 모두가 "정확히 1초 뒤에 다시"를 쓰면 1초 뒤에 열 명이 동시에 돌아와서 같은 이유로 또 거절돼요. 이게 반복되는 현상을 thundering herd(우르르 몰려오는 무리)라고 불러요. 상대가 실제로 고장 난 경우에도 일정 간격으로 계속 두드리면 복구를 늦춰요.
백오프는 상대에게 쉴 시간을 주고, 지터는 호출자들의 도착 시각을 흩어 놓아서 한꺼번에 몰리는 일을 막아요.
동작 원리
| 방식 | 대기 시간 | 문제 |
|---|---|---|
| 고정 간격 | 1s, 1s, 1s | 같이 실패한 호출자가 같이 돌아와요 |
| 지수 백오프 | 1s, 2s, 4s | 호출자 간 타이밍이 여전히 겹쳐요 |
| 백오프 + 지터 | 구간에서 무작위 | 겹침이 흩어져요 |
지터를 만드는 가장 단순한 방법은 구간에서 무작위로 뽑는 것이에요.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function withRetry<T>(
fn: () => Promise<T>,
{ attempts = 3, minMs = 1000, maxMs = 4000 } = {},
): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (error) {
if (!isRetryable(error) || attempt >= attempts) throw error;
await sleep(minMs + Math.random() * (maxMs - minMs));
}
}
}
두 층으로 나누는 구조도 흔해요. 호출 하나 안에서는 짧은 구간(예: 14초)으로 몇 번만 재시도해서 순간적 경합을 풀고, 그래도 안 되면 에러를 던져 큐가 더 긴 간격(수초수분)으로 다시 배달하게 해요(queue-based-load-leveling 참고).
실무 적용
- 무엇을 재시도할지 분류해요. 잠금 대기, 일시적 5xx, 시간 초과, HTML 에러 페이지처럼 "다시 하면 될 수 있는" 것만 재시도해요. 잘못된 입력(4xx)이나 인증 실패는 몇 번을 다시 해도 같은 결과예요.
- 상한을 둬요. 횟수 상한과 간격 상한(cap)이 없으면 요청이 끝없이 살아 있거나 간격이 계속 늘어나요.
- 포기한 뒤를 정해요. 한계에 도달하면 요청이 어디로 가는지(큐 재배달, 오류 보고, 사용자 안내)를 코드로 분명히 해요.
- 총 시도 횟수를 곱해서 봐요. 호출 안 3회 × 큐 재배달 8회라면 한 요청이 최대 24번까지 상대에 도달해요.
트레이드오프
| 선택 | 장점 | 비용 |
|---|---|---|
| 재시도를 많이 | 일시 장애를 더 잘 흡수 | 응답 지연, 상대 부하 증가 |
| 간격을 길게 | 상대에 여유 | 사용자가 결과를 늦게 알아요 |
| 지터 구간을 넓게 | 분산 효과 큼 | 최악 대기 시간이 늘어요 |
사용하면 안 되는 경우
- 반복 실행이 안전하지 않은 연산(두 번 실행되면 두 번 결제되는 것 등)은 멱등 키 없이 재시도하면 안 돼요(idempotent-consumer-ack-timing).
- 사용자가 화면 앞에서 기다리는 경로에서 긴 백오프를 쓰면 안 돼요. 짧게 하고 나머지는 비동기로 넘겨요.
- 영구적인 실패(잘못된 요청)를 재시도하면 같은 실패를 반복하며 상대만 두드려요.
흔한 실수
- 지터 없이 백오프만 쓰기. 같은 순간에 시작한 호출자들의 타이밍이 계속 겹쳐요.
- 모든 에러를 재시도하기. 4xx까지 재시도해서 로그만 늘어나요.
- 재시도 횟수와 간격에 상한을 안 두기.
- 여러 층의 재시도를 곱해 보지 않기. 각 층은 합리적인데 합쳐 보면 상대에게 과한 부하예요.
관련 개념
- queue-based-load-leveling — 재시도를 어느 층에서 할지 정하는 구조
- idempotent-consumer-ack-timing — 재시도가 같은 요청을 두 번 실행하게 만드는 이유와 대응
- playwright-flaky-vs-failed-triage — 재시도로 통과한 실패와 계속 실패하는 결함을 구분하는 관점