상품 리스트 API, 게시글 목록, 영화 목록 조회와 같이 누구나 접근할 수 있는 API는 봇에 의해 무차별적으로 호출될 수 있다. 이때 다음과 같은 문제가 발생한다:
INCR
방식은 TTL을 별도로 설정해야 하고, INCR → EXPIRE
흐름은 원자성이 보장되지 않아 race condition이 발생할 수 있다따라서 "1분에 최대 20회"만 요청을 허용하는 Rate Limit 정책이 필요하다.
단순한 카운터보다는 시간 기준 sliding window 구조가 필요하며, Redis의 ZSET
을 활용한 접근이 적합하다. 여기에 원자성을 더하기 위해 Lua 스크립트를 결합한다.
Lua 스크립트로 요청 시점을 ZSET에 저장하고, 1분 이전 데이터를 삭제한 후 최근 1분간의 요청 수를 계산하는 구조이다.
ZADD
— 현재 timestamp를 ZSET에 추가ZREMRANGEBYSCORE
— 1분(60000ms)보다 오래된 항목 삭제ZCARD
— 남아 있는 데이터 수 체크SET + EXPIRE
) 저장-- KEYS[1] = 요청 카운트용 ZSET
-- KEYS[2] = 차단 키 (rate limit에 걸렸을 경우 표시용)
-- ARGV[1] = 허용 횟수 (20)
-- ARGV[2] = 현재 시간 (timestamp)
-- ARGV[3] = 제한 시간 (예: 60000ms = 1분)
redis.call('ZADD', KEYS[1], ARGV[2], tostring(ARGV[2]))
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[2] - ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
if count > tonumber(ARGV[1]) then
redis.call('SET', KEYS[2], '1')
redis.call('EXPIRE', KEYS[2], math.ceil(ARGV[3] / 1000))
return 0
else
redis.call('EXPIRE', KEYS[1], math.ceil(ARGV[3] / 1000))
return 1
end
ZSET
에 시간 정보를 넣으면 자연스럽게 슬라이딩 윈도우가 구현된다EXPIRE
는 초 단위이므로 ms를 1000으로 나눠준다import http from 'k6/http';
import { sleep } from 'k6';
export default function () {
const res = http.get('http://localhost:8080/v4/movies');
console.log(`응답 코드: ${res.status}`);
sleep(2); // 1분당 약 30회 요청 발생하도록 설정
}
ratelimit:ip:<주소>:minute
키가 생성됨200 OK
429 Too Many Requests
발생ratelimit:ip:<주소>:blocked
)도 함께 생성됨127.0.0.1:6379> ZRANGE ratelimit:ip:0_0_0_0_0_0_0_1:minute 0 -1
"1745056542645"
"1745056544645"
...
127.0.0.1:6379> GET ratelimit:ip:0_0_0_0_0_0_0_1:blocked
"1"
@Service
class RedisRateLimitAdapter(private val redissonClient: RedissonClient) : RateLimitPort {
override fun checkIpRequestLimit(ip: String?) {
val normalizedIp = (ip ?: "unknown").replace(":", "_")
val key = "ratelimit:ip:$normalizedIp:minute"
val blockedKey = "ratelimit:ip:$normalizedIp:blocked"
val now = System.currentTimeMillis()
val limit = 20
val expireMillis = 60_000L
val luaScript = loadLua("ip_ratelimit.lua")
val result = redissonClient
.getScript(StringCodec.INSTANCE)
.eval<Long>(
RScript.Mode.READ_WRITE,
luaScript,
RScript.ReturnType.INTEGER,
listOf(key, blockedKey),
limit.toString(),
now.toString(),
expireMillis.toString()
)
if (result == null || result == 0L) {
throw RateLimitException("요청이 너무 많습니다. 1분 후 다시 시도해주세요.")
}
}
private fun loadLua(filename: String): String {
val path = "/lua/$filename"
val url = javaClass.getResource(path)
return url?.readText(StandardCharsets.UTF_8)
?: throw IllegalArgumentException("Lua 스크립트를 찾을 수 없습니다: $filename")
}
}
Redisson의
RScript.eval()
은 Lua 기반 스크립트를 실행할 수 있는 기능을 제공한다.
여기서 중요한 점은eval
자체가 Redis에서 원자적으로 실행된다는 것이다.