Rate Limiter 설계 — 초당 10만 요청 봇에서 서버를 지키는 법
2023년, 한 스타트업의 API가 새벽 3시에 다운됐다. 원인은 경쟁사 봇이 초당 5만 건의 요청을 보낸 것이었다. DB 커넥션 풀이 고갈되고 서비스 전체가 멈췄다. Rate Limiter가 있었다면? IP당 초당 100건 제한으로 이 봇의 요청 99.998%가 차단됐을 것이다. Rate Limiter는 “공정성”의 문제이기 전에 “생존”의 문제다.
왜 Rate Limiter가 필요한가
비유: 놀이공원 인기 어트랙션 앞의 “1회 탑승 후 재줄 서기” 규칙과 같다. 한 사람이 무한 반복 탑승하는 것을 막아 모든 사람이 공정하게 이용한다. 줄을 서지 않고 뒷문으로 수백 번 들어오려는 사람(봇)을 아예 입장 거부시킨다.
Rate Limiter 없으면 어떤 일이 생기는가:
| 상황 | 결과 |
|---|---|
| 악의적 봇: 초당 10만 요청 | 서버 다운 |
| 클라이언트 버그: 무한 루프 API 호출 | DB 커넥션 고갈 |
| 마케팅 이벤트: 트래픽 폭발 | 서비스 전체 느려짐 |
| 스크래퍼: 데이터 무단 수집 | 비용 폭발, 데이터 유출 |
Rate Limiting 알고리즘 5가지
알고리즘 1: 토큰 버킷 (Token Bucket)
비유: 물통에 일정 속도로 토큰(동전)이 채워진다. 요청마다 토큰 1개를 꺼낸다. 토큰이 없으면 요청 거부. 오래 기다리면 토큰이 쌓여 순간 폭발적 요청도 처리할 수 있다.
graph LR
Refill["매초 2개 토큰 보충"] --> Bucket["버킷\n현재: 7개"]
Request["요청 도착"] --> Check{"토큰 있나?"}
Check -->|"Yes: 1개 소비"| Allow["허용"]
Check -->|"No: 0개"| Deny["429 Too Many Requests"]
Bucket --> Check
class TokenBucket:
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity # 최대 토큰 수
self.refill_rate = refill_rate # 초당 보충 토큰 수
self.tokens = capacity
self.last_refill = time.time()
def allow_request(self) -> bool:
self._refill()
if self.tokens >= 1:
self.tokens -= 1
return True
return False
def _refill(self):
elapsed = time.time() - self.last_refill
# 경과 시간에 비례해 토큰 보충 (최대 용량 초과 불가)
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last_refill = time.time()
특징: 순간 버스트(burst) 허용 — 버킷이 꽉 찬 상태면 한 번에 많은 요청 처리 가능. 메모리 효율적.
알고리즘 2: 누출 버킷 (Leaky Bucket)
비유: 밑에 구멍 뚫린 양동이. 물을 아무리 빨리 부어도 일정 속도로만 흘러나온다. 균일한 처리 속도가 보장된다.
요청이 큐에 들어가고, 일정 속도로 꺼내 처리한다. 큐가 가득 차면 새 요청을 버린다. 서버에 균일한 부하를 보장할 때 유용하다.
알고리즘 3: 고정 윈도우 카운터 (Fixed Window Counter)
1분 단위로 카운터를 초기화하고, 그 안에서 N회 제한한다. 구현이 가장 단순하지만 경계 문제가 있다:
00:59 → 100건 (허용, 새 윈도우 직전)
01:00 → 100건 (허용, 새 윈도우 시작)
→ 2초 사이에 200건이 처리됨!
알고리즘 4: 슬라이딩 윈도우 로그 (Sliding Window Log)
각 요청의 타임스탬프를 저장해두고, 현재 시각 기준 “최근 1분” 윈도우를 정확하게 계산한다. 경계 문제 없지만 요청마다 타임스탬프를 저장하므로 메모리 사용량이 요청 수에 비례한다.
알고리즘 5: 슬라이딩 윈도우 카운터 (Sliding Window Counter) — 추천
고정 윈도우 카운터의 경계 문제를 해결하면서 메모리도 효율적이다. 실무에서 가장 널리 쓰이는 방식.
sequenceDiagram
participant C as "현재 시각 01:15"
participant P as "이전 윈도우 (00:00~01:00)"
participant N as "현재 윈도우 (01:00~02:00)"
Note over C,N: 현재 시각이 현재 윈도우의 25% 경과
Note over P: 이전 윈도우: 50건
Note over N: 현재 윈도우: 30건
Note over C: 추정치 = 50 × (1-0.25) + 30 = 67.5건
Note over C: 한도 100건이면 → 허용
class SlidingWindowCounter:
def allow_request(self, user_id: str, redis) -> bool:
now = time.time()
current_window = int(now / self.window) * self.window
prev_window = current_window - self.window
# 현재 윈도우에서 경과한 비율 (0.0 ~ 1.0)
elapsed_ratio = (now - current_window) / self.window
prev_count = int(redis.get(f"counter:{user_id}:{prev_window}") or 0)
curr_count = int(redis.get(f"counter:{user_id}:{current_window}") or 0)
# 이전 윈도우의 "남은 비율"만큼 가중치 적용
estimated = prev_count * (1 - elapsed_ratio) + curr_count
if estimated >= self.limit:
return False
redis.incr(f"counter:{user_id}:{current_window}")
return True
알고리즘 비교
| 알고리즘 | 메모리 | 정확도 | 버스트 허용 | 복잡도 |
|---|---|---|---|---|
| 토큰 버킷 | 낮음 | 중간 | O | 낮음 |
| 누출 버킷 | 낮음 | 높음 | X | 낮음 |
| 고정 윈도우 | 낮음 | 낮음 (경계 문제) | X | 매우 낮음 |
| 슬라이딩 로그 | 높음 | 높음 | X | 중간 |
| 슬라이딩 카운터 | 낮음 | 높음 | X | 중간 |
분산 환경에서의 문제 — 서버가 여러 대면 카운터가 분산된다
서버가 3대이고 각 서버가 독립적으로 카운터를 유지하면?
graph TD
User["사용자: 분당 100건 한도"]
Req1["요청 60건"] --> S1["서버 1\n카운터: 60"]
Req2["요청 60건"] --> S2["서버 2\n카운터: 60"]
Problem["문제: 실제 120건인데\n각 서버는 60건으로 판단 → 모두 허용"]
해결: 중앙화된 Redis로 카운터를 공유한다.
graph TD
S1["서버 1"] --> R["Redis 클러스터\n중앙 카운터"]
S2["서버 2"] --> R
S3["서버 3"] --> R
R --> Count["user_id별 통합 카운터"]
Lua 스크립트로 원자적 처리 (GET → 비교 → INCR 사이에 끼어들기 없음):
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0 -- 거부
end
local count = redis.call('INCR', key)
if count == 1 then
redis.call('EXPIRE', key, window)
end
return 1 -- 허용
만약 Lua 없이 GET → 비교 → INCR을 따로 하면? 두 서버가 동시에 GET → 둘 다 99건 → 둘 다 INCR → 실제 101건인데 모두 허용된다.
Rate Limiter 아키텍처 — 미들웨어로 구현
graph LR
Client["클라이언트"] --> MW["Rate Limiter 미들웨어"]
MW --> Redis["Redis 클러스터"]
MW -->|"허용"| API["API 서버"]
MW -->|"거부"| Resp["429 Too Many Requests\nRetry-After: 60"]
429 응답 헤더에 제한 정보를 담아야 클라이언트가 올바르게 재시도할 수 있다:
X-RateLimit-Limit: 100 → 한도
X-RateLimit-Remaining: 45 → 남은 횟수
X-RateLimit-Reset: 1704067260 → 윈도우 리셋 시각
Retry-After: 60 → 재시도 가능까지 대기 초
이 헤더가 없으면? 클라이언트가 즉시 재시도를 반복해서 오히려 더 많은 429를 만든다.
계층별 Rate Limiting
단일 계층만으로는 모든 상황을 막을 수 없다:
graph TD
Req["요청"] --> L1["L1: IP 레벨\n초당 100건/IP\n봇 DDoS 차단"]
L1 --> L2["L2: API 키 레벨\n시간당 1000건/키\n무료 플랜 제한"]
L2 --> L3["L3: 엔드포인트별\n/login: 분당 5건\n브루트포스 방지"]
L3 --> L4["L4: 사용자 티어\n유료 플랜 더 많이"]
L4 --> API["API 서버"]
RATE_LIMIT_TIERS = {
'free': {'per_day': 1_000, 'per_minute': 20, 'burst': 50},
'pro': {'per_day': 100_000, 'per_minute': 500, 'burst': 1_000},
'enterprise': {'per_day': 10_000_000, 'per_minute': 10_000, 'burst': 50_000},
}
# 엔드포인트별 추가 제한 (티어 제한과 AND 조건)
ENDPOINT_LIMITS = {
'/api/auth/login': (5, 60), # 분당 5번 — 브루트포스 방지
'/api/auth/register': (3, 3600), # 시간당 3번
'/api/send-sms': (10, 3600), # SMS는 비싸므로 엄격하게
}
극한 시나리오
graph TD
DDoS["봇넷\n1만 IP × 초당 1000건\n= 총 1000만 QPS"] --> CF["Cloudflare\n네트워크 레벨 차단\n(1ms 응답)"]
CF --> WAF["AWS WAF\nL7 규칙 매칭"]
WAF --> LB["로드밸런서\n연결 수 제한"]
LB --> AppRL["애플리케이션\nRate Limiter\nRedis 기반"]
AppRL --> API["API 서버\n정상 트래픽만 도달"]
자동 IP 차단:
class AdaptiveRateLimiter:
def check(self, ip: str) -> str:
if self.redis.sismember("banned_ips", ip):
return "BANNED" # 영구 차단 목록
minute_count = self._get_count(ip, 60)
if minute_count > 500: # 분당 500건 초과
self.redis.setex(f"temp_ban:{ip}", 3600, 1) # 1시간 임시 차단
self._alert_security_team(ip)
return "BLOCKED"
if minute_count > 100: # 분당 100건 초과
return "CHALLENGE" # CAPTCHA 요구
return "ALLOW"
핵심 설계 결정 요약
| 결정 | 선택 | 이유 |
|---|---|---|
| 알고리즘 | 슬라이딩 윈도우 카운터 | 정확도 + 메모리 효율 균형 |
| 저장소 | Redis Cluster | 분산 환경 원자적 카운터 |
| 원자성 | Lua 스크립트 | GET→비교→INCR 사이 Race Condition 방지 |
| 식별자 | API키 > 사용자ID > IP | 정밀도 높은 쪽 우선 |
| 응답 헤더 | X-RateLimit-* 표준 | 클라이언트가 재시도 타이밍 알 수 있게 |
| DDoS | 다층 방어 (CDN → WAF → 앱) | 단일 계층 우회 방지 |
댓글