Redis를 이용한 Spring Boot Rate Limiting
1. 개요
Rate limiting은 일정 시간 동안 클라이언트가 API를 너무 많이 호출하는 것을 막아 시스템 과부하나 악의적인 사용을 방지합니다. 이 글에서는 Redis를 중앙 카운터로 사용하고 Lua 스크립트와 결합하여 여러 애플리케이션 인스턴스가 동시에 접근할 때 원자성을 보장하는 방법을 설명합니다. 이 글을 마친 후, 당신은 직접 RateLimiter를 작성할 수 있으며 fixed window와 sliding window 알고리즘을 사용하고, interceptor를 통해 Spring Boot에 통합하고, 동작을 검증하는 테스트를 작성할 수 있습니다.
2. Rate limiting에 Redis를 선택하는 이유
애플리케이션이 여러 인스턴스로 실행될 때 (horizontal scaling) 각 인스턴스의 메모리에 저장된 카운터는 동기화될 수 없습니다. Redis는 이 문제를 해결합니다:
- 중앙 데이터 저장소이므로 모든 인스턴스가 같은 위치에서 읽고 쓸 수 있습니다.
- TTL(
EXPIRE)을 지원하여 오래된 키를 자동으로 정리하므로 cron 작업이 필요하지 않습니다. - Redis 서버에서 원자적으로 실행되는 Lua 스크립트를 지원하여 카운터를 증가시키고 임계값을 확인할 때 race condition을 방지합니다.
주의: 시스템에 단일 인스턴스만 있고 상태 공유가 필요 없다면, in-memory limiter(예: Guava
RateLimiter)를 사용하면 더 빠르고 간단합니다. 분산 rate limit이 필요할 때만 Redis를 사용하세요.
3. Dependency 설정
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379
Spring Boot는 이 property를 기반으로 RedisConnectionFactory와 StringRedisTemplate을 자동으로 구성합니다.
4. Fixed Window 알고리즘
Fixed window는 시간을 고정된 프레임으로 나눕니다 (예: 60초마다). 각 프레임에서 요청 수를 세고 임계값을 초과하면 거부합니다.
4.1. Lua 스크립트 원자성
Lua 스크립트를 사용하여 INCR과 EXPIRE을 원자적 명령으로 결합합니다. 다른 요청이 두 단계 사이에 개입하는 것을 방지합니다.
public class FixedWindowRateLimiter {
private static final String SCRIPT = """
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
""";
private final StringRedisTemplate redisTemplate;
private final RedisScript<Long> redisScript;
public FixedWindowRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.redisScript = RedisScript.of(SCRIPT, Long.class);
}
public boolean isAllowed(String clientId, int limit, int windowSeconds) {
String key = "rate:fixed:" + clientId;
Long current = redisTemplate.execute(
redisScript,
List.of(key),
String.valueOf(windowSeconds));
return current != null && current <= limit;
}
}
설명:
INCR KEYS[1]는 key의 카운터를 증가시키고 새 값을 반환합니다.current == 1이면 (key가 처음 생성됨)windowSeconds초로 TTL을 설정합니다.- 전체 스크립트는 Redis 서버에서 원자적으로 실행되며, 다중 스레드나 다중 인스턴스에서 실행되어도 다른 요청과 얽히지 않습니다.
isAllowed는 현재 요청 수가limit을 초과하지 않으면true를 반환합니다.
4.2. Spring Boot interceptor에서 사용
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final FixedWindowRateLimiter rateLimiter;
public RateLimitInterceptor(FixedWindowRateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws IOException {
String clientId = request.getRemoteAddr();
boolean allowed = rateLimiter.isAllowed(clientId, 100, 60);
if (!allowed) {
response.setStatus(429);
response.getWriter().write("요청이 너무 많습니다");
return false;
}
return true;
}
}
Interceptor는 클라이언트 IP를 clientId로 가져와 60초마다 100개 요청으로 제한합니다. 임계값을 초과하면 HTTP 429를 반환하고 controller로 전달되는 요청을 차단합니다.
주의: Fixed window는 “경계에서의 burst” 단점이 있습니다. 클라이언트가 59초 말에 100개 요청을 보내고 60초 초에 100개 더 보내면, 각 window가 유효하더라도 실제로는 1초에 200개의 요청이 있습니다. 더 정확한 제어가 필요하면 sliding window를 사용하세요.
5. Sliding Window Log 알고리즘
Sliding window log는 각 요청의 타임스탬프를 Sorted Set에 저장하고 확인할 때마다 오래된 타임스탬프를 제거합니다. Fixed window보다 정확하지만 각 요청을 저장하므로 메모리를 더 씁니다.
public class SlidingWindowRateLimiter {
private static final String SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - windowMs)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('PEXPIRE', key, windowMs)
return 1
end
return 0
""";
private final StringRedisTemplate redisTemplate;
private final RedisScript<Long> redisScript;
public SlidingWindowRateLimiter(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
this.redisScript = RedisScript.of(SCRIPT, Long.class);
}
public boolean isAllowed(String clientId, int limit, int windowMs) {
String key = "rate:sliding:" + clientId;
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
redisScript,
List.of(key),
String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(limit));
return result != null && result == 1;
}
}
Lua 스크립트의 각 단계 설명:
ZREMRANGEBYSCORE는 score(타임스탬프)가now - windowMs보다 작은 항목을 삭제합니다. 즉, 시간 창 밖의 요청을 제거합니다.ZCARD는 Sorted Set의 남은 항목 수를 셉니다. 이것이 현재 window의 요청 수입니다.count < limit이면 score가now인 새 항목을 추가하고, key에 대해windowMs로 TTL을 설정합니다 (traffic이 없을 때 자동 정리).1을 반환합니다 (허용).- 이미 가득 차면
0을 반환합니다 (거부). 새 항목을 추가하지 않습니다.
결과: 창이 실시간으로 연속적으로 이동하므로 fixed window처럼 경계에서의 burst 현상이 없습니다.
6. 두 알고리즘 비교
| 기준 | Fixed Window | Sliding Window Log |
|---|---|---|
| 정확도 | 낮음, 경계에서 burst 있음 | 높음 |
| 메모리 | 각 key당 O(1) | 각 key당 O(limit) |
| 복잡도 | 단순 | 더 복잡 |
| 적합 | 대략적 제한, 높은 트래픽 | 민감한 API, 정확성 필요 |
어떤 알고리즘을 선택할지 확실하지 않다면 fixed window로 시작하세요. 단순하고 성능이 좋으며 대부분의 내부 API에 충분합니다.
7. Testcontainers로 테스트 작성
Redis가 이미 설치되어 있어야 하는 test 의존성을 피하기 위해 Docker에서 Redis를 시작하는 Testcontainers를 사용합니다.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class FixedWindowRateLimiterTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
FixedWindowRateLimiter rateLimiter;
@BeforeEach
void setUp() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
redis.getHost(), redis.getMappedPort(6379));
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
factory.afterPropertiesSet();
StringRedisTemplate template = new StringRedisTemplate(factory);
template.afterPropertiesSet();
rateLimiter = new FixedWindowRateLimiter(template);
}
@Test
void shouldBlockRequestsExceedingLimit() {
String clientId = "client-A";
int limit = 3;
boolean r1 = rateLimiter.isAllowed(clientId, limit, 60);
boolean r2 = rateLimiter.isAllowed(clientId, limit, 60);
boolean r3 = rateLimiter.isAllowed(clientId, limit, 60);
boolean r4 = rateLimiter.isAllowed(clientId, limit, 60);
assertThat(r1).isTrue();
assertThat(r2).isTrue();
assertThat(r3).isTrue();
assertThat(r4).isFalse();
}
}
이 test는 limit = 3으로 연속 4개 요청을 보냅니다. 처음 3개 요청은 허용되고 (isTrue), 4번째 요청은 동일 window에서 임계값을 초과했으므로 거부됩니다 (isFalse). Testcontainers는 Redis 컨테이너를 자동으로 시작하고 종료하므로 test가 CI 환경의 설치된 환경과 독립적입니다.
8. 일반적인 오류
- key에 TTL을 설정하지 않음:
EXPIRE를 빠뜨리면 key가 Redis에 영구 존재하여 메모리 누수가 발생하고 counter가 절대 리셋되지 않습니다. - counter를 증가시키고 임계값을 확인하는 것을 두 개의 별도 명령으로 수행 (
GET후INCR): 많은 요청이 동시에 도착할 때 race condition이 발생하여 실제 임계값 초과로 이어집니다. 항상 Lua 스크립트로 합치거나 원자적INCR을 사용하세요. - 클라이언트를 구분하지 않는 key 사용:
clientId를 key에 포함시키지 않으면 모든 클라이언트가 같은 카운터를 사용하여 다른 사용자를 잘못 차단하게 됩니다. - 극대의 트래픽을 가진 sliding window log:
limit이 크고 많은 클라이언트가 동시에 접근하면 Sorted Set이 커집니다. 메모리 효율을 위해 sliding window counter (두 fixed window 결합)를 사용하는 것을 고려하세요.
9. 결론
Redis와 Lua 스크립트의 조합은 분산 rate limiter를 구축하는 인기 있는 선택입니다. 단순하고 원자적이며 애플리케이션 인스턴스 수에 따라 쉽게 확장됩니다. Fixed window는 단순함 때문에 대부분의 경우에 적합하며, sliding window log는 시간 경계에서 정확한 제어가 필요할 때 적합합니다. 항상 production에 배포하기 전에 제한 임계값(경계)에서의 동작을 철저히 테스트하세요. 여기가 논리 오류가 가장 쉽게 발생하는 곳이기 때문입니다.