서버가 한 대라면 메모리 속 토큰 버킷으로 충분합니다. 하지만 같은 사용자의 요청이 로드 밸런서를 거쳐 여러 서버로 흩어지면, 서버마다 따로 세는 한도는 서버 수만큼 느슨해집니다. 이 장에서는 Redis로 한도를 공유하는 방법, 웹 서버와 라이브러리가 제공하는 레이트 리미터, 그리고 운영에서 자주 겪는 함정을 정리합니다.
Redis INCR과 EXPIRE로 만드는 고정 창
가장 흔한 분산 구현은 창마다 키를 하나 만들고 INCR로 세는 것입니다. INCR은 원자적이라 여러 서버가 동시에 올려도 개수가 틀리지 않습니다. 키에는 만료 시간을 주어 지난 창이 저절로 지워지게 합니다.
import time
import redis
r = redis.Redis()
def allow_naive(key: str, limit: int, window: int) -> bool:
bucket = f"rl:{key}:{int(time.time()) // window}"
count = r.incr(bucket) # 원자적으로 1 증가, 없던 키는 1이 된다
if count == 1:
r.expire(bucket, window) # 첫 요청일 때만 만료 시간 지정
return count <= limit
이 코드에는 빈틈이 있습니다. INCR과 EXPIRE는 각각은 원자적이지만 둘을 합친 동작은 원자적이지 않습니다. 두 명령 사이에서 프로세스가 죽거나 연결이 끊기면 만료 시간이 없는 키가 영원히 남습니다. 이를 막는 방법은 두 명령을 Lua 스크립트 하나로 묶는 것입니다.
Lua 스크립트로 원자성 확보
Redis는 EVAL(또는 EVALSHA)로 받은 Lua 스크립트를 실행하는 동안 다른 클라이언트의 명령이나 다른 스크립트를 끼워 넣지 않습니다. 그래서 "읽고, 계산하고, 쓰는" 과정을 경쟁 상태 없이 한 번에 처리할 수 있고, 네트워크 왕복도 한 번으로 줄어듭니다. 알아 둘 점이 몇 가지 있습니다.
- 스크립트가 도는 동안 서버 전체가 다른 일을 못 하므로 스크립트는 짧게 유지합니다.
- 실행 도중 오류가 나면 그때까지 한 쓰기는 되돌려지지 않습니다. 관계형 데이터베이스의 롤백과는 다릅니다.
- 스크립트가 다루는 키는
KEYS로 넘깁니다. Redis Cluster에서는 한 스크립트의 키들이 같은 해시 슬롯에 있어야 합니다.
다음은 토큰 버킷 전체를 Redis 해시 하나에 담는 스크립트입니다.
-- KEYS[1] = 버킷 키, ARGV = rate, capacity, now(초), cost
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local state = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(state[1]) or capacity
local ts = tonumber(state[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate) * 2)
return {allowed, tostring(tokens)}
키가 없으면 가득 찬 버킷으로 봅니다. 그래서 버킷이 다시 가득 찰 시간이 지나면 키를 지워도(만료시켜도) 판정이 달라지지 않습니다. 남은 토큰을 문자열로 돌려주는 이유는 Lua 숫자를 그대로 돌려주면 Redis가 정수로 잘라 버리기 때문입니다. Python에서는 redis-py의 register_script로 불러 씁니다.
import time
import redis
r = redis.Redis()
token_bucket = r.register_script(open("token_bucket.lua").read())
def allow(user_id: str, rate: float = 5, capacity: float = 10) -> bool:
allowed, _remaining = token_bucket(keys=[f"rl:{user_id}"],
args=[rate, capacity, time.time(), 1])
return allowed == 1
now를 각 앱 서버가 넘기면 서버 사이의 시계 차이가 그대로 오차가 됩니다. Redis 5 이상에서는 스크립트 안에서 redis.call('TIME')으로 Redis 서버의 시계를 쓸 수도 있습니다.
웹 서버와 라이브러리
- nginx
limit_req: 리키 버킷 방식입니다. burst만큼 초과 요청을 큐에 담고, nodelay를 주면 큐에 담긴 요청을 기다리게 하지 않고 바로 처리합니다. 기본 거절 코드는 503이므로 limit_req_status 429로 바꿉니다. - Go
golang.org/x/time/rate: 크기 b, 초당 r개로 채워지는 토큰 버킷입니다. - Java Guava
RateLimiter, Bucket4j: 토큰 버킷 계열이며, Bucket4j는 Redis 등 분산 저장소도 지원합니다. - Envoy, AWS API Gateway: 각각 로컬 토큰 버킷과 "rate + burst" 설정으로 스로틀링합니다.
- Cloudflare는 슬라이딩 창 카운터 근사로 대규모 레이트 리미팅을 구현한 경험을 공개했습니다.
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
listen 80;
location /api/ {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
}
}
자주 겪는 함정
- 저장소 장애: Redis가 응답하지 않을 때 모두 허용(fail open)할지 모두 거절(fail closed)할지 미리 정합니다. 일반 API는 보통 허용하고, 로그인 시도 제한처럼 보안이 걸린 곳은 거절 쪽을 고려합니다.
- 거절된 요청까지 기록하기: 슬라이딩 로그에 거절된 요청도 쌓으면, 계속 두드리는 클라이언트는 영원히 풀리지 않고 메모리도 늘어납니다.
- 공정성: 키별 한도만 있으면 사용자가 아주 많을 때 전체 용량을 넘을 수 있습니다. 키별 한도와 전체 한도를 함께 두고, 비싼 작업에는 비용을 높게 매깁니다.
- 버스트 크기: 용량을 너무 작게 잡으면 페이지 하나를 열 때 동시에 나가는 정상 요청까지 거절됩니다.
- 알려 주기: 429와 함께
Retry-After, 남은 횟수를 알려 주고 문서에 한도를 적어 두면 클라이언트가 스스로 속도를 맞춥니다.
정리
- 여러 서버가 한도를 나눠 쓰려면 Redis 같은 공유 저장소가 필요합니다.
INCR과 EXPIRE를 따로 보내면 원자적이지 않으므로 Lua 스크립트로 묶습니다.- nginx, Go, Java 생태계에는 검증된 토큰 버킷과 리키 버킷 구현이 있습니다.
- 장애 시 정책, 공정성, 버스트 크기, 클라이언트 안내까지 함께 설계합니다.
더 읽을거리
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.