본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

요청 제한 — 429로 공정한 사용량 제어하기

~10 min · auth-security, rate-limiting, 429, retry-after

Level 0HTTP 입문자
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"요청 제한은 지금 처리할 수 있는 속도를 넘었다고 알리는 프로토콜이야. 서버는 429로 제한을 알리고, Retry-After를 보냈다면 클라이언트는 그 시각까지 기다려야 해. 실제 공정성과 순간 버스트 허용량은 식별 키와 알고리즘, 분산 상태를 어떻게 설계하느냐에 달려."

왜 요청 속도를 제한할까

클라이언트의 호출 빈도를 제한하는 데는 서로 다른 세 가지 이유가 있어:

  • 비용 통제. 요청마다 CPU와 메모리, DB 쿼리, 외부 API 호출 비용이 들어. 제한이 없으면 작은 반복문 하나가 짧은 시간에 큰 비용을 만들 수 있어.
  • 공정성. 한 사용자가 수용량을 독점하면 다른 사용자의 지연과 실패가 늘어나. 사용자나 조직별 한도는 공유 자원을 고르게 나누는 장치야.
  • 남용과 DoS 완화. 오류 난 재시도 루프나 악성 트래픽이 서비스를 고갈시키기 전에 속도를 낮춰. 다만 요청 제한만으로 전체 DoS 방어가 끝나는 것은 아니며, 앞단 차단과 용량 보호도 함께 필요해.

429와 Retry-After의 의미

요청 한도를 넘으면 서버는 429 Too Many Requests를 반환할 수 있어:

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json

{"error": {"code": "rate_limited", "message": "60초 후 다시 시도."}}

Retry-After는 기다릴 초 수나 HTTP-date 형식의 시각으로 올 수 있어. 헤더가 있다면 그 값을 최소 대기 기준으로 존중하고, 반복되는 제한에는 지수 백오프와 지터를 더해. 헤더가 없을 때는 클라이언트가 합리적인 백오프 정책을 적용해야 해. POST처럼 부작용이 있는 요청은 멱등성 키나 처리 여부 확인 없이 자동 재시도하면 안 돼.

많은 API는 성공과 실패 응답에 공급자별 요청 한도 힌트 헤더도 보내:

  • X-RateLimit-Limit: 100 — 해당 정책이 허용하는 요청 수를 나타내는 관례적 형식이야.
  • X-RateLimit-Remaining: 47 — 현재 정책에서 남은 요청 수를 나타내는 경우가 많아.
  • X-RateLimit-Reset: 1760000000 — 제한이 초기화되는 시각을 나타낼 수 있어.

X-RateLimit-* 헤더는 오래된 관례라 API마다 단위와 의미가 다를 수 있고, 모든 공급자가 제공하는 것도 아니야. 표준 RateLimit 필드를 쓰는 API도 있으니 반드시 해당 API 문서를 확인해. 남은 할당량을 알 수 있다면 클라이언트가 미리 속도를 낮출 수 있지만, 동시 요청이나 다른 제한 축 때문에 429를 완전히 피할 수 있다고 보장되지는 않아.

대표적인 네 가지 알고리즘

고정 윈도우. 예를 들어 분당 100개를 허용하고 분 경계마다 계수기를 초기화해. 구현은 단순하지만 경계 직전에 100개, 직후에 100개가 몰리면 아주 짧은 시간에 200개가 통과할 수 있어.

슬라이딩 윈도우 로그. 요청 시각을 기록하고 현재 시각을 기준으로 최근 60초의 항목만 세. 정확하고 경계 버스트가 없지만 요청마다 항목을 저장해야 해서 메모리와 정리 비용이 커져.

슬라이딩 윈도우 계수기. 현재 고정 윈도우의 개수에 이전 윈도우가 현재 구간과 겹치는 비율만큼 가중한 값을 더해 최근 구간을 근사해. 로그 방식보다 저렴하면서 경계 버스트를 줄이지만 정확한 개수와는 차이가 날 수 있어.

토큰 버킷. 클라이언트별 버킷에 토큰을 일정 속도로 채우고 요청 비용만큼 차감해. 평균 속도를 제한하면서 버킷 용량만큼의 순간 버스트를 허용할 수 있고, 비싼 요청에는 토큰을 더 많이 부과할 수도 있어. 충전 시각과 잔여 토큰을 원자적으로 갱신하는 게 핵심이야.

한도 헤더는 클라이언트의 협조를 돕고, 서버 알고리즘은 실제 경계를 집행해. 남은 할당량과 초기화 시각을 알려주면 클라이언트가 미리 지연하거나 묶음 처리할 수 있어. 429와 Retry-After는 한도를 넘었을 때의 복구 경로를 제공해. 하지만 헤더만으로 공정성이 생기지는 않고, 클라이언트도 헤더 형식과 재시도 안전성을 정확히 처리해야 해.

어떤 단위로 제한할까

보통 다음 세 층을 조합해:

  • IP별 — 인증 전 남용을 완화하는 데 유용하지만, NAT 뒤의 여러 사용자를 한 사람처럼 묶거나 공격자가 IP를 분산할 수 있어. 프록시 뒤에서는 신뢰할 수 있는 전달 헤더만 사용해야 해.
  • API 키·사용자·조직별 — 인증된 주체 사이의 공정성을 적용해. 키 하나를 여러 사용자가 공유한다면 조직 한도와 사용자 한도를 나눠야 할 수도 있어.
  • 엔드포인트별 — 검색이나 ML 추론처럼 비싼 작업에는 더 낮은 한도나 더 큰 요청 비용을 적용해.

예를 들어 API 키별 전역 한도를 분당 100개로 두면서 /search에는 분당 10개를 별도로 적용할 수 있어. 여러 정책이 동시에 걸린다면 어떤 정책 때문에 제한됐는지와 가장 이른 재시도 시점을 일관된 헤더와 오류 본문으로 알려줘.

분산 환경의 요청 제한

서비스 인스턴스마다 메모리 계수기를 따로 두면 복제본 수만큼 실질 한도가 늘어날 수 있어. Redis 같은 공유 저장소에서 식별 키별 상태를 원자적으로 갱신하거나, 게이트웨이 한곳에서 제한을 집행해야 해. 아래 Redis 예제는 이름과 달리 토큰 버킷이 아니라 윈도우 시작 시각별 INCR을 사용하는 공유 고정 윈도우 계수기야. 진짜 토큰 버킷이라면 잔여 토큰과 마지막 충전 시각을 Lua 스크립트나 동등한 원자적 연산으로 함께 갱신해야 해.

트래픽이 매우 크다면 계층형 한도, 로컬 토큰 임대, 게이트웨이 집행으로 공유 저장소 부하를 줄일 수 있어. 단순 표본 추출은 관측 비용은 낮추지만 공격 트래픽을 그대로 통과시킬 수 있으므로 강제 한도의 대체재로 쓰면 안 돼. 근사 알고리즘을 쓴다면 허용 오차의 상한을 명확히 해야 해.

cwkPippa의 요청 제한 경계

cwkPippa처럼 제한된 사용자만 접근하는 로컬 중심 API는 공개 API와 다른 위험 모델을 가질 수 있어. 그래도 외부 브레인 제공자의 429를 받으면 공급자별 Retry-After와 재시도 정책을 존중하고, 다른 제공자나 유료 경로로 전환할 때는 비용과 동작 차이를 사용자에게 분명히 알려야 해. 외부에서 호출할 수 있는 웹훅이나 공개 엔드포인트를 열게 되면 IP별 방어만으로는 부족하므로 서명 검증, API 키·조직별 한도, 엔드포인트 비용별 제한을 함께 설계해야 해. 공개 범위가 바뀌는 순간 요청 제한의 필요성도 다시 평가해야 해.

Code

slowapi 기반 FastAPI 요청 제한 — 실제 전략과 헤더 설정은 확인해·python
# FastAPI — slowapi (표준 라이브러리) 쓴 token-bucket rate limit
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

app = FastAPI()
limiter = Limiter(key_func=get_remote_address)  # client IP 당 rate-limit
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

@app.get('/api/cheap')
@limiter.limit('100/minute')
async def cheap_endpoint(request: Request):
    return {'ok': True}

@app.get('/api/expensive')
@limiter.limit('10/minute')  # 비싼 endpoint 에 더 빡빡 한도
async def expensive_endpoint(request: Request):
    return {'ok': True}

# 한도 치면 발생:
# HTTP/1.1 429 Too Many Requests
# Retry-After: 60
# X-RateLimit-Limit: 10
# X-RateLimit-Remaining: 0
# X-RateLimit-Reset: 1760000000
# {"error":"Rate limit exceeded: 10 per 1 minute"}
클라이언트: Retry-After 처리와 남은 할당량 기반 감속·python
# Client 쪽 — Retry-After 와 X-RateLimit-Remaining 존중
import httpx, time

class RateAwareClient:
    def __init__(self, base_url: str):
        self.client = httpx.Client(base_url=base_url)

    def request(self, method: str, path: str, **kwargs) -> httpx.Response:
        for attempt in range(3):
            resp = self.client.request(method, path, **kwargs)
            if resp.status_code == 429:
                wait = int(resp.headers.get('Retry-After', 5))
                time.sleep(wait)
                continue
            # 한도 가까우면 사전 backoff
            remaining = int(resp.headers.get('X-RateLimit-Remaining', 100))
            if remaining < 5:
                reset_ts = int(resp.headers.get('X-RateLimit-Reset', time.time() + 60))
                wait = max(0, reset_ts - int(time.time()))
                if wait > 0:
                    time.sleep(min(wait, 1))  # gentle 느려짐
            return resp
        return resp  # 포기; 429 caller 한테 surface
분산 요청 제한 — Redis를 이용한 공유 고정 윈도우 계수기·python
# Redis 통한 분산 rate limit — atomic check-and-decrement 가진 token bucket
import time, redis

rdb = redis.Redis()

def is_allowed(client_id: str, limit: int = 100, window_s: int = 60) -> tuple[bool, int]:
    """Returns (allowed, remaining). Atomicity 위해 Redis INCR + EXPIRE 씀."""
    now = int(time.time())
    window_start = now - (now % window_s)
    key = f'rl:{client_id}:{window_start}'

    pipe = rdb.pipeline()
    pipe.incr(key)
    pipe.expire(key, window_s * 2)  # cross-window request 위한 여유
    count, _ = pipe.execute()

    remaining = max(0, limit - count)
    return count <= limit, remaining

# FastAPI middleware 에서:
# allowed, remaining = is_allowed(client_id)
# if not allowed:
#     return JSONResponse(429, content={'error': 'rate_limited'},
#                         headers={'Retry-After': str(window_s),
#                                  'X-RateLimit-Remaining': '0'})

External links

Exercise

FastAPI의 /api/expensive에 분당 5회 요청 제한을 추가해. 모든 응답에 X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset을 넣고 429 응답에는 Retry-After를 포함해. 이어서 요청 20개를 보내는 클라이언트를 작성해 여섯 번째 요청 부근에서 429가 오는지 확인하고, Retry-After를 해석해 기다린 뒤 20개가 모두 성공할 때까지 안전하게 재시도해. 보너스로 메모리 계수기를 Redis 기반 공유 계수기로 바꾸고, 클라이언트 두 개가 동시에 호출해도 하나의 한도를 공유하는지 검증해.
Hint
slowapi는 Python 표준 라이브러리가 아닌 제3자 도구이며, 선택한 저장소와 제한 전략, 헤더 활성화 설정을 직접 확인해야 해. 사용자 정의 미들웨어로 헤더를 붙인다면 제한 판정과 같은 상태를 기준으로 계산해 불일치가 없게 만들어. 클라이언트는 Retry-After의 초 단위 값과 HTTP-date를 모두 처리하고, 지터와 최대 대기 시간을 둬. Redis 보너스에서는 INCR과 만료 설정을 원자적으로 실행하고 Retry-After를 현재 윈도우의 실제 남은 시간으로 계산해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 피파warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.