Rate limiting 없는 API 는 첫 번째 악성 사용자나 버그 있는 클라이언트 한 명이 서비스 전체를 마비시킨다. 이 가이드는 왜 rate limit 이 필요한지, 4 가지 고전 알고리즘 (fixed window / sliding window / token bucket / leaky bucket), 누구를 기준으로 측정할지 (IP / 사용자 / API key), 클라 이언트가 기대하는 응답 헤더와 Retry-After 계약을 정리한다.
왜 rate limit 이 필요한가
- 비용 보호 — DB / 외부 API / LLM API 호출당 비용. 한 사용자가 분당 1000 회 = 월 비용 폭발.
- 서비스 안정성 — 한 사용자의 폭주가 다른 사용자에게 영향 X. noisy neighbor 격리.
- 보안 — brute force, credential stuffing, scraping 완화.
- 공정성 — 무료 / 유료 tier 차별.
4 가지 고전 알고리즘
1. Fixed Window — 가장 단순
시간 윈도우 (예: 1 분) 마다 카운터 초기화. 카운트가 한도 초과 시 거부.
# Redis 의사 코드
key = `rl:${userId}:${Math.floor(Date.now() / 60000)}`;
count = INCR key
EXPIRE key 60
if count > 100: return 429- 장점: 구현 가장 단순. counter 한 개.
- 단점: 경계 burst — 윈도우 끝나기 직전 + 새 윈도우 시작 직후 = 한도의 2 배 호출 가능 (00:59 에 100 회, 01:00 에 100 회).
2. Sliding Window Log — 정확
모든 요청 타임스탬프를 저장. 윈도우 안 카운트.
# Redis sorted set
ZADD rl:user123 ${Date.now()} ${uuid}
ZREMRANGEBYSCORE rl:user123 0 ${Date.now() - 60000}
ZCARD rl:user123 # → 현재 윈도우 카운트- 장점: 경계 burst 없음. 정확.
- 단점: 메모리 = 요청 수. 트래픽 많으면 비용 ↑.
3. Sliding Window Counter — 균형
Fixed Window 두 개를 가중 평균. 현재 윈도우 카운트 + 이전 윈도우 카운트 × 비율.
prevCount = 60 # 이전 1 분
currCount = 30 # 현재 1 분
elapsedInCurrent = 30s # 30 초 경과
weightedCount = prevCount * (1 - elapsedInCurrent/60) + currCount
= 60 * 0.5 + 30
= 60- 장점: 경계 burst 거의 없음 + 메모리 작음. 가장 실용적.
- 단점: 약간의 오차 (분포 균등 가정).
4. Token Bucket — burst 허용
용량 N 의 bucket. 매 단위 시간마다 토큰 R 개 추가. 요청 = 토큰 1 개 소비. bucket 비면 거부.
# capacity=100, refill=10 tokens/sec
# 사용자가 평소 안 쓰면 100 토큰 차 있음 → 한 번에 100 회 burst OK
# 그 후엔 초당 10 회로 sustained- 장점: burst 허용 + 평균 rate 제한. 사용자 친화.
- 단점: Redis 의 SET + EXPIRE + Lua 스크립트 (atomic 계산 필요).
Stripe·GitHub API 가 이 모델 사용. 평소 안 쓰던 사용자가 잠깐 burst 한다.
5. Leaky Bucket — 일정 rate 강제
queue 형태. 요청이 들어와 큐 끝에 추가, 일정 속도로 처리. 큐 차면 거부.
- 장점: 출력 rate 가 매끄럽게 일정.
- 단점: latency 추가 (큐 대기). 동기 API 에는 부적합.
Token Bucket 의 대체로 많이 안 쓰임. 외부 API 에 요청 보내는 outbound rate limit 에 가끔.
알고리즘 선택
| 알고리즘 | 적합한 상황 | 예시 |
|---|---|---|
| Fixed Window | 경계 burst 무관 + 단순함 우선 | 로그인 시도 (1 분 5 회) |
| Sliding Window Counter | 일반 API rate limit | 읽기 API 시간당 1000 회 |
| Token Bucket | 가끔 burst 필요 + 평균 제한 | Stripe API, AI 추론 API |
| Sliding Window Log | 정확성이 중요 (낮은 한도) | 회원 가입 (1 시간 3 회) |
누구를 기준으로? — 스코프
같은 알고리즘이라도 무엇으로 묶느냐가 핵심.
IP 기반
- 인증 없는 endpoint 의 기본 스코프.
- 단점: NAT 뒤 회사 / 캠퍼스가 한 IP → 다수 사용자가 같은 한도 공유. 반대로 모바일은 IP 자주 바뀜.
X-Forwarded-For헤더 신뢰성 — Cloudflare/AWS ALB 같은 신뢰된 프록시 뒤에서만 사용.
사용자 ID 기반
- 인증된 endpoint 의 표준.
- 각 사용자가 일관된 한도. 정확.
API key 기반
- 외부 API 통합. tier 별 한도 부여.
- 무료 / pro / enterprise 등 plan 별 다른 limit.
endpoint 별
- 비싼 endpoint (예: 검색, AI 추론) 은 더 낮은 한도. 가벼운 GET 은 높음.
- path + 사용자 조합 key —
rl:${userId}:/api/search.
응답 헤더 — 클라이언트와 계약
RFC 9331 권장 (2024 표준화 이전엔 제공자 별 다름).
# 정상 응답 (한도 안)
HTTP/1.1 200 OK
RateLimit: limit=100, remaining=75, reset=30
RateLimit-Policy: 100;w=60
# 초과 응답
HTTP/1.1 429 Too Many Requests
RateLimit: limit=100, remaining=0, reset=42
RateLimit-Policy: 100;w=60
Retry-After: 42주요 헤더:
RateLimit— 현재 윈도우의 limit / remaining / reset (초).RateLimit-Policy— 정책 (limit; window 초).Retry-After— 다시 시도 가능 시각 (초 또는 HTTP-date).429 응답에는 필수.
2026 년 기준 GitHub·Stripe 는 여전히 자체 헤더 (X-RateLimit-*). 새 시스템은 표준 (RateLimit*) 권장.
429 가 적절한가?
HTTP 상태 코드 의 4xx 분류 — 429 Too Many Requests 가 표준.
- 503 도 가끔 — "서버가 busy". rate limit 보다는 일시적 과부하 의미. 권장 X.
- 403 X — 권한 문제 아니라 처리량 문제.
클라이언트 대응 — 지수 백오프
async function fetchWithRetry(url, opts, max = 5) {
for (let i = 0; i <= max; i++) {
const res = await fetch(url, opts);
if (res.status !== 429) return res;
const retryAfter = parseInt(res.headers.get("Retry-After") ?? "0", 10);
const wait = Math.min(retryAfter * 1000, 60_000) || 1000 * 2 ** i;
await new Promise((r) => setTimeout(r, wait + Math.random() * 1000));
}
throw new Error("Rate limit exhausted");
}포인트:
Retry-After가 있으면 그 값 우선 (서버의 정확한 지시).- 없으면 지수 백오프 (2 의 i 제곱).
- jitter 추가 — 여러 클라이언트가 동시 retry 하면 thundering herd.
cURL 빌더 로 retry 시나리오 테스트 URL 만들기 가능. UUID / ULID 생성기 로 idempotency key 함께 박아 안전 재시도.
분산 환경 — Redis
N 개 서버에서 같은 카운터 — Redis 가 표준.
-- Lua script (atomic)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
if current > limit then
return {0, current, redis.call("TTL", key)}
else
return {1, current, redis.call("TTL", key)}
endatomic INCR + EXPIRE 가 race-free. 라이브러리는 express-rate-limit, @upstash/ratelimit, node-rate-limiter-flexible 등.
Edge / Serverless 의 rate limit
Cloudflare Workers / Vercel Edge 에서는 Redis 호출이 latency 큼. 대안:
- Cloudflare Rate Limiting — 인프라 layer. WAF rule 로 설정.
- Durable Objects — Cloudflare 의 글로벌 stateful worker. 카운터 자체 관리.
- Upstash Redis — REST API + edge 가까운 region.
흔한 함정
1. Retry-After 누락
429 만 반환하면 클라이언트가 언제 재시도할지 모름. 표준 헤더 박는 게 계약.
2. IP 기반 만으로 충분하다고 가정
NAT 뒤 사용자 다수 = false positive. 모바일 IP 변경 = 한도 우회. 인증 후엔 user-based.
3. window 만료에 stampede
Fixed Window 의 경계에 사용자 N 명이 동시 retry. Sliding Window 또는 jitter 로 분산.
4. X-Forwarded-For 무조건 신뢰
proxy 뒤가 아닐 때 사용자가 임의 IP 박을 수 있음. trusted proxy 뒤에서 만 신뢰.
5. Redis race condition
INCR + EXPIRE 별도 호출 = race. 첫 호출이 EXPIRE 전 죽으면 영구 key. Lua 스크립트로 atomic 처리.
6. global rate limit 만
"분당 10000 요청" 만 박고 사용자별 한도 없음 → 한 사용자가 10000 차지. global + per-user 함께.
7. 인증 endpoint 의 rate limit 누락
로그인 endpoint = brute force 표적. 1 분 5 회 같이 엄격. 캡차 병행.
요약
- 알고리즘: 일반 API = Sliding Window Counter, burst 친화 = Token Bucket, 정확성 = Sliding Window Log.
- 스코프: 인증 endpoint = user-based, public = IP-based, B2B = API key. endpoint 별 차등 가능.
- 응답: 429 +
Retry-After+RateLimit-*헤더. - 클라이언트:
Retry-After따르기 + 지수 백오프 + jitter. - 분산: Redis Lua 스크립트 atomic. Edge 는 Durable Objects 또는 Upstash.
- 인증 endpoint 는 매우 엄격하게. brute force 첫 방어선.