API Rate Limiting 설계 — FastAPI 실전 구현 - 코드픽 블로그
API Rate Limiting 설계 — FastAPI 실전 구현
기술 가이드

API Rate Limiting 설계 — FastAPI 실전 구현

2026년 3월 2일 57 views by 코드벤터

API Rate Limiting 설계 — FastAPI 실전 구현

🚀 서론: API Rate Limiting, 왜 필요할까요?

현대 웹 서비스에서 API는 핵심적인 역할을 수행합니다. 마이크로서비스 아키텍처부터 모바일 앱 백엔드까지, 수많은 요청이 API를 통해 처리되죠. 하지만 모든 요청을 무제한으로 허용하는 것은 서비스의 안정성과 보안에 치명적인 위협이 될 수 있습니다. 악의적인 DDoS 공격, 무분별한 스크래핑, 또는 단순히 너무 많은 요청으로 인한 서버 과부하 등 다양한 문제가 발생할 수 있습니다.

이러한 문제를 해결하기 위한 강력한 도구 중 하나가 바로 API Rate Limiting입니다. Rate Limiting은 특정 기간 동안 클라이언트가 보낼 수 있는 요청의 수를 제한하여, 서비스의 안정성을 유지하고 자원을 보호하며, 공정한 사용을 유도하는 메커니즘입니다.

이번 포스팅에서는 Rate Limiting의 중요성, 주요 알고리즘, 그리고 빠르고 현대적인 웹 프레임워크인 FastAPI에서 이를 어떻게 효과적으로 구현할 수 있는지 실전 코드 예제와 함께 자세히 알아보겠습니다. 특히 분산 환경에서 필수적인 Redis를 활용한 구현 방법까지 다루어, 여러분의 서비스에 바로 적용할 수 있는 실용적인 가이드를 제공하고자 합니다.

🛡️ Rate Limiting의 중요성

Rate Limiting은 단순히 요청을 막는 것을 넘어, 서비스 운영에 있어 여러 가지 중요한 이점을 제공합니다.

  • 보안 강화 (Security): DDoS(Distributed Denial of Service) 공격, Brute-force 공격, 크리덴셜 스터핑(Credential Stuffing) 등 악의적인 공격으로부터 API를 보호합니다. 특정 IP나 사용자로부터 비정상적인 요청이 급증하는 것을 막아 시스템 자원이 고갈되는 것을 방지할 수 있습니다.
  • 서비스 안정성 유지 (Stability): 과도한 요청으로 인해 서버, 데이터베이스, 또는 기타 백엔드 서비스가 과부하 되는 것을 방지합니다. 이는 서비스 중단을 막고, 모든 사용자에게 일관된 서비스 품질을 제공하는 데 기여합니다.
  • 자원 보호 및 비용 절감 (Resource Protection & Cost Saving): 제한된 서버 자원(CPU, 메모리, 네트워크 대역폭, 데이터베이스 커넥션 등)을 효율적으로 사용하여 불필요한 인프라 비용 발생을 줄일 수 있습니다.
  • 공정 사용 유도 (Fair Usage): 모든 사용자가 API 자원을 공정하게 사용할 수 있도록 보장합니다. 특정 사용자가 모든 자원을 독점하는 것을 방지하여, 다른 사용자의 서비스 경험이 저하되는 것을 막습니다.
  • API 정책 시행 (API Policy Enforcement): 유료 API나 등급별 API 서비스에서 사용자의 구독 플랜에 따라 접근 권한 및 요청 한도를 설정하여 비즈니스 모델을 효과적으로 구현할 수 있습니다.

⚙️ 주요 Rate Limiting 알고리즘

Rate Limiting을 구현하는 데에는 여러 가지 알고리즘이 사용됩니다. 각 알고리즘은 장단점이 명확하며, 서비스의 요구사항에 따라 적절한 것을 선택해야 합니다.

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

가장 간단한 알고리즘입니다. 특정 시간 윈도우(예: 1분) 동안 허용된 요청 수를 계산합니다. 윈도우가 끝나면 카운터는 0으로 재설정됩니다.

  • 장점: 구현이 매우 간단하고 이해하기 쉽습니다.
  • 단점: 윈도우 경계에서 트래픽이 집중될 경우, 허용된 요청 수의 두 배까지 요청이 들어올 수 있는 "버스트(Burst)" 문제가 발생할 수 있습니다. 예를 들어, 1분당 100회 제한일 때, 59초에 100회, 다음 1초에 다시 100회를 보내면 실제로는 2초 동안 200회 요청이 허용됩니다.

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

들어오는 모든 요청의 타임스탬프를 기록하고, 현재 시간으로부터 이전 윈도우 기간 내에 있는 요청들만 카운트합니다.

  • 장점: 가장 정확한 방법으로, 버스트 문제를 효과적으로 방지합니다.
  • 단점: 모든 요청의 타임스탬프를 저장해야 하므로 메모리 사용량이 많고, 처리 비용이 높습니다.

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

고정 윈도우 카운터의 버스트 문제를 완화하면서 슬라이딩 윈도우 로그보다 효율적인 방법입니다. 현재 윈도우의 카운터와 이전 윈도우의 카운터를 사용하여 가중 평균을 계산합니다.

  • 장점: 고정 윈도우보다 정확하고, 슬라이딩 윈도우 로그보다 자원 소모가 적습니다.
  • 단점: 완벽하게 정확하지는 않지만, 대부분의 경우 충분히 효과적입니다.

4. 토큰 버킷 (Token Bucket)

버킷에 토큰이 주기적으로 채워지고, 요청이 들어올 때마다 버킷에서 토큰을 하나씩 소비합니다. 버킷에 토큰이 없으면 요청은 거부됩니다. 버킷의 크기는 최대로 허용되는 버스트 요청 수를 결정하고, 토큰이 채워지는 속도는 초당 허용 요청 수를 결정합니다.

  • 장점: 버스트 트래픽 처리에 유연하며, 구현이 상대적으로 간단합니다.
  • 단점: 구현 시 토큰 생성 속도와 버킷 크기 조정이 중요하며, 잘못 설정하면 문제가 발생할 수 있습니다.

5. 리키 버킷 (Leaky Bucket)

토큰 버킷과 유사하지만, 버킷에 요청이 쌓이고 일정한 속도로 요청이 처리되어 나가는 수도꼭지(leaky bucket)에 비유할 수 있습니다. 버킷이 가득 차면 추가 요청은 거부됩니다.

  • 장점: 요청 처리율을 매우 부드럽게 유지하여 서버 과부하를 방지하는 데 효과적입니다.
  • 단점: 버스트 트래픽에 대한 반응이 느릴 수 있으며, 구현이 약간 더 복잡합니다.

알고리즘 비교표

알고리즘정확성버스트 처리자원 사용량구현 복잡도비고
고정 윈도우 카운터낮음취약낮음낮음윈도우 경계 버스트 문제
슬라이딩 윈도우 로그높음우수높음높음모든 요청 타임스탬프 저장
슬라이딩 윈도우 카운터중간양호중간중간고정 윈도우와 슬라이딩 윈도우 로그의 절충안
토큰 버킷높음우수낮음중간버스트 허용량 및 처리율 제어
리키 버킷높음보통중간중간요청 처리율을 일정하게 유지

일반적으로, 분산 환경에서는 Redis의 INCREXPIRE 명령을 활용하여 슬라이딩 윈도우 카운터 또는 고정 윈도우 카운터를 구현하는 경우가 많습니다. 이는 구현의 용이성과 성능 사이의 좋은 균형을 제공하기 때문입니다. 이번 포스팅에서는 Redis를 활용한 고정 윈도우 카운터 기반의 Rate Limiting을 중점적으로 다루겠습니다.

🛠️ FastAPI 실전 구현

FastAPI에서 Rate Limiting을 구현하는 방법은 크게 두 가지로 나눌 수 있습니다: 인메모리(단일 인스턴스) 방식과 분산(Redis 기반) 방식. 프로덕션 환경에서는 여러 인스턴스가 동작할 수 있으므로 분산 방식이 필수적입니다.

1. 인메모리 Rate Limiter (단일 인스턴스용)

가장 간단한 형태의 Rate Limiter입니다. FastAPI 애플리케이션이 단일 프로세스로 실행될 때 테스트 또는 학습 목적으로 유용합니다. 실제 서비스에서는 여러 인스턴스가 실행될 수 있으므로, 아래 코드는 프로덕션용으로 적합하지 않습니다.

python
# main_in_memory.py
from fastapi import FastAPI, Request, HTTPException, status
from collections import defaultdict
import time
import asyncio

app = FastAPI()

# { "client_ip": { "timestamp": [request_times...], "count": N } }
# 또는 더 간단하게: { "client_ip": { "last_reset_time": float, "count": int } }
# 여기서는 더 간단한 고정 윈도우 카운터 방식을 사용합니다.
rate_limit_data = defaultdict(lambda: {"last_reset_time": 0.0, "count": 0})
RATE_LIMIT_WINDOW_SECONDS = 60  # 60초
RATE_LIMIT_MAX_REQUESTS = 5     # 60초당 최대 5회 요청

# 동시성 문제를 방지하기 위한 락 (FastAPI는 기본적으로 단일 스레드이지만, 비동기 작업 시 필요할 수 있음)
rate_limit_lock = asyncio.Lock()

async def get_client_ip(request: Request) -> str:
    """클라이언트 IP 주소를 가져옵니다."""
    # X-Forwarded-For 헤더를 우선적으로 확인하여 프록시 뒤에 있을 경우 실제 클라이언트 IP를 얻습니다.
    # 하지만 이 헤더는 조작될 수 있으므로, 보안이 중요한 서비스에서는 추가적인 검증이 필요합니다.
    return request.headers.get("X-Forwarded-For", request.client.host)

@app.get("/")
async def read_root(request: Request):
    client_ip = await get_client_ip(request)
    current_time = time.time()

    async with rate_limit_lock:
        client_data = rate_limit_data[client_ip]

        # 윈도우가 재설정될 시간인지 확인
        if current_time - client_data["last_reset_time"] > RATE_LIMIT_WINDOW_SECONDS:
            client_data["last_reset_time"] = current_time
            client_data["count"] = 1
        else:
            client_data["count"] += 1

        if client_data["count"] > RATE_LIMIT_MAX_REQUESTS:
            remaining_time = int(RATE_LIMIT_WINDOW_SECONDS - (current_time - client_data["last_reset_time"]))
            raise HTTPException(
                status_code=status.HTTP_429_TOO_MANY_REQUESTS,
                detail=f"Too many requests. Try again in {remaining_time} seconds."
            )
    
    # Rate Limit 관련 헤더 추가 (선택 사항이지만 권장)
    response_headers = {
        "X-RateLimit-Limit": str(RATE_LIMIT_MAX_REQUESTS),
        "X-RateLimit-Remaining": str(RATE_LIMIT_MAX_REQUESTS - client_data["count"]),
        "X-RateLimit-Reset": str(int(client_data["last_reset_time"] + RATE_LIMIT_WINDOW_SECONDS))
    }
    
    return {"message": f"Hello, {client_ip}! Request successful.", "headers": response_headers}

@app.get("/items/{item_id}")
async def read_item(item_id: int, request: Request):
    client_ip = await get_client_ip(request)
    current_time = time.time()

    async with rate_limit_lock:
        client_data = rate_limit_data[client_ip]

        if current_time - client_data["last_reset_time"] > RATE_LIMIT_WINDOW_SECONDS:
            client_data["last_reset_time"] = current_time
            client_data["count"] = 1
        else:
            client_data["count"] += 1

        if client_data["count"] > RATE_LIMIT_MAX_REQUESTS:
            remaining_time = int(RATE_LIMIT_WINDOW_SECONDS - (current_time - client_data["last_reset_time"]))
            raise HTTPException(
                status_code=status.HTTP_429_TOO_MANY_REQUESTS,
                detail=f"Too many requests. Try again in {remaining_time} seconds."
            )
            
    response_headers = {
        "X-RateLimit-Limit": str(RATE_LIMIT_MAX_REQUESTS),
        "X-RateLimit-Remaining": str(RATE_LIMIT_MAX_REQUESTS - client_data["count"]),
        "X-RateLimit-Reset": str(int(client_data["last_reset_time"] + RATE_LIMIT_WINDOW_SECONDS))
    }

    return {"item_id": item_id, "message": f"Item data for {client_ip}", "headers": response_headers}

이 코드를 실행하려면 uvicorn main_in_memory:app --reload 명령어를 사용합니다.
curl -v http://localhost:8000/ 명령어를 60초 안에 5번 이상 실행하면 429 Too Many Requests 응답을 확인할 수 있습니다.

2. Redis 기반 분산 Rate Limiter (프로덕션용)

실제 프로덕션 환경에서는 FastAPI 애플리케이션이 여러 인스턴스로 분산되어 실행될 수 있습니다. 이 경우 각 인스턴스별로 Rate Limiting을 처리하면 전체 시스템의 요청 한도를 정확하게 제어할 수 없습니다. 따라서 모든 인스턴스가 공유할 수 있는 중앙 집중식 저장소, 즉 Redis를 사용하는 것이 일반적입니다.

Redis는 인메모리 데이터 스토어로서 매우 빠른 읽기/쓰기 성능을 제공하며, INCR, EXPIRE와 같은 원자적(atomic) 명령을 통해 Rate Limiting을 안전하고 효율적으로 구현할 수 있게 해줍니다.

Redis 설치 및 설정

먼저 Redis 서버가 실행 중이어야 합니다. Docker를 사용하면 간단하게 실행할 수 있습니다.

bash
docker run --name my-redis -p 6379:6379 -d redis

FastAPI 애플리케이션에서 Redis와 통신하기 위해 redis-py 라이브러리를 설치합니다.

bash
pip install redis

FastAPI 의존성 주입을 활용한 Redis Rate Limiter

FastAPI의 강력한 기능인 **의존성 주입(Dependency Injection)**을 활용하여 Rate Limiting 로직을 깔끔하게 분리하고 여러 엔드포인트에 쉽게 적용할 수 있습니다.

python
# main_redis.py
from fastapi import FastAPI, Request, HTTPException, Depends, status
import redis.asyncio as redis
import time
from typing import Optional

app = FastAPI()

# Redis 클라이언트 초기화
# 실제 환경에서는 환경 변수 등을 통해 Redis URL을 관리하는 것이 좋습니다.
REDIS_URL = "redis://localhost:6379/0"
redis_client: Optional[redis.Redis] = None

# Rate Limiting 설정 (초당 요청 수)
RATE_LIMIT_WINDOW_SECONDS = 60  # 60초
RATE_LIMIT_MAX_REQUESTS = 5     # 60초당 최대 5회 요청

@app.on_event("startup")
async def startup_event():
    global redis_client
    redis_client = redis.from_url(REDIS_URL, encoding="utf-8", decode_responses=True)
    print("Redis client connected.")

@app.on_event("shutdown")
async def shutdown_event():
    if redis_client:
        await redis_client.close()
        print("Redis client closed.")

async def get_client_ip(request: Request) -> str:
    """클라이언트 IP 주소를 가져옵니다."""
    # X-Forwarded-For 헤더를 우선적으로 확인하여 프록시 뒤에 있을 경우 실제 클라이언트 IP를 얻습니다.
    # 실제 서비스에서는 이 헤더가 조작될 수 있으므로, 로드 밸런서/프록시 설정에 따라 신뢰할 수 있는 IP를 얻는 로직이 필요합니다.
    # 예: request.headers.get("X-Real-IP") 또는 특정 IP 범위만 신뢰
    return request.headers.get("X-Forwarded-For", request.client.host)

async def rate_limit_dependency(
    request: Request,
    limit: int = RATE_LIMIT_MAX_REQUESTS,
    window: int = RATE_LIMIT_WINDOW_SECONDS
):
    """
    FastAPI 의존성 주입으로 Rate Limiting을 적용합니다.
    고정 윈도우 카운터 알고리즘을 Redis로 구현합니다.
    """
    if not redis_client:
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Redis client not initialized.")

    client_id = await get_client_ip(request)
    # Redis 키는 "rate_limit:{client_id}:{window_start_timestamp}" 형태로 생성
    # 고정 윈도우이므로, 현재 시간을 윈도우 크기로 나눈 몫을 사용하여 윈도우 시작 시간을 계산합니다.
    current_window_start = int(time.time() / window) * window
    key = f"rate_limit:{client_id}:{current_window_start}"

    # Redis의 INCR 명령은 원자적으로 카운터를 증가시키고 현재 값을 반환합니다.
    # EXPIRE 명령은 키의 만료 시간을 설정합니다.
    # pipeline을 사용하여 두 명령을 하나의 트랜잭션처럼 실행하여 원자성을 보장합니다.
    pipe = redis_client.pipeline()
    pipe.incr(key)
    pipe.expire(key, window) # 윈도우가 끝나면 자동으로 키가 삭제됩니다.
    
    current_count, _ = await pipe.execute()

    if current_count > limit:
        # 남은 시간 계산: 다음 윈도우 시작 시간 - 현재 시간
        next_window_start = current_window_start + window
        remaining_time = int(next_window_start - time.time())
        
        # Rate Limit 관련 헤더 추가
        request.state.rate_limit_headers = {
            "X-RateLimit-Limit": str(limit),
            "X-RateLimit-Remaining": "0",
            "X-RateLimit-Reset": str(next_window_start)
        }
        
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail=f"Too many requests. Try again in {remaining_time} seconds."
        )
    
    # Rate Limit 관련 헤더를 request.state에 저장하여 응답에 추가할 수 있도록 합니다.
    request.state.rate_limit_headers = {
        "X-RateLimit-Limit": str(limit),
        "X-RateLimit-Remaining": str(limit - current_count),
        "X-RateLimit-Reset": str(current_window_start + window)
    }

    return True # 제한에 걸리지 않았다면 True 반환

@app.middleware("http")
async def add_rate_limit_headers(request: Request, call_next):
    """
    미들웨어를 사용하여 Rate Limiting 관련 HTTP 응답 헤더를 추가합니다.
    """
    response = await call_next(request)
    if hasattr(request.state, "rate_limit_headers"):
        for header, value in request.state.rate_limit_headers.items():
            response.headers[header] = value
    return response

@app.get("/", dependencies=[Depends(rate_limit_dependency)])
async def read_root():
    return {"message": "Hello, world! This is a rate-limited endpoint."}

@app.get("/protected", dependencies=[Depends(rate_limit_dependency)])
async def protected_endpoint():
    return {"message": "This is a protected endpoint with default rate limits."}

@app.get("/custom-limit", dependencies=[Depends(rate_limit_dependency(limit=2, window=10))])
async def custom_limit_endpoint():
    """
    특정 엔드포인트에 대해 맞춤형 Rate Limiting을 적용합니다.
    10초당 2회 요청으로 제한됩니다.
    """
    return {"message": "This endpoint has a custom rate limit (2 requests per 10 seconds)."}

# Rate Limiting이 적용되지 않는 엔드포인트
@app.get("/no-limit")
async def no_limit_endpoint():
    return {"message": "This endpoint has no rate limit."}

이 코드를 실행하려면 uvicorn main_redis:app --reload 명령어를 사용합니다.
curl -v http://localhost:8000/ 명령어를 60초 안에 5번 이상 실행하면 429 Too Many Requests 응답과 함께 X-RateLimit-* 헤더를 확인할 수 있습니다.

코드 설명:

  1. Redis 클라이언트 초기화: redis.asyncio를 사용하여 비동기 Redis 클라이언트를 생성합니다. startup_eventshutdown_event를 통해 애플리케이션 시작/종료 시 Redis 연결을 관리합니다.
  2. get_client_ip: 클라이언트 IP를 식별하는 함수입니다. 실제 환경에서는 로드 밸런서나 CDN을 사용할 경우 X-Forwarded-For 헤더를 신뢰할 수 없거나 추가적인 검증이 필요할 수 있습니다.
  3. rate_limit_dependency:
    • FastAPI의 Depends를 통해 라우트 핸들러에 주입됩니다.
    • client_id와 현재 윈도우 시작 시간을 기반으로 Redis 키를 생성합니다.
    • redis_client.pipeline()을 사용하여 INCREXPIRE 명령을 원자적으로 실행합니다. 이는 INCR가 먼저 실행되고, 그 결과에 따라 EXPIRE가 설정되어야 하는 상황에서 레이스 컨디션(Race Condition)을 방지합니다.
    • current_countlimit을 초과하면 HTTPException(429)를 발생시켜 요청을 거부합니다. 이때 remaining_time을 계산하여 클라이언트에게 다음 요청 가능 시간을 알려줍니다.
    • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset 헤더 정보를 request.state에 저장합니다.
  4. add_rate_limit_headers 미들웨어:
    • FastAPI의 미들웨어 기능을 사용하여 모든 응답에 Rate Limiting 관련 헤더를 자동으로 추가합니다. 이는 클라이언트에게 현재 Rate Limiting 상태를 명확하게 알려주어 불필요한 요청을 줄이는 데 도움이 됩니다.
  5. 엔드포인트 적용:
    • dependencies=[Depends(rate_limit_dependency)]를 사용하여 기본 Rate Limiting을 적용합니다.
    • dependencies=[Depends(rate_limit_dependency(limit=2, window=10))]와 같이 rate_limit_dependency 함수를 호출하여 특정 엔드포인트에 대한 맞춤형 Rate Limiting 설정을 적용할 수 있습니다. 이는 클로저(closure)를 활용한 패턴입니다.

3. 더 나은 라이브러리 활용: fastapi-limiter

위에서 직접 Redis 기반 Rate Limiter를 구현하는 방법을 보여드렸지만, 프로덕션 환경에서는 검증된 라이브러리를 사용하는 것이 훨씬 효율적입니다. fastapi-limiter는 FastAPI를 위한 Rate Limiting 라이브러리로, Redis를 백엔드로 사용하며 다양한 Rate Limiting 전략을 제공합니다.

설치:

bash
pip install fastapi-limiter[redis]

사용 예시:

python
# main_fastapi_limiter.py
from fastapi import FastAPI, Request, Depends
from fastapi_limiter import FastAPILimiter
from fastapi_limiter.depends import RateLimiter
import redis.asyncio as redis

app = FastAPI()

REDIS_URL = "redis://localhost:6379/0"

@app.on_event("startup")
async def startup():
    redis_connection = redis.from_url(REDIS_URL, encoding="utf-8", decode_responses=True)
    await FastAPILimiter.init(redis_connection)

@app.get("/", dependencies=[Depends(RateLimiter(times=5, seconds=60))])
async def read_root(request: Request):
    return {"message": "Hello, world! This is a rate-limited endpoint (5 req/min)."}

@app.get("/protected", dependencies=[Depends(RateLimiter(times=2, seconds=10))])
async def protected_endpoint(request: Request):
    return {"message": "This endpoint has a custom rate limit (2 req/10s)."}

@app.get("/no-limit")
async def no_limit_endpoint():
    return {"message": "This endpoint has no rate limit."}

fastapi-limiter를 사용하면 훨씬 간결하게 Rate Limiting을 적용할 수 있습니다. 내부적으로 Redis를 활용하여 분산 환경을 지원하며, 다양한 설정을 통해 유연하게 Rate Limiting 정책을 정의할 수 있습니다.

💡 Rate Limiting 구현 시 고려사항 및 모범 사례

개발 의뢰 상담

AI 서비스나 플랫폼 개발을
고민 중이신가요?

CodePick에서는
기획 → 개발 → 운영까지 함께합니다.
아이디어만 있어도 상담 가능합니다.

CodeVenter 개발팀이 직접 담당 · 1~2 영업일 내 회신

✓ 스타트업 MVP 개발✓ AI 서비스 개발✓ 웹 플랫폼 개발✓ 기업 시스템 구축✓ 모바일 앱 개발

AI Development Studio

코드픽 by 코드벤터

  • 대표: 윤승환 · 사업자등록번호: 121-57-64983
  • 대구광역시 중구 국채보상로 586, 16층 · info@codeventer.com

© 2025 코드벤터. All rights reserved.