Rate-Limit 종류 및 구현 방법
· 8 min read
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)
