본문으로 건너뛰기
API Rate Limiting 분산 시스템 구현 가이드 - 확장 가능한 속도 제한 아키텍처
백엔드 개발 / · PT4M read

API Rate Limiting: 분산 시스템에서 확장 가능한 속도 제한 구현하기

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가지

  1. 리소스 보호: CPU, 메모리, 데이터베이스 커넥션 고갈 방지
  2. 공정성 (Fairness): 특정 사용자가 리소스를 독점하지 못하게 하여 모든 사용자에게 공평한 경험 제공
  3. 비용 관리: AWS Lambda, API Gateway 등 종량제 서비스의 예상치 못한 비용 폭탄 방지
  4. 보안 강화: 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
 };
 }
}
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트
Split-Brain 프로덕션 디버깅 가이드 - Distributed Systems

Split-Brain 프로덕션 완벽 해결 가이드: 분산 시스템에서 두 개의 리더가 동시에 존재할 때 데이터 충돌 방지하기

Split-Brain 프로덕션 디버깅 완벽 가이드입니다. NVIDIA AIStore 실제 사례, Quorum 기반 방지, Raft/Paxos Consensus 알고리즘, STONITH Fencing으로 네트워크 파티션 상황에서 데이터 충돌을 방지하는 방법부터 Elasticsearch, Redis Cluster, Kafka 환경까지 실전 예제와 함께 설명합니다.

Production Redis High Availability +8