2026년 4월 20일

시스템 안정성의 수문장, Rate Limiting 마스터하기

230
시스템 안정성의 수문장, Rate Limiting 마스터하기

시스템 안정성의 수문장, Rate Limiting 마스터하기

시스템 안정성의 수문장, Rate Limiting 마스터하기

안녕하세요, 10년 경력의 소프트웨어 엔지니어이자 기술 교육자입니다. 오늘은 분산 시스템 시대에 서비스의 안정성과 보안을 지키는 데 필수적인 기술, 바로 **Rate Limiting (레이트 리미팅)**에 대해 이야기해보고자 합니다. 시스템 설계 면접에서 단골로 등장하고, 실제 서비스 운영에서 수많은 장애를 예방하는 핵심 방어선이 되어주는 이 개념을 함께 깊이 파헤쳐 봅시다.

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

1. 개념 소개: 정의, 탄생 배경, 왜 중요한지

Rate Limiting은 특정 시간 동안 사용자나 서비스가 보낼 수 있는 요청의 빈도(rate)를 제한하는 기술입니다. 쉽게 말해, "너는 1분 동안 최대 100번의 요청만 보낼 수 있어"와 같이 요청 횟수에 상한선을 두는 것이죠.

이 기술은 다음과 같은 배경에서 탄생했으며, 현대 웹 서비스에서 그 중요성이 더욱 커지고 있습니다.

  • 서버 과부하 방지: 무분별하거나 악의적인 대량 요청은 서버의 자원(CPU, 메모리, 네트워크 대역폭)을 고갈시켜 서비스 응답 속도를 저하시키거나 아예 마비시킬 수 있습니다. Rate Limiting은 이를 사전에 차단하여 시스템의 안정성을 유지합니다.
  • DoS/DDoS 공격 방어: 분산 서비스 거부(DDoS) 공격은 여러 소스에서 대량의 트래픽을 발생시켜 서비스를 마비시키는 공격입니다. Rate Limiting은 이러한 공격으로부터 1차적인 방어막 역할을 수행합니다.
  • 비용 절감: 클라우드 환경에서 API 호출 횟수나 데이터 전송량에 따라 비용이 청구되는 경우가 많습니다. 불필요하거나 과도한 요청을 제한하여 예상치 못한 비용 지출을 막을 수 있습니다.
  • 공정한 자원 분배: 제한된 서버 자원을 모든 사용자에게 공정하게 분배하여, 특정 사용자가 자원을 독점하거나 다른 사용자들의 서비스 이용을 방해하는 것을 막습니다. 이는 서비스 품질(QoS)을 유지하는 데 중요합니다.
  • API 남용 방지: 외부 API를 제공하는 경우, Rate Limiting은 API를 사용하는 개발자들이 정해진 사용 정책을 따르도록 강제하여 API 서비스의 안정성을 보장하고 수익 모델과도 연결됩니다.

결론적으로, Rate Limiting은 시스템의 안정성, 보안, 비용 효율성, 그리고 사용자 경험이라는 네 마리 토끼를 동시에 잡는 핵심적인 방어 전략입니다.

2. 핵심 원리 설명 (비유와 다이어그램 활용)

2. 핵심 원리 설명 (비유와 다이어그램 활용)

Rate Limiting을 구현하는 데는 여러 가지 알고리즘이 사용됩니다. 각 알고리즘은 장단점이 명확하므로, 서비스의 특성과 요구사항에 맞춰 적절한 것을 선택해야 합니다. 주요 알고리즘들을 비유를 통해 쉽게 설명해 드릴게요.

2.1. 고정 윈도우 카운터 (Fixed Window Counter)

  • 원리: 특정 시간(예: 1분)을 윈도우로 설정하고, 그 윈도우 내에서 들어온 요청 수를 카운트합니다. 카운트가 설정된 임계치(예: 100개)를 넘으면 해당 윈도우가 끝날 때까지 더 이상 요청을 받지 않습니다. 윈도우가 끝나면 카운트는 0으로 초기화됩니다.
  • 비유: '고속도로 톨게이트'에 비유할 수 있습니다. 톨게이트는 매 시간 정각에 열리고, 한 시간 동안 100대의 차만 통과시킵니다. 100대가 통과하면 다음 정각까지 톨게이트는 닫힙니다.
  • 장점: 구현이 매우 간단하고 직관적입니다.
  • 단점: '버스트 트래픽 (Burst Traffic)'에 취약합니다. 예를 들어, 1분 윈도우에서 99개의 요청이 59초에 들어오고, 다음 윈도우가 시작되는 0초에 또다시 99개의 요청이 들어온다면, 실제로는 1초 만에 198개의 요청이 처리되는 상황이 발생할 수 있습니다. 이는 서버에 순간적인 부하를 줄 수 있습니다.
       [--- 1분 윈도우 ---]     [--- 다음 1분 윈도우 ---]
시간: --|--|--|--|--|--|--|--|--|--|--|--|--|--|--|--|--|--|--
요청:  x  x  x  x  x  x  x  x  x  x  x  x  x  x  x  x  x  x  x  x
카운터: 10 20 30 40 50 60 70 80 90 (99) | (0) 10 20 30 40 ...
                                      ^
                                      윈도우 경계

2.2. 슬라이딩 윈도우 로그 (Sliding Window Log)

  • 원리: 요청이 들어올 때마다 해당 요청의 타임스탬프를 리스트(로그)에 기록합니다. 새로운 요청이 들어오면, 현재 시간부터 설정된 윈도우(예: 1분) 이전의 타임스탬프만 필터링하여 그 개수를 셉니다. 이 개수가 임계치를 넘으면 요청을 거부합니다.
  • 비유: '시간 기록 시스템'에 비유할 수 있습니다. 모든 차량이 톨게이트를 지날 때마다 시간을 기록합니다. 새로운 차량이 오면, "지금으로부터 1시간 안에 통과한 차량이 몇 대인지"를 정확히 세서, 너무 많으면 통과시키지 않습니다.
  • 장점: 가장 정확하며, 버스트 트래픽 문제에 강합니다.
  • 단점: 모든 요청의 타임스탬프를 저장해야 하므로, 메모리 사용량과 연산 비용이 매우 높습니다. 대규모 트래픽에는 적합하지 않습니다.

2.3. 슬라이딩 윈도우 카운터 (Sliding Window Counter)

  • 원리: 고정 윈도우 카운터의 버스트 문제를 완화하기 위해 고안되었습니다. 현재 윈도우의 카운터와 이전 윈도우의 카운터를 조합하여 사용합니다. 예를 들어, 1분 윈도우에서 현재 시점 T가 이전 윈도우의 70% 지점에 있다면, (이전 윈도우의 카운터 * 0.3) + (현재 윈도우의 카운터)를 계산하여 요청 수를 추정합니다.
  • 비유: 두 개의 톨게이트 카운터를 사용하되, 이전 톨게이트의 통과 차량 중 "아직 유효하다고 볼 수 있는" 비율만큼을 현재 톨게이트 카운터에 더해서 판단하는 방식입니다.
  • 장점: 고정 윈도우 카운터보다 버스트 트래픽에 강하고, 슬라이딩 로그보다 효율적입니다. 실제 서비스에서 많이 사용됩니다.
  • 단점: 완벽하게 정확하지는 않지만, 실용적인 수준의 정확도를 제공합니다.

2.4. 토큰 버킷 (Token Bucket)

  • 원리: 미리 정해진 속도(rate)로 토큰(요청을 처리할 수 있는 권한)이 버킷에 채워집니다. 버킷은 최대 토큰 개수(capacity)를 가지고 있어, 그 이상은 채워지지 않습니다. 요청이 들어오면 버킷에서 토큰 하나를 가져가고, 토큰이 없으면 요청을 거부합니다.
  • 비유: '젤리빈 통'에 비유할 수 있습니다. 이 통에는 젤리빈(토큰)이 매초 1개씩(rate) 자동으로 채워지지만, 통에는 최대 100개(capacity)까지만 들어갈 수 있습니다. 요청이 오면 젤리빈 하나를 꺼내고, 젤리빈이 없으면 요청은 거절됩니다. 젤리빈이 많이 쌓여있을 때는 순간적으로 많은 요청을 처리(버스트 허용)할 수 있습니다.
  • 장점:
    • 버스트 트래픽을 일정량 허용하면서도, 장기적인 평균 요청 처리율을 제한할 수 있습니다.
    • 구현이 비교적 간단합니다.
  • 단점: 버킷 용량 설정을 신중하게 해야 합니다.

2.5. 리키 버킷 (Leaky Bucket)

  • 원리: 요청이 들어오면 버킷에 쌓입니다. 버킷은 일정한 속도(rate)로 요청을 처리(누수)하며, 버킷이 가득 차면 새로운 요청은 거부됩니다.
  • 비유: '구멍 난 양동이'에 비유할 수 있습니다. 물(요청)이 양동이에 채워지고, 양동이 밑의 작은 구멍으로 물이 일정한 속도로 계속 빠져나갑니다. 양동이가 가득 차면 더 이상 물을 받을 수 없습니다.
  • 장점:
    • 요청 처리율을 매우 안정적으로 유지합니다.
    • 서버가 처리할 수 있는 일정한 속도로 트래픽을 균일하게 보낼 때 유용합니다.
  • 단점: 순간적인 버스트 트래픽을 처리하지 못하고 거부할 수 있습니다.

3. 코드 예제 2개 (Python 또는 JavaScript)

여기서는 실제 서비스에서 많이 사용되는 토큰 버킷(Token Bucket) 알고리즘과 간단한 **고정 윈도우 카운터(Fixed Window Counter)**를 Python으로 구현한 예제를 보여드리겠습니다. 분산 환경에서는 Redis 같은 외부 저장소를 사용해야 하지만, 여기서는 기본 개념 이해를 위해 인메모리 구현으로 진행합니다.

3.1. Python - 토큰 버킷 (Token Bucket) 구현

import time

class TokenBucketRateLimiter:
    def __init__(self, capacity: int, fill_rate: int):
        """
        토큰 버킷 레이트 리미터 초기화
        :param capacity: 버킷의 최대 토큰 개수 (한 번에 최대로 허용할 수 있는 요청 수)
        :param fill_rate: 초당 채워지는 토큰 개수 (평균 처리율)
        """
        self.capacity = capacity
        self.fill_rate = fill_rate
        self.tokens = capacity  # 초기에는 버킷을 가득 채웁니다.
        self.last_fill_time = time.monotonic() # 마지막으로 토큰을 채운 시간

    def _refill_tokens(self):
        """
        현재 시간 기준으로 토큰을 다시 채웁니다.
        """
        now = time.monotonic()
        # 마지막으로 채운 시간 이후로 흐른 시간 (초)
        time_elapsed = now - self.last_fill_time
        # 채워져야 할 토큰 개수
        tokens_to_add = time_elapsed * self.fill_rate
        
        # 현재 토큰 수에 채워져야 할 토큰 수를 더하고, 버킷 용량을 초과하지 않도록 합니다.
        self.tokens = min(self.capacity, self.tokens + tokens_to_add)
        self.last_fill_time = now # 마지막 채운 시간 업데이트

    def allow_request(self) -> bool:
        """
        요청 허용 여부를 확인합니다.
        :return: 요청 허용 시 True, 거부 시 False
        """
        self._refill_tokens() # 먼저 토큰을 최신 상태로 업데이트
        
        if self.tokens >= 1:
            self.tokens -= 1 # 요청 처리 시 토큰 하나 사용
            return True
        return False

# 사용 예제
if __name__ == "__main__":
    # 버킷 용량 10개, 초당 2개 토큰 채움
    limiter = TokenBucketRateLimiter(capacity=10, fill_rate=2) 
    
    print("--- 1초당 2개의 토큰이 채워지는 토큰 버킷 (용량 10) ---")

    # 초기 버스트 요청 테스트 (토큰이 가득 차 있으므로 10개까지는 바로 허용)
    for i in range(15):
        if limiter.allow_request():
            print(f"[{i+1}번째 요청] 허용됨. 남은 토큰: {limiter.tokens:.2f}")
        else:
            print(f"[{i+1}번째 요청] 거부됨. 남은 토큰: {limiter.tokens:.2f}")
        time.sleep(0.1) # 0.1초 간격으로 요청 시도

    print("\n--- 잠시 대기 후 다시 시도 (토큰 재충전 확인) ---")
    time.sleep(3) # 3초 대기 (3 * 2 = 6개의 토큰이 채워질 것으로 예상)

    for i in range(10):
        if limiter.allow_request():
            print(f"[{i+1}번째 요청] 허용됨. 남은 토큰: {limiter.tokens:.2f}")
        else:
            print(f"[{i+1}번째 요청] 거부됨. 남은 토큰: {limiter.tokens:.2f}")
        time.sleep(0.2) # 0.2초 간격으로 요청 시도

3.2. JavaScript (Node.js) - 고정 윈도우 카운터 (Fixed Window Counter) 미들웨어

Node.js 환경에서 Express 프레임워크를 사용한다고 가정하고, 간단한 고정 윈도우 카운터 미들웨어를 구현해 보겠습니다. 이 코드는 인메모리 Map을 사용하여 요청을 기록하므로, 서버가 재시작되면 상태가 초기화되고, 여러 인스턴스로 구성된 분산 환경에서는 정확하지 않을 수 있습니다. 실 서비스에서는 Redis 등을 활용해야 합니다.

const express = require('express');
const app = express();
const PORT = 3000;

// userId -> { count: number, windowStartTime: number }
const requestCounts = new Map(); 

// 레이트 리미팅 설정
const WINDOW_SIZE_MS = 60 * 1000; // 1분 (밀리초)
const MAX_REQUESTS = 5;         // 1분 동안 최대 5개 요청 허용

const fixedWindowRateLimiter = (req, res, next) => {
    // 실제 서비스에서는 req.ip 또는 사용자 인증 정보(req.user.id)를 사용합니다.
    const userId = req.ip || 'anonymous'; 
    const now = Date.now();

    let userRequests = requestCounts.get(userId);

    if (!userRequests || now - userRequests.windowStartTime > WINDOW_SIZE_MS) {
        // 새 윈도우 시작 또는 윈도우 만료: 카운트 초기화
        userRequests = {
            count: 1,
            windowStartTime: now
        };
        requestCounts.set(userId, userRequests);
        next(); // 요청 허용
    } else if (userRequests.count < MAX_REQUESTS) {
        // 현재 윈도우 내에서 허용된 요청 수 미만: 카운트 증가 및 허용
        userRequests.count++;
        requestCounts.set(userId, userRequests);
        next(); // 요청 허용
    } else {
        // 요청 수 초과: 거부
        res.status(429).set('Retry-After', Math.ceil((userRequests.windowStartTime + WINDOW_SIZE_MS - now) / 1000)).send('Too Many Requests');
    }
};

// 미들웨어 적용
app.use(fixedWindowRateLimiter);

app.get('/', (req, res) => {
    res.send('Hello, you are allowed!');
});

app.listen(PORT, () => {
    console.log(`Server running on http://localhost:${PORT}`);
    console.log(`1분당 최대 ${MAX_REQUESTS}개의 요청만 허용됩니다.`);
    console.log(`테스트: curl http://localhost:${PORT}`);
});

// 테스트 방법:
// 1. `node your_file_name.js` 실행
// 2. 터미널에서 `curl http://localhost:3000` 을 1분 안에 5번 이상 실행
//    -> 5번까지는 'Hello, you are allowed!' 응답, 6번째부터는 'Too Many Requests' 응답
// 3. 1분 후 다시 시도하면 정상 작동

4. 실무 적용 사례

Rate Limiting은 다양한 서비스 계층에서 활용됩니다.

  • API Gateway: AWS API Gateway, Nginx, Kong, Ocelot 등은 자체적으로 Rate Limiting 기능을 제공합니다. 이는 백엔드 서비스 이전에 트래픽을 제어하여, 백엔드 서비스가 과부하되지 않도록 보호하는 첫 번째 방어선 역할을 합니다.
  • 인증 및 보안:
    • 로그인 시도 제한: 로그인 실패 횟수를 제한하여 무차별 대입 공격(Brute-force attack)을 방지합니다.
    • 비밀번호 재설정 요청 제한: 비밀번호 재설정 이메일/SMS 발송 요청을 제한하여 악용을 막습니다.
    • 회원가입/아이디 찾기 요청 제한: 특정 IP에서 너무 많은 계정 생성이나 정보 조회를 시도하는 것을 막습니다.
  • 서드파티 API 연동: 외부 서비스의 API를 사용할 때, 해당 서비스의 Rate Limiting 정책을 준수해야 합니다. 반대로, 자신의 API를 외부에 제공할 때도 과도한 사용을 막기 위해 Rate Limiting을 적용합니다.
  • 크롤링/스크래핑 방지: 웹사이트의 콘텐츠를 무분별하게 긁어가는 봇(bot)이나 크롤러의 접근 속도를 제한하여 서버 부하를 줄이고 데이터 도용을 막습니다.
  • 리소스 보호: 데이터베이스 접근, 특정 마이크로서비스 호출 등 비용이 많이 들거나 중요한 내부 리소스에 대한 접근 빈도를 제한하여 시스템 전체의 안정성을 확보합니다.

5. 자주 하는 실수와 해결법

5.1. 분산 환경에서의 레이트 리미팅 문제

위 코드 예제들은 단일 서버(인메모리) 환경에서만 작동합니다. 여러 대의 서버 인스턴스로 구성된 분산 시스템에서는 각 서버 인스턴스가 독립적으로 요청 수를 카운트하므로, 실제 요청 제한보다 훨씬 많은 요청이 허용될 수 있습니다.

  • 해결법: 모든 서버 인스턴스가 접근할 수 있는 중앙 집중식 저장소(예: Redis)를 사용하여 요청 카운터나 토큰 정보를 공유해야 합니다. Redis의 INCR, EXPIRE, SETNX 등의 명령어를 활용하여 고정 윈도우, 토큰 버킷 등을 구현할 수 있습니다.

    # Redis를 사용한 고정 윈도우 카운터 예시 (개념만)
    import redis
    import time
    
    r = redis.Redis(host='localhost', port=6379, db=0)
    
    def redis_fixed_window_limiter(user_id, max_requests, window_seconds):
        key = f"rate_limit:{user_id}"
        current_time_ms = int(time.time() * 1000)
        
        # 윈도우 시작 시간을 기준으로 키 생성. 예를 들어, 1분 윈도우라면 현재 분의 시작 시간.
        window_key = f"{key}:{current_time_ms // (window_seconds * 1000)}" 
        
        # 요청 카운트를 1 증가시키고, 키가 없으면 새로 생성하며 TTL 설정
        count = r.incr(window_key)
        if count == 1:
            r.expire(window_key, window_seconds) # 윈도우 만료 시간에 맞춰 TTL 설정
        
        return count <= max_requests
    
    # 사용 예
    if __name__ == "__main__":
        user = "test_user_ip"
        limit = 5
        window = 60 # 1분
    
        print(f"--- Redis 기반 1분당 {limit}개 요청 제한 테스트 ---")
        for i in range(10):
            if redis_fixed_window_limiter(user, limit, window):
                print(f"[{i+1}번째 요청] 허용됨")
            else:
                print(f"[{i+1}번째 요청] 거부됨")
            time.sleep(5) # 5초마다 요청 (1분 안에 5개 이상 보낼 수 있음)
    

    이 예제는 간략화된 것이며, 실제로는 Lua 스크립트를 사용하여 Redis 명령을 ATOMIC하게 실행하여 경쟁 조건(Race Condition)을 방지하는 것이 좋습니다.

5.2. 잘못된 윈도우 크기 및 임계치 설정

너무 엄격하면 정당한 사용자도 불편을 겪고, 너무 느슨하면 Rate Limiting의 목적을 달성하기 어렵습니다.

  • 해결법:
    • 모니터링: 시스템의 정상적인 트래픽 패턴을 분석하여 적절한 기준을 찾습니다.
    • 점진적 적용: 처음에는 느슨하게 적용하고, 모니터링하면서 점진적으로 조절합니다.
    • 티어(Tier)별 정책: 일반 사용자, 프리미엄 사용자, 관리자 등 사용자 그룹에 따라 다른 제한을 적용할 수 있습니다.

5.3. Rate Limiting 에러 처리 및 사용자 경험

요청이 거부되었을 때 사용자에게 어떤 정보를 제공해야 할까요? 단순히 "에러 발생"이라고만 하면 사용자는 혼란스러워합니다.

  • 해결법:
    • HTTP 429 Too Many Requests: 표준 HTTP 상태 코드인 429를 사용하여 요청이 너무 많아서 거부되었음을 명확히 알립니다.
    • Retry-After 헤더: 응답 헤더에 Retry-After를 포함하여, 언제 다시 요청을 시도할 수 있는지(초 단위 또는 날짜/시간)를 알려줍니다. 이는 클라이언트가 불필요한 재시도를 하지 않도록 돕습니다.
    • 명확한 에러 메시지: "잠시 후 다시 시도해주세요"와 같은 사용자 친화적인 메시지를 제공합니다.
    • 클라이언트 측 로직: 클라이언트 애플리케이션에서도 Retry-After 헤더를 보고 적절히 대기한 후 재시도하는 로직(지수 백오프 등)을 구현해야 합니다.

5.4. 특정 사용자/IP 예외 처리 누락

내부 서비스나 특정 파트너사, 또는 서비스 운영을 위한 IP에 대해 Rate Limiting이 적용되면 문제가 발생할 수 있습니다.

  • 해결법: 화이트리스트(Whitelist)를 운영하여 특정 IP 주소, 사용자 ID, 또는 API 키에 대해서는 Rate Limiting을 우회하도록 설정합니다.

6. 더 공부할 리소스 추천

  • Redis 공식 문서: Redis를 활용한 Rate Limiting 구현 방법에 대한 좋은 자료가 많습니다. 특히 INCR, EXPIRE, Lua Script 관련 문서를 참고하세요.
  • Nginx 문서: Nginx의 ngx_http_limit_req_module을 통해 Rate Limiting을 설정하는 방법을 익힐 수 있습니다.
  • 시스템 디자인 인터뷰 자료: Rate Limiting은 시스템 디자인 인터뷰에서 단골 주제입니다. 관련 자료들을 찾아보며 다양한 시나리오와 알고리즘 선택에 대한 깊은 이해를 얻을 수 있습니다.
    • "System Design Interview" 책이나 관련 온라인 강의들을 추천합니다.
  • 블로그 포스트: "Rate Limiting Algorithms", "Distributed Rate Limiting" 등으로 검색하면 다양한 기술 블로그에서 심도 있는 설명을 찾을 수 있습니다.

Rate Limiting은 단순히 요청을 막는 것을 넘어, 시스템의 건강과 안정성을 유지하는 핵심적인 기술입니다. 이 글을 통해 Rate Limiting의 중요성과 다양한 구현 원리를 이해하고, 여러분의 서비스에 성공적으로 적용하는 데 도움이 되기를 바랍니다.