1. 처리율 제한(Rate Limiting)이란?
일정 시간 동안 처리할 수 있는 요청 수를 제한하는 기법입니다.
- 과도한 요청(정상적이든 공격이든)으로부터 시스템 자원을 보호, 안정성 보장
- API 또는 서비스에 대한 공정한 사용량 배분 가능
- API abuse, DDoS, 과도한 트래픽 등으로부터 시스템을 보호하는 데 필수적
2. 왜 Rate Limiting이 중요한가?
✅ 서버 과부하 방지
✅ 악의적 사용자/버그로 인한 폭주 대응
✅ 비용 관리 (클라우드 API 호출 등 과금 최소화)
✅ 서비스 품질(QoS) 유지 및 SLA 보장
✅ 공정성 (특정 사용자 독점 방지)
3. 처리율 제한 알고리즘
1️⃣ Token Bucket (🌟실무 인기)
- 일정 속도로 토큰을 생성하여 버킷에 채우고, 요청 시 토큰을 하나 씩 소모
- 토큰이 있으면 허용, 없으면 제한
- 장점: 속도 제한과 burst 처리 허용
- 단점: 토큰 보충 주기나 스케줄링 등 최적의 값을 찾아내는 부분이 까다로움 (구현이 다소 복잡할 수 있음)
- 실제 사용 예시: Google API, AWS API Gateway, Stripe, Twitter 등
2️⃣ Leaky Bucket
- 요청을 큐 (FIFO)에 저장하고, 일정 속도로만 소비, 큐가 넘치면 요청은 차단
- 장점: 고정 속도 유지 (흐름 제어에 유리)
- 단점: burst 요청 처리 불가 (일정 속도로만 요청을 처리하므로)
3️⃣ Fixed Window Counter
- 고정된 시간 구간(예: 1분)마다 요청 수를 카운트하고, 해당 윈도우에서 요청이 limit을 초과하면 요청 차단
- 예: "매 1분당 100건 요청 허용" → user:202406191230이라는 Redis 키로 1분 단위 카운트 저장
- 장점: 구현이 매우 간단
- 단점: 윈도우 경계 시간에 폭발적 요청 (burst) 발생 가능 (59초에 100건 + 다음 초에 또 100건)
4️⃣ Sliding Window Log
- 모든 요청 타임스탬프를 로그로 저장하고, 윈도우 크기만큼의 시간 이전의 로그를 삭제
- 윈도우 크기만큼의 시간 내에서의 요청 수를 체크
- 장점: 가장 정확한 방식으로, 요청 간 간격과 분포까지 확인 가능
- 단점: 로그를 저장하므로 많은 메모리를 사용, 단순한 카운트 증가가 아니므로 성능은 낮다.
3️⃣ Sliding Window Counter
- Fixed Window와 Sliding Window Log 방식의 장점을 취합한 방법
- 슬라이딩 윈도우를 여러 구간으로 나눠, 각 윈도우 내의 개수 합산
- 최근 10초간 요청 수 합산 = user:{timestamp} 10개 키의 합
- 적절한 정밀도와 성능의 균형
| 방식 | 윈도우 경계 문제 | 정밀도 | 구현 난이도 | 메모리 사용 |
| Fixed Window | ❌ 있음 | 보통 | 쉬움 | 적음 |
| Sliding Window Log | ✅ 없음 | 매우 높음 | 높음 | 높음 (로그 저장) |
| Sliding Window Counter | ✅ 없음 | 높음 | 중간 | 중간 (카운터 합산) |
✅ Redis 를 이용한 알고리즘 구현
- 분산 환경에서 여러 서버가 동일한 Rate Limit 정책을 공유해야 할 때 Redis가 표준처럼 사용됨
- 웹 계층은 stateless 하기 때문에, 각 요청에 대해 상태(기록)를 저장하지 않음.
- 그래서 어떤 사용자의 첫 번째 요청은 서버 A로 가고, 두 번째 요청은 서버 B로 갈 수 있음.
- 이 경우 서버 A는 사용자의 요청 수를 모르고, 서버 B도 마찬가지여서
→ Rate Limit이 제대로 적용되지 않음 (누적 요청 수 추적 불가) - 중앙 Redis를 사용해서 모든 요청 카운트를 하나의 위치에서 관리하면
→ 서버 A든 B든 관계없이 항상 동일한 기준으로 Rate Limit 판단 가능
- 빠른 속도, atomic 연산, 다양한 자료구조, key 만료 기능 등 실무에 적합
1) Fixed Window Counter
val now = Instant.now().epochSecond
val window = now / windowSeconds
val key = "fixed_rate_limit:$clientId:$window"
val current = redisTemplate.opsForValue().increment(key)
if (current == 1L) redisTemplate.expire(key, Duration.ofSeconds(windowSeconds))
2) Sliding Window Log (ZSet)
val now = System.currentTimeMillis()
val key = "zset_rate_limit:$clientId"
val member = "$now:${UUID.randomUUID()}"
redisTemplate.opsForZSet().add(key, member, now.toDouble())
redisTemplate.opsForZSet().removeRangeByScore(key, 0.0, (now - windowMillis).toDouble())
val count = redisTemplate.opsForZSet().size(key) ?: 0
3) Sliding Window Counter (1초 버킷 예시)
val now = Instant.now().epochSecond
val keys = (now - windowSeconds + 1..now).map { "rate_limit:$userId:$it" }
val counts = redisTemplate.opsForValue().multiGet(keys) ?: emptyList()
val total = counts.mapNotNull { it?.toIntOrNull() }.sum()
if (total < limit) {
val currentKey = "rate_limit:$userId:$now"
val count = redisTemplate.opsForValue().increment(currentKey)
if (count == 1L) redisTemplate.expire(currentKey, Duration.ofSeconds(windowSeconds))
}
4) Token Bucket (Lua Script)
-- KEYS[1]: 토큰 개수 key, KEYS[2]: 마지막 리필 시각 key
-- ARGV[1]: capacity, ARGV[2]: refillInterval, ARGV[3]: refillTokens, ARGV[4]: now
local tokens = tonumber(redis.call('get', KEYS[1])) or tonumber(ARGV[1])
local last_refill = tonumber(redis.call('get', KEYS[2])) or tonumber(ARGV[4])
local elapsed = tonumber(ARGV[4]) - last_refill
if elapsed >= tonumber(ARGV[2]) then
local refill_count = math.floor(elapsed / tonumber(ARGV[2]))
tokens = math.min(tonumber(ARGV[1]), tokens + refill_count * tonumber(ARGV[3]))
last_refill = last_refill + refill_count * tonumber(ARGV[2])
redis.call('set', KEYS[1], tokens)
redis.call('set', KEYS[2], last_refill)
end
if tokens > 0 then
redis.call('decr', KEYS[1])
return tokens - 1
else
return -1
end
> Spring Data Redis에서 Lua Script를 실행하여 atomic하게 처리
😬 Redis로 Rate Limiting 처리 시 경쟁 조건이 발생 가능
예: Fixed 또는 Sliding Counter에서 다음 순서로 처리할 때
- GET key → 현재 count = 4
- 요청 허용 판단 (limit = 5이므로 OK)
- INCR key → count = 5
- 동시에 다른 인스턴스도 GET → 4, INCR → 5
- 두 요청 모두 허용되어 최대 허용 요청 수 초과 발생
이런 경우 동시 요청이 제한을 우회하는 문제가 발생하게 됩니다.
💡 해결 방법 1: Redis Lua 스크립트 (atomic 처리)
Redis는 하나의 Lua 스크립트를 단일 트랜잭션처럼 실행하므로
GET, INCR, EXPIRE를 하나의 원자적 블록으로 처리할 수 있어요.
💡 해결 방법 2: Redisson, Bucket4j (Java/Kotlin에서 atomic 처리 도와주는 라이브러리)
- RedissonRateLimiter는 내부적으로 Lua 스크립트를 사용해서 안전하게 처리합니다.
- Bucket4j는 in-memory 또는 Redis 기반 token bucket 구현체로 경쟁 조건 없이 처리 가능
💡 해결 방법 3: Sliding Window Log 에서 구현한 것처럼 정렬 세트 자료구조 사용
실제로 sliding windows log는 redis 정렬 세트로, 나머지 알고리즘은 lua script로 구현한다고 합니다.
✅ 그 외 실제 시스템 설계 시 팁
- 버스트 허용 필요 → Token Bucket 사용 추천 (유연성, 실무에서 가장 많이 사용)
- 사용자 등급별 제한 → premium: 1000/min, free: 60/min
- Sliding Window Counter/Log: abuse 방지, 로그인 시도 제한 등 정밀한 제한 필요 시
- Fixed Window: 간단한 내부 서비스, 구현이 매우 쉬움
참고 라이브러리
- [Bucket4j](https://github.com/bucket4j/bucket4j): Java/Kotlin에서 Token Bucket, Sliding Window 등 지원, Redis 연동 가능
- [Spring Cloud Gateway RateLimiter](https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/rate-limiter.html): Redis 기반 Rate Limiter 내장
'Study' 카테고리의 다른 글
| 🚦 대규모 시스템 설계 기초 1권 – 6장: 키-값 저장소 설계 (2) | 2025.06.21 |
|---|---|
| 대규모 시스템 설계 기초 2 - 6장. 광고 클릭 이벤트 집계 (0) | 2024.11.21 |
| 대규모 시스템 설계 기초 2 - 5장. 지표 모니터링 및 경보 시스템 (3) | 2024.11.16 |
| 대규모 시스템 설계 기초 2 - 4장. 분산 메시지 큐 (3) | 2024.11.07 |
| 대규모 시스템 설계 기초 2 - 3장. 구글맵 (0) | 2024.11.02 |