1. Rate Limiter란
정해진 시간 안에 허용되는 요청 수를 제한하는 장치입니다.
목적으로는 다음과 같습니다.
- 남용/DDoS 방어, 서버 자원 보호(특히 2GB 같은 저사양)
- 공정한 사용(한 유저가 자원 독점 방지)
- 비용 제어(외부 API 호출량 등)
그러면 위 목적으로 사용하되 분명히 알아야할 것이 있습니다.
- rate limiter가 요청이 왔을 때 어떤 걸로 카운터를 셀 것인가.
- 이 요청에 대해 통과를 시킬까 막을까에 대한 수학적 방법 == 알고리즘
- 알고리즘 코드가 어느 서버/ 어느 층에서 도느냐. 즉, 요청이 지나가는 경로 중 어디에 rate limiter를 심을 것이냐
→ 그래서 Rate Limiter를 도입할 때는 다음과 같은 문장이 완성되어야합니다
[3번. NGINX 에서] [1번. IP 기준으로] [2번. Token Bucket 방식으로] 분당 10개 제한한다
1,2,3번에 대한 각각 부분을 좀 더 세밀하게 공부해도록 하겠습니다.
무엇을 기준으로 셀까? (Key)
분당 10개 제한이라고 하였을 때 누구/무엇의 10개냐를 정하는 것입니다. 카운터를 무슨 단위로 묶을지.
| 기준(key) | 의미 | 쓰는 곳 |
| IP 주소 | 이 IP에서 온 요청을 셈 | NGINX 기본, 익명 트래픽 |
| 유저 ID | 로그인한 이 사람 요청을 셈 | 로그인 후 API |
| API 키 | 이 키(외부 개발자)로 온 요청 | 공개 API |
| IP + 엔드포인트 | 이 IP가 /login에 온 것만 셈 | 로그인 brute-force 방어 |
이 기준이 중요한 이유는 기준을 잘못 잡으면 엉뚱하게 막히게 됩니다. 예를 들어서
- IP로만 세면 → 회사/카페 같은 IP 쓰는 100명이 한 덩어리로 묶여 억울하게 차단.
- 유저 ID로 세면 → 정확하지만 로그인 안 한 요청(회원가입, 로그인 자체)은 유저ID가 없어서 못 셈.
그래서 상황마다 key를 다르게 구분하는 것도 생각을 해봐야합니다. 로그인 전엔 IP, 로그인 후엔 유저 ID형태
어떤 알고리즘으로 셀까
어떻게 세느냐" 의 방식입니다. 이 알고리즘들은 크게 5가지 정도가 있습니다. 이 알고리즘들은 같은 분당 10개라도 세는 방식에 따라 동작이 달라집니다.
각 알고리즘들을 공부해보았습니다. 전부 분당 5개 허용 기준으로 공부해보았습니다.
Fixed Window Counter (고정 윈도우)
- 시간을 고정 구간으로 쪼개고, 구간마다 카운터를 세는 방법입니다.
[10:00:00~10:00:59] 카운터=0 → 요청마다 +1, 5 넘으면 거부
[10:01:00~10:01:59] 카운터=0으로 리셋
장점은 구현 초간단 (Redis INCR + EXPIRE 한 줄)하고, 메모리 소비량이 작습니다.
단점으로는 정말 치명적인 문제가 있습니다. 구간 경계에서 최대 2배까티 통과할 수 있는 경계 문제(burst at boundary)가 있습니다.
10:00:59 에 5개 + 10:01:00 에 5개
→ 실제로 1초 사이에 10개가 몰림 (한도 5인데!)
이 알고리즘은 시간을 딱 잘린 구간으로 나누고 구간이 바뀌면 카운터를 0으로 리셋합니다. 경계문제는 실제 흐르는 시간이 아니라 시계의 정각 기준으로 돌리게 됩니다.
구간 A: [10:00:00 ~ 10:00:59] 카운터 A
구간 B: [10:01:00 ~ 10:01:59] 카운터 B ← 10:01:00 되는 순간 0으로 리셋
카운터 A와 B는 서로를 전혀 모릅니다. A가 5개를 다 썼든, 말든, B는 10:01:00에 0에서 시작합니다.
그러면 타임라인으로
시각 요청 구간 카운터 판정
─────────────────────────────────────────────
10:00:57 1개 A A=1 통과
10:00:58 1개 A A=2 통과
10:00:59 3개 A A=3,4,5 통과 (A 꽉 참)
─────────── 10:01:00 정각, 카운터 리셋! ───────────
10:01:00 3개 B B=1,2,3 통과 (B 새 구간!)
10:01:01 2개 B B=4,5 통과 (B 꽉 참)
각 구간만 보면 A도 5개, B도 5개로 규칙을 지켰습니다. 하지만
문제의 핵심은 “구간 경계를 걸친 실제 시간 창”입니다.
10:00:57 ~ 10:01:01 이라는 실제 연속된 5초를 보면
A에서 5개 (10:00:57~59) + B에서 5개 (10:01:00~01) = 10개
어떤 연속된 1분(예: 10:00:30~10:01:29)을 잘라봐도 최대 10개가 들어올 수 있습니다. 한도는 5인데 실제론 2배(10개)가 통과해버린 것입니다.
즉, 규칙은 분당 5개인데 구간 경계 근처에 몰아치면 짧은 시간에 2배를 뚫을 수 있습니다.
쉽게 말하면 시간을 자르는 방식이 2가지 종류이기 때문입니다.
- A: 알고리즘이 자르는 창 - 시계 정각 기준으로 고정
- B: 실제로 우리가 지키고 싶은 창 - 아무 60초
Fixed Window는 A로만 검사하는데 B는 어느 구간을 봤을 때 한도를 넘을 수 있다느 것입니다.
그러면 이 문제가 왜 심각하냐면 이 알고리즘은 정각~정각 사이 5개만 보장합니다. 근데 공격자가 B방식으로 (정각을 걸쳐서) 자르면 10개가 잡히게 됩니다. 즉 알고리즘은 A로 5개 지켰다고 하는데 실제 세상에서는 10개가 들어온 것입니다.
그래서 Fixed Window는 자기가 정한 위치(정각)에서만 5개를 보장하고 다른 위치(경계 걸침)에선 최대 10개가 되는 걸 못 습니다.
Sliding Window Log (슬라이딩 로그)
모든 요청의 타임스탬프를 리스트로 저장하는 방식입니다. 매 요청마다 지금부터 1분 이내 개수를 셉니다.
요청 올 때: 1분 지난 타임스탬프 제거 → 남은 개수 < 5 면 허용하고 현재시각 기록
장점은 가장 정확하고 Fixed Window에서 나온 경계 문제가 없습니다.
하지만 단점으로는 요청마다 타임스탬프를 다 저장해야하기에 메모리를 많이 먹습니다. 초당 수천 요청이면 폭팔할 수도 있습니다. 구현은 Redis Sorted Set으로 구현할 수 있지만 비용이 비싸게 됩니다.
또한, 추가적인 단점으로는 clock skew(시계 오차)가 있습니다.
분산처리에서는 각 서버의 시계가 완벽히 똑같지 않습니다. NTP로 맞추어도 수십~수백ms씩 차이가 날 수 있습니다.
Spring #1 시계: 10:00:00.000
Spring #2 시계: 10:00:00.300 ← 300ms 빠름
Sliding Window Log는 지금부터 1분 이내 타임스탬프를 세는데 이 지금(현재시각)과 저장되는 타임스탬프를 누가 찍느냐가 문제가 됩니다.
만약 각 인스턴스가 자기 로컬 시계로 타임스탬프를 찍어서 Redis에 넣으면
#1이 찍은 10:00:59.900 과 #2가 찍은 10:01:00.100
→ 실제론 #2 시계가 빨라서 진짜 순서가 뒤바뀔 수도
→ "1분 이내" 판정이 인스턴스마다 미묘하게 달라짐
이것이 창 경계 계산이 어긋나서 한도를 살짝 초과 통과시키거나, 반대로 억울하게 일찍 거부할 수도 있습니다.
해결책으로는 시계를 하나로 통일할 수 있습니다. 즉, 단일 시계 하나를 기준으로 삼는 방법입니다.
- Redis 자신의 시간을 쓴다. 이 방법은 제일 흔한 방법입니다.
- NTP로 서버 시계 동기화 → 오차를 줄이지만 0으로 만들진 못한다. == 보조 방법
- 작은 오차는 그냥 감수
Sliding Window Counter (슬라이딩 카운터)
Fixed Window의 가벼움과 Sliding Log의 정확함을 합친 버전입니다. 즉, 현재 윈도우 + 이전 윈도우 카운터를 가중 평균한 것입니다.
현재 윈도우 진행률이 30%라면:
추정치 = 이전윈도우_카운트 × (1 - 0.3) + 현재윈도우_카운트
= 이전 5개 × 0.7 + 현재 2개 = 5.5 → 5 초과 → 거부
예시를 들어 한 번 자세하게 보겠습니다.
- 카운터를 2개 유지하게 됩니다. → 이전 구간, 현재 구간
이전 윈도우 [10:00:00~10:00:59] 카운트 = 5
현재 윈도우 [10:01:00~10:01:59] 카운트 = 2
지금 시각 = 10:01:18 → 현재 윈도우 18초 지남 = 진행률 30%
이 알고리즘의 목표는 지금 기준 뒤로 60초를 보는 것입니다.
지금이 10:01:18이니
진짜 보고 싶은 창 = [10:00:18 ~ 10:01:18] (딱 60초)
이 60초 창을 그림으로 보면 두 구간에 거려있습니다
이전 윈도우 현재 윈도우
[10:00:00 ────────── 10:00:59][10:01:00 ──── 10:01:18 ──── 10:01:59]
|◄── 겹침 (70%) ──►||◄─ 겹침(30%) ─►|
10:00:18 10:01:18
- 이전 윈도우의 뒤쪽 70% (10:00:18~10:00:59)가 겹침
- 현재 윈도우의 앞쪽 30% (10:01:00~10:01:18)가 겹침
문제는 이전 윈도우 요청이 언제 왔는지 모릅니다.
→ 이전 윈도우에 5개가 왔다는 거는 알 수 있지만 그게 앞에 몰렸는지 뒤에 몰렸는지에 대한 기록이 없습니다.. 그래서 고르게 퍼져 있다고 가정합니다.
고르게 퍼졌다면 겹치는 70% 구간엔 5개 중 70%인 3.5개가 있다고 추정합니다.
이전 윈도우 기여분 = 5 × 0.7 = 3.5
현재 윈도우 2개는 지금 창 안에 확실히 있으니 그대로
현재 윈도우 기여분 = 2
추정치 = 3.5 + 2 = 5.5 → 한도 5 초과 → 거부
공식을 정리하면 다음과 같습니다.
추정치 = 이전카운트 × (1 - 진행률) + 현재카운트
(1-진행률)이 왜 이전 윈도우 가중치냐면 현재 윈도우가 진행될 수록 60초 차으이 꼬리가 이전 윈도우 밖으로 빠져나가서 겹치는 부분이 점점 줄어들게 됩니다.
진행률 0% (10:01:00): 창 = [10:00:00~10:01:00] → 이전 윈도우 전체 겹침 → 가중치 1.0
진행률 30% (10:01:18): 이전 윈도우 70% 겹침 → 가중치 0.7
진행률 50% (10:01:30): 이전 윈도우 50% 겹침 → 가중치 0.5
진행률 100%(10:01:59): 이전 윈도우 0% 겹침 → 가중치 0.0
즉 시간이 갈수록 이전 윈도우의 영향력이 선형적으로 사라집니다. 그게 1 - 진행률 입니다.
장점으로는 메모리가 적고 경계 문제는 대두분 해결 됩니다. 보통 NGINX, 대형 서비스가 실제로 많이 쓰는 방법입니다.
단점으로는 근사치라 100% 정확하지는 않다는 것입니다.
Token Bucket (토큰 버킷)
이 알고리즘은 버킷에 토큰이 일정 속도로 채워지고, 요청은 토큰 1개를 소모하게 됩니다. 토큰이 없으면 거부하게 됩니다.
버킷 용량 = 5 (최대 저장)
채우는 속도 = 분당 5개 (12초에 1개)
- 요청 오면 토큰 1개 소모
- 안 쓰면 토큰이 쌓임(최대 5까지) → 잠깐 몰리는 burst 허용
- 비어 있으면 거부
조금 더 자세하게 본다면 비유부터 하자면 동전 저금통이라고 볼 수 있습니다. (위 예제를 좀 더 분석)
- 12초 마다 동전(토큰) 1개가 저금통에 자동으로 떨어집니다.
- 저금통은 최대 5개까지만 담깁니다. (넘치면 버려짐)
- 요청 1개 = 동전 1개 꺼내 쓰기 동전 옶으면 거부
안 쓰고 놔두면 동전이 5개까지 쌓이며 그러면 그 수간 5개를 한 번에 꺼내 쓸 수 있습니다. 이것이 brust입니다.
타임라인으로 보다면 다음과 같습니다.
시각 이벤트 토큰 잔량 판정
──────────────────────────────────────────────
10:00:00 시작 (가득) 5
...1분간 아무 요청 없음... (충전돼도 5가 max라 그대로)
10:01:00 토큰 = 5 (꽉 참) 5
10:01:00 요청 1 5→4 통과
10:01:00 요청 2 4→3 통과
10:01:00 요청 3 3→2 통과 ← 같은 순간에
10:01:00 요청 4 2→1 통과 5개가
10:01:00 요청 5 1→0 통과 ← 한꺼번에 통과! (burst)
10:01:00 요청 6 0 거부 (토큰 없음)
10:01:12 (12초 경과, +1 충전) 0→1
10:01:12 요청 7 1→0 통과
10:01:00에 동시에 5개가 몰렸지만 다 통과했습니다. 평소에 안 써서 토큰 5개를 쌓여뒀기 때문이죠. 하지만 6번째는 즉시 거부 그리고 그 뒤론 12초에 1개씩만 통과하게 됩니다.
2개의 파라미터가 각각 뭘 정하는지 본다면
Token Bucket은 이 두 개로 움직입니다.
| 파라미터 | 의미 | 위 예시 |
| 충전 속도 (refill rate) | 장기 평균 허용 속도 | 12초당 1개 = 분당 5개 |
| 버킷 용량 (capacity) | 한 번에 몰아칠 수 있는 최대량 (burst 크기) | 5개 |
- 충전 속도는 길게 보면 결국 이 속도로 수렴한다를 정하게 됩니다. 버스트로 5개 써버려도 그 후엔 12초당 1개 페이스로 강제됩니다.
- 용량은 얼마나 몰아칠 수 있나를 정하게 됩니다. 용량이 크면 큰 버스트 허용 작으면 빡빡하게 진행 됩니다.
장점은 현실 트래픽은 뚝뚝 끊겨서 요청이 몰려옵니다 예를 들어
- 사용자가 앱을 켜는 순간 API 5~6개 동시에 호출(프로필 + 알림 + 목록..)
- 평소엔 조용하다가 이 순간만 확 몰리게 됩니다.
초당 1개로 딱 자르는 (leaky bucket 식 - 밑에서 하게 됩니다) 방식이면 이 정상적인 앱 켜기도 막혀서 사용자 경험이 나빠집니다. Token Bucket은 평소 안 쓴 토큰을 쌓아뒀다가 이 순간 버트스를 허용하니 정상 사용자는 매끄럽게 쓰고, 지속적인 남용만 막습니다.
또 다른 장점으로는 상태가 토큰수/시각 2개뿐이라 가볍습니다.
사용되는 곳들은 bucket4j(자바), AWS API Gateway, Stripe 등. NGINX limit_req의 burst 옵션이 이 개념입니다.
Leaky Bucket (리키 버킷)
이 알고리즘은 요청을 큐에 넣고 일정 속도로만 처리하는 방법입니다. 큐가 다 차면 거부하게 됩니다
큐 처리 속도 = 12초에 1개 (분당 5개)
요청 → 큐에 쌓임 → 일정 속도로 빠져나감 → 큐 꽉 차면 거부
비유로 하면 바닥에 구멍 뚫린 양동이 입니다.
- 위에서 물(요청)이 아무 속도로나 쏟아져 들어오게 되빈다.
- 바닥 구멍으로는 항상 일정한 속도로만 물이 빠져나가게 됩니다(12초 1개)
- 양동이가 넘치면(큐가 꽉 차면) 그 물은 버려집니다.(거부)
들어오는 건 들쭉날쭉해도, 나가는 건 무조건 일정합니다.
타임라인으로 보면 (처리속도 12초당 1개, 큐 용량 5)
시각 들어옴 큐 상태 나감(처리) 비고
─────────────────────────────────────────────────────────
10:00:00 5개 몰림 [■■■■■] (5/5) - 5개가 확 들어와 큐에 쌓임
10:00:00 +1개 큐 꽉참! - → 거부 (넘침)
10:00:12 - [■■■■_] (4/5) 1개 처리 12초마다
10:00:24 - [■■■__] (3/5) 1개 처리 1개씩
10:00:36 - [■■___] (2/5) 1개 처리 일정하게
10:00:48 - [■____] (1/5) 1개 처리 빠져나감
10:01:00 - [_____] (0/5) 1개 처리
10:00:00에 5개가 동시에 몰렸지만, 실제 처리는 12초 간격으로 하나씩 나가게 됩니다. 요청들은 큐에서 기다렸다가 순서대로 처리됩니다. 6번째는 큐가 꽉 차서 거부.
즉 출력이 완벽하게 평탄합니다. 아무리 몰려와도 뒷단 예를 들어 Spring/DB엔 항상 12초에 1개씩 전달됩니다.
Token Bucket과의 결정적 차이는
| Token Bucket | Leaky Bucket | |
| 양동이에 든 것 | 토큰(허가증) | 요청(대기열) |
| 요청 처리 | 토큰 있으면 즉시 통과, 없으면 거부 | 큐에 넣고 기다렸다가 일정속도로 처리 |
| 지연(대기) | 없음 (통과 아니면 거부) | 있음 (큐에서 대기) |
| 출력 모양 | 버스트 가능 (쌓인 토큰만큼 순간 폭발) | 항상 평탄 (일정 속도) |
| 성격 | 버스트 허용 | 버스트 평탄화(흡수) |
그림으로
입력: ●●●●● (5개 동시에)
Token Bucket: ●●●●● ──────── ← 쌓인 토큰으로 5개 즉시 통과, 그 후 거부/대기
Leaky Bucket: ● ─ ● ─ ● ─ ● ─ ● ← 5개를 일정 간격으로 하나씩 흘려보냄
- Token: 지금 몰아쳐도 돼(토큰 있으면). 대신 순간 부하는 뒷단이 감당해야 함.
- Leaky: 몰아쳐도 뒷단엔 일정하게만 갈게. 대신 요청들은 기다려야 함(지연).
즉 Token은 응답이 빠른 대신 뒷단에 순간 부하, Leaky는 뒷단이 편한 대신 요청에 지연이 생기게 됩니다. 트레이드오프가 반대입니다.
NGINX limit_req에 매핑이 되게 됩니다. NGINX가 딱 이 Leaky Bucket 방식입니다.
limit_req_zone $binary_remote_addr zone=api:10m rate=5r/m;
location /api/ {
limit_req zone=api burst=5; # ← 큐 용량 = 5
}
- rate=5r/m = 구멍에서 빠지는 속도 (12초당 1개).
- burst=5 = 큐(양동이) 크기. 초과분을 5개까지 대기시킴.
- 큐 넘으면 → 503/429 거부.
nodelay 옵션의 의미:
limit_req zone=api burst=5 nodelay;
- nodelay 없으면: burst 요청을 큐에 넣고 천천히(12초 간격) 처리 → 요청이 지연됨(순수 leaky).
- nodelay 있으면: burst 한도 내 요청을 기다리게 하지 않고 즉시 통과시킴 → 이 순간만은 Token Bucket처럼 동작. (대신 토큰 자리는 여전히 rate 속도로 회복)
실무에서는 burst=N nodelay을 많이 쓴다고 합니다. 정상적인 순간 몰림은 즉시 통과시키되 지속적 남용은 막는 절충
Leaky를 쓰는 상황은
- 뒷단(DB, 외부 API)이 일정 속도 이상 못 버틸 때 - 무조건 평탄하게 흘려보내야 안전
- 트래픽 shaping(대역폭 제어, 결제/외부 연동 호출량 조절 등).
- 반대로 사용자 대면 API에서 응답 지연이 싫으면 Token Bucket(또는 nodelay)이 나음.
요약 비교표
| 알고리즘 | 정확도 | 메모리 | 버스트 | 구현 난이도 |
| Fixed Window | 낮음(경계문제) | 적음 | 경계서 2배 샘 | 매우 쉬움 |
| Sliding Log | 최고 | 많음 | 없음 | 중간 |
| Sliding Counter | 높음 | 적음 | 거의 없음 | 중간 |
| Token Bucket | 높음 | 적음 | 허용 | 쉬움 |
| Leaky Bucket | 높음 | 적음 | 평탄화 | 중간 |
어디서 셀까
카운팅 로직을 어느 위치에 둘거냐를 말해볼 수 있습니다. 다음은 예시 입니다.
클라이언트 → [CDN/WAF] → [NGINX/게이트웨이] → [애플리케이션] → [DB]
(엣지) (네트워크, IP) (유저/비즈니스)
- 엣지(CDN/WAF): 대규모 DDoS 흡수. Cloudflare 등.
- 게이트웨이/NGINX: IP 단위 거친 제한. 요청이 앱에 닿기 전 차단 → 제일 쌈.
- 애플리케이션: 유저, API키, 등급별 정밀 제한. 비즈니스 규칙 반영.
"앞단에서 거를수록 싸다"라고 말할 수 있습니다. 뒤로 갈수록 이미 자원을 쓴 뒤라 비싸게 됩니다. 즉, 위치에 따라 비용과 정밀도가 트레이드오프가 됩니다.
분산 환경의 어려움 (Redis)
인스턴스가 여러 개면 카운터를 공유해야 함 여기서 2가지 함정이 있습니다.
1. 원자성(atomicity) / race condition
동시에 두 요청이:
read 카운터=4 → 둘 다 "4<5니까 OK" → 둘 다 +1 → 실제 6 (한도 초과!)
read-modify-write가 쪼개지면 초과 통과하게 됩니다. Redis Lua 스크립트나 INCR(원자적 연산)로 "읽고-검사-증가"를 한 번에 처리해야 함. bucket4j-redis 같은 라이브러리가 이걸 해주게 됩니다.
2. 네트워크 비용
매 요청마다 Redis 왕복 → 지연 추가. 그래서 로컬 캐시 + 주기적 동기화 같은 최적화도 있습니다.
실무 (HTTP 규약)
- 초과 시 429 Too Many Requests 반환.
- 헤더로 상태 알려주기
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 30 (초 후 리셋)
Retry-After: 30 (재시도까지 대기 초)
- key 전략: IP / 유저ID / API키 / (IP+엔드포인트) 조합. 로그인 API는 IP+계정 조합으로 brute-force 방어.
- Fail-open vs fail-closed: rate limiter(Redis)가 죽으면? 요청을 통과시킬지(open, 가용성 우선) 막을지(closed, 보안 우선) 미리 정해두기.
'프로젝트 > toIT' 카테고리의 다른 글
| 2GB 서버를 지키는 가장 싼 방법 Rate Limiter 도입기 (0) | 2026.07.04 |
|---|---|
| Docker 및 Docker Compose 도입 - 7/1 (0) | 2026.07.02 |
| 통합검색 성능 개선기 (0) | 2026.07.02 |
| Spring의 요청 처리 순서 (0) | 2026.07.02 |
| OSIV (0) | 2026.07.02 |