Rate Limiting — 고정 윈도우, 슬라이딩 윈도우, 토큰 버킷

고정 윈도우 (Fixed Window)

시간을 고정 크기 윈도우로 분할:
  12:00:00 ~ 12:00:59  → 윈도우 1 (최대 100 요청)
  12:01:00 ~ 12:01:59  → 윈도우 2 (최대 100 요청)
fun isAllowed(userId: String, limit: Int): Boolean {
    val window = Instant.now().epochSecond / 60  // 분 단위 슬롯
    val key = "rate:fixed:$userId:$window"
 
    val count = redis.opsForValue().increment(key)!!
    if (count == 1L) {
        redis.expire(key, 60, TimeUnit.SECONDS)
    }
 
    return count <= limit
}

단점: 윈도우 경계에서 2배 버스트 가능

11:59:50 ~ 11:59:59: 100 요청 (윈도우 1의 마지막 10초)
12:00:00 ~ 12:00:10: 100 요청 (윈도우 2의 첫 10초)
→ 20초 내 200 요청!

슬라이딩 윈도우 (Sliding Window)

카운터 방식 (분 단위)

현재 시각을 기준으로 이전 1분간의 요청 수를 계산.
분 단위 슬롯 2개를 가중치로 합산.
fun isAllowed(userId: String, limit: Int): Boolean {
    val now = Instant.now()
    val currentMinute = now.epochSecond / 60
    val previousMinute = currentMinute - 1
    val elapsedSeconds = now.epochSecond % 60
 
    val currentKey = "rate:sliding:$userId:$currentMinute"
    val previousKey = "rate:sliding:$userId:$previousMinute"
 
    val currentCount = redis.opsForValue().get(currentKey)?.toLong() ?: 0L
    val previousCount = redis.opsForValue().get(previousKey)?.toLong() ?: 0L
 
    // 이전 분의 가중치: 지난 시간에 비례
    val windowFraction = 1.0 - (elapsedSeconds / 60.0)
    val estimatedCount = (previousCount * windowFraction + currentCount).toLong()
 
    if (estimatedCount >= limit) return false
 
    redis.opsForValue().increment(currentKey)
    redis.expire(currentKey, 120, TimeUnit.SECONDS)
    return true
}

ZSet 방식 (정확한 슬라이딩)

fun isAllowed(userId: String, limit: Int, windowSeconds: Long = 60): Boolean {
    val key = "rate:zset:$userId"
    val now = System.currentTimeMillis()
    val windowStart = now - windowSeconds * 1000
 
    return redis.execute(object : SessionCallback<Boolean> {
        override fun <K, V> execute(ops: RedisOperations<K, V>): Boolean {
            @Suppress("UNCHECKED_CAST")
            val zsetOps = (ops as RedisOperations<String, String>)
                .opsForZSet()
 
            // 윈도우 밖 항목 제거
            zsetOps.removeRangeByScore(key, Double.NEGATIVE_INFINITY, windowStart.toDouble())
 
            // 현재 카운트
            val count = zsetOps.zCard(key) ?: 0L
 
            if (count >= limit) return false
 
            // 새 요청 추가
            zsetOps.add(key, UUID.randomUUID().toString(), now.toDouble())
            ops.expire(key, windowSeconds + 1, TimeUnit.SECONDS)
 
            return true
        }
    })
}

Lua 스크립트 버전 (원자적):

-- sliding_window_rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])  -- 밀리초
local now = tonumber(ARGV[3])
local req_id = ARGV[4]
 
-- 오래된 항목 제거
redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)
 
-- 현재 카운트
local count = redis.call('ZCARD', key)
 
if count >= limit then
    return {0, count}
end
 
-- 추가
redis.call('ZADD', key, now, req_id)
redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)
 
return {1, count + 1}
val result = redis.execute(
    RedisScript.of(luaScript, List::class.java),
    listOf(key),
    limit.toString(),
    windowMs.toString(),
    System.currentTimeMillis().toString(),
    UUID.randomUUID().toString(),
) as List<*>
 
val allowed = (result[0] as Long) == 1L
val currentCount = result[1] as Long

토큰 버킷 (Token Bucket)

일정 속도로 토큰이 채워지고, 요청마다 토큰 소비.

버킷 용량: 100 토큰
채우기 속도: 1토큰/초

요청 → 토큰 1개 소비 → 처리
토큰 없음 → 거부
-- token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])     -- 버킷 최대 용량
local refill_rate = tonumber(ARGV[2])  -- 초당 채우는 토큰 수
local requested = tonumber(ARGV[3])    -- 소비할 토큰 수
local now = tonumber(ARGV[4])          -- 현재 시각 (초)
 
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])
 
if tokens == nil then
    tokens = capacity
    last_refill = now
end
 
-- 토큰 보충
local elapsed = now - last_refill
local new_tokens = math.min(capacity, tokens + elapsed * refill_rate)
 
if new_tokens < requested then
    -- 거부 — 현재 토큰 수와 다음 가능한 시각 반환
    local wait_time = math.ceil((requested - new_tokens) / refill_rate)
    redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
    redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 1)
    return {0, math.floor(new_tokens), wait_time}
end
 
-- 허용
new_tokens = new_tokens - requested
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 1)
return {1, math.floor(new_tokens), 0}

Spring Boot + Rate Limiting

인터셉터

@Component
class RateLimitInterceptor(
    private val rateLimiter: RateLimiter,
) : HandlerInterceptor {
 
    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
    ): Boolean {
        val userId = request.getAttribute("userId") as? String
            ?: request.remoteAddr
 
        val annotation = (handler as? HandlerMethod)
            ?.getMethodAnnotation(RateLimit::class.java)
 
        val limit = annotation?.limit ?: 100
        val window = annotation?.windowSeconds ?: 60L
 
        val result = rateLimiter.isAllowed(userId, limit, window)
 
        if (!result.allowed) {
            response.status = 429  // Too Many Requests
            response.setHeader("X-RateLimit-Limit", limit.toString())
            response.setHeader("X-RateLimit-Remaining", "0")
            response.setHeader("X-RateLimit-Reset", result.resetAt.toString())
            response.setHeader("Retry-After", result.retryAfter.toString())
            response.writer.write("""{"error":"Too many requests"}""")
            return false
        }
 
        response.setHeader("X-RateLimit-Limit", limit.toString())
        response.setHeader("X-RateLimit-Remaining", result.remaining.toString())
        return true
    }
}
 
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RateLimit(
    val limit: Int = 100,
    val windowSeconds: Long = 60,
)

사용

@RestController
class ApiController {
 
    @RateLimit(limit = 10, windowSeconds = 60)
    @PostMapping("/api/send-email")
    fun sendEmail(@RequestBody request: EmailRequest): ResponseEntity<*> {
        // 분당 10회 제한
        return ResponseEntity.ok(emailService.send(request))
    }
}

다양한 단위 Rate Limit

// 계층적 Rate Limit
fun isAllowedMultiLevel(userId: String): Boolean {
    return listOf(
        Triple("per-second", 10L, 1L),    // 초당 10회
        Triple("per-minute", 100L, 60L),   // 분당 100회
        Triple("per-hour", 1000L, 3600L),  // 시간당 1000회
        Triple("per-day", 10000L, 86400L), // 일당 10000회
    ).all { (level, limit, window) ->
        val key = "rate:$level:$userId"
        isWindowAllowed(key, limit, window)
    }
}

정리

알고리즘정확도메모리버스트 허용구현 복잡도
고정 윈도우낮음낮음경계에서 2배낮음
슬라이딩 윈도우 (카운터)중간낮음없음중간
슬라이딩 윈도우 (ZSet)높음높음없음중간
토큰 버킷높음낮음허용 (버킷 용량)높음
  • 간단한 API 제한: 고정 윈도우 (초당/분당)
  • 정확한 슬라이딩: ZSet 기반 (메모리 주의)
  • 버스트 허용 API: 토큰 버킷
  • 응답 헤더: X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After