2026년 3월 18일

Rate Limiting: 분산 시스템의 안정성과 공정성을 지키는 수문장

200
Rate Limiting: 분산 시스템의 안정성과 공정성을 지키는 수문장

Rate Limiting: 분산 시스템의 안정성과 공정성을 지키는 수문장

Rate Limiting: 분산 시스템의 안정성과 공정성을 지키는 수문장

개념 소개

개념 소개

안녕하세요! 10년 경력의 소프트웨어 엔지니어이자 기술 교육자로서, 오늘은 현대 분산 시스템에서 선택이 아닌 필수가 되어버린 아주 중요한 개념, 바로 **Rate Limiting(속도 제한)**에 대해 이야기해보려 합니다. Rate Limiting은 특정 기간 동안 사용자가 API 또는 시스템 리소스에 접근할 수 있는 요청의 수를 제한하는 기술입니다. 간단히 말해, "너는 이 시간 동안 이만큼만 요청할 수 있어!"라고 규칙을 정하고 지키게 하는 것이죠.

탄생 배경: 왜 Rate Limiting이 필요할까요?

Rate Limiting은 다음과 같은 복합적인 문제들을 해결하기 위해 탄생했습니다.

  1. 서비스 남용 및 공격 방지: 악의적인 사용자가 서비스에 과도한 요청을 보내 DoS(서비스 거부) 또는 DDoS(분산 서비스 거부) 공격을 시도하거나, 무차별 대입 공격(Brute-force attack)으로 계정을 탈취하려 할 수 있습니다. Rate Limiting은 이러한 공격 시도를 조기에 차단하여 시스템을 보호합니다.
  2. 인프라 과부하 방지 및 리소스 보호: 아무리 잘 설계된 시스템이라도 처리할 수 있는 요청량에는 한계가 있습니다. 갑작스러운 트래픽 폭증이나 예상치 못한 사용 패턴으로 인해 서버, 데이터베이스, 네트워크 등 인프라가 과부하 되면 서비스 전체가 중단될 수 있습니다. Rate Limiting은 시스템이 감당할 수 있는 수준으로 트래픽을 조절하여 핵심 리소스가 안정적으로 작동하도록 돕습니다.
  3. 공정한 리소스 분배: 모든 사용자에게 동등하거나 합리적인 수준의 서비스를 제공하기 위함입니다. 특정 사용자가 모든 리소스를 독점하는 것을 방지하고, 모든 사용자가 원활하게 서비스를 이용할 수 있도록 보장합니다. 예를 들어, 한 명의 사용자가 과도하게 API를 호출하여 다른 사용자들이 느려지는 상황을 막을 수 있습니다.
  4. 비용 절감: 클라우드 환경에서는 사용량에 따라 비용이 청구되는 경우가 많습니다. Rate Limiting은 불필요하거나 과도한 리소스 사용을 제어하여 운영 비용을 절감하는 데 기여합니다.

왜 중요한가?

결론적으로 Rate Limiting은 서비스의 안정성, 보안, 비용 효율성을 높이고, 궁극적으로는 사용자 경험을 개선하는 데 필수적인 요소입니다. 여러분이 만드는 서비스가 세상에 공개되고 많은 사람들이 사용하게 될수록, Rate Limiting의 중요성은 더욱 커질 것입니다. 면접이나 실무에서도 시스템 설계 질문에서 빠지지 않고 등장하는 주제이기도 합니다.

핵심 원리 설명

핵심 원리 설명

Rate Limiting의 핵심은 "어떻게 요청 수를 세고, 제한 시간을 관리하며, 언제 요청을 허용하거나 거부할 것인가?"에 있습니다. 이를 구현하기 위한 몇 가지 대표적인 알고리즘들이 있습니다. 각 알고리즘은 장단점이 명확하므로, 서비스의 특성에 맞는 것을 선택하는 것이 중요합니다.

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

가장 간단한 알고리즘입니다. 특정 시간 윈도우(예: 1분)를 정의하고, 이 윈도우 내에서 들어온 요청 수를 카운트합니다. 카운트가 미리 정해둔 제한(limit)을 초과하면 해당 윈도우가 끝날 때까지 모든 추가 요청을 거부합니다. 윈도우가 끝나면 카운터는 0으로 초기화되고 새로운 윈도우가 시작됩니다.

  • 비유: 상상해보세요. 매 정시마다 리셋되는 '100개의 토큰 상자'가 있습니다. 1시 정각에 상자가 채워지고, 요청이 올 때마다 토큰을 하나씩 꺼내 씁니다. 1시 59분 59초에 상자의 토큰이 모두 소진되었다면, 2시 정각이 될 때까지 어떤 요청도 처리할 수 없습니다. 2시가 되면 상자는 다시 100개의 토큰으로 채워집니다.
  • 장점: 구현이 매우 간단하고 이해하기 쉽습니다.
  • 단점: 버스트(Burst) 문제에 취약합니다. 예를 들어, 1분당 100회 제한이라면, 한 윈도우의 마지막 1초에 100회 요청이 들어오고, 다음 윈도우의 첫 1초에 또 100회 요청이 들어올 수 있습니다. 이 경우, 단 2초 만에 200회 요청이 발생하여 시스템에 과부하를 줄 수 있습니다.

2. 슬라이딩 윈도우 로거 (Sliding Window Log)

고정 윈도우 카운터의 버스트 문제를 해결하기 위해 고안되었습니다. 이 알고리즘은 모든 요청의 타임스탬프를 기록합니다. 새로운 요청이 들어올 때마다, 현재 시간으로부터 이전 윈도우 크기(예: 1분) 내에 기록된 모든 타임스탬프를 확인하여 요청 수를 계산합니다. 윈도우 밖으로 벗어난 오래된 타임스탬프는 제거합니다.

  • 비유: '최근 1분간 들어온 모든 요청을 기록하는 로그북'을 가지고 있다고 생각해보세요. 요청이 올 때마다 현재 시간을 로그북에 기록합니다. 그리고 요청을 허용할지 말지 결정할 때는, 로그북에서 지금으로부터 1분 전보다 오래된 기록은 모두 지우고, 남아있는 기록의 개수를 세어 제한을 초과하는지 확인합니다.
  • 장점: 버스트 문제를 효과적으로 방지하며, 가장 정확한 Rate Limiting을 제공합니다.
  • 단점: 모든 요청의 타임스탬프를 저장해야 하므로 저장 공간과 계산 비용이 많이 듭니다. 특히 요청 수가 많아지면 성능 저하가 발생할 수 있습니다. 분산 환경에서는 타임스탬프를 공유하고 관리하는 것이 복잡해집니다.

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

고정 윈도우 카운터의 구현 용이성과 슬라이딩 윈도우 로거의 정확성을 절충한 알고리즘입니다. 현재 윈도우와 이전 윈도우의 카운터를 조합하여 사용합니다. 예를 들어, 1분당 100회 제한이라면, 현재 윈도우의 카운터와 이전 윈도우의 카운터 중 현재 윈도우에 걸쳐 있는 부분의 비율을 계산하여 합산하는 방식입니다.

  • 비유: '현재 시간대의 토큰 상자'와 '이전 시간대의 토큰 상자'를 동시에 보는 것과 같습니다. 예를 들어 5분 윈도우 제한이라면, 현재 윈도우의 카운터와 함께, 이전 윈도우의 카운터 중 현재 시점에서 윈도우에 포함되는 비율만큼만 계산에 포함합니다. (예: 이전 윈도우가 50% 정도 현재 윈도우와 겹친다면, 이전 윈도우 카운터의 50%만 반영)
  • 장점: 고정 윈도우보다 버스트에 강하고, 슬라이딩 윈도우 로거보다 리소스 소모가 적습니다.
  • 단점: 완벽하게 정확하지는 않으며, 버스트 문제가 완전히 해소되지 않을 수도 있습니다.

4. 토큰 버킷 (Token Bucket)

가장 널리 사용되고 유연한 알고리즘 중 하나입니다. 일정 속도로 토큰이 생성되어 '버킷'에 쌓입니다. 버킷은 최대 용량(capacity)을 가지고 있으며, 용량을 초과하는 토큰은 버려집니다. 요청이 들어오면 버킷에서 토큰을 하나 꺼내 사용하고, 토큰이 없으면 요청을 거부하거나 대기시킵니다.

  • 비유: '일정 속도로 물이 채워지는 물통'을 상상해보세요. 요청이 올 때마다 물을 한 잔씩 꺼내 씁니다. 물통이 비어있으면 요청을 처리할 수 없습니다. 물통은 최대 용량이 있어서, 아무리 물이 많이 생성되어도 일정량 이상은 담을 수 없습니다.
  • 장점:
    • 버스트 트래픽 처리: 버킷에 토큰이 충분히 쌓여 있다면, 일시적인 대량 요청(버스트)을 처리할 수 있습니다.
    • 평활화(Smoothing) 효과: 토큰 생성 속도를 조절하여 시스템 처리량을 일정하게 유지할 수 있습니다.
    • 유연성: 토큰 용량과 생성 속도를 조절하여 다양한 정책을 구현할 수 있습니다.
  • 단점: 토큰 버킷의 상태(현재 토큰 수, 마지막 토큰 채움 시간)를 관리해야 합니다.

5. 리키 버킷 (Leaky Bucket)

토큰 버킷과 비슷하지만, 요청이 들어올 때 토큰을 소모하는 대신, 요청 자체가 버킷에 쌓이고 '일정 속도로' 버킷에서 흘러나와 처리됩니다. 버킷이 가득 차면 추가 요청은 거부됩니다.

  • 비유: '바닥에 구멍이 뚫려 일정 속도로 물이 새는 물통'을 상상해보세요. 요청이 들어올 때마다 물통에 물 한 컵을 붓습니다. 물통의 물은 항상 일정한 속도로 새어 나갑니다(요청 처리). 물통이 가득 차서 물이 넘치면, 추가적인 물(요청)은 버려집니다.
  • 장점: 시스템의 처리율을 매우 일정하게 유지하여, 백엔드 시스템에 일정한 부하를 가하는 데 효과적입니다.
  • 단점: 버스트 트래픽을 처리하는 능력이 토큰 버킷보다 떨어집니다. 요청이 많아지면 모든 요청이 버킷에 쌓여 대기 시간이 길어질 수 있습니다.

어떤 알고리즘을 선택할 것인가?

  • 구현 난이도: 고정 윈도우 < 토큰 버킷 < 슬라이딩 윈도우 로거
  • 정확성: 슬라이딩 윈도우 로거 > 슬라이딩 윈도우 카운터 > 토큰 버킷 = 리키 버킷 > 고정 윈도우
  • 버스트 처리: 토큰 버킷 (우수)
  • 평활화(Smoothing): 리키 버킷 (우수)

일반적으로는 토큰 버킷이 유연성과 버스트 처리 능력 덕분에 가장 널리 사용됩니다. 하지만 단순한 제한이 필요하다면 고정 윈도우도 좋은 시작점이 될 수 있습니다. 분산 환경에서는 Redis와 같은 중앙 집중식 캐시를 활용하여 토큰 버킷이나 슬라이딩 윈도우 카운터를 구현하는 경우가 많습니다.

코드 예제 (Python)

여기서는 이해를 돕기 위해 단일 인스턴스에서 작동하는 간단한 Rate Limiter 구현체를 Python으로 살펴보겠습니다. 분산 환경에서의 구현은 "자주 하는 실수와 해결법" 섹션에서 다루겠습니다.

예제 1: 고정 윈도우 카운터 (Fixed Window Counter)

이 예제는 특정 user_id를 기준으로 window_size_seconds 동안 limit_per_window 횟수만큼만 요청을 허용합니다.

import time
from collections import defaultdict

class FixedWindowRateLimiter:
    def __init__(self, limit_per_window, window_size_seconds):
        """
        고정 윈도우 카운터 Rate Limiter를 초기화합니다.
        :param limit_per_window: 각 윈도우 동안 허용되는 최대 요청 수
        :param window_size_seconds: 윈도우의 크기 (초 단위)
        """
        self.limit = limit_per_window
        self.window_size = window_size_seconds
        # 각 사용자별 요청 정보를 저장합니다.
        # { user_id: {'count': 현재 윈도우의 요청 수, 'window_start_time': 현재 윈도우 시작 시간} }
        self.requests = defaultdict(lambda: {'count': 0, 'window_start_time': 0})

    def allow_request(self, user_id):
        """
        주어진 user_id에 대한 요청을 허용할지 여부를 결정합니다.
        :param user_id: 요청을 보내는 사용자의 ID
        :return: 요청 허용 시 True, 거부 시 False
        """
        current_time = time.time()
        user_data = self.requests[user_id]

        # 1. 현재 윈도우 시작 시간이 설정되지 않았거나 (첫 요청),
        #    현재 시간이 윈도우 시작 시간으로부터 윈도우 크기를 초과했으면 (새 윈도우 시작)
        #    카운터와 윈도우 시작 시간을 초기화합니다.
        if user_data['window_start_time'] == 0 or \
           current_time - user_data['window_start_time'] >= self.window_size:
            user_data['count'] = 0
            user_data['window_start_time'] = current_time
            print(f"[{user_id}] New window started at {time.ctime(user_data['window_start_time'])}")

        # 2. 현재 윈도우의 요청 수가 제한보다 작으면 요청을 허용합니다.
        if user_data['count'] < self.limit:
            user_data['count'] += 1
            print(f"[{user_id}] Request allowed. Count: {user_data['count']}/{self.limit}")
            return True
        else:
            # 3. 제한을 초과했으면 요청을 거부합니다.
            print(f"[{user_id}] Request denied. Limit ({self.limit}) exceeded. Current count: {user_data['count']}")
            return False

# --- 사용 예시 ---
# 5초 동안 3회 요청 제한
limiter = FixedWindowRateLimiter(limit_per_window=3, window_size_seconds=5) 

print("--- User A: 5초에 3회 제한 테스트 ---")
for i in range(1, 11): # 10번 시도
    allowed = limiter.allow_request("user_A")
    print(f"Request {i} by user A allowed: {allowed}")
    if not allowed:
        print("    Rate limit exceeded for user A. Waiting 1 second...")
        time.sleep(1) # 거부되면 잠시 대기 후 다시 시도
    else:
        time.sleep(0.5) # 허용되면 0.5초 대기 후 다음 요청

print("\n--- User B: 다른 사용자는 독립적으로 제한 ---")
for i in range(1, 5): # 4번 시도
    allowed = limiter.allow_request("user_B")
    print(f"Request {i} by user B allowed: {allowed}")
    time.sleep(0.5)

print("\n--- User A: 5초 대기 후 다시 시도 (새 윈도우) ---")
time.sleep(5) # 5초 대기 (User A의 윈도우가 리셋될 시간)
for i in range(1, 6): # 5번 시도