Spring Boot API 레이트 리미팅: Fixed Window의 함정과 Redis Lua Sliding Window 전환기

최근 블랙 프라이데이 이벤트를 대비해 트래픽 부하 테스트를 진행하던 중, 분명 분당 100회 제한(100 RPM)을 걸어둔 API 게이트웨이가 특정 시점에 뚫리는 현상을 목격했습니다. 로그를 분석해보니 특정 악성 클라이언트가 00분 59초에 100번의 요청을 보내고, 01분 00초에 다시 100번의 요청을 보내는 패턴이었습니다. 시스템 입장에서는 2초라는 짧은 시간 동안 200회의 요청이 쏟아졌지만, 단순 카운터 방식의 로직은 이를 '정상'으로 판단했습니다.

API Rate Limiting 실패 분석과 Root Cause

당시 시스템 환경은 AWS EKS 상의 Spring Boot 3.2 애플리케이션과 Redis Cluster(ElastiCache)로 구성되어 있었으며, 평균 15,000 TPS를 처리하고 있었습니다. 초기 구현체는 가장 구현하기 쉬운 'Fixed Window Counter' 알고리즘이었습니다.

이 방식은 Redis의 INCR 명령어와 EXPIRE만 사용하므로 메모리 사용량이 적고 매우 빠릅니다. 하지만 앞서 언급한 '경계 시간(Boundary Time)'의 허점이 치명적이었습니다. API Rate Limiting의 본질은 서버 자원을 보호하고 트래픽 제어를 통해 모든 사용자에게 공정한 품질을 보장하는 것인데, Fixed Window 방식은 트래픽이 경계선에 몰릴 경우 순간적으로 허용 한도의 2배까지 부하를 허용하게 됩니다. 이는 DB 커넥션 풀 고갈로 이어져 백엔드 보안 및 가용성에 심각한 위협이 되었습니다.

Error Log:
ConnectionPoolTimeoutException: Timeout waiting for connection from pool.
[Analysis] 10:00:59.900 ~ 10:01:00.100 구간에서 허용치(Threshold)의 180% 트래픽 인입 확인.

우리는 단순히 윈도우 크기를 줄이는 시도(예: 1분 단위를 10초 단위로 변경)를 해보았지만, 이는 Burst 트래픽을 제어하기엔 역부족이었고 오히려 정상적인 사용자 경험까지 해치는 결과를 낳았습니다. 결국, 시간의 흐름에 따라 윈도우가 미끄러지듯 이동하며 카운팅하는 Sliding Window 알고리즘 도입이 불가피했습니다.

초기 접근: Java 레벨 제어의 실패

처음에는 라이브러리에 의존하지 않고 Java 코드 레벨에서 Redis의 ZSET(Sorted Set) 명령어를 순차적으로 호출하여 Sliding Window를 구현하려 했습니다.

  1. 현재 시간(timestamp)을 구한다.
  2. ZREMRANGEBYSCORE로 윈도우 밖의 오래된 요청을 제거한다.
  3. ZCARD로 현재 윈도우 내의 요청 수를 조회한다.
  4. 한도 미만이면 ZADD로 현재 요청을 추가한다.

하지만 이 방식은 치명적인 문제가 있었습니다. 바로 Race Condition(경쟁 상태)입니다. 높은 동시성 환경에서 여러 스레드가 동시에 ZCARD를 조회하면, 모두가 "아직 한도 미만이다"라고 판단하고 ZADD를 실행해버립니다. 이를 막기 위해 분산 락(Redisson 등)을 걸면 성능이 수직 하락했습니다. Redis 명령어 간의 원자성(Atomicity) 보장이 필수적이었습니다.

해결책: Redis Lua Script를 활용한 원자적 실행

해결책은 Redis Lua 스크립트를 사용하는 것입니다. Lua 스크립트는 Redis 서버 내부에서 단일 명령처럼 원자적으로 실행되므로, 별도의 락 없이도 경쟁 상태를 완벽하게 방지할 수 있습니다. 또한, 애플리케이션과 Redis 간의 네트워크 왕복(Round Trip)을 4회에서 1회로 줄여주어 Latency 측면에서도 이득입니다.

다음은 실제 프로덕션 환경에 적용한 Lua 스크립트와 호출 코드입니다.

// Redis Lua Script (sliding_window.lua)
// KEYS[1]: Rate Limit Key (예: "rate_limit:user:1234")
// ARGV[1]: 윈도우 크기 (밀리초 단위, 예: 60000)
// ARGV[2]: 최대 허용 요청 수 (예: 100)
// ARGV[3]: 현재 타임스탬프 (밀리초)
// ARGV[4]: 요청의 고유 ID (중복 방지용, UUID 등)

local key = KEYS[1]
local window_size = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
local request_id = ARGV[4]

-- 1. 윈도우 범위를 벗어난 오래된 요청 제거 (O(logN))
-- 현재 시간에서 window_size를 뺀 시간보다 작은 score를 가진 멤버 삭제
redis.call('ZREMRANGEBYSCORE', key, '-inf', current_time - window_size)

-- 2. 현재 윈도우 내의 요청 수 조회 (O(1))
local current_count = redis.call('ZCARD', key)

-- 3. 한도 체크 및 요청 추가
if current_count < limit then
    -- ZADD: Score=Time, Member=RequestID
    redis.call('ZADD', key, current_time, request_id)
    -- 키의 만료 시간 설정 (메모리 누수 방지, 윈도우 크기만큼 연장)
    redis.call('PEXPIRE', key, window_size)
    return 1 -- 허용
else
    return 0 -- 차단
end

위 스크립트의 핵심은 ZREMRANGEBYSCORE입니다. 요청이 들어올 때마다 과거의 데이터를 정리(Lazy Expiration)하므로, 별도의 배치 프로세스 없이도 윈도우를 실시간으로 유지합니다. PEXPIRE를 매번 갱신해주는 이유는, 사용자가 더 이상 요청을 보내지 않을 때 Redis 메모리에서 해당 키를 자동으로 정리하기 위함입니다.

Spring Boot에서는 StringRedisTemplate을 사용하여 이 스크립트를 실행합니다. execute 메서드는 스크립트의 SHA-1 해시를 미리 전송하여 네트워크 대역폭을 절약합니다.

@Service
@RequiredArgsConstructor
public class RateLimiterService {

    private final StringRedisTemplate redisTemplate;
    private final DefaultRedisScript<Long> redisScript;

    // 생성자에서 스크립트 초기화 권장
    @PostConstruct
    public void init() {
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/sliding_window.lua")));
        redisScript.setResultType(Long.class);
    }

    public boolean isAllowed(String userId, int limit, long windowSizeMs) {
        String key = "rate_limit:" + userId;
        long currentTime = System.currentTimeMillis();
        String uniqueId = UUID.randomUUID().toString(); // 고유 ID 생성

        Long result = redisTemplate.execute(
            redisScript,
            Collections.singletonList(key),
            String.valueOf(windowSizeMs),
            String.valueOf(limit),
            String.valueOf(currentTime),
            uniqueId
        );

        return result != null && result == 1L;
    }
}

성능 검증 및 벤치마크 분석

Fixed Window 방식과 Sliding Window 방식(Lua 적용)을 동일한 하드웨어 스펙에서 비교 테스트했습니다. 테스트 도구는 nGrinder를 사용하였으며, 가상 사용자(Vuser) 3,000명이 동시에 요청을 보내는 시나리오입니다.

지표 (Metric) Fixed Window (INCR) Sliding Window (Lua+ZSET) 비고
Avg Response Time 12ms 18ms ZSET 오버헤드로 인한 소폭 증가
Accuracy (정확도) 약 85% (경계선 Burst 허용) 99.9% (엄격한 제어) 보안 관점에서 결정적 차이
Redis CPU Usage 5% 12% Lua 실행 및 정렬 연산 비용
Throughput (TPS) 15,200 14,100 허용 가능한 수준의 Trade-off

결과를 분석해보면, Sliding Window 알고리즘을 적용했을 때 응답 시간이 약 6ms 증가하고 Redis CPU 사용량이 상승했습니다. 이는 ZSET 구조 특성상 데이터를 삽입할 때마다 정렬(Sorting)이 발생하고, ZREMRANGEBYSCORE로 데이터를 삭제하는 과정에서 비용이 발생하기 때문입니다. 하지만, 트래픽 제어의 정확도가 비약적으로 상승하여 '경계 시간 공격'을 완벽하게 방어할 수 있었습니다. 15,000 TPS 환경에서 6ms의 지연 증가는 백엔드 보안 강화라는 이점을 고려할 때 충분히 감수할 만한 비용입니다.

Redis Lua Script 공식 문서 확인

주의사항 및 Edge Cases

이 솔루션이 만능은 아닙니다. 적용 시 다음의 엣지 케이스를 반드시 고려해야 합니다.

메모리 사용량 주의: Sliding Window Log 방식은 윈도우 내의 모든 요청 기록을 ZSET에 저장합니다.
만약 제한이 '초당 100만 회'와 같이 매우 크다면, 하나의 키에 100만 개의 멤버가 저장되어 메모리 폭발(OOM)을 유발할 수 있습니다.

따라서, 이 방식은 유저별 분당 100~1,000회 수준의 정밀한 제어가 필요한 경우에 적합합니다. 만약 전체 시스템 레벨의 거대한 트래픽(예: DDoS 방어용 IP 차단)을 제어해야 한다면, 정확도를 조금 포기하더라도 Token Bucket 알고리즘이나 Redis의 HyperLogLog를 응용한 근사치 계산 방식을 사용하는 것이 메모리 효율상 좋습니다.

또한, Redis Cluster 환경에서는 KEYS[1](Rate Limit Key)이 해시 태그(Hash Tag)를 사용하여 올바른 슬롯에 매핑되도록 설계해야 합니다. Lua 스크립트는 원칙적으로 단일 노드 내의 키에 대해서만 원자성을 보장하기 때문입니다. Redis Cluster 환경 설정에 대한 더 자세한 내용은 이전 포스팅을 참고하시기 바랍니다.

최종 결과: 해당 로직 배포 후, 블랙 프라이데이 기간 동안 API 게이트웨이는 단 한 번의 커넥션 풀 고갈 없이 안정적으로 50,000 TPS 피크 트래픽을 처리해냈습니다.

결론

API Rate Limiting은 단순한 카운팅이 아닙니다. 서비스의 가용성을 지키는 방패이자, 사용자 간의 공정성을 보장하는 저울입니다. Fixed Window의 단순함에 안주하지 않고, Redis Lua와 Sliding Window 알고리즘을 통해 더욱 견고한 시스템을 구축하시기 바랍니다.

OlderNewest

Post a Comment