재시도는 모두가 동시에 하면 안 됩니다 — 지수 백오프와 지터
Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 여덟 번째 글입니다. 이번 요구는 외부 API 가 잠시 실패하거나 요청 수를 제한해도, 우리 쪽이 무너지지 않고 상대를 더 괴롭히지도 않게 다시 시도해야 한다는 것입니다.
다른 회사의 API 를 부르는 서버는 언젠가 실패를 만납니다. 상대 서버가 잠시 바쁠 수도 있고, 정해진 한도를 넘었다며 거절할 수도 있습니다. 이때 "다시 해 보기"를 어떻게 하느냐에 따라, 금방 회복되기도 하고 실패가 오래 이어지기도 합니다.
실패하면 다시 해 보는 것까지는 누구나 합니다
여기서 한도(rate limit)는 일정 시간 동안 받아 주는 요청 수의 상한입니다. 한도를 넘으면 서버는 "너무 많습니다"라는 응답(HTTP 429)을 돌려주거나, 잠시 요청을 막습니다.
가장 단순한 재시도는 실패하면 1초 쉬고 다시 보내는 것입니다. 요청하는 쪽이 하나라면 이것으로 충분합니다. 문제는 요청하는 쪽이 여럿이고, 그들이 같은 순간에 함께 실패할 때입니다.
다 같이 실패하면 다 같이 다시 옵니다
정전 뒤 전기가 들어오는 순간, 집집마다 냉장고와 에어컨이 동시에 켜지면 전력이 순간적으로 치솟습니다. 재시도도 같습니다. 같은 순간에 실패한 요청들이 똑같이 1초를 쉬면, 1초 뒤에 또 한꺼번에 몰려옵니다. 이런 현상을 몰림 현상(thundering herd)이라고 부릅니다.
이를 숫자로 보려고 작은 시뮬레이션을 만들었습니다. 요청하는 쪽 100개가 동시에 실패하고, 서버는 0.1초마다 10건까지만 처리하고 나머지는 거절한다고 가정했습니다. 모두가 한 번에 성공하려면 이론상 1초가 필요한 상황입니다.
| 재시도 방식 | 총 요청 수 | 재시도가 한 칸(0.1초)에 몰린 최대 | 모두 성공한 시점 |
|---|---|---|---|
| 고정 1초 간격 | 550 | 90 | 10.0초 |
| 지수 백오프(지터 없음) | 550 | 90 | 27.2초 |
| 지수 백오프 + 풀 지터 | 303 | 55 | 2.0초 |
고정 간격에서는 매번 90, 80, 70… 건이 같은 순간에 몰려와 그중 10건만 성공합니다. 서버는 받은 요청의 대부분을 거절하는 데 힘을 쓰고, 모두 성공하기까지 10초가 걸렸습니다.
다만 이 결과는 단순한 가정 위의 시뮬레이션입니다. 네트워크 지연이나 서버 처리량의 변화는 넣지 않았으므로, 실제 API 에서 같은 숫자가 나온다는 뜻은 아닙니다. 보여 주는 것은 "재시도 시점이 겹치느냐"가 결과를 얼마나 바꾸는지입니다.
지수 백오프는 간격을, 지터는 순간을 흩어 놓습니다
지수 백오프(exponential backoff)는 실패할 때마다 기다리는 시간을 두 배씩 늘리는 방식입니다. 0.1초, 0.2초, 0.4초… 로 늘어나고, 너무 길어지지 않게 상한을 둡니다. 상대가 바쁠수록 덜 두드리게 해 준다는 점에서 기본이 되는 정석입니다.
그런데 표를 보면 지수 백오프만으로는 오히려 더 오래 걸렸습니다. 모두가 같은 규칙으로 기다리니, 간격은 넓어져도 여전히 같은 순간에 몰려오기 때문입니다. 몰림은 그대로 두고 기다림만 길어진 셈입니다.
여기에 필요한 것이 지터(jitter), 기다리는 시간에 섞는 무작위 값입니다. AWS 의 Marc Brooker 는 2015년 글에서 여러 방식을 비교하고, 0 부터 지수 백오프 값 사이에서 무작위로 고르는 "풀 지터(Full Jitter)"를 소개했습니다. 이 방식을 넣자 재시도 순간이 흩어져, 총 요청은 303건으로 줄고 모두 성공하는 데 2초가 걸렸습니다.
// 풀 지터: 0 과 min(상한, 기본값 × 2^시도횟수) 사이에서 무작위로 기다린다
function backoffDelay(attempt: number, baseMs = 100, capMs = 5_000): number {
const ceiling = Math.min(capMs, baseMs * 2 ** attempt);
return Math.random() * ceiling;
}
서버가 기다릴 시간을 알려 주면 그것을 먼저 따릅니다
상대 서버가 "언제 다시 오라"고 알려 주는 경우도 있습니다. HTTP 에는 이를 위한 Retry-After 헤더가 있고, HTTP 표준(RFC 9110)에 따르면 값은 "몇 초 뒤"를 뜻하는 숫자이거나 날짜·시각입니다. 두 형식을 모두 읽도록 했습니다.
function retryAfterMs(value: string, now = Date.now()): number | null {
if (/^\d+$/.test(value)) return Number(value) * 1000; // "120" → 120초
const at = Date.parse(value); // "Wed, 21 Oct 2015 07:28:00 GMT"
return Number.isNaN(at) ? null : Math.max(0, at - now);
}
Retry-After: "120" -> 120000 ms
Retry-After: "Wed, 21 Oct 2015 07:28:00 GMT" -> 60000 ms (기준 시각 07:27:00)
Retry-After: "soon" -> null (읽지 못함 → 백오프로 대신한다)
API 에 따라서는 헤더 대신 에러 문장 안에 "몇 초 동안 막힌다"를 적어 보내기도 합니다. 그런 경우에도 원칙은 같습니다. 상대가 알려 준 대기 시간을 먼저 따르고, 읽지 못하면 백오프로 대신합니다. 읽을 수 없는 값을 0 초로 해석해 바로 다시 보내는 실수만은 피해야 합니다.
한도가 모두의 합산이라면, 숫자를 코드에 박지 않습니다
한도에는 두 종류가 있습니다. 우리 앱에만 따로 걸리는 한도와, 그 API 를 쓰는 모든 앱이 함께 나눠 쓰는 한도입니다. 뒤의 경우라면 문서에 적힌 "초당 몇 건"을 지켜도 다른 앱들 때문에 거절당할 수 있습니다. 이런 한도는 우리가 미리 계산할 수 있는 값이 아닙니다.
그래서 초당 몇 건이라는 숫자를 상수로 코드에 박는 대신, 상대가 보내 주는 신호(거절 응답과 대기 시간)에 반응하도록 했습니다. 대신 재시도의 끝은 분명히 정했습니다. 최대 횟수와 전체 대기 시간의 상한을 두고, 그 안에 성공하지 못하면 실패를 기록하고 멈춥니다. 끝없는 재시도는 실패를 숨기기만 한다는 생각이 들었습니다.
다시 보내도 되는 요청인지 먼저 확인합니다
- 다시 보내도 결과가 같은 요청만 재시도합니다. 조회는 괜찮지만, 무언가를 만드는 요청은 멱등 키 같은 장치가 있을 때만 다시 보냅니다. 멱등 키는 이 연재의 세 번째 글에서 다뤘습니다.
- 다시 해서 나아질 실패만 재시도합니다. 한도 초과(429)나 일시적 장애(503), 시간 초과는 기다리면 나아질 수 있습니다. 잘못된 요청(400)이나 권한 없음(401)은 몇 번을 보내도 같습니다.
정리하며
- 기다림은 늘리고, 순간은 흩어 놓습니다. 지수 백오프에 지터를 더해야 몰림이 풀립니다.
- 상대가 알려 준 대기 시간을 먼저 따릅니다. Retry-After 같은 신호를 읽고, 읽지 못하면 백오프로 대신합니다.
- 재시도에도 끝을 둡니다. 횟수와 시간의 상한을 두고, 넘으면 실패를 기록합니다.
재시도는 내 요청 하나를 성공시키는 기술 같지만, 실제로는 같은 상대를 부르는 모두가 함께 회복하게 만드는 약속에 가깝다는 생각이 들었습니다. 무작위 값 하나가 그 약속의 대부분을 해낸다는 점이 인상적이었습니다.
실험 환경: Node.js v26.5.0 · 2026-09-29. 시뮬레이션은 재현 가능한 난수(고정 시드)를 썼고, 서버 처리량과 요청 수는 설명을 위해 정한 가정입니다.
관련 글
같은 요청이 두 번 와도 한 번만 — 멱등 키(Idempotency-Key) 구현하기
응답이 사라지면 재시도는 피할 수 없습니다. 그래서 같은 요청이 두 번 와도 한 번만 처리하고, 두 번째에도 처음과 같은 응답을 돌려주는 멱등 키를 MySQL 로 직접 구현했습니다. 다섯 요청이 동시에 와도 확정은 한 번이었던 실험을 함께 정리했습니다.
NestJS 마이크로서비스 부하테스트: 전략 설계와 7가지 요구사항
Production 환경을 로컬에서 재현하여 시스템 한계점을 찾는 부하테스트 전략을 설계한 경험. 7가지 요구사항 정의부터 아키텍처 설계까지의 과정을 정리했습니다.
Next.js + NestJS로 광고 차단 우회 조회수 카운터 만들기 (2편)
first-party 엔드포인트 하나로 광고 차단기를 우회하는 조회수 API를 만들었습니다. Next.js BlogViewTracker 확장, NestJS ViewsModule의 isbot·$inc·24시간 디바운스, view_logs 컬렉션 설계까지 정리했습니다.