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

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

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

  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

글을 마치며

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

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

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


관련 포스트

# Memory Leak 프로덕션 디버깅 완벽 가이드: Go pprof와 Rust Profiling으로 50,000개 Goroutine 누수 해결하기

게시:

Production Memory Leak 디버깅 완벽 가이드입니다. Go pprof, Rust Bytehound, Continuous Profiling으로 50,000개 Goroutine 누수, 10GB 메모리 누수, OOMKilled를 해결하는 방법부터 2025년 최신 Flamegraph, DHAT, Tokio Console까지 실전 예제와 함께 설명합니다.

읽기

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

게시:

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

읽기