분산 시스템 Rate Limiting 완벽 구현 가이드: Redis + Token Bucket으로 API 남용 막기
월요일 오전 9시, API 서버의 CPU가 100%를 찍습니다. 로그를 확인하니 한 사용자가 초당 10,000개의 요청을 보내고 있습니다. 정상 사용자들은 503 에러를 받고, 고객 지원팀에 전화가 폭주합니다. 클라우드 비용은 평소의 10배로 치솟았고, 경영진이 긴급 회의를 소집합니다.
이런 상황을 어떻게 막을 수 있을까요?
답은 Rate Limiting(속도 제한)입니다. 이 글에서는 프로덕션 환경에서 검증된 분산 Rate Limiting 시스템을 Redis와 Token Bucket 알고리즘으로 구축하는 방법을 실전 코드와 함께 상세히 알려드립니다.
Rate Limiting이 왜 필수인가?
실제 발생하는 문제들
1. API 남용 (API Abuse)
// 악의적 사용자의 스크립트for (let i = 0; i < 1000000; i++) { fetch('https://api.example.com/expensive-operation').then(response => response.json()).then(data => console.log(data))}
// 결과:// - 100만 건의 요청이 동시에 발생// - 데이터베이스 과부하// - 정상 사용자 서비스 불가// - 클라우드 비용 폭증비용 영향:
정상 트래픽: 100 req/sec악의적 트래픽: 10,000 req/sec (100배)
AWS Lambda 비용:정상: $100/월공격 시: $10,000/월
데이터베이스 연결:정상: 50개공격 시: 5,000개 → 연결 풀 고갈!2. DDoS 공격
# 분산 서비스 거부 공격# 100개 IP에서 동시에 공격for ip in $(cat bot-ips.txt); do curl -X POST https://api.example.com/signup \ -H "X-Forwarded-For: $ip" \ -d '{"email":"fake@email.com"}' &done
# 1분에 100,000개 회원가입 요청!# 이메일 발송 비용만 $5,0003. 크롤러/봇 트래픽
# 무례한 크롤러import requests
for page in range(1, 100000): response = requests.get( f'https://api.example.com/products?page={page}' ) # 0.001초 간격으로 요청 (초당 1000회!) time.sleep(0.001)
# 결과:# - SEO 봇이 아닌 악의적 스크래핑# - 서버 리소스 낭비# - 정상 사용자 응답 지연4. 실수로 인한 루프
// 개발자의 실수function updateDashboard() { fetch('/api/stats').then(data => { renderChart(data) updateDashboard() // ️ 재귀 호출 종료 조건 없음! })}
updateDashboard() // 무한 루프 시작!
// 프로덕션 배포 후...// 수천 명의 사용자가 동시에 무한 요청// → 서버 다운!Rate Limiting의 경제적 가치
비용 절감:
시나리오: e-커머스 API
Without Rate Limiting:- API 호출: 1억 건/월- AWS 비용: $50,000- DB 비용: $30,000- 총: $80,000/월
With Rate Limiting:- API 호출: 2천만 건/월 (정상 트래픽만)- AWS 비용: $10,000- DB 비용: $6,000- 총: $16,000/월
절감액: $64,000/월 = $768,000/년!Rate Limiting 알고리즘 완벽 비교
1. Token Bucket (토큰 버킷) ⭐ 추천!
작동 원리:
버킷 용량: 100 토큰재충전 속도: 10 토큰/초
[토큰 버킷]┌─────────────┐│ ○ ○ ○ ○ ○ │ 토큰: 100개│ ○ ○ ○ ○ ○ ││ ○ ○ ○ ○ ○ │└─────────────┘
요청 1개 = 토큰 1개 소비1초마다 10개 토큰 충전구현 예시:
class TokenBucket: def __init__(self, capacity, refill_rate): self.capacity = capacity # 최대 100개 self.tokens = capacity # 현재 100개 self.refill_rate = refill_rate # 10개/초 self.last_refill = time.time()
def consume(self, tokens=1): # 시간 경과에 따라 토큰 재충전 now = time.time() elapsed = now - self.last_refill refill = int(elapsed * self.refill_rate)
self.tokens = min( self.capacity, self.tokens + refill ) self.last_refill = now
# 토큰이 충분한지 확인 if self.tokens >= tokens: self.tokens -= tokens return True # 요청 허용 return False # 요청 거부장점:
- 버스트 트래픽 처리: 순간적으로 100개 요청 가능
- 유연성: 평소엔 10 req/sec, 필요시 100 req/sec
- 사용자 친화적: 일시적 과부하 허용
단점:
- 구현 복잡도 높음
- 분산 환경에서 동기화 필요
2. Leaky Bucket (새는 양동이)
작동 원리:
[큐에 요청 쌓임] ↓ ↓ ↓┌─────────────┐│ REQ REQ REQ ││ REQ REQ │ 큐 크기: 100│ │└──────┬──────┘ ↓ 일정한 속도로 처리 (10 req/sec) [처리됨]구현:
class LeakyBucket: def __init__(self, capacity, leak_rate): self.capacity = capacity self.queue = [] self.leak_rate = leak_rate
def add_request(self, request): if len(self.queue) < self.capacity: self.queue.append(request) return True return False # 큐 가득 참
def process(self): # 일정한 속도로 처리 while self.queue: request = self.queue.pop(0) handle_request(request) time.sleep(1 / self.leak_rate)장점:
- 일정한 처리 속도: 후단 시스템 보호
- 구현 간단
단점:
- 버스트 트래픽 처리 불가
- 큐 관리 필요 (메모리 사용)
3. Fixed Window (고정 윈도우)
작동 원리:
분 단위로 카운트
00:00 - 00:59: 100 requests01:00 - 01:59: 100 requests02:00 - 02:59: 100 requests
각 윈도우마다 리셋구현:
import time
class FixedWindow: def __init__(self, limit, window_size): self.limit = limit # 100 requests self.window_size = window_size # 60 seconds self.counter = {}
def allow_request(self, user_id): current_window = int(time.time() / self.window_size) key = f"{user_id}:{current_window}"
count = self.counter.get(key, 0)
if count < self.limit: self.counter[key] = count + 1 return True return False장점:
- 매우 간단한 구현
- 메모리 효율적
단점:
- 경계 문제: 윈도우 경계에서 2배 요청 가능
00:59 - 01:00 사이:00:59:50 ~ 00:59:59: 100 requests01:00:00 ~ 01:00:09: 100 requests→ 20초간 200 requests! (Rate limit 우회)4. Sliding Window Log
작동 원리:
최근 60초간의 모든 요청 타임스탬프 기록
현재: 10:05:30
로그:10:04:31 (59초 전)10:04:45 (45초 전)10:05:10 (20초 전)10:05:29 (1초 전)
10:03:00 (90초 전 - 제외)구현:
from collections import dequeimport time
class SlidingWindowLog: def __init__(self, limit, window_size): self.limit = limit self.window_size = window_size self.requests = {} # {user_id: deque([timestamps])}
def allow_request(self, user_id): now = time.time() window_start = now - self.window_size
if user_id not in self.requests: self.requests[user_id] = deque()
user_log = self.requests[user_id]
# 오래된 로그 제거 while user_log and user_log[0] < window_start: user_log.popleft()
if len(user_log) < self.limit: user_log.append(now) return True return False장점:
- 정확한 제한: 경계 문제 없음
- 공정한 분배
단점:
- 메모리 많이 사용: 모든 타임스탬프 저장
- 성능 이슈: 대규모 트래픽에서 느림
알고리즘 선택 가이드
| 알고리즘 | 구현 난이도 | 정확도 | 메모리 | 버스트 처리 | 추천 용도 ||----------------|------------|-------|--------|-----------|----------------------------|| Token Bucket | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | | **API Gateway (최고추천)** || Leaky Bucket | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | | 백그라운드 작업 처리 || Fixed Window | ⭐ | ⭐ | ⭐ | | 간단한 프로토타입 || Sliding Log | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | | 정확한 제한이 필요한 경우 |프로덕션 추천: Token Bucket + Redis ⭐
Redis로 분산 Rate Limiting 구현하기
왜 Redis인가?
1. 빠른 성능
메모리 기반 저장소응답 시간: < 1ms처리량: 100,000 ops/sec2. 원자성 (Atomicity)
Lua 스크립트로 Race Condition 방지여러 서버에서 동시 접근해도 안전3. 만료 기능 (TTL)
자동으로 오래된 데이터 삭제메모리 관리 불필요기본 구현 (Node.js + Redis)
설치:
npm install redis ioredis간단한 Fixed Window 구현:
const Redis = require('ioredis')const redis = new Redis({ host: 'localhost', port: 6379})
async function rateLimit(userId, limit = 100, window = 60) { const key = `rate_limit:${userId}:${Math.floor(Date.now() / 1000 / window)}`
// 현재 카운트 증가 const current = await redis.incr(key)
// 첫 요청이면 TTL 설정 if (current === 1) { await redis.expire(key, window) }
// 제한 확인 if (current > limit) { const ttl = await redis.ttl(key) throw new Error(`Rate limit exceeded. Retry after ${ttl} seconds`) }
return { allowed: true, remaining: limit - current }}
// 사용 예시app.get('/api/data', async (req, res) => { try { const result = await rateLimit(req.user.id, 100, 60)
res.set('X-RateLimit-Limit', '100') res.set('X-RateLimit-Remaining', result.remaining)
// 실제 API 로직 res.json({ data: 'success' }) } catch (error) { res.status(429).json({ error: error.message }) }})문제점:
// ️ Race Condition 발생!// 두 서버에서 동시에 요청
Server 1: current = redis.incr(key) // 99Server 2: current = redis.incr(key) // 100
Server 1: if (current === 1) redis.expire(key, 60) // 실행 안됨Server 2: if (current === 1) redis.expire(key, 60) // 실행 안됨
// 결과: TTL이 설정되지 않음!// → 키가 영원히 Redis에 남음 (메모리 누수)프로덕션급 구현: Lua 스크립트
Lua로 원자성 보장:
-- rate_limit.lua-- Token Bucket 알고리즘
local key = KEYS[1]local capacity = tonumber(ARGV[1]) -- 최대 100local rate = tonumber(ARGV[2]) -- 10 tokens/seclocal requested = tonumber(ARGV[3]) -- 요청 토큰 수local now = tonumber(ARGV[4])
-- Redis에서 현재 상태 가져오기local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')local tokens = tonumber(bucket[1])local last_refill = tonumber(bucket[2])
-- 초기화if tokens == nil then tokens = capacity last_refill = nowend
-- 경과 시간 계산local elapsed = now - last_refill
-- 토큰 재충전local refill = math.floor(elapsed * rate)tokens = math.min(capacity, tokens + refill)
-- 마지막 재충전 시간 업데이트if refill > 0 then last_refill = nowend
-- 요청 처리local allowed = 0local remaining = tokens
if tokens >= requested then tokens = tokens - requested allowed = 1 remaining = tokensend
-- Redis 업데이트redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)redis.call('EXPIRE', key, 3600) -- 1시간 TTL
-- 결과 반환return {allowed, remaining, capacity}Node.js에서 사용:
const fs = require('fs')const Redis = require('ioredis')
const redis = new Redis()
// Lua 스크립트 로드const rateLimitScript = fs.readFileSync('./rate_limit.lua', 'utf8')
async function tokenBucketRateLimit( userId, capacity = 100, // 최대 100 토큰 rate = 10, // 초당 10 토큰 재충전 requested = 1 // 요청 토큰 수) { const key = `rate_limit:${userId}` const now = Date.now() / 1000 // 초 단위
const result = await redis.eval( rateLimitScript, 1, // 키 개수 key, capacity, rate, requested, now )
const [allowed, remaining, limit] = result
return { allowed: allowed === 1, remaining: remaining, limit: limit, retryAfter: allowed === 0 ? Math.ceil((requested - remaining) / rate) : null }}
// Express 미들웨어function rateLimitMiddleware(options = {}) { const { capacity = 100, rate = 10, getUserId = (req) => req.user?.id || req.ip } = options
return async (req, res, next) => { const userId = getUserId(req)
try { const result = await tokenBucketRateLimit(userId, capacity, rate, 1)
// 헤더 설정 res.set('X-RateLimit-Limit', result.limit) res.set('X-RateLimit-Remaining', result.remaining)
if (!result.allowed) { res.set('Retry-After', result.retryAfter) return res.status(429).json({ error: 'Too Many Requests', retryAfter: result.retryAfter }) }
next() } catch (error) { console.error('Rate limit error:', error) // 에러 시 요청 허용 (fail-open) next() } }}
// 사용app.use('/api', rateLimitMiddleware({ capacity: 1000, // 버스트: 1000 요청 rate: 100 // 평균: 100 req/sec}))Java + Spring Boot 구현
build.gradle:
dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.lettuce:lettuce-core' implementation 'com.github.vladimir-bukhtoyarov:bucket4j-core:7.6.0' implementation 'com.github.vladimir-bukhtoyarov:bucket4j-redis:7.6.0'}RateLimitService.java:
@Servicepublic class RateLimitService {
@Autowired private StringRedisTemplate redisTemplate;
private static final String RATE_LIMIT_SCRIPT = "local key = KEYS[1]\n" + "local capacity = tonumber(ARGV[1])\n" + "local rate = tonumber(ARGV[2])\n" + "local requested = tonumber(ARGV[3])\n" + "local now = tonumber(ARGV[4])\n" + "\n" + "local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')\n" + "local tokens = tonumber(bucket[1]) or capacity\n" + "local last_refill = tonumber(bucket[2]) or now\n" + "\n" + "local elapsed = now - last_refill\n" + "local refill = math.floor(elapsed * rate)\n" + "tokens = math.min(capacity, tokens + refill)\n" + "\n" + "if refill > 0 then\n" + " last_refill = now\n" + "end\n" + "\n" + "local allowed = 0\n" + "if tokens >= requested then\n" + " tokens = tokens - requested\n" + " allowed = 1\n" + "end\n" + "\n" + "redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)\n" + "redis.call('EXPIRE', key, 3600)\n" + "\n" + "return {allowed, tokens, capacity}\n";
public RateLimitResult checkRateLimit( String userId, int capacity, double rate, int requested ) { String key = "rate_limit:" + userId; double now = System.currentTimeMillis() / 1000.0;
DefaultRedisScript<List> script = new DefaultRedisScript<>(); script.setScriptText(RATE_LIMIT_SCRIPT); script.setResultType(List.class);
List<Long> result = redisTemplate.execute( script, Collections.singletonList(key), String.valueOf(capacity), String.valueOf(rate), String.valueOf(requested), String.valueOf(now) );
boolean allowed = result.get(0) == 1; int remaining = result.get(1).intValue(); int limit = result.get(2).intValue();
return new RateLimitResult(allowed, remaining, limit); }}
@Data@AllArgsConstructorpublic class RateLimitResult { private boolean allowed; private int remaining; private int limit;}RateLimitInterceptor.java:
@Componentpublic class RateLimitInterceptor implements HandlerInterceptor {
@Autowired private RateLimitService rateLimitService;
@Override public boolean preHandle( HttpServletRequest request, HttpServletResponse response, Object handler ) throws Exception { String userId = getUserId(request);
RateLimitResult result = rateLimitService.checkRateLimit( userId, 1000, // capacity 100, // rate (tokens/sec) 1 // requested tokens );
response.setHeader("X-RateLimit-Limit", String.valueOf(result.getLimit())); response.setHeader("X-RateLimit-Remaining", String.valueOf(result.getRemaining()));
if (!result.isAllowed()) { response.setStatus(429); response.setHeader("Retry-After", "10"); response.getWriter().write("{\"error\":\"Too Many Requests\"}"); return false; }
return true; }
private String getUserId(HttpServletRequest request) { // JWT 토큰에서 userId 추출 또는 IP 주소 사용 String token = request.getHeader("Authorization"); if (token != null) { // JWT 파싱 로직 return extractUserIdFromToken(token); } return request.getRemoteAddr(); }}Python + FastAPI 구현
from fastapi import FastAPI, Request, HTTPExceptionfrom fastapi.responses import JSONResponseimport redis.asyncio as redisimport timeimport math
app = FastAPI()
# Redis 연결redis_client = redis.from_url("redis://localhost:6379")
# Lua 스크립트RATE_LIMIT_SCRIPT = """local key = KEYS[1]local capacity = tonumber(ARGV[1])local rate = tonumber(ARGV[2])local requested = tonumber(ARGV[3])local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')local tokens = tonumber(bucket[1]) or capacitylocal last_refill = tonumber(bucket[2]) or now
local elapsed = now - last_refilllocal refill = math.floor(elapsed * rate)tokens = math.min(capacity, tokens + refill)
if refill > 0 then last_refill = nowend
local allowed = 0if tokens >= requested then tokens = tokens - requested allowed = 1end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)redis.call('EXPIRE', key, 3600)
return {allowed, tokens, capacity}"""
async def check_rate_limit( user_id: str, capacity: int = 100, rate: float = 10, requested: int = 1): key = f"rate_limit:{user_id}" now = time.time()
result = await redis_client.eval( RATE_LIMIT_SCRIPT, 1, key, capacity, rate, requested, now )
allowed = result[0] == 1 remaining = int(result[1]) limit = int(result[2])
return { "allowed": allowed, "remaining": remaining, "limit": limit, "retry_after": math.ceil((requested - remaining) / rate) if not allowed else None }
# 미들웨어@app.middleware("http")async def rate_limit_middleware(request: Request, call_next): # IP 주소 또는 사용자 ID user_id = request.client.host
result = await check_rate_limit(user_id, capacity=1000, rate=100)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(result["limit"]) response.headers["X-RateLimit-Remaining"] = str(result["remaining"])
if not result["allowed"]: return JSONResponse( status_code=429, content={"error": "Too Many Requests"}, headers={"Retry-After": str(result["retry_after"])} )
return response
@app.get("/api/data")async def get_data(): return {"message": "Success"}엔터프라이즈급 고급 기능
1. 계층별 Rate Limit
사용자 등급에 따라 다른 제한 적용:
const RATE_LIMITS = { free: { capacity: 100, rate: 10 }, // 100 burst, 10/sec pro: { capacity: 1000, rate: 100 }, // 1000 burst, 100/sec enterprise: { capacity: 10000, rate: 1000 } // 10000 burst, 1000/sec}
async function getUserRateLimit(userId) { const user = await db.users.findOne({ id: userId }) const tier = user.subscription || 'free' return RATE_LIMITS[tier]}
app.use('/api', async (req, res, next) => { const userId = req.user.id const limits = await getUserRateLimit(userId)
const result = await tokenBucketRateLimit( userId, limits.capacity, limits.rate, 1 )
if (!result.allowed) { // 업그레이드 제안 return res.status(429).json({ error: 'Rate limit exceeded', message: 'Upgrade to Pro for higher limits', upgradeUrl: '/pricing' }) }
next()})2. 동적 Rate Limit (서버 부하 기반)
async function getDynamicRateLimit() { const cpuUsage = await os.cpus().reduce((acc, cpu) => { const total = Object.values(cpu.times).reduce((a, b) => a + b) const idle = cpu.times.idle return acc + (1 - idle / total) }, 0) / os.cpus().length
const memUsage = 1 - os.freemem() / os.totalmem()
// 서버 부하가 높으면 제한 강화 if (cpuUsage > 0.8 || memUsage > 0.8) { return { capacity: 50, rate: 5 } // 엄격 } else if (cpuUsage > 0.5 || memUsage > 0.5) { return { capacity: 100, rate: 10 } // 보통 } else { return { capacity: 200, rate: 20 } // 관대 }}3. 엔드포인트별 다른 제한
const ENDPOINT_LIMITS = { '/api/search': { capacity: 10, rate: 1 }, // 비용이 높은 검색 '/api/user': { capacity: 100, rate: 10 }, // 일반 조회 '/api/upload': { capacity: 5, rate: 0.5 }, // 파일 업로드 '/api/stats': { capacity: 1000, rate: 100 } // 가벼운 통계}
app.use('/api', async (req, res, next) => { const endpoint = req.path const limits = ENDPOINT_LIMITS[endpoint] || { capacity: 100, rate: 10 }
const result = await tokenBucketRateLimit( `${req.user.id}:${endpoint}`, limits.capacity, limits.rate, 1 )
if (!result.allowed) { return res.status(429).json({ error: 'Rate limit exceeded for ' + endpoint }) }
next()})4. IP 기반 + 사용자 기반 복합 제한
async function dualRateLimit(req) { const ip = req.ip const userId = req.user?.id
// 1. IP 제한 (DDoS 방어) const ipLimit = await tokenBucketRateLimit( `ip:${ip}`, 500, // IP당 500 burst 50 // 50/sec )
if (!ipLimit.allowed) { throw new Error('IP rate limit exceeded') }
// 2. 사용자 제한 (API 남용 방어) if (userId) { const userLimit = await tokenBucketRateLimit( `user:${userId}`, 1000, // 사용자당 1000 burst 100 // 100/sec )
if (!userLimit.allowed) { throw new Error('User rate limit exceeded') } }
return { allowed: true }}프로덕션 배포 전략
1. Redis Cluster 구성
고가용성을 위한 클러스터:
version: '3'services: redis-master: image: redis:7-alpine command: redis-server --appendonly yes ports: - "6379:6379" volumes: - redis-master-data:/data
redis-replica-1: image: redis:7-alpine command: redis-server --slaveof redis-master 6379 --appendonly yes volumes: - redis-replica-1-data:/data depends_on: - redis-master
redis-replica-2: image: redis:7-alpine command: redis-server --slaveof redis-master 6379 --appendonly yes volumes: - redis-replica-2-data:/data depends_on: - redis-master
redis-sentinel-1: image: redis:7-alpine command: > redis-sentinel /etc/redis/sentinel.conf --sentinel monitor mymaster redis-master 6379 2 --sentinel down-after-milliseconds mymaster 5000 --sentinel parallel-syncs mymaster 1 --sentinel failover-timeout mymaster 10000 depends_on: - redis-master
volumes: redis-master-data: redis-replica-1-data: redis-replica-2-data:2. 모니터링 대시보드
Prometheus + Grafana:
const prometheus = require('prom-client')
// 메트릭 정의const rateLimitCounter = new prometheus.Counter({ name: 'rate_limit_requests_total', help: 'Total rate limit checks', labelNames: ['user', 'result'] // result: allowed, blocked})
const rateLimitHistogram = new prometheus.Histogram({ name: 'rate_limit_duration_seconds', help: 'Rate limit check duration', buckets: [0.001, 0.005, 0.01, 0.05, 0.1]})
// 사용async function tokenBucketRateLimitWithMetrics(userId, capacity, rate, requested) { const start = Date.now()
const result = await tokenBucketRateLimit(userId, capacity, rate, requested)
const duration = (Date.now() - start) / 1000 rateLimitHistogram.observe(duration)
rateLimitCounter.inc({ user: userId, result: result.allowed ? 'allowed' : 'blocked' })
return result}
// Prometheus 엔드포인트app.get('/metrics', async (req, res) => { res.set('Content-Type', prometheus.register.contentType) res.end(await prometheus.register.metrics())})Grafana 쿼리:
# Rate limit 차단율rate(rate_limit_requests_total{result="blocked"}[5m])/ rate(rate_limit_requests_total[5m]) * 100
# 평균 응답 시간rate(rate_limit_duration_seconds_sum[5m])/ rate(rate_limit_duration_seconds_count[5m])
# 사용자별 차단 횟수topk(10, sum by (user) (rate_limit_requests_total{result="blocked"}))3. 알림 설정
groups:- name: rate_limiting interval: 30s rules: # Rate limit 차단율이 높음 - alert: HighRateLimitBlockRate expr: | rate(rate_limit_requests_total{result="blocked"}[5m]) / rate(rate_limit_requests_total[5m]) > 0.5 for: 5m labels: severity: warning annotations: summary: "High rate limit block rate" description: "{{ $value | humanizePercentage }} of requests are being blocked"
# Redis 연결 실패 - alert: RedisConnectionFailure expr: redis_up == 0 for: 1m labels: severity: critical annotations: summary: "Redis is down" description: "Rate limiting may not work properly"
# Rate limit 응답 시간 증가 - alert: SlowRateLimitCheck expr: | rate(rate_limit_duration_seconds_sum[5m]) / rate(rate_limit_duration_seconds_count[5m]) > 0.1 for: 5m labels: severity: warning annotations: summary: "Rate limit checks are slow" description: "Average duration: {{ $value }}s"트러블슈팅 가이드
문제 1: Redis 메모리 부족
증상:
Error: OOM command not allowed when used memory > 'maxmemory'해결책:
# redis.confmaxmemory 2gbmaxmemory-policy allkeys-lru # LRU로 오래된 키 제거
# 또는 휘발성 키만 제거maxmemory-policy volatile-lru애플리케이션 레벨 해결:
// TTL을 더 짧게 설정redis.call('EXPIRE', key, 300) // 5분 (기존 1시간에서 단축)
// 또는 사용자별 다른 TTLconst ttl = isPremiumUser(userId) ? 3600 : 300redis.call('EXPIRE', key, ttl)문제 2: Race Condition (Lua 스크립트 미사용 시)
증상:
TTL이 설정되지 않아 키가 계속 쌓임해결책:
// 잘못된 방법const current = await redis.incr(key)if (current === 1) { await redis.expire(key, 60) // Race condition!}
// 올바른 방법 1: Lua 스크립트const result = await redis.eval(luaScript,...)
// 올바른 방법 2: SETEX 사용await redis.setex(key, 60, 1)await redis.incr(key)문제 3: 분산 환경에서 부정확한 카운팅
증상:
여러 서버에서 다른 Redis 인스턴스를 사용카운트가 분산되어 제한이 제대로 작동 안함해결책:
// 잘못된 샤딩const serverId = process.env.SERVER_IDconst redis = new Redis({ host: `redis-${serverId}` })
// 올바른 샤딩: 일관된 해싱const Consistent = require('consistent-hashing')const ring = new Consistent()
ring.add('redis-1')ring.add('redis-2')ring.add('redis-3')
function getRedisForUser(userId) { const server = ring.get(userId) // 같은 userId는 항상 같은 서버 return redisClients[server]}실전 체크리스트
### 구현 전 체크리스트
□ 알고리즘 선택 (Token Bucket 추천)□ Redis 설치 및 구성□ Lua 스크립트 작성□ Rate limit 수치 결정 - Capacity (버스트 허용량) - Rate (평균 처리율) - TTL (키 만료 시간)□ 사용자 식별 방법 (IP, JWT, API Key)
### 배포 전 체크리스트
□ Redis Cluster 구성 (고가용성)□ 모니터링 설정 (Prometheus + Grafana)□ 알림 설정 (AlertManager)□ 부하 테스트 완료□ Fail-open 정책 확인 (Redis 장애 시)□ 에러 메시지 사용자 친화적으로 작성□ HTTP 헤더 추가 - X-RateLimit-Limit - X-RateLimit-Remaining - Retry-After
### 운영 체크리스트
□ 일일 차단율 모니터링□ Redis 메모리 사용률 체크□ 응답 시간 추적□ 사용자 피드백 수집□ Rate limit 수치 조정□ 로그 분석 (악의적 사용자 탐지)결론: 완벽한 Rate Limiting 시스템
프로덕션 환경에서 Rate Limiting은 선택이 아닌 필수입니다. 이 가이드에서 다룬 내용을 요약하면:
핵심 원칙
- Token Bucket + Redis - 가장 검증된 조합
- Lua 스크립트 - Race Condition 완벽 방지
- 계층별 제한 - 비즈니스 모델에 맞춘 유연성
- 모니터링 - 실시간 추적 및 알림
비즈니스 가치
Rate Limiting 구현 비용: $1,000 (개발 + 인프라)연간 절감 비용: $768,000 (API 남용 방지)
ROI: 76,700%추천 아키텍처
[사용자 요청] ↓[API Gateway] ↓[Rate Limit Middleware] ↓[Redis (Lua Script)] ↓[Token Bucket 확인] ↓[허용/거부 결정] ↓[응답 + 헤더]마지막 조언
“처음부터 완벽할 필요는 없습니다.”
1주차: 간단한 Fixed Window로 시작 2주차: Token Bucket으로 업그레이드 3주차: Redis Cluster 구성 4주차: 모니터링 및 알림 추가
점진적으로 개선하면서 프로덕션에 안착시키세요.
Rate Limiting은 한 번 구현하면 영원히 당신의 서비스를 지켜줍니다. 이제 악의적 사용자와 DDoS 공격으로부터 당신의 API를 보호하세요!
다음 글 예고: “GraphQL N+1 쿼리 문제 완벽 해결 가이드: DataLoader로 성능 100배 향상”
질문이나 경험 공유는 댓글로! Rate Limiting 구현 과정에서 겪은 어려움이 있으신가요?