Chapter 04
처리율 제한 장치의 설계
Rate Limiter 알고리즘(토큰 버킷, 누출 버킷, 고정 윈도우, 이동 윈도우 로그/카운터)과 분산 환경에서의 동기화, Redis 활용.
개요
처리율 제한 장치(Rate Limiter)는 클라이언트나 서비스가 정해진 시간 동안 요청할 수 있는 횟수를 제한하는 컴포넌트다. 임계치를 넘어선 요청은 차단된다. 트위터(시간당 트윗 300개), 구글 docs(분당 쓰기 요청 300건) 같은 익숙한 제약이 모두 이런 형태.
이 장치를 두는 이유는 크게 세 가지다.
- DoS(Denial of Service) 공격 방어 — 의도적·비의도적 과도 트래픽으로부터 서비스 보호
- 비용 절감 — 외부 API 호출(LLM, SMS, 이메일 등)에 지불하는 돈 통제
- 서버 과부하 방지 — 봇이나 잘못된 사용 패턴으로 인한 자원 고갈 차단
1단계: 문제 이해 및 설계 범위 확정
면접에서 먼저 확정해야 할 요구사항:
- 클라이언트 측 vs 서버 측 제한기? (보통 서버 측)
- 제한 기준은 IP? 사용자 ID? 다른 속성?
- 시스템 규모 — 스타트업 수준 vs 거대 기업 규모?
- 분산 환경에서 동작해야 하는가?
- 독립 서비스 vs 애플리케이션 코드에 포함?
- 제한된 요청에 사용자에게 알려야 하는가?
대표적인 요구사항 묶음 예시:
- 설정된 임계치를 초과하는 요청을 정확하게 제한
- 낮은 응답 지연 — 사용자 경험 저해 금지
- 가능한 적은 메모리 사용
- 분산 환경에서 여러 서버·프로세스 간 공유
- 예외 처리 — 사용자에게 명확히 알림
- 높은 결함 감내성 — 제한 장치 장애가 전체 시스템 장애로 이어지지 않게
2단계: 개략적 설계 — 어디에 둘까?
크게 세 가지 위치 중 선택할 수 있다.
- 클라이언트 측 — 우회가 쉽고 클라이언트 구현을 통제할 수 없어 일반적으로 권장하지 않음.
- 서버 측 — 가장 일반적. 애플리케이션 미들웨어 또는 전담 컴포넌트로 구현.
- API 게이트웨이 — 인증·SSL·IP 화이트리스트와 함께 처리율 제한도 게이트웨이가 담당. 클라우드 환경의 표준.
"어디에 둘 것인가"의 결정은 기술 스택, 엔지니어링 인력, 우선순위, 사업적 필요에 따라 다르다. 중간 수준의 트래픽엔 게이트웨이 활용이 편하고, 대규모·세밀한 제어가 필요하면 직접 구축한다.
처리율 제한 알고리즘
이 장의 핵심. 5가지 알고리즘을 비교한다. 각각 메모리 사용량, 정확도, 구현 복잡도가 다르다.
① 토큰 버킷(Token Bucket)
정해진 용량의 버킷에 일정한 비율로 토큰이 채워진다. 요청이 오면 토큰 1개를 소모. 토큰이 없으면 거부. 가장 널리 쓰이는 알고리즘 (Amazon, Stripe).
- 파라미터 2개: 버킷 용량, 토큰 보충률
- 장점: 구현 쉬움, 메모리 적음, 짧은 트래픽 폭주(burst) 허용
- 단점: 두 파라미터를 잘 튜닝해야 함
- 🛠 실무 도구: AWS API Gateway throttling(burst + steady-state), Stripe, GitHub Primary Rate Limit, 라이브러리는
guava RateLimiter(Java),golang.org/x/time/rate(Go)
① 토큰 버킷
용량 4 · 보충 1/s💡 빠르게 4번 누르면 버킷이 비어 다음 토큰이 채워질 때까지 거부됩니다. 잠시 기다리면 다시 모입니다 — 버스트 허용의 의미.
② 누출 버킷(Leaky Bucket)
요청이 큐(고정 크기)에 들어가고, 일정한 비율로 빠져나가 처리된다. 큐가 가득 차면 새 요청 거부. 보통 FIFO 큐로 구현 (Shopify).
- 파라미터: 버킷 크기(큐 크기), 처리율(누수 속도)
- 장점: 처리 속도가 일정해 안정적
- 단점: 트래픽 폭주를 못 흡수 (오래된 요청도 처리 못함)
- 🛠 실무 도구: Nginx
limit_req(가장 유명한 구현체 — 사실상 표준), Shopify, 메시지 큐 워커 자체가 누출 버킷 형태(Kafka consumer with rate limit, AWS SQS)
② 누출 버킷
큐 4 · 누수 1/s💡 토큰 버킷과 달리 처리 속도가 일정합니다. 빠르게 보내도 한꺼번에 통과하지 않고 1/s로 천천히 빠져나갑니다. 그래서 버스트 허용 X.
🤔 그럼 누출 버킷은 사용자 입장에선 답답한데 왜 쓰나?
맞다. 그래서 사용자가 화면에서 직접 응답을 기다리는 API에는 거의 다 토큰 버킷을 쓴다. 누출 버킷은 사용자가 직접 기다리지 않는 곳, 즉 받는 쪽 시스템이 한꺼번에 몰리는 트래픽을 못 받아주는 경우에 쓴다. 예를 들어:
- Stripe API 호출 — Stripe는 1초에 100건만 받음. 토큰 버킷처럼 적립해뒀다 한꺼번에 200건 보내면 외부에서 막힌다. 누출 버킷이 1초에 100건씩 천천히 흘려보내야 안전.
- SMS / 이메일 발송 (AWS SES 14/s 등) — 초과 시 throttle. 비동기로 처리하니 사용자도 즉시 응답 안 기다림.
- 핫 로우가 많은 DB 쓰기 워커 — 한꺼번에 몰리면 락 경합/데드락. 평탄화하면 경합 빈도 감소.
※ 단, DB 동시 쓰기의 정확성 문제(lost update 등)는 처리율 제한이 아니라 트랜잭션·락으로 풀어야 한다. 누출 버킷은 경합 "빈도"만 낮춰주는 부수 효과.
→ 토큰 버킷 = "사용자가 빠를 때 빠르게"
→ 누출 버킷 = "받는 쪽이 못 견디는 트래픽 평탄화"
③ 고정 윈도우 카운터(Fixed Window Counter)
타임라인을 고정 길이 윈도우(예: 1분)로 나누고, 각 윈도우의 카운터를 증가시켜 임계치 초과 시 거부.
- 장점: 메모리 효율 좋음, 이해·구현 단순
- 단점: 윈도우 경계에서 트래픽이 몰리면 임계치의 2배까지 허용될 수 있음. 예: 분당 5건 한도에서 12:00:59에 5건 + 12:01:00에 5건 가능.
- 유리한 상황: 윈도우가 길어 경계 2배 문제가 무시할 만할 때. 시간/일/월 단위 쿼터 — GitHub API 시간당 5,000회, SaaS "월 1만 호출" 같은 과금성 한도. 사용자당
(count, window_start)두 값만 저장하면 돼서 메모리 초저렴(INCR + EXPIRE원자 1회). - 🛠 실무 도구: GitHub 시간당 쿼터, Reddit, 대부분의 SaaS 월 쿼터. 직접 만들 땐 Redis
INCR key; EXPIRE key 60두 줄이면 끝.
③ 고정 윈도우 카운터
한도 5/3s⚠ 경계 문제: 현재 윈도우 끝부분에서 5번 빠르게 보내고, 다음 윈도우 시작에서 또 5번 보내보세요. 1초 안에 10개가 통과합니다 — 한도(5/3s)의 거의 2배.
④ 이동 윈도우 로그(Sliding Window Log)
요청 타임스탬프를 시간 정렬 집합(Redis Sorted Set 등)에 저장. 요청이 오면 윈도우 밖 타임스탬프는 제거하고 집합 크기로 임계치 비교.
- 장점: 매우 정확. 윈도우 경계 문제 없음
- 단점: 거부된 요청도 타임스탬프를 저장하므로 메모리 사용 큼
- 🛠 실무 도구: 보안 시스템(로그인 시도 5회 잠금), 과금 정확성 중요한 LLM/결제 호출 한도. Redis Sorted Set (
ZADD+ZREMRANGEBYSCORE) 패턴.
- 로그인 시도 "60초 내 5회 실패 시 잠금"→ 사용자당 5 타임스탬프
- 결제 / LLM 호출 "분당 10회"→ 사용자당 10 타임스탬프
- 어뷰징 탐지 정확한 시간 분포가 의사결정에 직접 영향
[12:34:56.123, 12:34:56.789, 12:35:01.234, ... 13:33:59.999] ← 5,000개
{ count: 3247,
window_start: 13:00:00 }④ 이동 윈도우 로그
한도 5/5s💡 매 요청마다 5초 밖 타임스탬프가 자동으로 빠져나갑니다. 정확하지만 거부된 요청도 저장하므로 메모리가 큽니다.
⑤ 이동 윈도우 카운터(Sliding Window Counter)
고정 윈도우 카운터와 이동 윈도우 로그의 절충안. 현재 윈도우 카운터와 이전 윈도우의 일부(가중평균)로 추정.
요청 수 ≈ 현재 윈도우 카운터 + 이전 윈도우 카운터 × (윈도우에 겹치는 비율)
- 장점: 메모리 효율 + 정확도 절충
- 단점: 직전 윈도우의 요청이 균등 분포라는 가정에 의존
- 🛠 실무 도구: Cloudflare Rate Limiting이 실제로 이걸 씀 (실측 오차 0.003%로 발표). Nginx Plus의 일부 구현, 다수의 API 게이트웨이.
⑤ 이동 윈도우 카운터
한도 5/3s💡 이전 윈도우가 균등 분포라고 가정합니다. 윈도우가 진행될수록 이전 윈도우의 영향(가중치)이 줄어듭니다.
비교: 경계 공격에 세 알고리즘은 어떻게 다른가
한도 5/3s시나리오: 윈도우 경계 직전에 5개, 직후에 5개 → 0.5초 안에 10개. 각 알고리즘이 어떻게 반응하는지 보세요.
W-1 카운트: 0 / 5
저장 배열 크기: 0개
= 추정 0.00 / 5
🤔 그럼 이동 윈도우 카운터가 최강인가?
아니다. 비교 시각화에선 깔끔해 보이지만 근사치다. "이전 윈도우 요청이 균등 분포"라는 가정 위에 서 있어서, 트래픽이 한쪽에 쏠리면 어긋난다.
현재 4.5s (50% 진행)
진짜 윈도우 [1.5~4.5s] 안: 0건
카운터 추정: 5 × 0.5 = 2.5
현재 4.5s (50% 진행)
진짜 윈도우 [1.5~4.5s] 안: 5건
카운터 추정: 5 × 0.5 = 2.5
실측에선 0.003% 수준의 오차라(Cloudflare 데이터) 대부분의 일반 API엔 충분. 단, burst-heavy 트래픽이나 보안 정확성이 중요한 곳엔 부적합.
각 알고리즘이 이기는 영역이 따로 있다:
- 장기 쿼터 (시간/일/월) → 고정 윈도우 (더 단순, 경계 문제 무시할 만함)
- 보안 (로그인 시도, 어뷰징 탐지) → 이동 윈도우 로그 (정확값 필요)
- 사용자 UX, 버스트 허용 → 토큰 버킷 (적립 개념이 정책)
- 외부 API/DB 보호, 순간율 엄수 → 누출 버킷 (평탄화)
- "보통의 API 한도" (요구사항 모호) → 이동 윈도우 카운터 (안전한 기본값)
→ 트레이드오프 좋음 ≠ 최선. 시스템마다 알고리즘이 다른 게 정상.
알고리즘 비교 한눈에
| 알고리즘 | 정확도 | 메모리 | 버스트 | 구현 |
|---|---|---|---|---|
| 토큰 버킷 | 중 | 적음 | 허용 | 쉬움 |
| 누출 버킷 | 중 | 중 | 흡수 X | 중 |
| 고정 윈도우 | 낮음 (경계 문제) | 적음 | 허용 | 쉬움 |
| 이동 윈도우 로그 | 높음 | 큼 | — | 중 |
| 이동 윈도우 카운터 | 중상 | 적음 | — | 중 |
실무에선 직접 만들지 않는다
여기까지 알고리즘과 분산 처리를 다뤘지만, 실제로 회사에서 대부분의 개발자는 이걸 처음부터 구현하지 않는다. 이미 만들어진 도구를 고르고·설정·조립한다. 이론을 배우는 진짜 이유는 그 도구를 의심·비교·디버깅할 수 있기 위해서.
계층별 도구 매핑
실제 시스템은 여러 층의 보호막이 겹쳐 있다. 바깥에서 안쪽으로:
| 계층 | 대표 제품 | 주 역할 |
|---|---|---|
| 🌐 CDN / WAF | Cloudflare, AWS WAF, Fastly | IP 거친 한도, DDoS 방어 |
| 🚪 API 게이트웨이 | Kong, AWS API Gateway, Apigee | API 키별·사용자별 한도 |
| 🔄 리버스 프록시 | Nginx, HAProxy, Envoy | IP 한도 + 부하 분산 |
| ⚙ 앱 미들웨어 | express-rate-limit, Bucket4j, Flask-Limiter | 비즈니스 로직 연동 (+ Redis) |
| 🔌 외부 호출 클라이언트 | bottleneck, Resilience4j | 다운스트림 보호 (Stripe, OpenAI) |
회사 규모별 표준 조합
| 규모 | 일반적인 스택 | 직접 구현하는 것 |
|---|---|---|
| 스타트업 / 사이드 | Cloudflare(무료) + 앱 미들웨어 + Redis | 거의 없음. 라이브러리 설치·설정만 |
| 중소 | Cloudflare + Nginx + 앱 미들웨어 + Redis | 비즈니스 정책 매핑 (사용자 티어별 한도) |
| 중대규모 | + API 게이트웨이(Kong/AWS) + ElastiCache | 게이트웨이 플러그인 / Lua 스크립트 |
| 하이퍼스케일 | + 자체 분산 한도 서비스 (Lyft ratelimit 등) | 핵심 부품 직접 빌드 (Doorman, Stripe Async) |
실제 설정은 이렇게 짧다
Nginx (누출 버킷, IP당 10 req/s, 버스트 20)
http {
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
}
}
}Express (Node.js, 토큰 버킷, 사용자당 100 req/min)
import { RateLimiterRedis } from 'rate-limiter-flexible';
import Redis from 'ioredis';
const limiter = new RateLimiterRedis({
storeClient: new Redis(),
keyPrefix: 'api',
points: 100, // 한도
duration: 60, // 60초당
});
app.use(async (req, res, next) => {
try {
await limiter.consume(req.user.id);
next();
} catch {
res.status(429).send('Too Many Requests');
}
});Cloudflare (대시보드 클릭만)
- Rules → Rate limiting rules
- "같은 IP에서 1분에 100건 넘으면 차단" 입력 → 저장 끝
- 알고리즘은 내부적으로 이동 윈도우 카운터(2,500개 데이터센터에 분산)
그럼 이론은 어디서 쓰나?
- 도구 비교/선택 — "Nginx로 충분한가, Kong까지 가야 하나?"
- 설정 의미 이해 — Nginx
burst=20이 누출 버킷의 큐 크기란 걸 알아야 정확히 튜닝 - 장애 디버깅 — "윈도우 경계마다 모니터링이 튐" → 고정 윈도우 경계 문제로 추정 가능
- 특수 요건 직접 구현 — "사용자 티어×엔드포인트×시간대별 한도" 같은 비표준 요건. 1~5%의 경우.
- 인프라 팀과 소통 — 같은 언어로 이야기 가능
💡 책이 가르치는 원리 ↔ 실무가 다루는 도구. 둘은 같은 것을 다른 추상화 수준에서 본다. 원리를 모르면 도구를 영업 자료대로 믿고, 도구를 모르면 원리만 알고 못 만든다. 양쪽을 오갈 수 있어야 진짜 시스템을 책임질 수 있음.
3단계: 상세 설계
처리율 제한 규칙
규칙은 보통 설정 파일(YAML/JSON)로 디스크에 저장하고 메모리에 캐시. 예시:
domain: messaging
descriptors:
- key: message_type
value: marketing
rate_limit:
unit: day
requests_per_unit: 5처리율 한도 초과 트래픽 처리
거부할 때의 선택지:
- HTTP 429 Too Many Requests를 즉시 반환하거나
- 중요 요청은 큐에 보관 후 나중에 처리(소프트 한도) 가능
응답에 다음 헤더를 함께 반환하면 클라이언트가 적응할 수 있다.
X-Ratelimit-Limit— 윈도우당 최대 요청 수X-Ratelimit-Remaining— 윈도우 안 남은 요청 수X-Ratelimit-Reset— 한도가 리셋되는 시각Retry-After— 클라이언트가 재시도까지 기다려야 할 시간
전체 아키텍처
Redis가 표준 저장소인 이유: 초고속, 만료 시간(TTL) 지원, 원자적 연산 (INCR, EXPIRE). 카운터를 디스크 DB에 두면 매 요청마다 디스크 I/O가 들어가 너무 느리다.
분산 환경의 두 가지 도전
경쟁 조건(Race Condition)
"GET counter → +1 → SET counter" 패턴은 두 요청이 동시에 들어오면 둘 다 같은 값을 읽고 같은 값을 쓰게 돼 카운트 누락 발생. 해법:
- Lua 스크립트 — Redis는 Lua 스크립트를 원자적으로 실행. 여러 명령을 한 단위로 묶음.
- 정렬 집합(Sorted Set) — 이동 윈도우 로그 구현 시 자연스러운 원자성 제공.
- 락(Lock) — 가능하지만 성능 저하 큼. 마지막 수단.
동기화(Synchronization)
처리율 제한 서버가 1대뿐이면 카운터를 메모리에 두면 끝. 그런데 트래픽이 커지면 처리율 제한 서버 자체도 여러 대로 늘려야 한다. 이때 문제:
서버1이 사용자 A의 카운트를 4로 알고 있어도, 다음 요청이 서버2로 가면 서버2는 그 사실을 모른다. 한도가 사용자당 5건인데 서버 N대 있으면 최대 5N건까지 통과한다. 해법:
해법 1: 고정 세션 (Sticky Session)
로드밸런서가 "사용자 A는 무조건 서버1로" 같은 규칙을 적용. 카운터는 각 서버 메모리에 둠.
- 장점: 단순. Redis 없어도 됨.
- 단점: 서버1이 죽으면 사용자 A의 카운트도 같이 사라짐 (결함 감내 약함). 트래픽 분포 불균형 발생 가능 (인기 사용자가 한 서버에 몰림).
해법 2: 중앙 저장소 (Redis) ⭐ 표준
모든 처리율 제한 서버가 공통의 한 곳에 카운터를 저장. 어느 서버로 요청이 가도 같은 데이터를 읽고 쓴다.
잠깐, Redis가 뭔데?
메모리(RAM)에 데이터를 저장하는 데이터베이스. 디스크 기반인 MySQL/PostgreSQL과 달리 모든 데이터를 RAM에 둬서 매우 빠르다.
| MySQL/PostgreSQL | Redis | |
|---|---|---|
| 저장 위치 | 디스크 | 메모리 |
| 속도 | ~수 ms | ~수십 µs (1000배) |
| 데이터 모델 | 테이블 (행/열) | 키-값 (단순) |
| 영속성 | 강함 | 약함 (서버 죽으면 일부 손실 가능) |
비유: MySQL = 창고(안전, 크지만 느림), Redis = 책상 위 메모지(빠르지만 작고 휘발성). 처리율 제한처럼 매 요청마다 카운터 한 번 증가 같은 작업엔 메모지가 딱 맞음.
왜 Redis가 처리율 제한의 표준인가
- 속도 — 디스크 DB로 카운터 관리하면 매 요청에 5ms × 초당 1만 요청 = 디스크 폭주. Redis는 1/1000 시간이라 사용자 응답 지연에 거의 영향 없음.
- 원자적 연산 —
INCR한 줄이 "읽고 +1 해서 쓰기"를 한 번에 처리. 두 요청이 동시에 들어와도 카운트 누락 없음 (race condition 해결).# 사용자 1234의 분당 카운터를 1 증가 INCR ratelimit:user:1234:202604261430 # 결과로 새 카운트가 반환됨, 5보다 크면 거부
- 자동 만료(TTL) — 윈도우가 끝나면 키가 자동 삭제. 청소 코드 불필요.
INCR ratelimit:user:1234:14:30 EXPIRE ratelimit:user:1234:14:30 60 # 60초 뒤 자동 삭제
- 풍부한 자료구조 — 알고리즘별로 딱 맞는 자료구조 제공:
- 고정 윈도우 → 단순 키 + INCR
- 이동 윈도우 로그 → Sorted Set (시간순 정렬)
- 토큰 버킷 → Lua 스크립트로 복잡한 원자 연산
- 적당한 영속성 손실 허용 — 서버 죽었을 때 처리율 카운터가 잠깐 사라져도 시스템 안 망함 (몇 초간 한도 초과 통과될 뿐). 반대로 결제·주문 데이터는 Redis에 두면 안 됨.
실제 코드 예시 (고정 윈도우, Node.js)
import Redis from 'ioredis';
const redis = new Redis(); // 보통 매니지드 (ElastiCache 등)
async function checkRateLimit(userId: string): Promise<boolean> {
const window = Math.floor(Date.now() / 60000); // 1분 윈도우
const key = `ratelimit:${userId}:${window}`;
const count = await redis.incr(key); // ① 원자적 증가
if (count === 1) {
await redis.expire(key, 60); // ② 첫 요청에만 TTL 설정
}
return count <= 100; // ③ 한도 100/분
}서버가 100대든 1000대든 똑같이 동작. 왜냐하면 모든 서버가 같은 Redis를 보니까.
Redis도 죽으면?
이건 분산 환경의 항상 따라오는 질문이다. 보통:
- 매니지드 Redis(AWS ElastiCache, GCP Memorystore) + 복제본 → 자동 페일오버
- fail-open 정책 — Redis 죽었을 때 일단 모두 통과시킴 (가용성 우선). 잠시 한도 초과해도 시스템은 살아있음.
- 로컬 캐시 폴백 — Redis 응답 안 오면 각 서버 메모리 카운터로 임시 동작
4단계: 마무리 — 추가 논의
- 하드 vs 소프트 처리율 제한
- 하드 — 임계치 초과 시 무조건 거부
- 소프트 — 짧은 시간 임계치 초과 허용 (대신 평균은 지킴)
- OSI 다른 계층의 처리율 제한 — IPTables(L3 IP 기반), 웹 애플리케이션(L7 사용자/엔드포인트 기반) 등 계층별로 적용 가능.
- 클라이언트 측 회피 방법
- 캐시로 API 호출 자체를 줄임
- 임계치를 이해하고 짧은 시간 안에 너무 많이 보내지 않음
- 예외/에러를 우아하게 처리(지수 백오프)
- 재시도 로직에 충분한 backoff와 jitter 추가
- 결함 감내 — 처리율 제한 장치가 죽었을 때 fail-open (모두 통과) vs fail-closed(모두 차단) 정책 결정.