핵심 요약
웹 API에서 429 Too Many Requests가 발생할 때 제한 계층과 기준 키를 찾고, Retry-After 해석, 지수형 백오프·jitter·동시성 제어·멱등성을 순서대로 점검합니다.
검수 범위
브라우저·모바일 클라이언트가 HTTP API를 호출하고 CDN·WAF·게이트웨이·애플리케이션 중 한 계층에서 요청률을 제한하는 상황을 대상으로 합니다. 로그인 실패 잠금, 결제 승인 거절, 유료 사용량 한도는 별도 정책으로 구분합니다.
429 Too Many Requests는 클라이언트가 일정 시간에 너무 많은 요청을 보냈다는 뜻입니다. 하지만 원인은 단순한 새로고침 횟수만이 아닙니다. 화면의 중복 호출, 짧은 polling 간격, 토큰 갱신 경쟁, 여러 서버의 재시도 중첩, NAT 뒤에서 같은 IP를 공유하는 사용자까지 서로 다른 상황이 같은 429로 보일 수 있습니다.
해결의 출발점은 대기 시간을 임의로 늘리는 것이 아니라 어느 계층이 누구를 어떤 단위로 제한했는지 확인하는 것입니다. 그 뒤 서버가 보낸 Retry-After를 우선하고, 값이 없을 때만 상한이 있는 지수형 백오프와 jitter를 적용합니다. 쓰기 요청은 자동 재시도 전에 멱등성도 함께 검토해야 합니다.
증상으로 먼저 범위를 좁히기
| 관찰되는 패턴 | 가능성이 높은 원인 | 먼저 볼 값 |
|---|---|---|
| 한 사용자의 모든 API가 동시에 429 | 사용자·토큰 단위 전체 한도 | 사용자 ID, API key, 제한 정책 이름 |
| 특정 경로만 429 | route별 별도 한도 또는 비싼 작업 보호 | 요청 경로, HTTP method, 정책 ID |
| 회사·학교 네트워크에서만 다수 발생 | 공유 공인 IP 기준 제한 | 원본 IP 추출 방식, 인증 사용자 기준 유무 |
| 화면 진입 순간 요청이 몰림 | 중복 초기화, 병렬 fan-out, 재렌더링 | 동일 request ID·URL의 시작 시각 |
| 장애 직후 재시도가 파도처럼 반복 | 고정 간격 재시도와 jitter 부재 | attempt, backoff, retry reason |
| 동시에 몇 개만 보내도 429 | 요청률이 아니라 동시 처리 수 제한 | in-flight 수, 응답 시간, queue 길이 |
| 브라우저에서는 대기 값을 읽지 못함 | CORS에서 응답 헤더를 노출하지 않음 | Access-Control-Expose-Headers |
1단계: 429 응답 한 건을 원본 그대로 확보
브라우저 개발자 도구의 Network 탭이나 앱 네트워크 로그에서 실패 요청 한 건을 골라 URL, method, 상태 코드, 응답 헤더, 응답 본문, 시작 시각과 request ID를 함께 저장합니다. 같은 화면에서 성공한 요청과 비교하면 제한이 경로별인지 전체인지 빨리 구분할 수 있습니다.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
X-Request-Id: req_7f2a
{
"type": "https://api.example.com/problems/rate-limit",
"title": "Too Many Requests",
"status": 429,
"detail": "요청 간격을 늦춘 뒤 다시 시도하세요."
}
RFC 6585는 429 응답에 제한 조건을 설명하는 표현을 포함하도록 권고하고, Retry-After를 함께 보낼 수 있다고 정의합니다. 응답 본문이 HTML인지 JSON인지, CDN 이름이나 게이트웨이 식별 헤더가 있는지도 기록합니다. 단, 공개 로그에는 토큰·쿠키·개인정보를 남기지 않습니다.
2단계: 429를 만든 계층 찾기
같은 도메인이라도 요청은 CDN 또는 WAF, 로드 밸런서, API 게이트웨이, 애플리케이션, 외부 API 순서로 지나갈 수 있습니다. 앞단이 429를 반환했다면 애플리케이션 로그에는 요청 자체가 없을 수 있습니다.
- request ID나 trace ID가 CDN·게이트웨이 로그에 있는지 확인합니다.
- 같은 ID가 애플리케이션 access log와 handler 로그까지 도착했는지 확인합니다.
- 애플리케이션이 외부 API를 호출한다면 그 하위 응답 코드를 별도로 기록합니다.
- 각 계층의 정책 이름, 임계값, 적용 경로를 배포 설정과 대조합니다.
응답 본문 문구만으로 발생 계층을 단정하지 않습니다. 프록시가 하위 429를 그대로 전달하거나 자체 형식으로 바꿀 수 있기 때문입니다. 요청이 마지막으로 관측된 계층과 실제 정책 설정을 함께 봐야 합니다.
3단계: 제한 기준 키와 시간 창 확인
429 표준은 서버가 사용자를 어떻게 식별하고 요청을 어떻게 세는지 정하지 않습니다. 구현은 IP, 로그인 사용자, 세션 쿠키, API key, tenant, 경로 또는 여러 값을 조합할 수 있습니다. 따라서 “분당 100회”라는 숫자만 알아서는 충분하지 않습니다.
| 확인 항목 | 질문 | 잘못 설정됐을 때의 증상 |
|---|---|---|
| partition key | IP·사용자·tenant 중 무엇으로 묶는가? | 공유 IP 사용자 전체가 함께 막힘 |
| window | 고정 창·sliding window·token bucket 중 무엇인가? | 경계 시각에 요청이 몰리거나 체감 한도가 다름 |
| burst | 짧은 순간의 허용량이 따로 있는가? | 평균 요청률은 낮아도 화면 진입 때 실패 |
| cost | 모든 요청을 1회로 세는가? | 검색·내보내기 같은 비싼 작업이 한도를 빨리 소모 |
| scope | 전체 서비스·route·method별 정책인가? | 상관없는 기능까지 동시에 막힘 |
| shared counter | 여러 서버가 같은 카운터를 원자적으로 공유하는가? | 서버별로 결과가 달라 재현이 불규칙함 |
4단계: 요청률 제한과 동시성 제한 구분
초당 요청 수가 적어도 각 요청이 오래 걸리면 동시에 처리 중인 수가 커집니다. 서버가 동시성 5개를 허용하는데 응답이 10초 걸리면, 6번째 요청은 평균 요청률과 상관없이 거절될 수 있습니다. 반대로 응답이 빨라도 자동완성·스크롤 이벤트가 짧은 시간에 수십 번 발생하면 burst 한도에 걸립니다.
- rate: 일정 시간 창에 시작된 요청 수
- concurrency: 아직 끝나지 않은 in-flight 요청 수
- queue: 서버나 클라이언트에서 대기 중인 작업 수
세 값을 같은 그래프에 그리면 해결 방향이 달라집니다. rate가 문제면 debounce·batch·cache를, concurrency가 문제면 semaphore·작업 큐·처리 시간 단축을 우선합니다. 무제한 queue는 실패 대신 지연과 메모리 사용량을 키우므로 최대 길이와 대기 시간도 정합니다.
5단계: Retry-After를 두 형식 모두 해석
RFC 9110의 Retry-After 값은 대기할 초 수 또는 HTTP 날짜입니다. 숫자만 처리하면 날짜 형식을 받은 클라이언트가 즉시 재시도할 수 있습니다. 날짜 형식은 단말 시계 오차 때문에 음수가 될 수 있으므로 0 이상으로 보정하고, 지나치게 긴 값은 제품 정책에 맞는 최대 대기 시간과 사용자 안내로 처리합니다.
function retryAfterMs(value, now = Date.now()) {
if (!value) return null;
if (/^d+$/.test(value.trim())) {
return Math.max(0, Number(value.trim()) * 1000);
}
const retryAt = Date.parse(value);
return Number.isNaN(retryAt) ? null : Math.max(0, retryAt - now);
}
브라우저가 다른 출처의 API를 호출하면 Retry-After는 기본 CORS 공개 응답 헤더 목록에 포함되지 않습니다. 프런트 코드에서 값을 읽어야 한다면 API가 Access-Control-Expose-Headers: Retry-After를 응답하도록 설정합니다. 헤더가 실제 응답에 있어도 JavaScript의 Response.headers에서 보이지 않는 경우 이 설정을 확인합니다.
6단계: Retry-After가 없을 때만 백오프와 jitter 적용
모든 클라이언트가 1초, 2초, 4초처럼 정확히 같은 시각에 재시도하면 대기 뒤 다시 큰 파도가 생깁니다. 지수형 백오프에 무작위 jitter를 더해 재시도 시각을 분산하고, 최대 시도 횟수·최대 지연·전체 시간 예산을 함께 둡니다.
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
function fullJitter(attempt, baseMs = 500, capMs = 30000) {
const ceiling = Math.min(capMs, baseMs * (2 ** attempt));
return Math.floor(Math.random() * ceiling);
}
async function fetchWithRateLimit(url, options = {}) {
const maxAttempts = 4;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await fetch(url, options);
if (response.status !== 429 || attempt === maxAttempts - 1) {
return response;
}
const serverDelay = retryAfterMs(response.headers.get('Retry-After'));
const delay = serverDelay ?? fullJitter(attempt);
await sleep(Math.min(delay, 30000));
}
throw new Error('unreachable');
}
이 코드는 재시도 간격의 뼈대입니다. 실제 앱에서는 화면 이탈 시 취소할 AbortSignal, 전체 timeout, 온라인 상태, 사용자 취소와 SDK 자체 재시도 여부를 반영해야 합니다. SDK가 이미 재시도한다면 바깥 함수에서 다시 반복해 시도 횟수가 곱해지지 않는지 확인합니다.
7단계: 중복 요청의 시작점을 줄이기
재시도를 잘 구현해도 원래 요청이 불필요하게 두 번씩 나가면 한도를 계속 낭비합니다. 429 직전 10초의 호출 스택과 화면 이벤트를 묶어서 봅니다.
- 버튼을 처리 중 비활성화하고 같은 제출의 중복 클릭을 막습니다.
- 검색 자동완성은 짧은 debounce를 적용하고 이전 요청을 취소합니다.
- 동일 리소스 조회가 동시에 시작되면 하나의 Promise를 공유합니다.
- polling은 탭이 숨겨졌거나 앱이 background일 때 중지합니다.
- 토큰 갱신은 여러 401이 동시에 발생해도 한 번만 실행하는 single-flight로 만듭니다.
- 실패한 하위 서비스마다 재시도하지 말고 전체 호출 경로에서 책임 계층 하나를 정합니다.
캐시 가능한 조회는 ETag와 조건부 요청 또는 짧은 로컬 캐시도 검토할 수 있습니다. 다만 429 응답 자체를 정상 데이터처럼 캐시해 사용자 전체에 재사용하면 안 됩니다. RFC 6585는 429 응답을 캐시에 저장하지 않도록 규정합니다.
8단계: POST 자동 재시도 전에 멱등성 보장
GET처럼 같은 요청을 반복해도 의도한 효과가 달라지지 않는 작업과 주문 생성·결제처럼 중복 실행이 문제인 작업을 구분합니다. RFC 9110은 비멱등 method를 자동 재시도하려면 요청 자체가 멱등하다는 사실을 알거나 원래 요청이 적용되지 않았음을 확인할 수 있어야 한다고 설명합니다.
서버가 처리 전에 429를 반환했다고 가정하더라도 여러 프록시와 하위 호출이 있는 구조에서는 상태를 관찰할 방법을 마련하는 편이 안전합니다.
- 클라이언트가 작업마다 안정적인 idempotency key를 생성합니다.
- 서버는 키와 요청 내용의 fingerprint를 함께 저장합니다.
- 같은 키·같은 내용이면 기존 결과를 반환합니다.
- 같은 키·다른 내용이면 충돌로 거절합니다.
- 저장 기간과 tenant 범위를 문서화합니다.
사용자가 버튼을 다시 누르는 수동 재시도도 중복 실행이므로 같은 정책이 필요합니다. 성공 여부를 조회할 operation ID를 반환하면 네트워크 단절 뒤에도 상태를 확인하기 쉽습니다.
9단계: 서버가 클라이언트에게 수정 가능한 정보를 제공
응답은 단순한 문자열보다 상태 코드, Retry-After, 안정적인 오류 type, request ID를 함께 제공하는 편이 좋습니다. RFC 9457의 application/problem+json 형식을 사용하면 사람이 읽는 설명과 프로그램이 분기할 값을 나눌 수 있습니다.
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 15
Access-Control-Expose-Headers: Retry-After, X-Request-Id
X-Request-Id: req_7f2a
{
"type": "https://api.example.com/problems/rate-limit",
"title": "Too Many Requests",
"status": 429,
"detail": "15초 뒤 다시 시도할 수 있습니다."
}
내부 카운터 키, 실제 IP, 다른 사용자의 사용량이나 보안 규칙은 노출하지 않습니다. 클라이언트가 행동을 결정하는 데 필요한 대기 시간과 정책 종류만 제공합니다. 제품별 rate-limit 헤더를 사용한다면 이름·단위·초기화 시각을 API 문서에 명시하고 Retry-After와 모순되지 않게 합니다.
10단계: 프록시 뒤 실제 클라이언트 식별을 검증
애플리케이션이 임의의 X-Forwarded-For 첫 값을 신뢰하면 공격자가 주소를 바꾸거나 모든 사용자가 잘못된 주소로 묶일 수 있습니다. 신뢰하는 reverse proxy 구간을 명시하고 프레임워크의 trusted proxy 설정으로 원본 주소를 계산합니다.
로그인 API처럼 인증 전 요청은 IP 기준이 필요할 수 있지만, 인증된 일반 API까지 IP 하나로만 묶으면 회사·학교·통신사 NAT 사용자가 서로 한도를 소모합니다. 인증 뒤에는 사용자·tenant·API key를 우선하고, IP 제한은 별도의 악용 방지 계층으로 분리하는 방식이 원인과 대응을 설명하기 쉽습니다.
11단계: 관측 지표로 정책이 실제 트래픽과 맞는지 확인
429 건수만 세면 제한이 정상적으로 공격을 막은 것인지, 정상 사용자가 막힌 것인지 알기 어렵습니다. 원문 토큰이나 개인정보 대신 다음 필드를 구조화해 수집합니다.
- route·method·status·limiter layer·policy ID
- 익명화된 partition key 또는 tenant ID
- limit·remaining·retry delay·window
- attempt 번호·client 종류·SDK 버전
- in-flight 수·queue 길이·처리 시간
- request ID·trace ID와 하위 서비스 상태
대시보드에서는 전체 요청 대비 429 비율, 영향을 받은 고유 사용자 수, 경로별 p95 응답 시간, 재시도 성공률을 함께 봅니다. 한도를 올린 뒤 응답 시간이 급격히 증가하면 문제를 해결한 것이 아니라 과부하 지점을 뒤로 옮긴 것일 수 있습니다.
429·503·403을 같은 재시도 대상으로 묶지 않기
| 상태 | 의미의 중심 | 클라이언트 기본 행동 |
|---|---|---|
| 429 Too Many Requests | 이 클라이언트 또는 할당량의 요청이 너무 많음 | Retry-After 우선, 요청률·동시성 축소 |
| 503 Service Unavailable | 서비스가 일시적으로 처리 불가 | Retry-After가 있으면 따르고 짧게 제한 재시도 |
| 403 Forbidden | 권한·정책상 요청을 허용하지 않음 | 같은 요청을 자동 반복하지 않고 정책 확인 |
| 401 Unauthorized | 유효한 인증 정보가 필요함 | 인증 갱신을 한 번만 수행하고 실패 시 로그인 안내 |
서비스가 과부하인데 모든 사용자에게 429를 반환하면 클라이언트별 한도 문제와 운영 장애가 섞입니다. 반대로 특정 사용량 한도를 503으로 보내면 클라이언트가 장애로 판단해 공격적으로 재시도할 수 있습니다. 발생 주체와 회복 조건에 맞는 상태를 선택합니다.
수정 후 재현 테스트
| 테스트 | 기대 결과 |
|---|---|
| 한도 직전까지 일정 속도로 호출 | 정책에 정의한 키와 창에서만 429 발생 |
| Retry-After 숫자 응답 | 지정 초보다 먼저 재시도하지 않음 |
| Retry-After HTTP 날짜 응답 | 날짜를 해석하고 시계 오차를 0 이상으로 보정 |
| Retry-After가 없는 429 | 상한 있는 지수형 백오프와 jitter 적용 |
| 브라우저 cross-origin 호출 | JavaScript가 노출된 Retry-After를 읽음 |
| 여러 클라이언트 동시 재시도 | 재시도 시각이 분산되고 두 번째 폭주가 없음 |
| 느린 API에 병렬 요청 | 클라이언트 semaphore가 in-flight 상한 유지 |
| 같은 POST를 같은 key로 두 번 호출 | 작업은 한 번만 실행되고 같은 결과 반환 |
| 공유 IP의 서로 다른 계정 | 인증 사용자별 한도가 불필요하게 공유되지 않음 |
| CDN·게이트웨이·앱 각각 제한 | 로그와 request ID로 발생 계층 식별 가능 |
가장 짧은 수정 순서
- 실패 요청의 응답 헤더·본문·request ID와 시각을 확보합니다.
- CDN, 게이트웨이, 앱, 외부 API 중 429를 만든 계층을 찾습니다.
- 제한 기준 키, 시간 창, burst와 동시성 조건을 확인합니다.
- 중복 클릭, polling, 토큰 갱신 경쟁과 SDK 중첩 재시도를 제거합니다.
Retry-After를 우선하고 없을 때만 제한된 백오프와 jitter를 적용합니다.- POST 등 쓰기 요청에는 idempotency key와 상태 조회 수단을 마련합니다.
- 정상 사용자 영향과 서버 지연을 함께 측정하며 정책을 조정합니다.
이 순서로 점검하면 단순히 한도를 높여 과부하를 숨기는 대신, 불필요한 요청을 줄이고 클라이언트와 서버가 같은 회복 규칙을 공유할 수 있습니다.
검증 기준과 참고 자료
이 글은 2026-09-16에 HTTP 상태 코드와 Retry-After 표준, 브라우저 CORS 응답 헤더 동작, Google Cloud와 AWS의 재시도 지침을 기준으로 검수했습니다. 실제 제한 기준과 허용량은 사용하는 CDN·게이트웨이·외부 API의 문서를 함께 확인하세요.
- RFC 6585: 429 Too Many Requests
- RFC 9110: Retry-After
- RFC 9110: Idempotent Methods
- RFC 9457: Problem Details for HTTP APIs
- MDN: 429 Too Many Requests
- MDN: Retry-After
- MDN: Access-Control-Expose-Headers
- Google Cloud: Retry strategy
- AWS Builders’ Library: Timeouts, retries and backoff with jitter
- AWS Builders’ Library: Making retries safe with idempotent APIs