2026년 7월 8일

마스터하기: Rate Limiting - 분산 시스템의 안정성과 공정성을 지키는 파수꾼

20
마스터하기: Rate Limiting - 분산 시스템의 안정성과 공정성을 지키는 파수꾼

마스터하기: Rate Limiting - 분산 시스템의 안정성과 공정성을 지키는 파수꾼

마스터하기: Rate Limiting - 분산 시스템의 안정성과 공정성을 지키는 파수꾼

안녕하세요! 10년 경력의 소프트웨어 엔지니어이자 기술 교육자로서 여러분과 함께 오늘도 흥미로운 주제를 파고들어 보려 합니다. 현대 소프트웨어 시스템, 특히 마이크로서비스 아키텍처나 공개 API를 제공하는 서비스라면 반드시 고려해야 할 핵심 개념 중 하나가 바로 'Rate Limiting'입니다. 시스템의 안정성을 지키고, 자원을 공정하게 분배하며, 심지어 보안까지 책임지는 이 파수꾼에 대해 자세히 알아보겠습니다.

1. 개념 소개

1. 개념 소개

정의

Rate Limiting은 특정 시간 동안 사용자나 서비스가 보낼 수 있는 요청의 수를 제한하는 기술입니다. 예를 들어, "한 IP 주소당 1초에 최대 10개의 요청만 허용한다"와 같은 규칙을 설정하여 시스템에 과도한 부하가 걸리는 것을 방지합니다.

탄생 배경

웹 서비스가 발전하고 API를 통해 다양한 서비스들이 상호작용하면서 다음과 같은 문제들이 발생하기 시작했습니다.

  • API 남용: 악의적인 사용자가 무제한으로 API를 호출하여 데이터를 긁어모으거나, 서비스에 부담을 주어 다른 사용자의 접근을 방해하는 행위.
  • 서비스 거부 공격(DoS/DDoS): 대량의 트래픽을 한 번에 전송하여 서버를 마비시키려는 시도.
  • 비용 절감: 클라우드 서비스 사용 시 API 호출 수에 따라 과금되는 경우가 많으므로, 불필요한 호출을 제한하여 비용을 절감.
  • 공정한 자원 분배: 모든 사용자가 시스템 자원을 공정하게 사용할 수 있도록 보장.
  • 시스템 과부하 방지: 서버가 처리할 수 있는 이상의 요청이 들어올 때 시스템이 다운되거나 성능 저하가 발생하는 것을 막기 위함.

이러한 문제들을 해결하고 시스템의 견고함을 유지하기 위해 Rate Limiting이 필수적인 기술로 자리 잡게 되었습니다.

왜 중요한가?

Rate Limiting은 단순히 요청을 막는 것을 넘어, 시스템의 전반적인 건강과 사용자 경험에 지대한 영향을 미칩니다.

  • 시스템 안정성: 과도한 트래픽으로부터 서버를 보호하여 시스템 다운타임을 줄이고, 안정적인 서비스를 제공합니다.
  • 보안: 무차별 대입 공격(Brute-force attack), DoS 공격 등을 효과적으로 방어하여 시스템의 보안을 강화합니다.
  • 비용 효율성: 클라우드 자원 사용량을 최적화하여 운영 비용을 절감할 수 있습니다.
  • 사용자 경험: 악성 사용자로부터 시스템을 보호함으로써 선량한 사용자들이 원활하게 서비스를 이용할 수 있도록 보장합니다.

2. 핵심 원리 설명

2. 핵심 원리 설명

Rate Limiting을 구현하는 다양한 알고리즘이 있으며, 각각 장단점과 특정 시나리오에 적합한 특성을 가집니다. 대표적인 알고리즘들을 비유와 함께 설명해 드리겠습니다.

2.1 토큰 버킷 (Token Bucket)

  • 비유: 주유소의 기름통과 주유기. 기름통에는 일정한 속도로 기름(토큰)이 채워지고, 손님(요청)이 오면 기름통에서 기름을 사용합니다. 기름통이 비어있으면 손님은 기다리거나 돌아가야 합니다. 기름통의 크기는 최대로 담을 수 있는 기름의 양을 의미합니다.
  • 원리:
    1. 정해진 속도(예: 초당 10개)로 '토큰'이 생성되어 '버킷'에 채워집니다.
    2. 버킷은 최대 용량(예: 100개)을 가집니다. 버킷이 가득 차면 더 이상 토큰이 채워지지 않습니다.
    3. 요청이 들어오면 버킷에서 토큰 하나를 소비합니다.
    4. 버킷에 토큰이 있으면 요청을 처리하고, 없으면 요청을 거부하거나 대기시킵니다.
  • 장점: 일시적인 트래픽 급증(버스트 트래픽)을 허용하면서도 장기적인 평균 요청 속도를 제한할 수 있습니다. 구현이 비교적 간단합니다.
  • 단점: 버킷 크기와 토큰 생성 속도를 잘 조절해야 합니다.
+----------------+   토큰 생성 속도   +--------------+   토큰 사용   +-----------------+
| 토큰 생성기      | ------------------> | 토큰 버킷      | --------------> | 요청 처리/거부    |
+----------------+                     +--------------+                 +-----------------+
                                       (최대 용량 제한)

2.2 리키 버킷 (Leaky Bucket)

  • 비유: 바닥에 구멍이 뚫린 물탱크. 물(요청)이 어떤 속도로든 탱크에 들어올 수 있지만, 바닥 구멍을 통해 일정하고 꾸준한 속도로만 물이 빠져나갑니다. 탱크가 가득 차면 더 이상 물을 받을 수 없습니다.
  • 원리:
    1. 요청이 들어오면 '버킷'에 쌓입니다.
    2. 버킷에 쌓인 요청들은 일정한 속도(예: 초당 5개)로 처리됩니다.
    3. 버킷이 가득 차면 새로 들어오는 요청은 거부됩니다.
  • 장점: 출력(처리) 속도가 항상 일정하여 시스템에 일정한 부하를 유지할 수 있습니다.
  • 단점: 버스트 트래픽에 취약합니다. 버킷이 가득 차면 요청이 즉시 거부되므로, 사용자가 갑자기 많은 요청을 보낼 때 불편함을 느낄 수 있습니다.
+-----------------+   요청 유입   +--------------+   일정 속도 배출   +-----------------+
| 클라이언트 요청   | --------------> | 리키 버킷      | -----------------> | 요청 처리         |
+-----------------+                 +--------------+                   +-----------------+
                                    (최대 용량 제한,
                                     가득 차면 거부)

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

  • 비유: 1분짜리 모래시계 2개가 있습니다. 하나는 현재 시간대를 나타내고, 다른 하나는 바로 직전 시간대를 나타냅니다. 현재 요청 수를 계산할 때, 이 두 모래시계의 비율을 합산하여 더 부드럽게 계산합니다.
  • 원리:
    1. 시간 윈도우(예: 1분)를 설정하고, 이 윈도우를 다시 작은 단위(예: 10초)로 나눕니다.
    2. 각 작은 단위 윈도우마다 요청 카운트를 유지합니다.
    3. 현재 요청 수를 계산할 때, 현재 윈도우의 카운트와 이전 윈도우의 카운트를 '슬라이딩' 비율에 따라 가중 평균하여 사용합니다. 예를 들어, 현재 윈도우의 70%가 지났다면, (현재 윈도우 카운트 * 70%) + (이전 윈도우 카운트 * 30%) 와 같이 계산합니다.
    4. 이 계산된 값이 제한을 초과하면 요청을 거부합니다.
  • 장점: 고정 윈도우 방식의 경계값 문제(윈도우 전환 시 갑작스러운 요청 허용)를 해결하여 더욱 부드러운 제한을 제공합니다. 고정 윈도우 로그 방식보다 메모리 효율적입니다.
  • 단점: 고정 윈도우 방식보다 구현이 복잡합니다.
[--- 이전 윈도우 ---|--- 현재 윈도우 ---]
^                   ^
0초                 60초 (윈도우 경계)
현재 시간: 70초 (현재 윈도우의 10초 지점)

제한 계산 = (이전 윈도우 카운트 * 50/60) + (현재 윈도우 카운트 * 10/60)

(위 다이어그램은 텍스트로 표현한 것이며, 실제로는 시간 축 위에 윈도우와 카운트가 시각적으로 표시됩니다.)

3. 코드 예제

Python으로 토큰 버킷과 슬라이딩 윈도우 카운터의 기본적인 인메모리 구현을 살펴보겠습니다. 실제 분산 환경에서는 Redis와 같은 공유 저장소를 사용해야 합니다.

예제 1: 토큰 버킷 (Python)

import time

class TokenBucketRateLimiter:
    def __init__(self, capacity, fill_rate):
        """
        :param capacity: 버킷의 최대 토큰 수
        :param fill_rate: 초당 채워지는 토큰 수
        """
        self.capacity = float(capacity)
        self.fill_rate = float(fill_rate)
        self.tokens = float(capacity)  # 현재 버킷에 있는 토큰 수
        self.last_refill_time = time.time() # 마지막으로 토큰을 채운 시간

    def _refill_tokens(self):
        """
        토큰을 채우고, 마지막 채움 시간을 업데이트합니다.
        """
        now = time.time()
        # 지난 시간 동안 채워져야 할 토큰 수 계산
        tokens_to_add = (now - self.last_refill_time) * self.fill_rate
        self.tokens = min(self.capacity, self.tokens + tokens_to_add)
        self.last_refill_time = now

    def allow_request(self, tokens_needed=1):
        """
        요청 처리 가능 여부를 확인하고 토큰을 소비합니다.
        :param tokens_needed: 요청 처리에 필요한 토큰 수 (기본 1)
        :return: True (허용), False (거부)
        """
        self._refill_tokens() # 토큰 업데이트
        if self.tokens >= tokens_needed:
            self.tokens -= tokens_needed
            return True
        return False

# 사용 예제
if __name__ == "__main__":
    limiter = TokenBucketRateLimiter(capacity=10, fill_rate=2) # 최대 10개, 초당 2개 채워짐

    print("--- 10개 버스트 요청 시도 (초당 2개 채워짐) ---")
    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초 대기 (토큰이 다시 채워짐)
    print(f"3초 후 남은 토큰: {limiter.tokens:.2f}") # _refill_tokens 호출 전이므로 이전 값
    if limiter.allow_request():
        print(f"[추가 요청] 허용됨. 남은 토큰: {limiter.tokens:.2f}")
    else:
        print(f"[추가 요청] 거부됨. 남은 토큰: {limiter.tokens:.2f}")

    print("\n--- 다시 5개 요청 시도 ---")
    for i in range(5):
        if limiter.allow_request():
            print(f"[{i+1}번째 요청] 허용됨. 남은 토큰: {limiter.tokens:.2f}")
        else:
            print(f"[{i+1}번째 요청] 거부됨. 남은 토큰: {limiter.tokens:.2f}")
        time.sleep(0.2)

예제 2: 슬라이딩 윈도우 카운터 (Python)

import time
from collections import deque

class SlidingWindowCounterRateLimiter:
    def __init__(self, window_size_seconds, limit):
        """
        :param window_size_seconds: 윈도우 크기 (초)
        :param limit: 윈도우 내 최대 허용 요청 수
        """
        self.window_size = window_size_seconds
        self.limit = limit
        # 각 요청의 타임스탬프를 저장하는 큐
        # 실제 슬라이딩 윈도우 카운터는 이전 윈도우의 카운트를 가중 평균하지만,
        # 이 예제에서는 단순화를 위해 슬라이딩 윈도우 '로그'에 가까운 방식을 사용합니다.
        # (각 요청의 타임스탬프를 기록하여 윈도우 내 카운트를 정확히 계산)
        # 진정한 슬라이딩 윈도우 카운터는 윈도우를 여러 구간으로 나누어 각 구간의 카운트만 저장합니다.
        # 여기서는 초중급 개발자를 위해 '로그' 방식의 이해를 돕고,
        # 효율성 측면의 개선이 필요함을 언급하는 방향으로 진행합니다.
        self.request_timestamps = deque()

    def allow_request(self):
        """
        요청 처리 가능 여부를 확인합니다.
        :return: True (허용), False (거부)
        """
        now = time.time()
        # 현재 윈도우 밖으로 나간 오래된 타임스탬프 제거
        while self.request_timestamps and self.request_timestamps[0] <= now - self.window_size:
            self.request_timestamps.popleft()

        # 현재 윈도우 내 요청 수가 제한을 초과하는지 확인
        if len(self.request_timestamps) < self.limit:
            self.request_timestamps.append(now)
            return True
        return False

# 사용 예제
if __name__ == "__main__":
    limiter = SlidingWindowCounterRateLimiter(window_size_seconds=10, limit=5) # 10초에 5개 요청 제한

    print("--- 10초에 5개 제한 테스트 ---")
    for i in range(10):
        if limiter.allow_request():
            print(f"[{i+1}번째 요청] 허용됨. 현재 윈도우 내 요청 수: {len(limiter.request_timestamps)}")
        else:
            print(f"[{i+1}번째 요청] 거부됨. 현재 윈도우 내 요청 수: {len(limiter.request_timestamps)}")
        time.sleep(1) # 1초 간격으로 요청 시도

    print("\n--- 5초 후 추가 요청 시도 ---")
    time.sleep(5) # 5초 대기 (일부 오래된 요청이 윈도우 밖으로 나감)
    for i in range(3):
        if limiter.allow_request():
            print(f"[추가 {i+1}번째 요청] 허용됨. 현재 윈도우 내 요청 수: {len(limiter.request_timestamps)}")
        else:
            print(f"[추가 {i+1}번째 요청] 거부됨. 현재 윈도우 내 요청 수: {len(limiter.request_timestamps)}")
        time.sleep(0.5)

참고: 위의 슬라이딩 윈도우 카운터 예제는 정확히는 '슬라이딩 윈도우 로그' 방식에 가깝습니다. 각 요청의 타임스탬프를 모두 저장하므로 정확하지만, 메모리 사용량이 많아집니다. 실용적인 '슬라이딩 윈도우 카운터'는 여러 개의 작은 고정 윈도우 카운터를 조합하여 메모리를 절약하면서 부드러운 제한을 달성합니다. 하지만 초중급 개발자의 이해를 위해 로그 방식을 먼저 제시했습니다.

4. 실무 적용 사례

Rate Limiting은 다양한 서비스와 시스템에서 필수적으로 활용됩니다.

  • Public API: GitHub, Twitter, AWS 등 대부분의 공개 API는 개발자 키 또는 IP 주소별로 요청 제한을 둡니다. 이는 API 남용을 방지하고 모든 사용자가 공정하게 API를 사용할 수 있도록 하기 위함입니다.
  • 로그인 시스템: 특정 IP 주소에서 일정 시간 내에 비밀번호를 너무 많이 틀리면 해당 IP의 로그인을 잠시 제한합니다. 이는 무차별 대입 공격(Brute-force attack)을 방지하는 효과적인 방법입니다.
  • 검색 엔진 크롤링 방지: 웹사이트에서 과도한 크롤링으로 인한 서버 부하를 막기 위해 크롤러 봇의 요청 속도를 제한합니다.
  • 결제 시스템: 사용자가 실수로 결제 버튼을 여러 번 누르거나, 악의적으로 중복 결제를 시도하는 것을 방지하기 위해 일정 시간 동안의 결제 요청 횟수를 제한합니다.
  • 채팅 서비스: 사용자가 너무 빠른 속도로 메시지를 전송하는 것을 막아 스팸을 방지하고 서버 부하를 줄입니다.
  • 마이크로서비스 간 통신: 서비스 A가 서비스 B를 호출할 때, 서비스 B가 감당할 수 있는 요청 수를 초과하지 않도록 서비스 B에 Rate Limiter를 적용할 수 있습니다. 이는 서비스 간의 안정적인 상호작용을 보장합니다.

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

5.1 너무 엄격하거나 느슨한 정책 설정

  • 문제점: 너무 엄격하면 선량한 사용자도 서비스 이용에 불편함을 느끼고, 너무 느슨하면 Rate Limiting의 목적을 달성하지 못합니다.
  • 해결법: 실제 사용자들의 트래픽 패턴과 시스템의 처리 능력을 면밀히 분석하여 정책을 수립해야 합니다. 초기에는 다소 느슨하게 시작하여 모니터링하면서 점진적으로 조정하는 것이 좋습니다. A/B 테스트를 통해 여러 정책의 효과를 비교할 수도 있습니다.

5.2 분산 환경에서의 문제

  • 문제점: 여러 서버 인스턴스에서 Rate Limiting을 독립적으로 구현할 경우, 각 서버는 다른 서버의 요청 수를 알지 못해 전체 제한을 초과하는 요청이 발생할 수 있습니다 (예: 각 서버가 10개씩 허용하면 총 100개의 서버에서 1000개의 요청이 처리될 수 있음).
  • 해결법: Redis와 같은 중앙 집중식 데이터 저장소를 사용하여 모든 서버 인스턴스가 공유하는 카운터나 토큰 버킷 상태를 유지해야 합니다. 분산 락(Distributed Lock)이나 Redis의 INCR 명령어를 활용하여 원자적으로 상태를 갱신하는 것이 중요합니다.

5.3 클라이언트 측의 잘못된 구현 및 처리

  • 문제점: 서버에서 요청을 거부했을 때 클라이언트가 단순히 실패 처리하거나, 무한 재시도를 하는 경우.
  • 해결법: 서버는 Rate Limit에 의해 요청이 거부되었을 때 HTTP 429 Too Many Requests 상태 코드를 반환하고, Retry-After 헤더를 통해 클라이언트에게 언제 다시 시도해야 하는지 알려줘야 합니다. 클라이언트는 이 헤더를 존중하여 백오프(Backoff) 전략을 구현해야 합니다. 즉, 즉시 재시도하는 대신 Retry-After 값만큼 기다린 후 다시 시도하거나, 지수적으로 대기 시간을 늘려가며 재시도해야 합니다.

5.4 누락된 에지 케이스 처리

  • 문제점: 짧은 시간 내에 갑자기 많은 요청이 몰리는 버스트 트래픽, 특정 사용자의 대량 요청 등 예외적인 상황에 대한 고려 부족.
  • 해결법:
    • 토큰 버킷은 버스트 트래픽에 효과적입니다.
    • 중요한 API에는 다른 API보다 높은 제한을 설정하거나, 특정 프리미엄 사용자에게는 더 높은 제한을 적용하는 등 차등 정책을 고려할 수 있습니다.
    • Rate Limiting이 너무 자주 발생하면 시스템의 다른 문제일 수 있으므로, 모니터링을 통해 근본 원인을 파악해야 합니다.

5.5 단일 지점 실패(SPOF)

  • 문제점: Rate Limiter 자체가 단일 서버에 구현되어 해당 서버에 문제가 생기면 전체 시스템의 Rate Limiting 기능이 마비될 수 있습니다.
  • 해결법: Rate Limiter 역시 고가용성(High Availability)을 고려하여 여러 인스턴스로 분산 배치하고 로드 밸런싱해야 합니다. 클라우드 기반의 Rate Limiting 서비스(예: AWS WAF, Cloudflare Rate Limiting)를 사용하면 이러한 문제를 쉽게 해결할 수 있습니다.

6. 더 공부할 리소스 추천

Rate Limiting은 시스템 디자인 면접에서 자주 등장하는 주제이자 실무에서 매우 중요한 기술입니다. 더 깊이 이해하기 위해 다음 리소스들을 참고해 보세요.

  • 시스템 디자인 인터뷰 가이드: "Grokking the System Design Interview"와 같은 자료에서 Rate Limiting 섹션을 찾아보세요. 다양한 알고리즘과 분산 환경에서의 구현 방식에 대한 심층적인 분석을 제공합니다.
  • Redis 공식 문서: Redis는 분산 Rate Limiting 구현에 널리 사용됩니다. Redis의 INCR, EXPIRE 명령어와 Lua 스크립트를 활용한 Rate Limiting 패턴에 대해 학습해 보세요.
  • Nginx, Envoy 프록시 서버 문서: Nginx나 Envoy와 같은 API 게이트웨이 및 프록시 서버는 자체적으로 강력한 Rate Limiting 기능을 제공합니다. 실제 프로덕션 환경에서 어떻게 설정하고 사용하는지 알아보는 것이 좋습니다.
  • 클라우드 서비스 문서: AWS WAF, Cloudflare, Google Cloud Armor 등 클라우드 제공업체들이 제공하는 웹 애플리케이션 방화벽(WAF) 및 Rate Limiting 서비스의 기능을 살펴보세요. 이를 통해 인프라 레벨에서 Rate Limiting을 어떻게 적용할 수 있는지 이해할 수 있습니다.
  • 블로그 및 기술 문서: Rate Limiting과 관련된 다양한 기술 블로그 글이나 아티클을 찾아 읽어보세요. 특히 Uber, Stripe, Netflix 등 대규모 서비스를 운영하는 회사들의 기술 블로그에서 실제 적용 사례와 당면했던 문제점, 해결책에 대한 통찰을 얻을 수 있습니다.

Rate Limiting은 단순히 요청을 제한하는 것을 넘어, 시스템의 안정성과 지속 가능성을 보장하는 핵심적인 설계 원칙입니다. 이 글을 통해 Rate Limiting의 중요성과 다양한 구현 방식에 대한 이해를 높이고, 여러분의 시스템을 더욱 견고하게 만드는 데 도움이 되기를 바랍니다.