--- id: backend-rate-limiting title: Rate Limiting — Token Bucket vs Sliding Window category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [backend, rate-limit, throttling, vibe-coding] tech_stack: { language: "TypeScript / Redis", applicable_to: ["Backend", "API gateway"] } applied_in: [] aliases: [throttle, leaky bucket, sliding window, fixed window] --- # Rate Limiting > 입구 차단으로 시스템 보호. **per-user / per-IP / per-API key** 다중 차원. 알고리즘 4종 — Fixed window / Sliding window / Token bucket / Leaky bucket. 분산 환경은 Redis 가 기본. ## 📖 핵심 개념 - **Fixed window**: "1분당 100회". 단순. 경계에서 burst 두 배. - **Sliding window**: 시간 가중. 경계 burst 없음. 계산 복잡. - **Token bucket**: 토큰 N개, 초당 R 회복. burst 허용. 현실적 선택. - **Leaky bucket**: 큐 + 일정 속도 처리. 평탄화. ## 💻 코드 패턴 ### Token bucket — Redis Lua atomic ```lua -- KEYS[1] = bucket key, ARGV[1] = capacity, ARGV[2] = refillPerSec, ARGV[3] = now local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(data[1]) or tonumber(ARGV[1]) local ts = tonumber(data[2]) or tonumber(ARGV[3]) local elapsed = math.max(0, tonumber(ARGV[3]) - ts) tokens = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2])) local allowed = 0 if tokens >= 1 then tokens = tokens - 1; allowed = 1 end redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3]) redis.call('EXPIRE', KEYS[1], 3600) return allowed ``` ```ts // Express middleware async function rateLimit(req, res, next) { const key = `rl:${req.ip}:${req.user?.id ?? 'anon'}`; const allowed = await redis.eval(LUA, 1, key, /*cap*/100, /*refill*/10, Date.now()/1000); if (!allowed) { res.setHeader('Retry-After', '1'); return res.status(429).json({ error: 'rate_limited' }); } next(); } ``` ### Sliding window — Redis sorted set ```ts async function slidingAllow(key: string, limit: number, windowSec: number): Promise { const now = Date.now(); const windowStart = now - windowSec * 1000; const tx = redis.multi(); tx.zremrangebyscore(key, 0, windowStart); tx.zadd(key, now, `${now}-${Math.random()}`); tx.zcard(key); tx.expire(key, windowSec); const [, , count] = await tx.exec(); return (count as number) <= limit; } ``` ### Layered limits ```ts const limits = [ { key: `rl:ip:${req.ip}`, limit: 100, windowSec: 60 }, // IP 기준 { key: `rl:user:${userId}`, limit: 1000, windowSec: 3600 }, // 사용자 시간당 { key: `rl:plan:${userId}`, limit: 50000, windowSec: 86400 }, // 일일 ]; for (const l of limits) { if (!await slidingAllow(l.key, l.limit, l.windowSec)) return res.status(429).end(); } ``` ## 🤔 의사결정 기준 | 차원 | 키 | |---|---| | Per-IP | `rl:ip:${req.ip}` | | Per-user | `rl:user:${userId}` | | Per-API key | `rl:apikey:${key}` | | Per-endpoint | `rl:endpoint:${path}:${userId}` | | 비용 큰 작업 (PDF 생성) | per-user 더 엄격 | | 알고리즘 | 권장 | |---|---| | 단순 / 정확성 보통 | Fixed window | | 정확한 sliding 필요 | Sliding window log (sorted set) | | burst 허용 + 평균 통제 | Token bucket | | 평탄한 처리 (DB 보호) | Leaky bucket | ## ❌ 안티패턴 - **메모리 in-process counter**: 다중 인스턴스에서 의미 없음. Redis 또는 외부. - **`X-Forwarded-For` 검증 없이 IP 사용**: 클라이언트가 위조 가능. 신뢰 가능한 proxy 만. - **429 응답에 Retry-After 누락**: 클라이언트가 언제 재시도할지 모름. - **인증 endpoint 에 너무 헐거운 limit**: brute force 무방비. - **per-IP 만**: NAT 뒤 다수 사용자 = 한 IP. user 기준 병행. - **rate limit 자체에 타임아웃 누락**: Redis 응답 늦으면 모든 요청 block. - **fail-open vs fail-closed 결정 안 함**: Redis 다운 시 다 통과 (fail-open) 또는 다 차단 (fail-closed). 명시. ## 🤖 LLM 활용 힌트 - 다층 limit (IP + user + plan) + Redis sorted set 권장. - 429 + Retry-After + JSON error code 표준. ## 🔗 관련 문서 - [[Backpressure_Patterns]] - [[Backend_Retry_Strategy]]