프로그래밍 PROGRAMMING/자바 JAVA AND FRAMEWORKS

API Rate Limiting: 토큰 버킷·슬라이딩 윈도우로 트래픽 제어하기

매운할라피뇨 2026. 4. 15. 08:50
반응형

목차

  1. 개요
  2. 핵심 알고리즘 이해: 토큰 버킷과 슬라이딩 윈도우
  3. 토큰 버킷 알고리즘 구현
  4. 슬라이딩 윈도우 알고리즘 구현
  5. 성능 비교와 알고리즘 선택 기준
  6. 운영 환경 적용 시 고려사항
  7. 맺음말

개요

문제 배경: 왜 API Rate Limiting이 필요한가

API Rate Limiting은 서버가 처리할 수 있는 요청 수를 시간 단위로 제한하는 트래픽 제어 메커니즘입니다. 분산 시스템에서 특정 클라이언트 혹은 사용자 그룹이 과도한 요청을 보내는 상황은 언제든 발생할 수 있으며, 이를 방치하면 전체 서비스의 안정성이 위협받습니다. 실제 프로젝트에서 API를 외부에 개방하거나 내부 마이크로서비스 간 호출 빈도를 관리해야 할 때, Rate Limiting은 서비스 가용성을 유지하는 첫 번째 방어선 역할을 합니다.
과거에는 단순한 카운터 기반의 고정 윈도우(Fixed Window) 방식이 주로 사용되었습니다. 예를 들어, "1분에 100번 이상 요청하면 차단"이라는 규칙을 정해두고 분 단위 카운터를 초기화하는 방식입니다. 이 방법은 구현이 간단하지만, 경계 지점에서 두 윈도우의 요청이 중첩되어 실제로는 허용 임계치의 두 배에 달하는 트래픽이 몰릴 수 있는 버스트(burst) 문제를 내포합니다. 이 한계를 극복하기 위해 등장한 알고리즘이 바로 토큰 버킷(Token Bucket)슬라이딩 윈도우(Sliding Window)입니다.

기존 방식의 한계

고정 윈도우 방식의 핵심적인 약점은 윈도우 경계에서의 순간적인 트래픽 폭발을 제어하지 못한다는 점입니다. 00:00:59에 99건, 00:01:00에 99건이 연달아 들어오면 카운터 상으로는 각각 한도 이하이지만, 실제 서버 입장에서는 2초 안에 198건을 처리해야 합니다. 또한 고정 윈도우 방식은 짧은 시간의 트래픽 패턴을 전혀 고려하지 않기 때문에, 순간적인 스파이크에 무방비 상태입니다.
리키 버킷(Leaky Bucket) 알고리즘은 이를 보완하려 했지만, 트래픽을 일정한 속도로만 내보내는 구조상 정상적인 버스트 트래픽까지 억제하는 부작용이 있습니다. 결과적으로 합법적인 사용자의 경험이 나빠지는 문제가 생깁니다. 이런 맥락에서 토큰 버킷과 슬라이딩 윈도우는 각각 다른 방향으로 이 문제를 해결하며, 현업에서 가장 널리 채택되는 Rate Limiting 전략으로 자리 잡았습니다.


핵심 알고리즘 이해: 토큰 버킷과 슬라이딩 윈도우

토큰 버킷의 동작 원리

토큰 버킷 알고리즘은 이름 그대로 "버킷에 토큰을 채워두고, 요청이 들어올 때마다 토큰을 소모하는" 방식으로 동작합니다. 버킷에는 최대 용량(capacity)이 정해져 있으며, 토큰은 정해진 속도(rate)로 지속적으로 채워집니다. 요청이 들어오면 버킷에 토큰이 충분한지 확인하고, 있으면 토큰을 차감한 뒤 요청을 처리합니다. 토큰이 없으면 요청을 거부하거나 대기열에 넣습니다.
이 알고리즘의 핵심적인 장점은 버스트 허용에 있습니다. 버킷이 꽉 찬 상태라면, 순간적으로 최대 용량만큼의 요청을 한꺼번에 처리할 수 있습니다. 동시에, 장기적으로는 토큰 충전 속도 이상으로는 요청을 처리할 수 없으므로 평균 처리율이 자연스럽게 제어됩니다. 클라우드 서비스나 공개 API에서 일시적인 사용 패턴 증가를 유연하게 수용하면서도 지속적인 과부하를 방지해야 할 때 이 특성이 매우 유용합니다.
또한 토큰 버킷은 사용자별 독립적인 버킷 관리가 자연스럽습니다. 각 사용자 혹은 API 키마다 별도의 버킷을 유지하면, 특정 사용자의 과도한 요청이 다른 사용자의 서비스 품질에 영향을 미치지 않도록 격리할 수 있습니다. Redis 같은 인메모리 저장소를 백엔드로 사용하면 분산 환경에서도 일관된 토큰 상태를 관리할 수 있습니다.


 

슬라이딩 윈도우의 동작 원리

슬라이딩 윈도우 알고리즘은 고정 윈도우의 경계 문제를 직접적으로 해결합니다. 고정 윈도우가 1분이라는 정해진 시간 구간을 사용한다면, 슬라이딩 윈도우는 현재 시점을 기준으로 과거 1분간의 요청 수를 실시간으로 계산합니다. 새로운 요청이 들어올 때마다 "지금으로부터 1분 전 이후에 들어온 요청이 몇 건인가?"를 따지는 것입니다.
구현 방식은 크게 두 가지로 나뉩니다. 첫째는 슬라이딩 윈도우 로그(Sliding Window Log) 방식으로, 각 요청의 타임스탬프를 정렬된 리스트에 저장하고, 새 요청이 들어올 때마다 윈도우 바깥의 타임스탬프를 제거한 뒤 남은 수를 세는 방법입니다. 정확도가 가장 높지만 메모리 사용량이 요청 수에 비례하여 늘어납니다. 둘째는 슬라이딩 윈도우 카운터(Sliding Window Counter) 방식으로, 현재 윈도우와 이전 윈도우의 카운터를 가중 평균으로 합산하는 근사치 계산 방식입니다. 메모리 효율이 높고 Redis에서 구현하기 적합합니다.

두 알고리즘의 핵심 차이

토큰 버킷과 슬라이딩 윈도우는 접근 관점 자체가 다릅니다. 토큰 버킷은 "미래의 처리 가능량을 사전에 축적"하는 개념이고, 슬라이딩 윈도우는 "과거의 실제 요청 이력을 추적"하는 개념입니다. 토큰 버킷은 버스트에 관대하지만 슬라이딩 윈도우는 시간 분포를 더 엄격하게 통제합니다. API 특성에 따라 어느 쪽이 적합한지 달라지며, 두 알고리즘을 계층적으로 조합하는 방법도 자주 사용됩니다. 예를 들어 토큰 버킷으로 단기 버스트를 허용하되, 슬라이딩 윈도우로 장기 평균을 제한하는 식입니다.


토큰 버킷 알고리즘 구현

Redis를 활용한 분산 토큰 버킷 설계

단일 서버 환경에서는 메모리 내 자료구조로 토큰 버킷을 구현할 수 있지만, 여러 인스턴스가 병렬로 운영되는 분산 환경에서는 공유 저장소가 필요합니다. Redis는 원자적 연산과 TTL 지원 덕분에 Rate Limiting 구현에 가장 널리 사용되는 선택지입니다. Lua 스크립트를 통해 토큰 확인과 차감을 단일 원자적 트랜잭션으로 처리하면 경쟁 조건(race condition)을 방지할 수 있습니다.

아래 예제는 Spring Boot 3.x 환경에서 Lettuce Redis 클라이언트를 활용해 토큰 버킷 Rate Limiter를 구현한 코드입니다. Lua 스크립트를 사용해 토큰 충전과 소모를 원자적으로 처리하며, 키 만료를 통해 비활성 사용자의 데이터를 자동 정리합니다.

@Component
public class TokenBucketRateLimiter {

    private final StringRedisTemplate redisTemplate;
    // 버킷 최대 용량 (최대 허용 토큰 수)
    private static final long BUCKET_CAPACITY = 10;
    // 초당 토큰 충전 속도
    private static final double REFILL_RATE = 2.0;

    // 토큰 확인 및 소모를 원자적으로 처리하는 Lua 스크립트
    private static final String LUA_SCRIPT = """
        local key = KEYS[1]
        local capacity = tonumber(ARGV[1])
        local refillRate = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local requested = tonumber(ARGV[4])

        -- 현재 버킷 상태 조회
        local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
        local tokens = tonumber(bucket[1]) or capacity
        local lastRefill = tonumber(bucket[2]) or now

        -- 마지막 충전 이후 경과 시간 기반으로 토큰 보충
        local elapsed = math.max(0, now - lastRefill)
        local newTokens = math.min(capacity, tokens + (elapsed * refillRate))

        if newTokens >= requested then
            -- 토큰 차감 후 상태 저장
            newTokens = newTokens - requested
            redis.call('HMSET', key, 'tokens', newTokens, 'lastRefill', now)
            redis.call('EXPIRE', key, 3600)
            return 1  -- 요청 허용
        else
            -- 토큰 부족: 상태만 업데이트, 요청 거부
            redis.call('HMSET', key, 'tokens', newTokens, 'lastRefill', now)
            redis.call('EXPIRE', key, 3600)
            return 0  -- 요청 거부
        end
        """;

    public TokenBucketRateLimiter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * @param userId  요청 사용자 식별자
     * @param tokens  이번 요청에 필요한 토큰 수 (기본 1)
     * @return true: 요청 허용 / false: 요청 거부
     */
    public boolean tryAcquire(String userId, int tokens) {
        String key = "rate_limit:token_bucket:" + userId;
        double now = System.currentTimeMillis() / 1000.0;  // 초 단위 타임스탬프

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
            List.of(key),
            String.valueOf(BUCKET_CAPACITY),
            String.valueOf(REFILL_RATE),
            String.valueOf(now),
            String.valueOf(tokens)
        );

        return Long.valueOf(1L).equals(result);
    }
}
// 실행 결과 예시 (초당 2토큰 충전, 버킷 최대 10토큰)
userId=user123, 연속 12회 요청:
[1~10] 허용 (버킷에 토큰 충분)
[11~12] 거부 (토큰 소진)
// 0.5초 대기 후 (1토큰 충전)
[13] 허용
[14] 거부

Spring AOP를 활용한 Rate Limit 어노테이션 적용

Lua 스크립트의 핵심 포인트는 세 가지입니다. 첫째, HMGETHMSET을 Lua 블록 안에서 처리해 Redis의 단일 스레드 실행 모델을 활용함으로써 별도의 분산 락 없이도 원자성을 보장합니다. 둘째, 토큰 충전을 요청 시점에 지연 계산(lazy refill)하므로 별도의 토큰 충전 스케줄러가 필요하지 않습니다. 셋째, EXPIRE를 매 요청마다 갱신해 활성 사용자의 키가 만료되지 않도록 하면서, 비활성 사용자의 키는 1시간 후 자동 삭제합니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int tokens() default 1;       // 요청당 소모 토큰 수
    String keyPrefix() default ""; // 키 접두사 (엔드포인트 구분)
}

@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {

    private final TokenBucketRateLimiter rateLimiter;

    @Around("@annotation(rateLimit)")
    public Object checkRateLimit(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable {
        // 요청 컨텍스트에서 사용자 ID 추출 (SecurityContext 혹은 헤더 기반)
        String userId = SecurityContextHolder.getContext()
            .getAuthentication().getName();

        String key = rateLimit.keyPrefix().isEmpty()
            ? userId
            : rateLimit.keyPrefix() + ":" + userId;

        if (!rateLimiter.tryAcquire(key, rateLimit.tokens())) {
            throw new RateLimitExceededException(
                "요청 한도를 초과했습니다. 잠시 후 다시 시도해 주세요."
            );
        }
        return pjp.proceed();
    }
}

// 컨트롤러 적용 예시
@RestController
@RequestMapping("/api/v1")
public class SearchController {

    @GetMapping("/search")
    @RateLimit(tokens = 1, keyPrefix = "search")
    public ResponseEntity<SearchResult> search(@RequestParam String query) {
        // 검색 로직
    }

    @PostMapping("/export")
    @RateLimit(tokens = 5, keyPrefix = "export")  // 무거운 작업은 토큰 더 소모
    public ResponseEntity<ExportResult> export(@RequestBody ExportRequest request) {
        // 내보내기 로직
    }
}

이 구조의 장점은 Rate Limiting 로직이 비즈니스 코드와 완전히 분리된다는 점입니다. 어노테이션 하나로 어떤 엔드포인트에든 다른 토큰 비용을 부여할 수 있으며, 향후 정책 변경 시에도 비즈니스 코드를 수정하지 않고 설정만 바꿀 수 있습니다. 특히 tokens 파라미터를 통해 리소스 비용이 높은 엔드포인트(데이터 내보내기, 파일 업로드 등)에 가중치를 부여하는 방식은 실제 프로젝트에서 세밀한 트래픽 제어를 가능하게 합니다.


슬라이딩 윈도우 알고리즘 구현

Redis Sorted Set 기반 슬라이딩 윈도우 로그

슬라이딩 윈도우 로그 방식은 Redis의 Sorted Set(ZSet)을 활용해 매우 직관적으로 구현할 수 있습니다. Sorted Set은 각 멤버에 점수(score)를 부여해 정렬된 상태를 유지하는 자료구조인데, 요청 타임스탬프를 score로, 요청 고유 ID를 멤버로 저장하면 시간 범위 기반 조회가 O(log N)으로 가능합니다. 새 요청이 들어올 때마다 윈도우 이전의 오래된 항목을 ZREMRANGEBYSCORE로 제거하고, ZCARD로 남은 요청 수를 세는 방식입니다.

아래 구현은 슬라이딩 윈도우 로그를 Redis Sorted Set으로 구현한 예제로, 역시 Lua 스크립트를 통해 원자성을 보장합니다.

@Component
public class SlidingWindowRateLimiter {

    private final StringRedisTemplate redisTemplate;
    private static final long WINDOW_SIZE_MS = 60_000L;   // 60초 윈도우
    private static final long MAX_REQUESTS   = 100L;       // 윈도우당 최대 요청 수

    private static final String LUA_SCRIPT = """
        local key        = KEYS[1]
        local now        = tonumber(ARGV[1])   -- 현재 시각 (ms)
        local windowSize = tonumber(ARGV[2])   -- 윈도우 크기 (ms)
        local maxReq     = tonumber(ARGV[3])   -- 최대 요청 수
        local reqId      = ARGV[4]             -- 요청 고유 ID

        -- 윈도우 시작 시각 계산
        local windowStart = now - windowSize

        -- 1) 만료된 요청 제거
        redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)

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

        if count < maxReq then
            -- 3) 현재 요청 추가 (score=타임스탬프, member=요청ID)
            redis.call('ZADD', key, now, reqId)
            redis.call('PEXPIRE', key, windowSize)
            return 1   -- 허용
        else
            return 0   -- 거부
        end
        """;

    public boolean tryAcquire(String userId) {
        String key   = "rate_limit:sliding_window:" + userId;
        long   now   = System.currentTimeMillis();
        // 요청 고유 ID: 타임스탬프 + 난수 조합으로 중복 방지
        String reqId = now + "-" + ThreadLocalRandom.current().nextLong(1_000_000);

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(LUA_SCRIPT, Long.class),
            List.of(key),
            String.valueOf(now),
            String.valueOf(WINDOW_SIZE_MS),
            String.valueOf(MAX_REQUESTS),
            reqId
        );

        return Long.valueOf(1L).equals(result);
    }
}
// 실행 결과 예시 (60초 윈도우, 최대 100건)
t=00:00 ~ t=00:10: 100건 요청 → 전부 허용
t=00:10: 101번째 요청 → 거부
t=00:30: 이전 60초 윈도우에 100건 존재 → 거부
t=01:05: 00:00~00:05 구간 요청 5건이 윈도우 밖으로 밀려남 → 5건 허용

슬라이딩 윈도우 카운터: 메모리 효율적인 근사치 방식

슬라이딩 윈도우 로그는 정확하지만 요청 수만큼 메모리를 사용합니다. 사용자 수가 많거나 요청이 폭발적으로 몰리는 환경에서는 이 메모리 비용이 부담이 될 수 있습니다. 이에 대한 실용적인 대안이 슬라이딩 윈도우 카운터입니다. 이 방식은 정확한 타임스탬프 대신, 이전 윈도우와 현재 윈도우의 카운터를 선형 보간하여 추정치를 계산합니다. 예를 들어 현재 윈도우가 30% 진행되었다면, 이전 윈도우 카운터의 70%와 현재 윈도우 카운터를 더해 근사 요청 수를 구합니다.
이 방식은 요청 1건당 단 2개의 Redis 키(현재·이전 윈도우 카운터)만 유지하므로 메모리 효율이 매우 높습니다. 정확도는 약 0.1~1% 오차 범위로, 대부분의 Rate Limiting 요구사항에서 허용 가능한 수준입니다. Cloudflare, NGINX 등 대규모 트래픽을 처리하는 시스템에서 이 방식을 채택하는 이유가 여기 있습니다.

슬라이딩 윈도우 카운터의 구체적인 공식은 다음과 같습니다: 예상 요청 수 = 이전 윈도우 카운터 × (1 - 현재 윈도우 경과 비율) + 현재 윈도우 카운터. 이 값이 최대 허용치를 초과하면 요청을 거부합니다. 카운터는 각 윈도우 구간이 시작될 때 0으로 초기화되며, TTL로 자동 만료됩니다. 구현상 주의할 점은, 현재 윈도우 경과 비율 계산에 밀리초 단위 정밀도가 필요하다는 것입니다. 초 단위로 자르면 경계에서 오차가 커질 수 있습니다.


성능 비교와 알고리즘 선택 기준

알고리즘별 성능 특성

토큰 버킷과 슬라이딩 윈도우는 동일한 Redis 기반으로 구현하더라도 성능 프로파일이 다릅니다. 토큰 버킷은 각 요청마다 HMGET, HMSET, EXPIRE 세 가지 Redis 명령을 사용하며, 저장되는 데이터는 사용자당 두 개의 필드(tokens, lastRefill)뿐입니다. 사용자 수가 수백만 명이더라도 메모리 사용량이 선형적으로 예측 가능합니다.

320x100

슬라이딩 윈도우 로그는 ZREMRANGEBYSCORE, ZCARD, ZADD, PEXPIRE를 사용하며, 메모리는 윈도우 내 요청 수에 비례합니다. 초당 1,000건의 요청을 처리하고 윈도우가 60초라면, 최악의 경우 사용자당 60,000개의 항목이 Sorted Set에 저장될 수 있습니다. Redis의 Sorted Set은 항목당 약 64바이트를 소비하므로, 이 경우 사용자당 약 3.7MB가 필요합니다. 반면 슬라이딩 윈도우 카운터는 토큰 버킷과 비슷하게 O(1) 메모리를 유지합니다.


대안 기술과의 비교

Resilience4j RateLimiter는 JVM 내부에서 AtomicReference 기반으로 슬라이딩 윈도우를 구현하여 Redis 네트워크 왕복 비용을 완전히 제거합니다. 단일 서버 환경이나 마이크로서비스 내부 자기 보호(self-protection)에는 탁월한 선택이지만, 여러 인스턴스가 동일한 Rate Limit을 공유해야 하는 경우에는 사용할 수 없습니다.
Bucket4j는 분산 환경을 위해 Hazelcast, Infinispan, Redis 등 다양한 백엔드를 지원하며, 토큰 버킷 알고리즘에 특화된 라이브러리입니다. 설정 DSL이 직관적이고 Spring Boot 자동 구성을 지원해 빠르게 적용할 수 있습니다. 단, 슬라이딩 윈도우 방식은 지원하지 않으며, Lua 스크립트를 직접 제어하고 싶은 경우 유연성이 제한됩니다.

NGINX rate limiting (limit_req_zone, limit_conn_zone)은 L7 리버스 프록시 레벨에서 Rate Limiting을 처리하므로, 애플리케이션 코드를 전혀 수정하지 않고도 적용할 수 있습니다. 하지만 사용자 인증 정보 기반의 동적 Rate Limiting이나 엔드포인트별 세밀한 정책 적용에는 한계가 있습니다. API 게이트웨이(Kong, AWS API Gateway)를 사용하는 경우에도 비슷한 트레이드오프가 존재합니다.

어떤 상황에서 어떤 알고리즘을 선택할 것인가

토큰 버킷이 적합한 경우는 다음과 같습니다. 첫째, 정상적인 사용 패턴에서 간헐적 버스트가 발생하는 API(예: 배치 업로드, 파일 처리)입니다. 둘째, 리소스 비용이 다른 엔드포인트를 하나의 버킷으로 통합 관리하고 싶을 때입니다. 셋째, 미사용 토큰을 누적해 향후 사용을 허용하는 크레딧 방식의 과금 모델이 필요한 경우입니다.
슬라이딩 윈도우 로그는 요청 빈도를 매우 정밀하게 제어해야 하는 환경, 예를 들어 금융 API나 규제 요건이 있는 시스템에 적합합니다. 정확한 감사 로그(audit log)가 필요한 경우에도 요청 타임스탬프를 이미 보관하고 있다는 점에서 활용할 수 있습니다. 슬라이딩 윈도우 카운터는 대규모 트래픽 환경에서 메모리 효율이 중요할 때, 그리고 약간의 근사치 오차를 허용할 수 있는 일반적인 API 서비스에 적합합니다.


운영 환경 적용 시 고려사항

흔한 실수와 함정

가장 자주 발생하는 문제는 Redis 연결 장애 시 처리 전략 미정입니다. Redis가 다운되면 Rate Limiter도 함께 동작을 멈추는데, 이때 기본 동작을 어떻게 설정하느냐가 중요합니다. 두 가지 선택지가 있습니다. 하나는 Fail-Open 방식으로, Redis 장애 시 Rate Limiting을 우회하고 모든 요청을 허용합니다. 서비스 가용성을 최우선으로 하는 경우에 적합하지만 DDoS 공격에 취약해집니다. 다른 하나는 Fail-Closed 방식으로, Redis 장애 시 모든 요청을 거부합니다. 보안이 중요한 금융·인증 서비스에 적합하지만 정상 사용자도 영향을 받습니다. 운영 환경에서는 Redis Sentinel이나 Redis Cluster를 통해 고가용성을 확보하고, 로컬 폴백 카운터를 유지하는 하이브리드 방식을 고려해야 합니다.

두 번째 함정은 키 설계의 중요성을 간과하는 것입니다. Rate Limiting 키는 사용자 ID만이 아니라, 엔드포인트 + 사용자 ID + API 버전 등을 조합해 설계해야 합니다. 예를 들어 rate_limit:v1:search:user123 형태로 구조화하면, 특정 엔드포인트나 API 버전의 한도를 독립적으로 관리할 수 있습니다. 또한 인증되지 않은 요청에 대해서는 IP 주소 기반 Rate Limiting을 적용하되, NAT 뒤에 여러 사용자가 같은 IP를 공유할 수 있다는 점을 고려해 IP 기반 임계치를 사용자 기반보다 더 넉넉하게 설정하는 것이 일반적입니다.

세 번째로 흔한 실수는 응답 헤더 미포함입니다. RFC 6585에는 429 Too Many Requests 상태 코드와 함께 Retry-After 헤더를 반환하도록 권고합니다. 더 나아가 X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset 헤더를 통해 클라이언트가 Rate Limit 상태를 인식하고 재시도 로직을 구현할 수 있도록 해야 합니다. 이 정보가 없으면 클라이언트는 무작위 재시도를 반복해 오히려 부하를 증폭시킵니다.


모니터링과 디버깅

Rate Limiting 시스템의 핵심 모니터링 지표는 세 가지입니다. 첫째, 거부율(rejection rate)입니다. 전체 요청 대비 Rate Limit으로 거부된 요청 비율을 시간대별로 추적하면 과도한 제한이나 부족한 제한을 조기에 발견할 수 있습니다. 거부율이 갑자기 높아지면 이상 트래픽 유입이나 한도 설정 오류를 의심해야 합니다.

둘째, Redis 연산 지연(latency)입니다. Rate Limiter는 모든 요청의 처리 경로에 위치하므로, Redis 응답이 느려지면 전체 서비스 응답 시간에 직접 영향을 줍니다. Redis의 SLOWLOGLATENCY HISTORY 명령을 활용해 이상 징후를 조기에 탐지해야 합니다. Lua 스크립트는 Redis 서버 측에서 실행되므로 네트워크 왕복 횟수를 줄여주지만, 복잡한 스크립트는 Redis 싱글 스레드를 블로킹할 수 있습니다.

셋째, 메모리 사용량 추이입니다. 특히 슬라이딩 윈도우 로그 방식에서는 트래픽이 증가할수록 Sorted Set의 크기가 커집니다. Redis의 MEMORY USAGE 명령이나 INFO memory 명령을 통해 Rate Limiting 키가 차지하는 메모리를 주기적으로 점검해야 합니다. 키 패턴에 만료(TTL)를 명시적으로 설정하는 것은 물론, 주기적으로 비정상적으로 큰 키를 탐지하는 스크립트를 운영하는 것이 좋습니다.


확장과 마이그레이션

서비스가 성장하면 Rate Limiting 정책도 함께 진화해야 합니다. 초기에는 전체 API에 단일 글로벌 정책을 적용했더라도, 사용자 등급(무료/프리미엄/엔터프라이즈)별로 서로 다른 한도를 적용해야 하는 시점이 옵니다. 이를 위해 Rate Limiter 설정을 하드코딩하지 않고 데이터베이스나 설정 서버(Spring Cloud Config, AWS Parameter Store 등)에서 동적으로 로드하는 구조를 처음부터 설계에 반영하면 향후 변경 비용을 크게 줄일 수 있습니다.
멀티 리전 배포 환경에서는 Rate Limiting 상태를 어디까지 공유할지 결정해야 합니다. 리전별로 독립적인 한도를 적용할 경우 사용자가 여러 리전에 걸쳐 요청을 분산시켜 한도를 우회할 수 있습니다. 반면 글로벌 Redis 클러스터로 상태를 공유하면 크로스 리전 레이턴시가 Rate Limiter의 병목이 될 수 있습니다. 실제 프로젝트에서는 짧은 윈도우(초 단위) 한도는 로컬 Redis로, 긴 윈도우(일 단위) 한도는 글로벌 저장소로 나누어 관리하는 이중 레이어 방식이 균형 잡힌 해법이 됩니다.
한도 변경은 무중단으로 진행되어야 합니다. 기존 사용자의 토큰 상태를 유지하면서 새 정책을 적용하려면, 키 구조에 정책 버전을 포함시키거나 그림자 배포(shadow deployment) 방식으로 새 정책을 먼저 시뮬레이션한 뒤 단계적으로 전환하는 방법을 고려해야 합니다.


맺음말

핵심 요약

이 글에서는 API Rate Limiting의 두 핵심 알고리즘인 토큰 버킷과 슬라이딩 윈도우의 원리와 구현 방법을 살펴보았습니다. 토큰 버킷은 일시적 버스트에 유연하게 대응하면서도 장기 처리율을 제어하는 데 강점이 있으며, 슬라이딩 윈도우 로그는 시간 분포 기반의 정밀한 제어에 적합합니다. 슬라이딩 윈도우 카운터는 두 방식의 중간 지점으로, 대규모 트래픽 환경에서 메모리 효율과 정확도를 균형 있게 유지합니다. Redis Lua 스크립트를 활용한 원자적 처리는 분산 환경에서 경쟁 조건 없는 일관된 상태 관리를 가능하게 합니다.

적용 판단 기준

간헐적 버스트가 정상 사용 패턴의 일부인 API, 요청 비용에 따라 가중 소모가 필요한 엔드포인트, 또는 크레딧 기반 과금 모델이 필요한 서비스라면 토큰 버킷이 더 자연스러운 선택입니다. 반면 규제 환경, 금융 거래, 또는 초당 정확한 요청 수 보장이 필요한 서비스라면 슬라이딩 윈도우 로그가 적합합니다. 서비스 규모가 크고 Redis 메모리 비용이 제약 조건이라면 슬라이딩 윈도우 카운터가 현실적입니다. 두 알고리즘을 조합해 단기 제한에는 토큰 버킷을, 장기 제한에는 슬라이딩 윈도우를 적용하는 이중 레이어 접근도 효과적입니다.

다음 단계

Rate Limiting을 더 깊이 이해하고 싶다면, Resilience4j의 공식 문서와 Bucket4j의 GitHub 저장소가 실용적인 출발점이 됩니다. 분산 Rate Limiting의 이론적 배경을 파고들고 싶다면 Cloudflare의 엔지니어링 블로그(https://blog.cloudflare.com/counting-things-a-lot-of-different-things/)에서 슬라이딩 윈도우 카운터의 실전 적용 사례를 확인할 수 있습니다. 또한 서킷 브레이커(Circuit Breaker), 벌크헤드(Bulkhead) 패턴과 Rate Limiting을 함께 적용하는 방어적 프로그래밍(defensive programming) 전략으로 학습을 확장하면, 단순한 요청 제한을 넘어 시스템 복원력(resilience) 전체를 체계적으로 설계할 수 있는 역량을 갖추게 됩니다.

반응형