Rate-Limit 종류 및 구현 방법
Rate Limit이란
Rate Limit에 대한 설명
Rate Limit은 클라이언트가 일정 시간 동안 수행할 수 있는 요청 수를 제한하는 기법입니다. API 서버, 웹 애플리케이션, 캐시 시스템 등에서 서버 자원 보호, 공정한 서비스 제공, 악의적 공격 방어(DDoS) 등의 목적으로 활용됩니다.
Rate Limit의 필요성
- 시스템 과부하 방지: 갑작스러운 요청 폭주로 인한 서버 다운을 예방
- 서비스 품질 유지: 공정한 자원 분배를 통해 전체 사용자에게 일관된 응답 품질 제공
- 비용 절감: 클라우드 환경에서는 호출량에 따른 과금이 발생하는 경우가 많아 제한이 필요
Client에서의 처리 방안
- 서버로부터
429 Too Many Requests
응답을 받을 경우, 재시도 딜레이(backoff) 적용 - 헤더 정보(
Retry-After
)를 활용한 재요청 시점 조정 - 클라이언트 측 로컬 캐시로 서버 요청 자체를 최소화
Rate Limit의 종류
1. Fixed Window
특징: 고정된 시간 단위(예: 1분) 동안의 요청 수 제한 단점: 윈도우 경계에서 트래픽 버스트가 가능함
val WINDOW_SIZE_MS = 60000L
val windowKey = "rate:user:${userId}:${System.currentTimeMillis() / WINDOW_SIZE_MS}"
val count = redisTemplate.opsForValue().increment(windowKey)
redisTemplate.expire(windowKey, Duration.ofMillis(WINDOW_SIZE_MS))
if (count != null && count > limit) {
throw RateLimitExceededException("Too many requests")
}
2. Sliding Window Log
특징: 타임스탬프를 Redis Sorted Set에 저장하여 최근 윈도우 범위 내 요청 수를 검사 단점: ZSET 명령어 조합으로 인해 원자성이 부족함
val WINDOW_SIZE_MS = 60000L
val now = System.currentTimeMillis()
val key = "rate:user:$userId"
val windowStart = now - WINDOW_SIZE_MS
redisTemplate.opsForZSet().add(key, now.toString(), now.toDouble())
redisTemplate.opsForZSet().removeRangeByScore(key, 0.0, windowStart.toDouble())
val count = redisTemplate.opsForZSet().size(key) ?: 0
if (count > limit) throw RateLimitExceededException()
3. Token Bucket
특징: 일정 주기로 토큰을 생성하고 요청 시 토큰을 소모 단점: 갱신/소비 로직이 분리되어 있어 race condition 발생 가능
val now = System.currentTimeMillis()
val bucketKey = "bucket:user:$userId"
val tokens = redisTemplate.opsForHash<String, String>().get(bucketKey, "tokens")?.toIntOrNull() ?: 10
if (tokens <= 0) throw RateLimitExceededException()
redisTemplate.opsForHash<String, String>().put(bucketKey, "tokens", (tokens - 1).toString())
4. Leaky Bucket
특징: 일정 속도로 요청을 처리 (누수 방식 큐) 단점: 현재 상태와 누수량 계산을 동시에 해야 하므로 다중 요청 시 충돌 가능
val key = "leaky:user:$userId"
val now = System.currentTimeMillis()
val last = redisTemplate.opsForValue().get(key)?.toLongOrNull() ?: now
val leaked = ((now - last) / 1000).toInt()
val current = maxOf(0, redisTemplate.opsForValue().increment(key, -leaked.toLong()) ?: 0)
if (current >= capacity) throw RateLimitExceededException()
redisTemplate.opsForValue().increment(key)
개선점
위에서 제시된 코드를 원자적으로 개선
Sliding Window Log - Lua Script and some codes
Lua-Script
redis.call("ZADD", KEYS[1], ARGV[1], ARGV[1])
redis.call("ZREMRANGEBYSCORE", KEYS[1], 0, ARGV[2])
local count = redis.call("ZCARD", KEYS[1])
if tonumber(count) > tonumber(ARGV[3]) then
return 0
else
return 1
end
Kotlin code for lua-script
val now = System.currentTimeMillis()
val windowStart = now - WINDOW_SIZE_MS
val script = DefaultRedisScript<Int>()
script.scriptText = loadLuaScriptFromClasspath("scripts/sliding_window.lua")
script.resultType = Int::class.java
val allowed = redisTemplate.execute(
script,
listOf("rate:user:$userId"),
now.toString(),
windowStart.toString(),
limit.toString()
)
if (allowed == 0) throw RateLimitExceededException()
Token Bucket - Lua Script and some codes
Lua-Script
local bucket = redis.call("HMGET", KEYS[1], "tokens", "last_refill")
local tokens = tonumber(bucket[1]) or tonumber(ARGV[3])
local last_refill = tonumber(bucket[2]) or tonumber(ARGV[1])
local now = tonumber(ARGV[1])
local refill = math.floor((now - last_refill) / 1000 * tonumber(ARGV[2]))
tokens = math.min(tokens + refill, tonumber(ARGV[3]))
last_refill = now
if tokens < tonumber(ARGV[4]) then
return 0
else
tokens = tokens - tonumber(ARGV[4])
redis.call("HMSET", KEYS[1], "tokens", tokens, "last_refill", last_refill)
redis.call("EXPIRE", KEYS[1], 60)
return 1
end
Kotlin code for lua-script
val now = System.currentTimeMillis()
val script = DefaultRedisScript<Int>()
script.scriptText = loadLuaScriptFromClasspath("scripts/token_bucket.lua")
script.resultType = Int::class.java
val allowed = redisTemplate.execute(
script,
listOf("bucket:user:$userId"),
now.toString(),
"10", // refill rate
"100", // capacity
"1" // requested tokens
)
if (allowed == 0) throw RateLimitExceededException()
Leaky Bucket - Lua Script
Lua-Script
local state = redis.call("HMGET", KEYS[1], "count", "last_leak")
local count = tonumber(state[1]) or 0
local last_leak = tonumber(state[2]) or tonumber(ARGV[1])
local now = tonumber(ARGV[1])
local leaked = math.floor((now - last_leak) / tonumber(ARGV[2]))
count = math.max(0, count - leaked)
last_leak = last_leak + leaked * tonumber(ARGV[2])
if count + 1 > tonumber(ARGV[3]) then
return 0
else
count = count + 1
redis.call("HMSET", KEYS[1], "count", count, "last_leak", last_leak)
redis.call("EXPIRE", KEYS[1], 60)
return 1
end
Kotlin code for lua-script
val now = System.currentTimeMillis()
val script = DefaultRedisScript<Int>()
script.scriptText = loadLuaScriptFromClasspath("scripts/leaky_bucket.lua")
script.resultType = Int::class.java
val allowed = redisTemplate.execute(
script,
listOf("leaky:user:$userId"),
now.toString(),
"1000", // leak rate (ms per request)
"10" // capacity
)
if (allowed == 0) throw RateLimitExceededException()
Helper codes
Loading lua-script using class-loader
fun loadLuaScriptFromClasspath(path: String): String {
val classLoader = Thread.currentThread().contextClassLoader
val inputStream = classLoader.getResourceAsStream(path)
?: throw IllegalArgumentException("Script not found: $path")
return inputStream.bufferedReader().use { it.readText() }
}
Load scripts on Redis
# 등록
SCRIPT LOAD "$(cat scripts/token_bucket.lua)"
# 반환된 SHA 값 사용
EVALSHA "<SHA1>" 1 bucket:user:123 <now> 10 100 1
Applying using WebFilter
How it works
- WebFilter: 모든 요청을 가로채어 rate-limit 검사
- Lua Script: Redis에서 원자적으로 rate-limit 로직 수행
- RedisTemplate: 스크립트 실행 및 결과 확인
- 적용 대상: 전체 요청 또는 특정 URL/헤더/사용자에 따라 유연하게 적용 가능
How it results
- 사용자별 X-USER-ID 기준으로 초당 10개, 최대 100개의 토큰을 부여
- 초과 시 429 Too Many Requests 응답
- 정밀한 동시성 제어와 성능을 Lua + Redis로 처리
Example
@Component
class RateLimitWebFilter(
private val redisTemplate: StringRedisTemplate
) : WebFilter {
private val scriptSha: String
init { // fail-fast from instancing when application is booting-up
val scriptText = loadLuaScriptFromClasspath("scripts/token_bucket.lua")
val sha = redisTemplate.execute(RedisCallback { connection ->
connection.serverCommands().scriptLoad(scriptText.toByteArray())
})?.toHexString()
this.scriptSha = sha ?: throw IllegalStateException("Failed to register Lua script with Redis")
}
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
val request = exchange.request
val userId = request.headers.getFirst("X-USER-ID") ?: "anonymous"
val now = System.currentTimeMillis()
val passed = redisTemplate.execute(RedisCallback { connection ->
connection.evalSha(
scriptSha,
ReturnType.INTEGER,
1,
"bucket:user:$userId".toByteArray(),
now.toString().toByteArray(),
"10".toByteArray(), // refill rate (tokens/sec)
"100".toByteArray(), // capacity
"1".toByteArray() // tokens required
)
}) as? Long
return if (passed == 1L) {
chain.filter(exchange)
} else {
exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS
exchange.response.setComplete()
}
}
private fun loadLuaScriptFromClasspath(path: String): String {
val stream = Thread.currentThread().contextClassLoader.getResourceAsStream(path)
?: throw IllegalArgumentException("Lua script not found: $path")
return stream.bufferedReader().use { it.readText() }
}
private fun ByteArray.toHexString(): String =
joinToString("") { "%02x".format(it) }
}
Note
위의 예제는 인입되는 모든 요청에 대해 rate-limit을 적용합니다. 상황에 따라 적용 대상을 분리하여 적용할 수 있는데요. 그런 경우,request의 header, method 그리고 path등을 이용하여 처리할 수 있습니다.
마무리
Rate Limit은 시스템 안정성과 사용자 공정성을 동시에 만족시키기 위한 필수 전략입니다. 단순한 구현으로 시작하더라도 실제 서비스 환경에서는 정밀한 제어와 동시성 문제 해결이 중요해집니다.
이번 글에서는 대표적인 4가지 알고리즘(Fixed, Sliding, Token, Leaky)의 원리와 구현, 그리고 Redis를 통한 Lua Script 기반의 원자적 처리 방식까지 살펴보았습니다.
적절한 전략을 선택하고, 필요에 따라 WebFilter
또는 API Gateway 레벨에서 적용한다면 여러분의 서비스는 더욱 강력하고 유연하게 확장될 수 있습니다. 🙂