본문으로 건너뛰기
yutils

Rate Limiting 전략 — Token Bucket·Sliding Window 와 429 Retry-After 계약

rate limit 의 이유, 4 가지 고전 알고리즘 (fixed/sliding/token/leaky), IP·사용자·API key 별 스코프, 클라이언트가 기대하는 헤더.

약 9분 읽기

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)}
end

atomic 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 첫 방어선.
가이드 목록으로