Table of Contents
API를 공개하는 순간, 두 가지 문제에 직면하게 됩니다: 악의적인 공격과 의도치 않은 과부하. 단 하나의 클라이언트가 초당 10,000개의 요청을 보내 서버를 다운시킬 수 있고, 버그가 있는 코드가 무한 루프로 API를 호출할 수도 있습니다.
Rate Limiting은 이러한 문제를 해결하는 핵심 방어막입니다. 이 글에서는 간단한 구현부터 Stripe, GitHub, Twitter 같은 대규모 서비스가 사용하는 분산 아키텍처까지, 실전 Rate Limiting 시스템을 구축하는 방법을 단계별로 알아봅니다.
Rate Limiting이 왜 필요한가요?
실제 사례: Rate Limiting 없이 발생한 장애들
사례 1: 무한 루프 버그
// 클라이언트 코드의 버그
async function syncUserData() {
while (true) { // 종료 조건 없음!
try {
await fetch('https://api.example.com/users/sync');
break; // 이 라인이 실행되지 않음 (에러 발생 시)
} catch (err) {
// 재시도하지만 while(true)로 인해 무한 반복
console.log('Retrying...');
}
}
}
결과:
- 1대의 클라이언트가 초당 15,000 요청 전송
- 5분 내에 서버 CPU 100% 도달
- 정상 사용자들도 접근 불가 (Denial of Service)
- 연간 손실: $50,000+ (가동 중단 비용)
사례 2: 크리덴셜 스터핑(Credential Stuffing) 공격
# 공격자의 자동화 스크립트
for password in leaked_passwords.txt; do
curl -X POST https://api.example.com/auth/login \
-d "username=admin&password=$password"
done
통계:
- 100만 개의 비밀번호 무차별 대입 시도
- Rate limiting 없음 = 1시간 내 완료
- Rate limiting 있음 = 몇 주 소요 (공격 실효성 상실)
Rate Limiting의 핵심 목표 4가지
- 리소스 보호: CPU, 메모리, 데이터베이스 커넥션 고갈 방지
- 공정성 (Fairness): 특정 사용자가 리소스를 독점하지 못하게 하여 모든 사용자에게 공평한 경험 제공
- 비용 관리: AWS Lambda, API Gateway 등 종량제 서비스의 예상치 못한 비용 폭탄 방지
- 보안 강화: Brute-force, DDoS 공격 완화
알고리즘 선택: 4가지 주요 방식 비교
1. Fixed Window (고정 윈도우)
개념: 고정된 시간 단위(예: 1분)당 요청 수 제한
// 메모리 기반 구현 예시
class FixedWindowRateLimiter {
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = new Map(); // userId -> { count, resetTime }
}
async isAllowed(userId) {
const now = Date.now();
const userRequest = this.requests.get(userId);
if (!userRequest || now > userRequest.resetTime) {
// 새 윈도우 시작
this.requests.set(userId, {
count: 1,
resetTime: now + this.windowMs
});
return { allowed: true, remaining: this.maxRequests - 1 };
}
if (userRequest.count < this.maxRequests) {
userRequest.count++;
return {
allowed: true,
remaining: this.maxRequests - userRequest.count
};
}
// 제한 초과
return {
allowed: false,
remaining: 0,
resetAt: userRequest.resetTime
};
}
}
장점:
- 구현이 매우 간단
- 메모리 효율적 (사용자당 카운터 1개만 저장)
- 빠른 처리 속도 (O(1))
단점:
- Window Edge 문제: 윈도우 경계에서 트래픽 폭주(Burst) 발생 가능
Window 1: 00:00:00 - 00:00:59
Window 2: 00:01:00 - 00:01:59
타임라인:
00:00:59 → 100 requests (허용됨)
00:01:00 → 100 requests (허용됨)
= 단 1초 사이에 200 requests 처리! (서버 과부하 위험)
2. Sliding Window (슬라이딩 윈도우)
개념: 현재 시점 기준 과거 N초의 요청 수를 정확히 계산
class SlidingWindowRateLimiter {
constructor(maxRequests, windowMs) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
this.requests = new Map(); // userId -> [timestamps]
}
async isAllowed(userId) {
const now = Date.now();
const windowStart = now - this.windowMs;
// 현재 사용자의 요청 기록 가져오기
let userRequests = this.requests.get(userId) || [];
// 윈도우 밖의 오래된 요청 제거
userRequests = userRequests.filter(timestamp => timestamp > windowStart);
if (userRequests.length < this.maxRequests) {
userRequests.push(now);
this.requests.set(userId, userRequests);
return {
allowed: true,
remaining: this.maxRequests - userRequests.length
};
}
// 가장 오래된 요청이 만료될 시간 계산
const oldestRequest = userRequests[0];
const resetAt = oldestRequest + this.windowMs;
return {
allowed: false,
remaining: 0,
resetAt
};
}
}
장점:
- 정확한 제한: Edge 문제 완벽 해결
- 균일한 분산: 트래픽이 부드럽게 분산됨
단점:
- 메모리 사용량 증가 (모든 요청의 타임스탬프 저장)
- 높은 트래픽 시 성능 저하 (O(N))
메모리 사용량 비교:
- Fixed Window: 10,000 users × 16 bytes = 160 KB
- Sliding Window: 10,000 users × 100 requests × 8 bytes = 8 MB (50배 차이!)
3. Token Bucket (토큰 버킷) - Stripe 방식
개념: 일정 속도로 토큰이 채워지는 버킷. 요청마다 토큰 소비.
class TokenBucketRateLimiter {
constructor(capacity, refillRate, refillIntervalMs) {
this.capacity = capacity; // 버킷 최대 용량
this.refillRate = refillRate; // 리필할 토큰 수
this.refillIntervalMs = refillIntervalMs; // 리필 간격
this.buckets = new Map(); // userId -> { tokens, lastRefill }
}
async isAllowed(userId, cost = 1) {
const now = Date.now();
let bucket = this.buckets.get(userId);
if (!bucket) {
// 새 사용자: 가득 찬 버킷으로 시작
bucket = {
tokens: this.capacity,
lastRefill: now
};
this.buckets.set(userId, bucket);
}
// 토큰 리필 계산
const timePassed = now - bucket.lastRefill;
const refillCount = Math.floor(timePassed / this.refillIntervalMs);
if (refillCount > 0) {
bucket.tokens = Math.min(
this.capacity,
bucket.tokens + (refillCount * this.refillRate)
);
bucket.lastRefill = now;
}
// 요청 처리
if (bucket.tokens >= cost) {
bucket.tokens -= cost;
return {
allowed: true,
remaining: bucket.tokens
};
}
// 다음 리필까지 대기 시간
const nextRefillIn = this.refillIntervalMs - (now - bucket.lastRefill);
return {
allowed: false,
remaining: bucket.tokens,
retryAfter: Math.ceil(nextRefillIn / 1000)
};
}
}
장점:
- Burst 허용: 버킷에 토큰이 쌓여있으면 순간적인 트래픽 처리 가능
- 비용 차별화: API마다 다른 비용(토큰) 책정 가능 (예: 조회 1개, 쓰기 5개)
- 부드러운 제한: 급격한 차단 대신 점진적 제한
4. Leaky Bucket (누수 버킷)
개념: 고정된 속도로 요청을 처리하는 큐(Queue) 방식
장점:
- 부드러운 트래픽: 백엔드 서비스 보호에 이상적 (일정한 부하 유지)
- 예측 가능: 처리 속도가 일정함
단점:
- 구현이 복잡함
- 큐 대기 시간 발생 가능
Redis를 활용한 분산 Rate Limiting
단일 서버 구현은 다중 서버 환경(로드 밸런싱)에서 작동하지 않습니다. 서버 간 상태 공유를 위해 Redis가 필수적입니다.
Redis 기반 Token Bucket 구현 (Lua 스크립트 활용)
Redis의 Lua 스크립트를 사용하면 여러 명령어를 **원자적(Atomic)**으로 실행할 수 있어 Race Condition을 방지합니다.
// redis-rate-limiter.js
const Redis = require('ioredis');
class RedisTokenBucketLimiter {
constructor(redisClient, options = {}) {
this.redis = redisClient;
this.capacity = options.capacity || 100;
this.refillRate = options.refillRate || 10;
this.refillIntervalMs = options.refillIntervalMs || 1000;
}
async isAllowed(userId, cost = 1) {
const key = `rate_limit:${userId}`;
const now = Date.now();
// Lua 스크립트로 원자적 연산 보장
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local refillInterval = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local now = tonumber(ARGV[5])
-- 현재 토큰과 마지막 리필 시간 가져오기
local tokens = tonumber(redis.call('HGET', key, 'tokens'))
local lastRefill = tonumber(redis.call('HGET', key, 'lastRefill'))
-- 첫 요청
if not tokens then
tokens = capacity
lastRefill = now
end
-- 토큰 리필 계산
local timePassed = now - lastRefill
local refillCount = math.floor(timePassed / refillInterval)
if refillCount > 0 then
tokens = math.min(capacity, tokens + (refillCount * refillRate))
lastRefill = now
end
-- 토큰 소비 시도
if tokens >= cost then
tokens = tokens - cost
-- 상태 저장
redis.call('HSET', key, 'tokens', tokens)
redis.call('HSET', key, 'lastRefill', lastRefill)
redis.call('EXPIRE', key, 3600) -- 1시간 TTL (사용 안하면 삭제)
return {1, tokens} -- allowed=true, remaining
else
local nextRefillIn = refillInterval - (now - lastRefill)
return {0, tokens, nextRefillIn} -- allowed=false, remaining, retryAfter
end
`;
const result = await this.redis.eval(
luaScript,
1, // number of keys
key,
this.capacity,
this.refillRate,
this.refillIntervalMs,
cost,
now
);
const [allowed, remaining, retryAfter] = result;
return {
allowed: allowed === 1,
remaining: remaining,
retryAfter: retryAfter ? Math.ceil(retryAfter / 1000) : null
};
}
}