[Redis] 레이트 리미팅 구현: 고정 윈도, 슬라이딩 윈도, 토큰 버킷 비교와 Lua 원자화
![[Redis] 레이트 리미팅 구현: 고정 윈도, 슬라이딩 윈도, 토큰 버킷 비교와 Lua 원자화](https://blog.kakaocdn.net/dna/bH8BTl/dJMcafGToaD/AAAAAAAAAAAAAAAAAAAAAFV8nvka09VuaYw8Uq5z8qtb27SS1AOUqg3EvIRXT7bL/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1785509999&allow_ip=&allow_referer=&signature=1Iw5pLfg4%2B2K3br670dCASw7f%2Bg%3D)
[Redis] 레이트 리미팅 구현: 고정 윈도, 슬라이딩 윈도, 토큰 버킷 비교와 Lua 원자화
API에 "1분에 100번까지"처럼 호출 횟수를 제한하는 게 레이트 리미팅이에요. 어뷰징을 막고 트래픽 폭주로부터 시스템을 지켜요. 요금제별 한도를 거는 데도 써요. 그런데 서버가 여러 대면 각자 세면 안 되고 모든 서버가 같은 카운터를 공유해야 해요. 그래서 Redis로 구현해요.
레이트 리미팅을 왜 굳이 Redis로 하는지부터 짚고 고정 윈도·슬라이딩 윈도·토큰 버킷이 각각 어떻게 다른지 장단점을 비교한 뒤, Lua로 원자적으로 구현하는 법까지 가 볼게요. 원자성은 트랜잭션과 Lua 글을 먼저 보면 좋아요.
01. 왜 Redis인가
레이트 리미팅의 핵심은 카운터예요. "이 사용자가 이번 윈도에 몇 번 호출했나"를 세서 한도와 비교해요. 문제는 서버가 여러 대일 때예요.

서버마다 자기 메모리에 카운터를 두면, 사용자가 서버 A·B·C로 분산되면서 각 서버는 1/3씩만 세요. 그래서 실제로는 한도의 3배를 허용하게 돼요. 카운터를 모든 서버가 공유해야 정확한데, 그 공유 카운터 저장소로 Redis가 딱이에요. INCR이 원자적이라 동시성 걱정도 없고요.
02. 고정 윈도 (Fixed Window)
가장 단순해요. "1분"처럼 시간을 고정 구간으로 나누고 그 구간의 카운터를 올려요. 한도를 넘으면 거부해요.
// 키: rate:user:1:202607301230 (분 단위)
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofMinutes(1));
}
boolean allowed = count <= 100;

구현이 쉽고 메모리도 적게 쓰지만 경계 함정이 있어요. 12시 00분 59초에 100번, 12시 01분 00초에 또 100번을 치면, 각 윈도 안에서는 합법이라 1초 사이에 200번이 통과해요. "1분에 100회"라는 약속이 윈도 경계에서 순간적으로 2배 새는 거예요. 정밀하지 않아도 되는 곳엔 충분하지만 엄격해야 하면 부족해요.
03. 슬라이딩 윈도 (Sliding Window)
경계 문제를 풀려고 윈도를 "지금부터 과거 1분"으로 미끄러뜨려요. 두 방식이 있어요.
슬라이딩 윈도 로그는 모든 요청의 타임스탬프를 저장(Sorted Set)하고 매번 1분보다 오래된 건 지운 뒤 남은 개수를 세요. 정확하지만 요청마다 타임스탬프를 다 저장하니 메모리를 많이 써요.
// Sorted Set에 요청 시각 기록, 윈도 밖은 제거
zSet.add(key, requestId, now);
zSet.removeRangeByScore(key, 0, now - 60_000); // 1분 이전 제거
Long count = zSet.size(key);
// 한도 초과로 거부했다면 방금 add한 멤버를 다시 빼야 해요 — 남겨두면 다음 판정을 왜곡해요
슬라이딩 윈도 카운터는 절충이에요. 현재 구간과 직전 구간의 카운터를 가중 평균해서 근사해요. 예를 들어 현재 구간이 30% 지났으면 직전 구간 카운트의 70%에 현재 구간 카운트를 더해서 추정해요. 로그처럼 모든 요청을 저장하지 않으니 메모리를 훨씬 적게 쓰면서, 고정 윈도의 경계 문제를 상당히 완화해요. 정확도와 비용의 균형이 좋아서 대부분의 실무에는 이 방식이 잘 맞아요.
04. 토큰 버킷 (Token Bucket)
버스트(순간적인 몰림)를 허용하고 싶으면 토큰 버킷이 좋아요. 버킷에 토큰이 일정 속도로 채워지고 요청마다 토큰을 하나 써요. 토큰이 있으면 통과, 없으면 거부예요.
핵심은 버킷에 토큰이 쌓일 수 있다는 거예요. 한동안 호출이 없었으면 토큰이 가득 차서 갑자기 몰리는 요청을 한 번에 받아줘요(버스트 허용). 대신 평균적으로는 채워지는 속도를 못 넘어요. "평소엔 여유롭게, 평균은 제한"이 필요한 API에 잘 맞아요. 슬라이딩 윈도가 매끈하게 제한한다면, 토큰 버킷은 여유를 두고 제한해요.
05. Lua로 원자적으로 구현하기
어떤 알고리즘이든 "카운트 읽고 - 판단하고 - 갱신"을 해요. 이걸 나눠서 하면 그 사이에 다른 요청이 끼어들어 카운트가 새거나 TTL이 안 걸려요. <code>INCR</code>과 <code>EXPIRE</code> 사이에 프로세스가 죽으면 TTL 없는 카운터가 영구히 남아서 그 사용자는 윈도가 영영 안 비워져요. 그래서 Lua로 한 번에 묶어요.
-- 고정 윈도: 증가 + 첫 요청 TTL + 한도 체크 (원자적)
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if count > tonumber(ARGV[2]) then
return 0 -- 거부
end
return 1 -- 허용
토큰 버킷처럼 "현재 토큰을 계산해 채우고 하나 빼는" 로직도 Lua로 묶어야 정확해요. 분산 환경에서 여러 서버가 동시에 같은 키를 건드리니, 원자성이 없으면 한도가 새요. 레이트 리미팅에서 Lua는 선택이 아니라 거의 필수예요.
06. 어떤 알고리즘을 고르나

- 고정 윈도 — 가장 단순. 경계의 2배 허용을 감수할 수 있는 느슨한 제한에.
- 슬라이딩 윈도 카운터 — 정확도와 메모리의 균형. 대부분의 실무 기본값.
- 슬라이딩 윈도 로그 — 가장 정확하지만 메모리 큼. 엄격한 소규모에.
- 토큰 버킷 — 버스트를 허용하면서 평균을 제한할 때.
대부분은 슬라이딩 윈도 카운터나 토큰 버킷으로 충분해요. 무엇을 고르든 "어느 단위로 제한할지"(사용자·IP·API 키)를 키로 잘 잡는 게 먼저예요.
07. 스프링에 적용하기
실제로는 컨트롤러마다 체크 코드를 넣지 않고 요청 앞단에서 한 번에 걸러요. 인터셉터나 필터에서 레이트 리밋을 체크하고 넘으면 429 Too Many Requests를 돌려줘요.
public class RateLimitInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object h) {
String key = "rate:" + clientId(req);
Long allowed = redisTemplate.execute(rateScript, List.of(key), "60", "100");
if (allowed == 0L) {
res.setStatus(429);
return false; // 요청 차단
}
return true;
}
}
제한 단위(키)를 무엇으로 잡을지가 중요해요. 사용자별이면 사용자 ID, 비로그인 트래픽이면 IP, 외부 API면 API 키로 잡아요. 그리고 응답 헤더에 남은 횟수(X-RateLimit-Remaining)를 실어주면 클라이언트가 스스로 조절할 수 있어요.
직접 구현 대신 Bucket4j 같은 라이브러리를 Redis와 붙여 쓰기도 해요. 토큰 버킷을 검증된 구현으로 제공하니, 알고리즘을 직접 짜기 부담스러우면 라이브러리를 쓰는 것도 방법이에요.
08. 자주 만나는 문제
한도보다 많이 통과돼요
고정 윈도의 경계 문제거나, 카운트 증가·체크가 원자적이지 않은 거예요. 슬라이딩 윈도로 바꾸거나 Lua로 원자화해요.
서버를 늘렸더니 한도가 N배가 됐어요
서버별 로컬 카운터를 쓴 거예요. 카운터를 Redis로 공유해야 전체에서 한 한도가 적용돼요.
Redis가 느려지면 API가 다 느려져요
레이트 리밋 체크가 모든 요청의 앞단에 있다는 걸 잊기 쉬워요. Redis가 느려지면 모든 API가 그만큼 같이 느려져요. 그래서 체크용 Redis 호출엔 타임아웃을 짧게 잡고 체크가 실패했을 때 통과시킬지(fail-open) 막을지(fail-closed)를 미리 정해둬야 해요. 보호 장치 자체가 단일 장애점이 되면 안 되니까요. 일반 API는 fail-open, 어뷰징 방어가 핵심인 곳은 fail-closed가 보통이에요.
정리
레이트 리미팅은 모든 서버가 공유하는 카운터로 호출을 제한하는 것이에요. 서버마다 따로 세면 한도가 서버 수만큼 새니까 카운터를 Redis로 모으고 고정 윈도·슬라이딩 윈도·토큰 버킷 중 필요한 정확도와 버스트 허용에 맞춰 골라요. "읽고-판단-갱신"은 Lua로 한 번에 묶어요. 어느 단위(사용자·IP·API 키)로 제한할지 키를 잘 잡는 게 먼저고 알고리즘은 대부분 슬라이딩 윈도 카운터나 토큰 버킷이면 충분해요.
이 처리율 제한은 Lua 원자성 위에 서 있고 캐시 사태에서 DB를 지키는 장치로도 쓰여요. 같은 흐름의 다음 주제는 세션 저장소예요.
'백엔드 > 캐시' 카테고리의 다른 글
| [Redis] Streams 메시지 큐: 소비자 그룹, XACK, PEL 재처리 그리고 Kafka 비교 (0) | 2026.06.30 |
|---|---|
| [Redis] Spring Session 분산 세션: 설정, 세션 만료 TTL, 세션 vs JWT 비교 (0) | 2026.06.29 |
| [Redis] 캐시 스탬피드, 관통, 사태: 원인과 해결 방법 (TTL jitter, 뮤텍스, Bloom Filter) (0) | 2026.06.25 |
| [Redis] 분산 락 구현: SETNX, Lua 안전 해제, Redisson watchdog, Redlock 한계 (0) | 2026.06.24 |
| [Redis] MULTI/EXEC 트랜잭션과 Lua 스크립트: 롤백 없는 원자성, WATCH, EVALSHA (1) | 2026.06.23 |
📚 같이 보면 좋은
"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."