Rate Limiting
# Rate Limit 가이드
이 문서는 백엔드 API의 Rate Limit(요청 제한) 시스템에 대한 가이드입니다.
1. Rate Limit 개요¶
Rate Limit은 API 남용을 방지하고 서버 안정성을 보장하기 위한 요청 제한 시스템입니다.
동작 방식¶
sequenceDiagram
participant Client
participant Middleware as RateLimitMiddleware
participant Storage as InMemoryStorage
participant API as API Endpoint
Client->>Middleware: API 요청
Middleware->>Middleware: 규칙 매칭 (method + path)
Middleware->>Storage: 요청 카운트 조회
alt 한도 초과
Storage-->>Middleware: 초과 상태
Middleware-->>Client: 429 Too Many Requests
else 한도 이내
Storage-->>Middleware: 허용 상태
Middleware->>API: 요청 전달
API-->>Middleware: 응답
Middleware-->>Client: 응답 + X-RateLimit-* 헤더
end
핵심 개념¶
| 용어 | 설명 |
|---|---|
| Window | 요청 카운트를 측정하는 시간 윈도우 (기본 60초) |
| Max Requests | 윈도우 내 허용되는 최대 요청 수 |
| User Identifier | 사용자 식별 방법 (JWT sub 또는 IP 주소) |
| Sliding Window | 슬라이딩 윈도우 방식으로 정확한 요청 제한 |
사용자 식별¶
Rate Limit은 사용자별로 독립적으로 적용됩니다:
| 인증 상태 | 식별자 |
|---|---|
| 인증됨 | JWT의 sub claim (OIDC 사용자 ID) |
| 미인증 | 클라이언트 IP 주소 (X-Forwarded-For 또는 직접 연결 IP) |
2. 환경변수 설정¶
환경변수 목록¶
| 환경변수 | 필수 | 기본값 | 설명 |
|---|---|---|---|
RATE_LIMIT_ENABLED |
X | true |
Rate Limit 활성화 여부 |
RATE_LIMIT_DEFAULT_WINDOW |
X | 60 |
기본 윈도우 크기 (초) |
RATE_LIMIT_DEFAULT_REQUESTS |
X | 60 |
기본 최대 요청 수 |
CF_ENABLED |
X | false |
Cloudflare 프록시 사용 여부 |
CF_IP_CACHE_TTL |
X | 86400 |
Cloudflare IP 목록 캐시 TTL (초, 기본 24시간) |
TRUSTED_PROXY_IPS |
X | "" |
신뢰할 프록시 IP 목록 (콤마 구분, CIDR 지원) |
예시 설정 (.env)¶
Docker Compose 설정¶
# compose.yaml
services:
backend:
environment:
- RATE_LIMIT_ENABLED=true
- RATE_LIMIT_DEFAULT_WINDOW=60
- RATE_LIMIT_DEFAULT_REQUESTS=60
3. 활성화/비활성화¶
Rate Limit 비활성화¶
개발 환경이나 테스트 시 Rate Limit을 비활성화할 수 있습니다.
방법 1: 환경변수 설정¶
방법 2: 환경변수 직접 지정¶
비활성화 시 동작¶
- 모든 요청이 제한 없이 통과됩니다
- X-RateLimit-* 응답 헤더가 추가되지 않습니다
- 로그에 Rate Limit 관련 메시지가 출력되지 않습니다
주의
프로덕션 환경에서는 반드시 RATE_LIMIT_ENABLED=true로 설정하세요!
4. 프록시 환경 설정¶
프록시(Cloudflare, Nginx, 로드밸런서 등) 뒤에서 운영할 때 클라이언트 IP를 정확히 식별하기 위한 설정입니다.
보안 경고¶
주의
중요: 프록시 헤더(X-Forwarded-For, CF-Connecting-IP)는 클라이언트가 조작할 수 있습니다. 반드시 신뢰할 수 있는 프록시에서 온 요청에서만 이 헤더를 사용해야 합니다.
Cloudflare 환경 (권장)¶
Cloudflare를 사용하는 경우 CF_ENABLED=true로 설정하세요:
IP 목록 캐시¶
- Cloudflare IP 목록은 거의 변경되지 않습니다 (보통 몇 달에 1번)
- 기본 캐시 TTL: 24시간 (
CF_IP_CACHE_TTL=86400) - 앱 재시작 또는 TTL 만료 시 백그라운드에서 자동 갱신
Fetch 실패 시 동작¶
- 이전 캐시 있음: 이전 캐시 계속 사용
- 캐시 없음: Fail-Safe 모드 - 프록시 헤더 무시,
request.client.host직접 사용
Nginx/기타 프록시 환경¶
Cloudflare가 아닌 다른 프록시를 사용하는 경우 TRUSTED_PROXY_IPS를 설정하세요:
지원 형식¶
- 단일 IP:
127.0.0.1 - CIDR 범위:
10.0.0.0/8 - 콤마로 구분:
127.0.0.1,10.0.0.0/8\
직접 연결 (개발 환경)¶
프록시 없이 직접 연결하는 경우 아무 설정도 필요 없습니다:
모든 프록시 헤더를 무시하고 request.client.host를 직접 사용합니다.
설정 매트릭스¶
| CF_ENABLED | TRUSTED_PROXY_IPS | 요청 출처 | IP 추출 방식 |
|---|---|---|---|
true |
(무시됨) | Cloudflare IP | CF-Connecting-IP 헤더 |
true |
(무시됨) | 다른 IP | request.client.host |
false |
설정됨 | Trusted IP | X-Forwarded-For 헤더 |
false |
설정됨 | 다른 IP | request.client.host |
false |
비어있음 | 어디든 | request.client.host |
Docker Compose 설정 예시¶
# compose.yaml
services:
backend:
environment:
# Cloudflare 환경
- CF_ENABLED=true
# 또는 Nginx 프록시 환경
# - CF_ENABLED=false
# - TRUSTED_PROXY_IPS=nginx,10.0.0.0/8
5. 엔드포인트별 규칙¶
Rate Limit 규칙은 엔드포인트와 HTTP 메서드에 따라 다르게 적용됩니다.
기본 규칙¶
| 엔드포인트 패턴 | 메서드 | Window | Max Requests | 설명 |
|---|---|---|---|---|
/v1/todos |
POST | 60초 | 30 | Todo 생성 제한 |
/v1/schedules |
POST | 60초 | 30 | Schedule 생성 제한 |
/v1/timers |
POST | 60초 | 30 | Timer 생성 제한 |
/v1/tags |
POST | 60초 | 30 | Tag 생성 제한 |
/v1/tags/groups |
POST | 60초 | 30 | TagGroup 생성 제한 |
/v1/todos/* |
ALL | 60초 | 100 | Todo 일반 작업 |
/v1/schedules/* |
ALL | 60초 | 60 | Schedule 일반 작업 |
/v1/timers/* |
ALL | 60초 | 60 | Timer 일반 작업 |
/v1/tags/* |
ALL | 60초 | 60 | Tag 일반 작업 |
/v1/graphql |
ALL | 60초 | 60 | GraphQL 요청 |
/v1/* |
ALL | 60초 | 60 | 기타 API (폴백) |
규칙 적용 우선순위¶
- 구체적인 규칙 우선: 더 구체적인 경로/메서드 규칙이 먼저 적용됩니다
- 첫 번째 매칭 사용: 규칙 리스트를 순회하며 첫 번째 매칭 규칙 사용
- 폴백 규칙: 매칭되는 규칙이 없으면
/v1/*전역 규칙 적용
예시: POST /v1/todos¶
요청: POST /v1/todos
→ 규칙 매칭: RateLimitRule(methods=["POST"], path_pattern="/v1/todos", ...)
→ 적용: 60초당 30회 제한
예시: GET /v1/todos¶
요청: GET /v1/todos
→ POST 전용 규칙 스킵
→ 규칙 매칭: RateLimitRule(path_pattern="/v1/todos", ...)
→ 적용: 60초당 100회 제한
6. 응답 헤더¶
모든 /v1/* API 응답에 Rate Limit 정보 헤더가 포함됩니다.
헤더 목록¶
| 헤더 | 설명 | 예시 |
|---|---|---|
X-RateLimit-Limit |
윈도우 내 최대 허용 요청 수 | 60 |
X-RateLimit-Remaining |
남은 요청 수 | 42 |
X-RateLimit-Reset |
윈도우 리셋까지 남은 시간 (초) | 35 |
응답 예시¶
HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 35
Content-Type: application/json
{"id": "...", "title": "My Schedule"}
429 응답 시 추가 헤더¶
HTTP/1.1 429 Too Many Requests
Retry-After: 35
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 35
Content-Type: application/json
{
"detail": "요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.",
"retry_after": 35
}
7. 429 에러 처리¶
프론트엔드 처리 방법¶
JavaScript (fetch)¶
const response = await fetch('http://localhost:2614/v1/schedules', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (response.status === 429) {
const errorData = await response.json();
const retryAfter = response.headers.get('Retry-After') || errorData.retry_after;
// 사용자에게 알림
showNotification(`요청 한도 초과. ${retryAfter}초 후 다시 시도해주세요.`);
// 자동 재시도 (선택적)
setTimeout(() => {
retryRequest();
}, retryAfter * 1000);
}
Axios 인터셉터¶
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:2614/v1',
});
// 응답 인터셉터: 429 처리
api.interceptors.response.use(
(response) => {
// 남은 요청 수 로깅 (디버깅용)
const remaining = response.headers['x-ratelimit-remaining'];
if (remaining && parseInt(remaining) < 10) {
console.warn(`Rate limit warning: ${remaining} requests remaining`);
}
return response;
},
async (error) => {
if (error.response?.status === 429) {
const retryAfter = error.response.headers['retry-after'] || 60;
// 토스트 알림
showToast(`요청 한도 초과. ${retryAfter}초 후 다시 시도해주세요.`, 'warning');
// 자동 재시도 (지수 백오프)
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
return api.request(error.config);
}
return Promise.reject(error);
}
);
권장 클라이언트 동작¶
- Retry-After 헤더 존중: 서버가 제공하는 대기 시간을 따르세요
- 지수 백오프: 연속 429 응답 시 대기 시간을 점진적으로 증가
- 사용자 피드백: 명확한 에러 메시지와 남은 시간 표시
- 요청 배치: 가능하면 여러 요청을 하나로 묶기 (GraphQL 활용)
8. 규칙 커스터마이징¶
규칙 수정 위치¶
규칙 추가 예시¶
# app/ratelimit/config.py
RATE_LIMIT_RULES: List[RateLimitRule] = [
# 새로운 규칙 추가 (기존 규칙보다 위에 배치)
RateLimitRule(
methods=["POST"],
path_pattern="/v1/bulk-import", # 대량 가져오기 엔드포인트
window_seconds=3600, # 1시간
max_requests=5, # 시간당 5회만 허용
),
# ... 기존 규칙들 ...
]
규칙 구조¶
class RateLimitRule(BaseModel):
methods: Optional[List[str]] = None # None = 모든 메서드
path_pattern: str # fnmatch 패턴
window_seconds: int # 윈도우 크기 (초)
max_requests: int # 최대 요청 수
Path Pattern 문법¶
fnmatch 패턴을 사용합니다:
| 패턴 | 설명 | 매칭 예시 |
|---|---|---|
/v1/todos |
정확히 일치 | /v1/todos |
/v1/todos/* |
하위 경로 포함 | /v1/todos/123, /v1/todos/123/complete |
/v1/*/export |
중간 와일드카드 | /v1/schedules/export, /v1/todos/export |
9. WebSocket Rate Limit¶
WebSocket 연결에 대한 레이트 리밋은 REST API와 별도로 관리됩니다.
동작 방식¶
sequenceDiagram
participant Client
participant WS as WebSocket Handler
participant Limiter as WSRateLimiter
participant Storage as InMemoryStorage
Note over Client,WS: 연결 시도
Client->>WS: WebSocket 연결 요청
WS->>Limiter: 연결 Rate Limit 체크
Limiter->>Storage: 연결 카운트 조회
alt 연결 한도 초과
Storage-->>Limiter: 초과 상태
Limiter-->>WS: 차단
WS-->>Client: 연결 거부 (4029)
else 연결 허용
Storage-->>Limiter: 허용
WS-->>Client: 연결 수락
Note over Client,WS: 메시지 교환
Client->>WS: 메시지 전송
WS->>Limiter: 메시지 Rate Limit 체크
alt 메시지 한도 초과
Limiter-->>WS: 차단
WS-->>Client: 에러 메시지 (RATE_LIMIT_EXCEEDED)
else 메시지 허용
WS->>WS: 메시지 처리
WS-->>Client: 응답 메시지
end
end
환경변수 설정¶
| 환경변수 | 기본값 | 설명 |
|---|---|---|
WS_RATE_LIMIT_ENABLED |
true |
WebSocket Rate Limit 활성화 |
WS_CONNECT_WINDOW |
60 |
연결 제한 윈도우 (초) |
WS_CONNECT_MAX |
10 |
윈도우 내 최대 연결 횟수 |
WS_MESSAGE_WINDOW |
60 |
메시지 제한 윈도우 (초) |
WS_MESSAGE_MAX |
120 |
윈도우 내 최대 메시지 수 |
예시 설정 (.env)¶
# WebSocket Rate Limit 설정
WS_RATE_LIMIT_ENABLED=true
WS_CONNECT_WINDOW=60 # 1분
WS_CONNECT_MAX=10 # 분당 10회 연결
WS_MESSAGE_WINDOW=60 # 1분
WS_MESSAGE_MAX=120 # 분당 120개 메시지
두 가지 제한 유형¶
1. 연결 제한 (Connection Rate Limit)¶
- 목적: 동일 사용자가 짧은 시간 내에 반복적으로 연결/해제하는 것 방지
- 적용 시점: WebSocket 연결 수락 전
- 초과 시: 연결 거부 (close code:
4029)
// 클라이언트 측 에러 처리
ws.onclose = (event) => {
if (event.code === 4029) {
console.error('연결 Rate Limit 초과:', event.reason);
// 재연결 대기
}
};
2. 메시지 제한 (Message Rate Limit)¶
- 목적: 연결된 사용자의 메시지 폭주 방지
- 적용 시점: 각 메시지 수신 시
- 초과 시: 에러 메시지 응답 (연결 유지)
{
"type": "error",
"payload": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "WebSocket 메시지 한도를 초과했습니다. 35초 후에 다시 시도해주세요."
}
}
프론트엔드 처리¶
// WebSocket Rate Limit 처리 예시
class TimerWebSocket {
constructor(url, token) {
this.url = url;
this.token = token;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.connect();
}
connect() {
// 보안상 쿼리 파라미터 대신 Sec-WebSocket-Protocol 헤더 사용
this.ws = new WebSocket(this.url, [`authorization.bearer.${this.token}`]);
this.ws.onclose = (event) => {
if (event.code === 4029) {
// 연결 Rate Limit - 지수 백오프 재연결
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 60000);
console.warn(`연결 Rate Limit. ${delay/1000}초 후 재연결...`);
setTimeout(() => this.connect(), delay);
this.reconnectAttempts++;
}
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'error' && message.payload.code === 'RATE_LIMIT_EXCEEDED') {
// 메시지 Rate Limit - 일시적으로 전송 중지
console.warn('메시지 Rate Limit:', message.payload.message);
this.pauseMessageQueue();
}
};
this.ws.onopen = () => {
this.reconnectAttempts = 0; // 성공 시 리셋
};
}
pauseMessageQueue() {
// 메시지 큐 일시 중지 로직
}
}
권장 클라이언트 동작¶
| 상황 | 권장 동작 |
|---|---|
| 연결 Rate Limit (4029) | 지수 백오프로 재연결 시도 |
| 메시지 Rate Limit | 메시지 큐 일시 중지, 에러 메시지에서 retry_after 확인 |
| 정상 연결 끊김 | 즉시 재연결 시도 |
비활성화¶
개발/테스트 환경에서 WebSocket Rate Limit을 비활성화할 수 있습니다:
주의
프로덕션 환경에서는 반드시 활성화하세요!
관련 코드 참조¶
| 파일 | 설명 |
|---|---|
app/ratelimit/config.py |
규칙 정의 및 매칭 로직 |
app/ratelimit/middleware.py |
REST API Rate Limit 미들웨어 |
app/ratelimit/limiter.py |
요청 카운트 및 제한 로직 |
app/ratelimit/websocket.py |
WebSocket Rate Limit 로직 |
app/ratelimit/cloudflare.py |
Cloudflare/Trusted Proxy IP 관리 및 클라이언트 IP 추출 |
app/ratelimit/storage/memory.py |
인메모리 저장소 (슬라이딩 윈도우) |
app/websocket/router.py |
WebSocket 엔드포인트 (Rate Limit 적용) |
app/core/config.py |
환경변수 설정 |
FAQ¶
Q: Rate Limit을 테스트하고 싶어요¶
테스트 환경에서 빠르게 한도에 도달하도록 설정할 수 있습니다:
또는 비활성화 후 테스트:
Q: 특정 IP나 사용자를 화이트리스트에 추가하고 싶어요¶
현재 버전에서는 화이트리스트 기능이 없습니다. 필요시 RateLimitMiddleware를 수정하세요:
# app/ratelimit/middleware.py
WHITELIST_IPS = {"192.168.1.100", "10.0.0.1"}
async def dispatch(self, request, call_next):
# 화이트리스트 IP는 스킵
client_ip = request.client.host if request.client else None
if client_ip in WHITELIST_IPS:
return await call_next(request)
# ... 기존 로직 ...
Q: Redis를 사용하고 싶어요¶
현재는 인메모리 저장소만 지원합니다. Redis 지원이 필요하면 app/ratelimit/storage/ 디렉토리에 새 저장소를 구현하세요:
# app/ratelimit/storage/redis.py
from app.ratelimit.storage.base import RateLimitStorage
class RedisStorage(RateLimitStorage):
async def record_request(self, key: str, window_seconds: int) -> int:
# Redis 구현
pass
async def get_window_info(self, key: str, window_seconds: int) -> tuple[int, int]:
# Redis 구현
pass
Q: GraphQL 요청도 Rate Limit이 적용되나요?¶
네, /v1/graphql 엔드포인트에 60초당 60회 제한이 적용됩니다.
GraphQL의 특성상 하나의 요청으로 여러 쿼리를 묶을 수 있으므로 효율적으로 사용하세요.
Q: 분산 환경에서는 어떻게 되나요?¶
현재 인메모리 저장소는 단일 인스턴스용입니다. 분산 환경에서는: 1. Redis 저장소 구현 필요 2. 또는 로드밸런서에서 Rate Limit 처리 (nginx, Kong 등)