레이트 리미터(rate limiter)는 정해진 시간 동안 받아들이는 요청 수를 제한하는 장치입니다. "한 사용자는 1분에 100번까지", "로그인 시도는 IP마다 1분에 5번까지"처럼 규칙을 정해 두고, 규칙을 넘는 요청은 거절하거나 늦춥니다. 이 장에서는 왜 요청을 제한하는지, 어떤 알고리즘들이 있는지, 그리고 이후 장에서 계속 쓰는 용어를 정리합니다.
왜 요청을 제한하는가
서버의 자원은 한정되어 있습니다. 한 클라이언트가 짧은 시간에 요청을 쏟아 내면 다른 사용자의 응답이 느려지고, 심하면 서비스 전체가 멈춥니다. 레이트 리미터는 다음 문제를 막습니다.
- 과부하 방지: 갑작스러운 트래픽이나 잘못 짠 재시도 루프가 데이터베이스와 서버를 쓰러뜨리지 않게 합니다.
- 공정성: 한 사용자가 자원을 독점하지 못하게 해서 모든 사용자가 고르게 서비스를 받게 합니다.
- 보안: 비밀번호 무차별 대입, 계정 열거, 스크래핑 같은 공격의 속도를 늦춥니다.
- 비용 관리: 외부 유료 API나 SMS 발송처럼 호출마다 돈이 드는 작업의 사용량을 묶어 둡니다.
- 요금제 구분: 무료 요금제는 분당 60회, 유료 요금제는 분당 1,000회처럼 상품을 나누는 기준이 됩니다.
기본 용어
대표 알고리즘 다섯 가지
- 고정 창(fixed window): 시간을 1분 같은 칸으로 자르고 칸마다 카운터 하나를 셉니다. 가장 단순하지만 칸 경계에서 한도의 두 배까지 몰릴 수 있습니다.
- 슬라이딩 창 로그(sliding window log): 요청 시각을 모두 기록해 두고 "지금부터 1분 전까지" 기록만 셉니다. 정확하지만 요청마다 기억 공간을 씁니다.
- 슬라이딩 창 카운터(sliding window counter): 이전 칸과 현재 칸의 카운터 두 개를 시간 비율로 섞어 근사합니다. 가볍고 꽤 정확합니다.
- 토큰 버킷(token bucket): 버킷에 일정한 속도로 토큰이 차고, 요청은 토큰을 하나씩 꺼내 씁니다. 평균 속도를 지키면서 용량만큼의 버스트를 허용합니다.
- 리키 버킷(leaky bucket): 요청을 큐에 담고 일정한 속도로 내보냅니다. 출력이 고르게 펴지는 대신 요청이 기다리는 시간이 생깁니다.
고정 창 카운터는 다음처럼 몇 줄이면 됩니다. 이후 장에서 이 단순한 방식의 약점을 하나씩 보완해 갑니다.
import time
from collections import defaultdict
LIMIT, WINDOW = 5, 60 # 1분에 5번
counters = defaultdict(int) # (키, 창 번호) -> 횟수
def allow(key: str) -> bool:
window_id = int(time.time() // WINDOW)
counters[(key, window_id)] += 1
return counters[(key, window_id)] <= LIMIT
이 코드는 지난 창의 카운터를 지우지 않으므로 실제로는 오래된 항목을 정리하거나, Redis처럼 만료 시간을 줄 수 있는 저장소를 씁니다.
무엇을 키로 삼을까
같은 알고리즘이라도 키를 무엇으로 잡느냐에 따라 효과가 크게 달라집니다.
- 사용자 ID나 API 키: 로그인한 요청에 가장 공정합니다. 요금제별 한도를 걸기도 쉽습니다.
- IP 주소: 로그인 전 요청(로그인, 회원 가입, 비밀번호 재설정)에 씁니다. 다만 회사나 모바일 통신망은 NAT 뒤에서 여러 사람이 같은 IP를 쓰고, IPv6 사용자는 주소 블록(/64 등)을 통째로 받아 주소를 쉽게 바꿀 수 있습니다.
- 엔드포인트: 비싼 검색 API와 가벼운 조회 API에 다른 한도를 줍니다.
- 조합:
user:42:POST /search처럼 여러 기준을 이어 붙여 세밀하게 나눕니다.
프록시 뒤에 있는 서버라면 X-Forwarded-For 헤더를 믿을 수 있는지 확인해야 합니다. 이 헤더는 클라이언트가 마음대로 넣을 수 있으므로, 신뢰하는 프록시가 덧붙인 값만 써야 합니다.
def client_ip(remote_addr: str, forwarded_for: str | None,
trusted_proxies: set[str]) -> str:
# 직접 연결한 쪽이 우리 프록시일 때만 헤더를 믿는다
if forwarded_for and remote_addr in trusted_proxies:
hops = [h.strip() for h in forwarded_for.split(",")]
# 오른쪽부터 보며 신뢰하지 않는 첫 주소가 실제 클라이언트
for hop in reversed(hops):
if hop not in trusted_proxies:
return hop
return remote_addr
거절한 뒤에는: HTTP 429와 Retry-After
HTTP에서 한도를 넘은 요청에는 보통 429 Too Many Requests 상태 코드를 돌려줍니다. 이 코드는 RFC 6585에 정의되어 있습니다. 함께 Retry-After 헤더로 몇 초 뒤에 다시 시도하면 되는지(또는 HTTP 날짜)를 알려 주면, 잘 만든 클라이언트는 그만큼 기다렸다가 재시도합니다. 많은 API는 남은 횟수와 초기화 시각도 헤더로 알려 줍니다(GitHub의 x-ratelimit-remaining 등).
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{"error": "rate_limited", "message": "Too many requests. Retry in 12 seconds."}
정리
- 레이트 리미터는 키마다 일정 시간의 요청 수를 제한해 과부하, 독점, 공격, 비용 폭주를 막습니다.
- 대표 알고리즘은 고정 창, 슬라이딩 창 로그, 슬라이딩 창 카운터, 토큰 버킷, 리키 버킷입니다.
- 키(사용자, IP, 엔드포인트)를 잘 고르는 일이 알고리즘 선택만큼 중요합니다.
- 거절할 때는 429와
Retry-After로 클라이언트가 언제 다시 시도할지 알려 줍니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.