Rate Limiting

API Rate Limiting은 시스템의 안정성과 보안을 유지하면서 공정한 리소스 분배를 보장하는 핵심 메커니즘이다.

Rate Limiting은 특정 시간 간격 동안 API에 대한 요청 수를 제한하는 기술이다. 쉽게 말해, 사용자나 클라이언트가 특정 시간 동안 보낼 수 있는 요청의 횟수에 상한선을 두는 것이다.

예를 들어, “1분당 최대 60회 요청” 또는 “1시간당 1000회 요청"과 같은 제한을 설정할 수 있다. 이러한 제한을 초과하면 API는 일반적으로 HTTP 429 상태 코드(“Too Many Requests”)를 반환하며 요청을 거부한다.

Rate Limiting이 필요한 이유

  1. 시스템 보호
    과도한 API 호출은 서버에 부하를 주어 성능 저하나 서비스 중단을 초래할 수 있다. Rate Limiting은 이러한 위험을 방지한다.

  2. 비용 관리
    각 API 호출에는 컴퓨팅 리소스, 데이터베이스 쿼리, 네트워크 대역폭 등의 비용이 발생한다. 요청 수를 제한함으로써 이러한 비용을 예측 가능한 수준으로 유지할 수 있다.

  3. 공정한 사용 보장
    Rate Limiting은 소수의 사용자가 API 리소스를 독점하는 것을 방지하고, 모든 사용자에게 공정한 접근 기회를 제공한다.

  4. 보안 강화
    악의적인 사용자의 무차별 대입 공격(brute force attack)이나 서비스 거부 공격(DDoS)을 방지하는 데 도움이 된다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 간단한 Rate Limiting 구현 예시 (Python/Flask)
from flask import Flask, request, jsonify
import time
from collections import defaultdict

app = Flask(__name__)

# 사용자별 요청 추적을 위한 딕셔너리
request_history = defaultdict(list)
RATE_LIMIT = 5  # 1분당 최대 5회 요청
TIME_WINDOW = 60  # 1분(60초)

@app.before_request
def check_rate_limit():
    client_ip = request.remote_addr
    current_time = time.time()
    
    # 지난 TIME_WINDOW 초 동안의 요청만 유지
    request_history[client_ip] = [timestamp for timestamp in request_history[client_ip] 
                               if current_time - timestamp < TIME_WINDOW]
    
    # 현재 요청 수가 제한을 초과하는지 확인
    if len(request_history[client_ip]) >= RATE_LIMIT:
        return jsonify({"error": "Rate limit exceeded. Try again later."}), 429
    
    # 현재 요청 시간 기록
    request_history[client_ip].append(current_time)

@app.route('/api/resource')
def get_resource():
    return jsonify({"data": "This is the requested resource"})

if __name__ == '__main__':
    app.run(debug=True)

주요 Rate Limiting 알고리즘

다양한 알고리즘이 Rate Limiting을 구현하는 데 사용된다.
각각의 장단점을 이해하는 것이 중요하다.

고정 윈도우(Fixed Window)

가장 단순한 형태로, 정해진 시간 단위(예: 1시간, 1일)마다 카운터를 리셋한다.

작동 방식:

  1. 각 시간 윈도우(예: 매시 정각부터 59분까지)에 대해 카운터를 0으로 초기화
  2. 각 요청마다 카운터 증가
  3. 카운터가 제한을 초과하면 요청 거부
  4. 새 윈도우가 시작되면 카운터 리셋

장점:

단점:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Node.js에서의 고정 윈도우 구현
const rateLimits = {};
const WINDOW_SIZE_MS = 60000; // 1분
const MAX_REQUESTS = 100; // 분당 최대 요청

function fixedWindowRateLimit(userId) {
  const currentTime = Date.now();
  const windowStart = Math.floor(currentTime / WINDOW_SIZE_MS) * WINDOW_SIZE_MS;
  
  // 새 윈도우라면 카운터 초기화
  if (!rateLimits[userId] || rateLimits[userId].windowStart !== windowStart) {
    rateLimits[userId] = {
      windowStart,
      count: 0
    };
  }
  
  // 요청 카운트 증가
  rateLimits[userId].count++;
  
  // 제한 확인
  return rateLimits[userId].count <= MAX_REQUESTS;
}

슬라이딩 윈도우(Sliding Window)

시간 윈도우가 고정되지 않고 현재 시점에서 일정 시간 이전까지의 요청을 계산한다.

작동 방식:

  1. 각 요청의 타임스탬프를 저장
  2. 현재 시점에서 지정된 시간 이전의 요청 수를 계산
  3. 이 수가 제한을 초과하면 요청 거부

장점:

단점:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Python에서의 슬라이딩 윈도우 구현
import time
from collections import deque

class SlidingWindowRateLimiter:
    def __init__(self, max_requests, window_size):
        self.max_requests = max_requests  # 윈도우당 최대 요청 수
        self.window_size = window_size    # 윈도우 크기(초)
        self.requests = {}  # 사용자별 요청 타임스탬프 저장
        
    def is_allowed(self, user_id):
        current_time = time.time()
        
        # 새 사용자면 초기화
        if user_id not in self.requests:
            self.requests[user_id] = deque()
        
        # 윈도우 밖의 오래된 요청 제거
        while (self.requests[user_id] and 
               self.requests[user_id][0] < current_time - self.window_size):
            self.requests[user_id].popleft()
        
        # 현재 윈도우 내 요청 수 확인
        if len(self.requests[user_id]) < self.max_requests:
            self.requests[user_id].append(current_time)
            return True
        
        return False

토큰 버킷(Token Bucket)

토큰이 일정 속도로 버킷에 추가되고, 각 요청은 토큰을 소비한다.
버킷이 비어있으면 요청이 거부된다.

작동 방식:

  1. 고정 용량의 버킷에 일정 속도로 토큰 추가
  2. 각 API 요청은 버킷에서 토큰을 가져옴
  3. 버킷이 비어있으면 요청이 거부됨
  4. 버킷은 최대 용량 이상으로 토큰을 저장할 수 없음

장점:

단점:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Java에서의 토큰 버킷 구현
public class TokenBucket {
    private final long capacity;       // 버킷 용량
    private final double refillRate;   // 초당 리필되는 토큰 수
    private double tokens;             // 현재 토큰 수
    private long lastRefillTimestamp;  // 마지막 리필 시간
    
    public TokenBucket(long capacity, double refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.tokens = capacity;
        this.lastRefillTimestamp = System.currentTimeMillis();
    }
    
    public synchronized boolean tryConsume(int tokensRequired) {
        refill();
        
        if (tokens < tokensRequired) {
            return false;  // 토큰 부족, 요청 거부
        }
        
        tokens -= tokensRequired;
        return true;       // 토큰 소비, 요청 허용
    }
    
    private void refill() {
        long now = System.currentTimeMillis();
        double tokensSinceLastRefill = (now - lastRefillTimestamp) / 1000.0 * refillRate;
        tokens = Math.min(capacity, tokens + tokensSinceLastRefill);
        lastRefillTimestamp = now;
    }
}

누수 버킷(Leaky Bucket)

요청이 버킷에 추가되고 일정한 속도로 처리된다. 버킷이 가득 차면 새 요청이 거부된다.

작동 방식:

  1. 고정 용량의 버킷에 요청을 추가
  2. 버킷에서 일정한 속도로 요청을 처리
  3. 버킷이 가득 차면 새 요청이 거부됨

장점:

단점:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// JavaScript에서의 누수 버킷 구현
class LeakyBucket {
  constructor(capacity, leakRate) {
    this.capacity = capacity;     // 버킷 용량
    this.leakRate = leakRate;     // 초당 처리율
    this.currentLevel = 0;        // 현재 버킷 수위
    this.lastLeakTime = Date.now();
  }
  
  leak() {
    const now = Date.now();
    const elapsedTime = (now - this.lastLeakTime) / 1000; // 초 단위 경과 시간
    const leakedAmount = elapsedTime * this.leakRate;
    
    this.currentLevel = Math.max(0, this.currentLevel - leakedAmount);
    this.lastLeakTime = now;
  }
  
  addRequest(size = 1) {
    this.leak();
    
    if (this.currentLevel + size <= this.capacity) {
      this.currentLevel += size;
      return true;  // 요청 허용
    }
    
    return false;   // 버킷 가득 참, 요청 거부
  }
}

Rate Limiting 구현 시 고려사항

식별자 선택

Rate Limiting을 적용할 때 어떤 기준으로 요청을 구분할지 결정해야 한다:

  1. IP 주소: 가장 기본적인 접근법이지만, NAT나 프록시 환경에서는 여러 사용자가 동일한 IP를 공유할 수 있다.
  2. API 키: 더 정확한 식별이 가능하지만 키 관리가 필요하다.
  3. 사용자 ID: 로그인한 사용자를 기준으로 제한한다.
  4. 복합 식별자: 여러 속성을 조합 (예: IP + 엔드포인트)

세부 제한 설정

다양한 수준에서 Rate Limiting을 적용할 수 있다:

  1. 글로벌 제한: 전체 API에 대한 총 요청 수 제한
  2. 사용자/계정별 제한: 각 사용자나 계정에 대한 제한
  3. 엔드포인트별 제한: 특정 API 엔드포인트에 대한 제한
  4. 메서드별 제한: GET, POST 등 HTTP 메서드별 다른 제한 적용

분산 시스템에서의 Rate Limiting

마이크로서비스 아키텍처나 여러 서버가 있는 환경에서는 중앙화된 Rate Limiting 솔루션이 필요하다:

  1. Redis 활용: 인메모리 데이터 스토어를 사용하여 요청 카운터 관리
  2. 전용 Rate Limiting 서비스: 모든 요청이 통과하는 중앙 서비스 구현
  3. API 게이트웨이 활용: 대부분의 API 게이트웨이는 Rate Limiting 기능 제공
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// Node.js + Redis를 사용한 분산 Rate Limiting 예시
const redis = require('redis');
const { promisify } = require('util');
const client = redis.createClient();

// Redis 명령어를 Promise로 변환
const incrAsync = promisify(client.incr).bind(client);
const expireAsync = promisify(client.expire).bind(client);
const ttlAsync = promisify(client.ttl).bind(client);

async function rateLimit(identifier, limit, windowSec) {
  const key = `ratelimit:${identifier}`;
  
  try {
    // 카운터 증가
    const count = await incrAsync(key);
    
    // 첫 요청이면 만료 시간 설정
    if (count === 1) {
      await expireAsync(key, windowSec);
    }
    
    // 현재 창의 남은 시간 확인
    const ttl = await ttlAsync(key);
    
    // 응답 헤더에 포함할 정보
    const headers = {
      'X-RateLimit-Limit': limit,
      'X-RateLimit-Remaining': Math.max(0, limit - count),
      'X-RateLimit-Reset': ttl
    };
    
    // 제한 초과 여부 확인
    if (count > limit) {
      return {
        limited: true,
        headers: {
          headers,
          'Retry-After': ttl
        }
      };
    }
    
    return {
      limited: false,
      headers
    };
  } catch (err) {
    console.error('Rate limiting error:', err);
    // 오류 발생 시 요청 허용 (안전 장치)
    return { limited: false, headers: {} };
  }
}

// 사용 예시
async function handleRequest(req, res) {
  const userId = req.user?.id || 'anonymous';
  const result = await rateLimit(userId, 100, 3600); // 시간당 100회
  
  // 응답 헤더에 Rate Limit 정보 추가
  Object.entries(result.headers).forEach(([key, value]) => {
    res.setHeader(key, value);
  });
  
  if (result.limited) {
    return res.status(429).json({ 
      error: 'Too many requests, please try again later.' 
    });
  }
  
  // 정상 요청 처리 계속…
}

사용자 경험 최적화

Rate Limiting을 구현할 때 사용자 경험도 고려해야 한다:

명확한 오류 메시지

Rate Limit에 도달했을 때, 사용자에게 다음 정보를 제공하는 것이 좋다:

  1. 현재 제한
  2. 남은 요청 수
  3. 제한이 초기화되는 시간
  4. 대응 방안 (예: 나중에 다시 시도, 다른 API 키 사용 등)

HTTP 헤더 활용

표준 Rate Limit HTTP 헤더를 사용하여 클라이언트에게 제한 정보를 전달:

1
2
3
4
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 75
X-RateLimit-Reset: 1589458812
Retry-After: 120

클라이언트 안내

API 문서에 Rate Limiting 정책을 명확히 설명하고, 클라이언트가 이를 효과적으로 처리하는 방법을 안내한다:

  1. 지수 백오프(exponential backoff) 구현
  2. 캐싱 활용
  3. 배치 요청 사용
  4. 필수 데이터만 요청

실제 구현 예시: 다양한 환경에서의 Rate Limiting

API 게이트웨이

대부분의 API 게이트웨이 솔루션은 기본적인 Rate Limiting 기능을 제공한다:

  1. AWS API Gateway: 사용량 계획(Usage Plans)을 통한 제한
  2. Kong: 다양한 Rate Limiting 플러그인 제공
  3. NGINX: limit_req_zone 모듈
  4. Apigee: Traffic Management 정책

웹 프레임워크 미들웨어

대부분의 웹 프레임워크는 Rate Limiting 미들웨어 또는 라이브러리를 제공:

  1. Express.js: express-rate-limit 패키지
  2. Django: django-ratelimit
  3. Rails: rack-attack
  4. Spring Boot: bucket4j-spring-boot-starter

데이터베이스를 활용한 Rate Limiting

Redis나 Memcached 같은 고성능 키-값 저장소를 사용하여 구현:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# Python + Redis를 사용한 구현 예시
import redis
import time

class RedisRateLimiter:
    def __init__(self, redis_client, limit, window):
        """
        Redis 기반 Rate Limiter 초기화
        
        Args:
            redis_client: Redis 클라이언트 인스턴스
            limit: 윈도우당 최대 요청 수
            window: 시간 윈도우(초)
        """
        self.redis = redis_client
        self.limit = limit
        self.window = window
    
    def is_allowed(self, identifier):
        """
        주어진 식별자에 대한 요청이 허용되는지 확인
        
        Args:
            identifier: 요청 식별자(사용자 ID, IP 등)
            
        Returns:
            (allowed, remaining, reset_time) 튜플
        """
        current_time = int(time.time())
        window_start = current_time - self.window
        
        # 윈도우 키
        key = f"ratelimit:{identifier}"
        
        # 파이프라인으로 여러 Redis 명령 실행
        pipe = self.redis.pipeline()
        
        # 현재 시간 윈도우 외의 요청 제거
        pipe.zremrangebyscore(key, 0, window_start)
        
        # 현재 요청 추가
        pipe.zadd(key, {str(current_time): current_time})
        
        # 현재 윈도우 내 요청 수 계산
        pipe.zcard(key)
        
        # 키 만료 설정 (윈도우 시간 + 약간의 버퍼)
        pipe.expire(key, self.window + 10)
        
        # 명령 실행 및 결과 가져오기
        _, _, current_count, _ = pipe.execute()
        
        # 요청이 허용되는지 확인
        allowed = current_count <= self.limit
        remaining = max(0, self.limit - current_count)
        reset_time = current_time + self.window
        
        return (allowed, remaining, reset_time)

고급 Rate Limiting 전략

동적 Rate Limiting

시스템 부하, 사용자 행동, 시간대 등에 따라 제한을 동적으로 조정:

  1. 서버 부하 기반: 서버 부하가 높을 때 제한 강화
  2. 사용자 행동 기반: 의심스러운 패턴 감지 시 제한 강화
  3. 시간대 기반: 피크 시간대와 비피크 시간대에 다른 제한 적용

차등 Rate Limiting

모든 사용자나 요청을 동일하게 취급하지 않고 차별화:

  1. 사용자 등급별: 무료/유료 사용자에 따른 차등 제한
  2. 요청 중요도별: 중요한 작업과 덜 중요한 작업에 다른 제한 적용
  3. 리소스 비용별: 리소스 소비가 많은 엔드포인트에 더 엄격한 제한 적용

비용 기반 Rate Limiting

단순히 요청 수가 아닌 각 요청의 “비용” 또는 리소스 소비량을 고려:

  1. 각 API 엔드포인트에 비용 값 할당
  2. 사용자별 “예산” 설정
  3. 각 요청마다 비용에 따라 예산 차감
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// Java에서의 비용 기반 Rate Limiting 구현 예시
public class CostBasedRateLimiter {
    private final Map<String, TokenBucket> userBuckets = new ConcurrentHashMap<>();
    private final Map<String, Integer> endpointCosts;
    private final int defaultBucketCapacity;
    private final double refillRate;
    
    public CostBasedRateLimiter(int defaultCapacity, double tokensPerSecond) {
        this.defaultBucketCapacity = defaultCapacity;
        this.refillRate = tokensPerSecond;
        
        // 엔드포인트별 비용 정의 (실제로는 설정 파일 등에서 로드)
        this.endpointCosts = Map.of(
            "/api/simple-query", 1,          // 가벼운 요청
            "/api/user-profile", 2,          // 중간 요청
            "/api/complex-report", 10,       // 무거운 요청
            "/api/data-export", 25           // 매우 무거운 요청
        );
    }
    
    public boolean allowRequest(String userId, String endpoint) {
        // 사용자별 토큰 버킷 가져오기 (없으면 생성)
        TokenBucket bucket = userBuckets.computeIfAbsent(userId, 
            id -> new TokenBucket(defaultBucketCapacity, refillRate));
        
        // 엔드포인트 비용 계산 (기본값은 1)
        int cost = endpointCosts.getOrDefault(endpoint, 1);
        
        // 요청 허용 여부 확인 (비용만큼 토큰 소비)
        return bucket.tryConsume(cost);
    }
    
    // 토큰 버킷 구현
    private static class TokenBucket {
        private final double capacity;
        private final double refillRate;
        private double tokens;
        private long lastRefillTimestamp;
        
        public TokenBucket(double capacity, double refillRate) {
            this.capacity = capacity;
            this.refillRate = refillRate;
            this.tokens = capacity;
            this.lastRefillTimestamp = System.currentTimeMillis();
        }
        
        public synchronized boolean tryConsume(int tokensRequired) {
            refill();
            
            if (tokens < tokensRequired) {
                return false;
            }
            
            tokens -= tokensRequired;
            return true;
        }
        
        private void refill() {
            long now = System.currentTimeMillis();
            double elapsed = (now - lastRefillTimestamp) / 1000.0;
            double tokensToAdd = elapsed * refillRate;
            
            if (tokensToAdd > 0) {
                tokens = Math.min(capacity, tokens + tokensToAdd);
                lastRefillTimestamp = now;
            }
        }
    }
}

실제 사례 연구: 주요 API의 Rate Limiting 전략

GitHub API

GitHub API는 다양한 수준의 Rate Limiting을 구현:

  1. 인증되지 않은 요청: 시간당 60회
  2. 인증된 요청: 시간당 5,000회
  3. 검색 API: 분당 30회 (인증된 사용자)
  4. GraphQL API: 시간당 5,000점 (쿼리 복잡성에 따른 포인트 시스템)

이러한 다층적 접근 방식은 서비스의 안정성을 유지하면서도 다양한 사용자 요구를 수용한다.

Twitter API

Twitter API는 계층화된 접근 방식 사용:

  1. 표준 API: 15분당 450-900개 요청 (엔드포인트별로 다름)
  2. 검색 API: 15분당 180개 요청
  3. 미디어 업로드: 3시간당 30MB

이를 통해 Twitter는 플랫폼 부하를 관리하면서도 다양한 애플리케이션을 지원한다.

Stripe API

Stripe의 접근 방식:

  1. 라이브 환경: 초당 25-100개 요청 (엔드포인트별 차등)
  2. 테스트 환경: 더 관대한 제한
  3. 웹훅 전송: 초당 최대 100개
  4. 계정 메타데이터 읽기/쓰기: 초당 최대 15개 요청

결제 처리와 같은 중요한 작업에서는 안정성이 특히 중요하므로, Stripe는 각 엔드포인트의 성격에 맞는 Rate Limiting을 적용한다.

모범 사례와 권장 사항

Rate Limiting을 효과적으로 구현하기 위한 몇 가지 권장 사항:


용어 정리

용어설명
레이트 리밋(Rate Limit)지정된 시간 창(time window) 내에 허용되는 최대 요청 수
쓰로틀링(Throttling)요청 속도를 늦추거나 제한하는 프로세스
시간 창(Time Window)레이트 리밋이 적용되는 시간 간격(초, 분, 시간, 일 단위)
리밋 식별자(Limit Identifier)제한이 적용되는 엔티티(IP 주소, API 키, 사용자 ID 등)

참고 및 출처