이번 시간에는 Redis와 Python으로 요청 제한을 고정 윈도우, 슬라이딩 로그, 토큰 버킷 세 가지 방식으로 만들고, 같은 요청 패턴을 보내 비교해 보며 배워가는 시간입니다. API 서버 두 대가 같은 사용자 키를 동시에 갱신할 때 한도가 지켜지는지도 확인해요.
예를 들어서 우리가 사용자 한 명이 10초에 10번까지만 API를 부르도록 Redis로 제한을 걸었다고 해볼게요.
그런데 접근 로그를 보면 같은 사용자의 요청 20건이 1초도 안 되는 사이에 모두 200으로 처리돼 있습니다. 왜 그럴까요? 요청 수를 세는 시간 구간을 어떻게 나누느냐에 따라 이런 일이 생기거든요.
실습 준비
이 글의 명령은 모두 실습 코드의 redis-rate-limiter/ 디렉터리에서 실행합니다.
compose.yaml: Redis 한 대와 Python 컨테이너app1,app2limiter.py: Lua 스크립트 네 개를 등록하고 방식별 제한 함수를LIMITERS에 모은 모듈fixed.lua: 고정 윈도우 Lua 스크립트log.lua: 슬라이딩 로그 Lua 스크립트counter.lua: 슬라이딩 카운터 Lua 스크립트(이 글에서는 쓰지 않아요)bucket.lua: 토큰 버킷 Lua 스크립트pattern.py: 같은 요청 패턴 두 가지를 방식만 바꿔 보내는 스크립트race.py: 두 서버에서 같은 키로 동시에 요청을 보내 한도 초과를 세는 스크립트
compose.yaml은 Redis 한 대와 Python 컨테이너 두 대를 띄워요. app1과 app2는 같은 이미지이고, 뒤에서 API 서버 두 대 역할을 맡습니다. 그 전까지는 app1만 쓸게요. 영속화는 필요 없어서 꺼 두었어요.
compose 프로젝트 이름은 rrate로 정하고, 자주 쓰는 명령은 셸 함수로 만들어 놓을게요. rcli는 Redis 컨테이너의 redis-cli를, py는 app1 컨테이너의 Python을 실행한다고 보시면 됩니다.
app 컨테이너는 시작할 때 pip install을 하기 때문에 몇 초 기다린 뒤에 실습을 시작해 주세요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export COMPOSE_PROJECT_NAME=rrate
docker compose up -d
rcli() { docker compose exec -T redis redis-cli --no-raw "$@"; }
py() { docker compose exec -T app1 python "$@"; }
요청 제한기와 패턴 생성기
한도는 사용자당 10초에 10회입니다. 토큰 버킷은 용량 10, 초당 1개 채움으로 같은 평균 속도에 맞췄어요.
방식별 Lua 스크립트는 뒤에서 방식마다 설명하고, 이걸 불러 쓰는 함수는 limiter.py에 모아 두었습니다. limiter.py는 Lua 스크립트 네 개를 등록하고, 방식 이름마다 함수를 LIMITERS에 둡니다.
방식 이름은 이렇게 정했어요. 고정 윈도우는 fixed-a(명령 따로)와 fixed(Lua), 슬라이딩 로그는 log, 토큰 버킷은 bucket입니다.
모든 함수는 허용 여부와 판단에 쓴 Redis 시간을 돌려줘요. 판단은 서버마다 다를 수 있는 앱 서버 시계가 아니라 Redis TIME으로 합니다. 서버 시계가 얼마나 어긋날 수 있는지 더 궁금하면 분산 환경의 시계를 다룬 글을 참고하세요.
pattern.py는 같은 요청 패턴을 방식만 바꿔 보냅니다. python pattern.py <방식> boundary로 실행하면 아래 경계 패턴을 보내요. 다른 패턴 steady는 이 글에서는 쓰지 않아요.
boundary: RedisTIME으로 다음 윈도우 경계(10초 단위)를 계산하고, 경계 0.5초 전에 10회, 경계 0.2초 뒤에 10회를 보냅니다.O는 허용,X는 거절입니다. 시간은 Redis가 판단한 시간을 경계 기준으로 뺀 값입니다.
경계 시간은 클라이언트가 sleep으로 맞추기 때문에 몇 ms씩 어긋나요. 그래서 경계 앞뒤로 0.5초와 0.2초의 여유를 두었습니다.
실습 1: 고정 윈도우, INCR과 EXPIRE
고정 윈도우는 시간을 10초 단위 구간(윈도우)으로 자르고, 사용자마다 카운터 키 하나에 INCR을 하는 방식이에요. 결과가 10 이하면 허용합니다.
카운터는 윈도우가 끝나면 사라져야 하니까 첫 요청에서 만료 시간을 걸어 줘야 해요. 여기서는 EXPIREAT으로 지금 윈도우가 끝나는 시간(10초 단위 경계)에 키가 사라지게 했습니다.
limiter.py의 fixed_a는 이 일을 명령 세 개(TIME, INCR, EXPIREAT)로 따로 보내고, fixed_b는 같은 일을 fixed.lua 한 번으로 끝냅니다.
fixed.lua는 TIME으로 Redis 시간을 읽고 INCR한 뒤, 결과가 1이면(윈도우의 첫 요청) 지금 윈도우가 끝나는 10초 단위 경계로 EXPIREAT을 겁니다. 카운터가 한도 이하면 허용(1)을 돌려줘요.
limiter.py는 불러올 때 Lua 스크립트 네 개를 모두 읽습니다. 뒤에서 쓸 log.lua, bucket.lua와, 이 글에서는 쓰지 않는 슬라이딩 카운터용 counter.lua도 zip에 들어 있어요.
그럼 경계 패턴을 두 구현에 보내 볼게요.
py pattern.py fixed-a boundary
py pattern.py fixed boundary
묶음 1 (경계 -0.497~-0.493초): O O O O O O O O O O
묶음 2 (경계 +0.203~+0.206초): O O O O O O O O O O
허용 20회 / 처음 허용부터 마지막 허용까지 0.703초
묶음 1 (경계 -0.489~-0.486초): O O O O O O O O O O
묶음 2 (경계 +0.211~+0.214초): O O O O O O O O O O
허용 20회 / 처음 허용부터 마지막 허용까지 0.702초
출력을 보면 두 구현 모두 20회를 다 허용했습니다.
경계 0.5초 전의 10회는 이전 윈도우 카운터를 1에서 10까지 올렸고, 경계에서 그 키가 만료된 뒤 들어온 10회는 새 카운터를 다시 1부터 올렸어요. 그러니까 처음 허용부터 마지막 허용까지 0.7초 사이에 한도의 두 배가 통과한 거죠. 세 번 실행해 모두 같았습니다.
여기서 눈여겨볼 건 고정 윈도우에서 "10초에 10회"가 윈도우마다 10회까지라는 뜻이라는 점이에요. 어느 10초를 잘라 봐도 10회 이하가 되도록 보장해 주지는 않습니다.
실습 2: 슬라이딩 로그와 토큰 버킷
윈도우 경계를 없애려면 "지금부터 10초 전까지" 들어온 요청을 세면 됩니다.
슬라이딩 로그는 허용한 요청마다 시간을 점수로 Sorted Set에 넣고, 판단할 때마다 10초보다 오래된 기록을 지운 뒤 남은 개수를 세는 방식이에요.
log.lua는 TIME으로 지금 시간을 밀리초로 구하고, ZREMRANGEBYSCORE로 10초보다 오래된 기록을 지운 뒤 ZCARD로 센 개수가 한도보다 작으면 허용합니다.
허용하면 ZADD로 요청 ID를 지금 시간 점수로 넣고 PEXPIRE로 윈도우만큼 만료를 걸어요.
py pattern.py log boundary
묶음 1 (경계 -0.489~-0.487초): O O O O O O O O O O
묶음 2 (경계 +0.205~+0.207초): X X X X X X X X X X
허용 10회 / 처음 허용부터 마지막 허용까지 0.003초
이번에는 경계 뒤의 10회가 모두 거절됐어요. 0.7초 전에 허용한 10회가 아직 "최근 10초" 안에 있기 때문이죠. 세 번 실행해 모두 10회만 허용했습니다.
토큰 버킷은 사용자마다 토큰을 담는 통(버킷)을 하나 두는 방식이에요. 토큰은 일정 속도로 채워지고 용량 이상으로는 쌓이지 않습니다. 요청은 토큰 하나를 써야 허용돼요.
- 용량: 한꺼번에 몰린 요청을 얼마나 연달아 허용할지 정합니다. 여기서는 10입니다.
- 채움 속도: 오래 보면 허용되는 평균 속도입니다. 여기서는 초당 1개입니다.
매초 토큰을 채우는 작업을 따로 돌리지는 않아요. Hash에 남은 토큰 수(tokens)와 마지막으로 계산한 시간(ts)만 저장하고, 요청이 올 때 지난 시간만큼 토큰을 더합니다. 읽고, 계산하고, 다시 쓰는 일이니 스크립트 하나로 처리해요.
bucket.lua는 HMGET으로 tokens와 ts를 읽고(없으면 용량과 지금 시간), 지난 시간 × 채움 속도만큼 토큰을 더하되 용량을 넘지 않게 자릅니다.
py pattern.py bucket boundary
묶음 1 (경계 -0.496~-0.495초): O O O O O O O O O O
묶음 2 (경계 +0.203~+0.206초): X X X X X X X X X X
허용 10회 / 처음 허용부터 마지막 허용까지 0.001초
경계 패턴에서는 처음 10회가 버킷의 토큰 10개를 다 쓰고, 0.7초 뒤의 두 번째 묶음은 모두 거절됐어요. 그사이 채워진 토큰이 0.7개라 1개가 되지 않았기 때문이에요. 토큰 버킷에는 윈도우가 없으니 경계 시간과는 관계가 없습니다.
실습 3: 서버가 여러 대면, 원자성
실제로는 API 서버 여러 대가 같은 사용자의 요청을 동시에 처리하죠. 이때 흔히 보이는 구현이 GET으로 카운터를 읽어 한도 미만인지 확인한 뒤 INCR하는 코드예요.
INCR 자체는 원자적이지만, 확인과 증가가 명령 두 개로 나뉘어 있으면 그 사이에 다른 서버의 명령이 끼어들 수 있어요. 그래서 읽고 계산해서 다시 쓰는 일은 명령 하나로 끝내야 합니다. Redis가 명령을 하나씩 차례로 실행하는 방식이 더 궁금하면 Redis가 빠른 이유를 다룬 글을 참고하세요.
race.py로 직접 확인해 볼게요. 한도는 100입니다. app1과 app2에서 각각 스레드 25개가 한 사용자 키로 요청을 4회씩, 합쳐서 200회 보내요. 두 서버가 같은 순간에 시작하도록 prepare가 Redis 시간으로 2초 뒤를 시작 시간으로 정합니다.
get:GET으로 확인하고 한도 미만이면INCR합니다. Redis까지 두 번 오갑니다.lua: 고정 윈도우의fixed.lua를 씁니다. 윈도우는 60초로 늘렸습니다.
두 서버에서 동시에 실행하고 결과를 합치는 셸 함수를 만들어 세 번씩 돌려 볼게요.
race() {
start=$(py race.py prepare)
docker compose exec -T app1 python race.py run $1 $start app1 &
docker compose exec -T app2 python race.py run $1 $start app2 &
wait
py race.py result
}
for i in 1 2 3; do race get; done
for i in 1 2 3; do race lua; done
app2: 100회 중 허용 67회
app1: 100회 중 허용 64회
허용 합계 131회 / 카운터 값 131
app1: 100회 중 허용 60회
app2: 100회 중 허용 62회
허용 합계 122회 / 카운터 값 122
app1: 100회 중 허용 61회
app2: 100회 중 허용 57회
허용 합계 118회 / 카운터 값 118
app1: 100회 중 허용 49회
app2: 100회 중 허용 51회
허용 합계 100회 / 카운터 값 200
app2: 100회 중 허용 55회
app1: 100회 중 허용 45회
허용 합계 100회 / 카운터 값 200
app2: 100회 중 허용 50회
app1: 100회 중 허용 50회
허용 합계 100회 / 카운터 값 200
출력을 보면 get 구현은 한도 100인데 118~131회를 허용했어요. 여러 스레드가 같은 값(예를 들어 99)을 읽고 각자 한도 미만이라고 판단해 모두 INCR했기 때문이에요.
카운터 값도 허용 수와 같이 100을 넘었어요. 확인과 증가 사이를 넓히려고 sleep을 넣지 않았는데도 매번 초과가 나왔습니다.
lua 구현은 세 번 모두 정확히 100회를 허용했어요. 카운터 값이 200인 것은 거절한 요청도 INCR했기 때문이고, 허용 여부는 INCR이 돌려준 값으로 판단하니 한도를 넘지 않습니다. 스크립트가 실행되는 동안 다른 명령은 실행되지 않으므로 두 서버의 요청 200개는 하나씩 차례로 판단돼요.
고정 윈도우는 INCR이 돌려준 값만 보고 판단하면 되지만, 슬라이딩 로그와 토큰 버킷은 읽고 계산하고 쓰는 단계가 있으니 Lua 스크립트로 한 번에 실행해 주셔야 합니다.
NGINX limit_req의 동작 방식이 더 궁금하면 NGINX 요청 제한을 다룬 글을 참고하세요.
정리
범위: Redis 8.10.2(redis:8.10.2 이미지), Python redis 6.4.0, 노트북 Docker, 사용자당 10초에 10회(토큰 버킷은 용량 10, 초당 1개 채움), 경계 패턴은 방식마다 3번 실행.
- 고정 윈도우는 경계 0.5초 전 10회와 0.2초 뒤 10회를 모두 허용해 0.7초 사이에 한도의 두 배인 20회가 통과했어요. 어느 10초를 잘라도 한도를 넘지 않아야 하면 고정 윈도우는 쓰지 마세요.
- 슬라이딩 로그와 토큰 버킷은 같은 경계 패턴에서 10회만 허용했어요. 어느 10초를 잘라도 한도를 지켜야 하면 슬라이딩 로그를, 몰린 요청의 허용량과 평균 속도를 따로 정하려면 토큰 버킷을 쓰세요.
- 서버 두 대가
GET으로 확인한 뒤INCR하자 한도 100에 118~131회가 허용됐고,fixed.lua는 세 번 모두 정확히 100회였어요. 읽고 계산하고 쓰는 판단은 Lua 스크립트로 Redis 안에서 한 번에 끝내 주세요.