외부 API를 호출하다 보면 어느 순간 429 Too Many Requests가 쏟아집니다. 짧은 시간에 허용된 호출 한도를 넘겼다는 신호입니다. 이때 곧바로 같은 속도로 재시도하면 한도는 더 빠르게 막히고, 모든 클라이언트가 동시에 재시도하면 서버가 회복되는 순간 다시 몰려 무너지는 악순환이 생깁니다. 올바른 대응은 세 축으로 이뤄집니다. 실패하면 대기 시간을 점점 늘리는 지수 백오프에 무작위 지터를 더하고, 서버가 알려 준 Retry-After를 존중하며, 애초에 한도를 넘지 않도록 토큰 버킷·동시성 제한·큐로 송출 속도를 제어하는 것입니다. 아래에서 원인부터 재시도 상한·멱등성·모니터링까지 순서대로 정리합니다.
429는 왜 발생하나
레이트리밋은 서버가 자원을 보호하고 모든 사용자에게 공정한 처리량을 보장하기 위해 단위 시간당 호출 수를 제한하는 장치입니다. 한도를 넘으면 서버는 요청을 처리하는 대신 429 상태 코드를 돌려줍니다. 이는 RFC 6585에서 정의되었고, 현재 HTTP 시맨틱은 RFC 9110이 정리합니다. 상태 코드와 Retry-After 헤더의 정의는 MDN 429 Too Many Requests 문서에서 확인할 수 있습니다. 핵심은 429가 “당신 잘못이니 잠시 멈추고 다시 오라”는 일시적 거절이라는 점입니다. 영구 실패가 아니므로 적절히 기다렸다가 재시도하면 성공합니다.
한도는 여러 차원으로 걸립니다. 초당·분당 요청 수(RPS/RPM), 분당 토큰 수(LLM의 TPM), 동시 연결 수, 일일 쿼터 등이 흔합니다. 같은 API라도 엔드포인트마다, 요금제마다 한도가 다릅니다. 그래서 429를 만났을 때 어떤 한도에 걸린 것인지 구분하는 것이 첫걸음입니다. 분당 한도에 걸렸다면 수십 초 뒤 풀리지만, 일일 쿼터를 소진했다면 재시도로 해결되지 않습니다.
많은 API는 응답 헤더로 현재 한도 상태를 알려 줍니다. 남은 호출 수, 한도 초기화 시각, 그리고 재시도까지 기다릴 시간을 담은 헤더가 대표적입니다. 이 정보를 무시하고 무작정 재시도하는 것이 가장 흔한 실수입니다.
지수 백오프와 지터
지수 백오프는 재시도 간격을 매번 배로 늘리는 전략입니다. 첫 재시도는 1초, 다음은 2초, 그다음 4초, 8초처럼 지수적으로 대기 시간을 키웁니다. 서버가 과부하라면 시간이 지날수록 회복될 가능성이 높으므로, 점점 더 오래 기다리는 것이 합리적입니다. 고정 간격으로 재시도하면 서버가 막혀 있는 동안 같은 빈도로 계속 두드려 부담만 가중됩니다.
그런데 지수 백오프만으로는 부족합니다. 여러 클라이언트가 동시에 429를 받으면, 모두 똑같이 1초→2초→4초 후에 재시도하므로 재시도 시점이 한 점으로 몰립니다. 이 “천둥 떼(thundering herd)” 현상은 서버가 막 회복되는 순간 다시 밀려와 재차 무너뜨립니다. 해법이 지터(jitter), 즉 대기 시간에 무작위성을 더하는 것입니다. 각 클라이언트가 서로 다른 시점에 재시도하도록 흩어 주면 부하가 시간축으로 분산됩니다.
// 지수 백오프 + 풀 지터 (개념 예시)
async function withBackoff(fn, { maxRetries = 5, baseMs = 500, capMs = 20000 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
if (!isRetriable(err) || attempt > maxRetries) throw err;
// 서버가 Retry-After를 줬으면 그 값을 우선 존중
const serverWait = retryAfterMs(err);
// 지수적 상한 계산 후, 그 범위에서 무작위로 뽑는 '풀 지터'
const expo = Math.min(capMs, baseMs * 2 ** (attempt - 1));
const wait = serverWait ?? Math.random() * expo;
await sleep(wait);
}
}
}
function isRetriable(err) {
// 429, 408, 5xx, 네트워크 오류만 재시도 대상
const s = err.status;
return s === 429 || s === 408 || (s >= 500 && s < 600) || err.code === "ECONNRESET";
}지터에는 몇 가지 방식이 있습니다. 계산된 지수 간격에 ±일부를 더하는 방식, 0부터 지수 상한 사이에서 완전히 무작위로 뽑는 풀 지터, 직전 대기 시간을 반영하는 디코릴레이티드 지터 등입니다. 단순함과 분산 효과의 균형이 좋아 풀 지터가 널리 권장됩니다. 어느 방식이든 핵심은 동일합니다. 재시도 시점을 흩어 부하 집중을 막는 것입니다.
Retry-After를 존중하기
많은 서버는 429나 503과 함께 Retry-After 헤더로 “이만큼 기다린 뒤 다시 오라”고 명시적으로 알려 줍니다. 이 값은 초 단위 숫자이거나 HTTP 날짜 형식일 수 있습니다. 서버가 직접 알려 준 대기 시간이므로, 우리가 계산한 백오프 값보다 항상 우선해야 합니다. 서버가 30초를 기다리라고 했는데 우리가 2초 만에 재시도하면 또 429를 받을 뿐입니다.
| 응답 신호 | 의미 | 대응 |
|---|---|---|
| Retry-After: 30 | 30초 후 재시도 권고 | 최소 30초 대기(우선 적용) |
| Retry-After: 날짜 | 해당 시각까지 대기 | 현재 시각과의 차이만큼 대기 |
| RateLimit-Reset 류 | 한도 초기화 시각 | 그 시점까지 송출 중단 |
| RateLimit-Remaining: 0 | 남은 호출 없음 | 재시도 전 선제적 대기 |
| 헤더 없음 | 정보 미제공 | 지수 백오프+지터로 추정 |
구현에서는 Retry-After가 있으면 그 값을 쓰고, 없을 때만 지수 백오프 계산값으로 대체하는 우선순위를 둡니다. 또한 남은 호출 수 헤더가 0에 가까워지면 429를 받기 전에 미리 송출을 늦추는 선제적 제어를 더하면, 아예 429 자체를 줄일 수 있습니다. 사후 재시도보다 사전 예방이 항상 비용이 적습니다.
토큰 버킷과 동시성 제한
재시도는 사후 대응이고, 근본 해결은 한도를 넘지 않도록 보내는 속도를 제어하는 것입니다. 가장 널리 쓰이는 모델이 토큰 버킷입니다. 일정 속도로 버킷에 토큰을 채우고, 요청 하나가 토큰 하나를 소비합니다. 토큰이 없으면 채워질 때까지 기다립니다. 이렇게 하면 평균 송출률을 한도 이하로 유지하면서도, 버킷에 쌓인 여유분만큼 짧은 순간의 버스트는 허용할 수 있습니다.
// 토큰 버킷 (개념 예시)
class TokenBucket {
constructor(ratePerSec, capacity) {
this.rate = ratePerSec;
this.capacity = capacity;
this.tokens = capacity;
this.last = Date.now();
}
async take() {
while (true) {
const now = Date.now();
this.tokens = Math.min(
this.capacity,
this.tokens + ((now - this.last) / 1000) * this.rate
);
this.last = now;
if (this.tokens >= 1) { this.tokens -= 1; return; }
await sleep(((1 - this.tokens) / this.rate) * 1000);
}
}
}요청 수 한도뿐 아니라 동시성도 함께 제한해야 합니다. 한도가 분당 호출 수로 걸려 있어도, 수천 개의 요청을 동시에 띄우면 서버 큐가 순간적으로 폭주하거나 연결 수 한도에 걸립니다. 동시 실행 개수를 N개로 묶는 세마포어 형태의 제한을 함께 두면, 평균 속도와 순간 동시성을 모두 통제할 수 있습니다. LLM API처럼 요청당 무거운 작업에서는 동시성 제한이 특히 중요합니다.
요청 큐 설계
송출 속도를 제어하려면 요청을 즉시 쏘지 않고 큐에 넣어 일정 속도로 꺼내 처리하는 구조가 자연스럽습니다. 생산자는 큐에 작업을 넣기만 하고, 소비자(워커)가 토큰 버킷·동시성 제한을 지키며 큐에서 꺼내 실행합니다. 이렇게 분리하면 순간적으로 요청이 몰려도 큐가 완충 역할을 해 한도를 매끄럽게 지킵니다.
큐를 설계할 때는 몇 가지를 정해야 합니다. 큐가 무한정 커지지 않도록 최대 길이를 두고, 초과 시 거절하거나 가장 오래된 항목을 버리는 정책을 정합니다. 우선순위가 다른 작업이 섞인다면 우선순위 큐로 사용자 대면 요청을 배치성 작업보다 먼저 처리합니다. 또한 큐가 메모리에만 있으면 프로세스가 죽을 때 작업이 사라지므로, 손실을 허용할 수 없는 작업은 영속 큐(예: 메시지 브로커)로 옮깁니다.
| 큐 설계 요소 | 선택지 | 고려점 |
|---|---|---|
| 적체 한계 | 최대 길이 + 거절/폐기 | 무한 증가로 메모리 고갈 방지 |
| 우선순위 | FIFO vs 우선순위 큐 | 사용자 요청을 배치보다 우선 |
| 영속성 | 인메모리 vs 브로커 | 장애 시 작업 손실 허용 여부 |
| 처리 속도 | 토큰 버킷 + 동시성 N | 평균률과 순간 동시성 동시 제어 |
재시도 상한과 멱등성
재시도는 무한이어선 안 됩니다. 영구적 실패(잘못된 요청, 인증 오류 같은 4xx 대부분)는 몇 번을 재시도해도 성공하지 않으므로 즉시 포기해야 합니다. 재시도 대상은 429, 408, 5xx, 일시적 네트워크 오류로 한정합니다. 그리고 재시도 가능한 오류라도 최대 횟수와 총 대기 시간 상한(deadline)을 둬, 일정 시간이 지나면 실패로 처리하고 호출자에게 알립니다. 상한이 없으면 장애 상황에서 요청이 끝없이 쌓여 시스템 전체가 멈춥니다.
재시도는 멱등한 요청에만 안전합니다.
응답을 받기 전에 타임아웃이 났다면, 서버가 이미 요청을 처리했을 수도 있습니다. 이 상태에서 재시도하면 결제·주문·발송 같은 작업이 두 번 일어날 수 있습니다. GET·PUT·DELETE는 보통 멱등하지만 POST는 그렇지 않으므로, 멱등 키(Idempotency-Key)를 함께 보내 서버가 같은 키의 중복 요청을 한 번만 처리하도록 보장해야 합니다.
멱등 키는 클라이언트가 요청마다 부여하는 고유 식별자입니다. 서버는 같은 키로 들어온 요청을 한 번만 실행하고, 재시도로 다시 와도 처음 결과를 그대로 돌려줍니다. 결제·메일 발송처럼 부작용이 있는 호출에서는 멱등 키 없이 재시도하는 것이 위험합니다. 재시도 로직과 멱등성 설계는 한 쌍으로 다뤄야 합니다.
모니터링과 운영
레이트리밋 대응이 잘 작동하는지는 지표로 확인해야 합니다. 눈으로 보이지 않으면 서서히 한도에 다가가는 상황을 놓치기 쉽습니다. 최소한 다음을 계측합니다. 429 발생률, 재시도 횟수 분포, 재시도 후 최종 성공률, 큐 적체 길이, 요청당 총 대기 시간입니다. 429율이 꾸준히 오르면 한도 증설이나 송출률 하향을 검토할 신호입니다.
지속적인 장애에는 재시도만으로 부족합니다. 외부 API가 한참 동안 429나 5xx만 돌려주는데도 계속 재시도하면, 늘어난 백오프 대기 때문에 우리 쪽 요청이 줄줄이 묶여 시스템 전체가 느려집니다. 이때는 서킷 브레이커를 함께 두는 것이 좋습니다. 일정 시간 안에 실패가 임계치를 넘으면 회로를 열어 한동안 호출 자체를 즉시 차단하고, 정해진 시간이 지나면 소수의 시험 요청만 흘려보내 회복 여부를 확인합니다. 회복됐으면 회로를 닫아 정상으로 돌아가고, 아니면 다시 차단을 유지합니다. 이렇게 하면 죽은 외부 서비스에 헛된 재시도를 퍼붓는 대신, 빠르게 실패를 반환해 사용자 경험과 자원을 보호할 수 있습니다. 재시도가 일시적 흔들림을 흡수하는 장치라면, 서킷 브레이커는 장기 장애로부터 우리를 격리하는 장치입니다.
운영 점검 체크리스트
- 재시도 대상을 429·408·5xx·네트워크 오류로 한정했는가(4xx 영구 실패는 즉시 포기)
- 지수 백오프에 지터를 더해 재시도 시점을 분산했는가
Retry-After가 있으면 계산값보다 우선 적용하는가- 최대 재시도 횟수와 총 deadline 상한을 두었는가
- 토큰 버킷·동시성 제한으로 애초에 429를 줄이는가
- 부작용 있는 POST에 멱등 키를 붙여 중복 실행을 막았는가
- 429율·재시도 분포·큐 길이를 대시보드로 보고 있는가
자주 묻는 질문
- 왜 고정 간격이 아니라 지수 백오프를 쓰나요?
- 서버 과부하는 시간이 지날수록 회복될 가능성이 높습니다. 고정 간격은 막혀 있는 동안 같은 빈도로 계속 두드려 부담을 키우는 반면, 지수 백오프는 점점 길게 기다려 서버에 회복할 여유를 줍니다.
- 지터는 꼭 필요한가요?
- 네. 여러 클라이언트가 같은 백오프 곡선을 따르면 재시도가 한 시점에 몰려 서버가 회복되는 순간 다시 무너집니다. 대기 시간에 무작위성을 더해 재시도 시점을 흩어야 부하가 시간축으로 분산됩니다.
- 모든 요청을 재시도해도 되나요?
- 아닙니다. 잘못된 파라미터·인증 오류 같은 4xx는 재시도해도 성공하지 않으니 즉시 포기합니다. 그리고 결제·주문처럼 부작용이 있는 요청은 멱등 키 없이 재시도하면 중복 처리 위험이 있어 멱등성 설계가 선행돼야 합니다.
- 재시도와 큐 중 무엇이 먼저인가요?
- 둘은 역할이 다릅니다. 토큰 버킷·큐로 송출 속도를 제어해 429를 예방하는 것이 우선이고, 그래도 발생하는 일시적 429를 백오프 재시도로 흡수합니다. 사전 예방이 사후 재시도보다 비용이 적습니다.
- 한도 헤더는 어떻게 활용하나요?
- 남은 호출 수가 0에 가까워지면 429를 받기 전에 미리 송출을 늦추고, 초기화 시각까지 대기합니다.
Retry-After가 오면 그 값을 그대로 따릅니다. 헤더명과 형식은 API마다 다르므로 공식 문서를 확인하세요.
이 글은 MDN·RFC 6585·RFC 9110의 429/Retry-After 정의와 주요 클라우드·API 제공사의 재시도 권고를 일반화해 정리한 것입니다. 구체적인 한도 수치, 한도 관련 헤더명, 멱등 키 지원 여부는 API마다 다르므로 실제 적용 전 각 제공사의 공식 문서를 확인하시기 바랍니다. 코드는 원리 설명용 개념 예시이며, 프로덕션에서는 검증된 라이브러리 사용을 권장합니다.