일정 시간 안에 처리할 요청 수를 제한하는 장치.
왜 필요한가
- 특정 사용자가 자원을 독점하는 것을 막는다
- 악의적 공격(무차별 대입, 스크래핑)을 완화한다
- 하위 시스템·외부 API의 한도를 지킨다
- 비용을 통제한다 (호출당 과금되는 서비스)
- 과부하로부터 시스템을 보호한다
서킷 브레이커가 "나가는 호출"을 보호한다면, rate limit은 "들어오는 요청"을 막는다.
알고리즘 네 가지
① 고정 윈도우 (Fixed Window) 10:00:00~10:00:59 에 100개 허용 가장 단순하지만 경계 문제가 있다.
10:00:59 에 100개 ┐
10:01:00 에 100개 ┘ → 1초 사이에 200개가 통과한다
② 슬라이딩 로그 (Sliding Log) 각 요청의 타임스탬프를 전부 저장하고, "지금부터 1분 전까지"를 센다 정확하지만 메모리를 많이 쓴다.
③ 슬라이딩 윈도우 (Sliding Window Counter) 현재 윈도우 카운트 + 이전 윈도우 카운트 × 겹치는 비율
- 10:00 윈도우 80개, 10:01 윈도우 30개, 현재 10:01:15 (25% 경과)
- 30 + 80 × 0.75 = 90 정확도와 메모리의 좋은 절충이라 실무에서 널리 쓰인다.
④ 토큰 버킷 (Token Bucket) 버킷에 초당 N개씩 토큰이 채워진다 (최대 M개) 요청 하나당 토큰 하나를 쓴다. 없으면 거부
- 평소 안 쓰면 토큰이 쌓여 있다가 순간적으로 몰아 쓸 수 있다 (버스트 허용)
버스트를 허용하는 것이 장점이다. 사용자 경험이 자연스럽다 — 평소 조용하던 사용자가 잠깐 여러 번 클릭하는 것은 정상 행동이기 때문이다. AWS·Stripe 등 많은 서비스가 이 방식을 쓴다.
누출 버킷(Leaky Bucket) 은 반대로 출력 속도를 일정하게 만든다. 버스트를 허용하지 않고 평탄화한다.
분산 환경에서
- 서버가 5대인데 각자 세면?
- 각 서버가 100개씩 허용 = 실제로 500개 통과 ✗
Redis에 중앙 집중시켜야 한다.
INCR ratelimit:user:123:1704067200 EXPIRE ratelimit:user:123:1704067200 60
원자성이 중요하다 — INCR과 EXPIRE 사이에 문제가 생기면 키가 영원히 남는다. Lua 스크립트로 묶어 원자적으로 실행한다.
무엇을 기준으로 셀 것인가
| 기준 | 특징 |
|---|---|
| IP | 로그인 전에도 가능. 단, NAT·회사망은 여러 명이 한 IP |
| 사용자 ID | 정확하지만 로그인 후에만 |
| API 키 | B2B API에 적합 |
| 엔드포인트별 | 로그인 5회/분, 조회 1000회/분처럼 차등 |
여러 층으로 겹치는 것이 실무적이다 — IP 기준 전역 제한 + 사용자 기준 제한 + 민감한 엔드포인트 추가 제한.
응답 — 표준 헤더
HTTP/1.1 429 Too Many Requests
Retry-After: 30
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1704067260
Retry-After를 반드시 준다. 클라이언트가 언제 다시 시도할지 알 수 있어,
무작정 재시도해 부하를 더하는 것을 막는다.
설계 시 고려할 것
- ① 제한에 걸리는 것이 정상 사용자인가 공격자인가
- 정상 사용자가 자주 걸리면 한도가 잘못된 것
- ② 429가 사용자에게 어떻게 보이는가
- "잠시 후 다시 시도해 주세요" + 남은 시간 표시
- ③ 화이트리스트
- 내부 시스템·모니터링은 제외
- ④ 점진적 제재
- 즉시 차단보다 지연(throttling)이 나은 경우도 있다
스프링에서
// Bucket4j + Redis
Bucket bucket = Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.intervally(100, ofMinutes(1))))
.build();
if (!bucket.tryConsume(1)) {
return ResponseEntity.status(429).header("Retry-After", "60").build();
}
보통 Filter나 Interceptor에 두어 컨트롤러 진입 전에 차단한다.