API를 외부에 공개했는데, 특정 사용자가 초당 수천 건의 요청을 보내기 시작했습니다. 서버를 보호하려면 어떻게 해야 할까요?

개념 정의

Rate Limiting 은 일정 시간 동안 허용되는 요청 수를 제한하는 기법입니다. API 남용 방지, 서버 과부하 보호, 공정한 자원 분배를 위해 사용하며, 대부분의 공개 API가 기본적으로 적용하고 있습니다.

왜 Redis인가

Rate Limiting은 모든 요청마다 카운터를 확인하고 갱신해야 하므로, 저장소의 성능이 곧 API의 응답 지연에 직결됩니다. Redis가 이 용도에 적합한 이유를 정리하면 다음과 같습니다.

  • **인메모리 속도 **: 카운터 조회·갱신이 마이크로초 단위로 처리됩니다
  • ** 원자적 연산 **: INCR, ZADD 등의 명령이 원자적이고, Lua Script로 복잡한 로직도 원자적으로 실행할 수 있습니다
  • **TTL 자동 만료 **: EXPIRE로 윈도우가 끝나면 키가 자동 삭제되어 별도 정리가 필요 없습니다
  • ** 분산 환경 공유 **: 여러 서버 인스턴스가 하나의 Redis를 바라보면, 사용자별 전체 요청 수를 정확히 추적할 수 있습니다

로컬 메모리(ConcurrentHashMap 등)로도 구현할 수 있지만, 서버가 2대 이상이면 각 서버가 독립적으로 카운트하게 되어 실제 제한의 N배까지 허용될 수 있습니다. 분산 환경이라면 Redis가 거의 필수입니다.

알고리즘별 구현

Fixed Window Counter

가장 단순한 방식입니다. 시간을 고정된 윈도우(예: 1분 단위)로 나누고, 각 윈도우마다 카운터를 증가시킵니다.

** 기본 아이디어:**

PLAINTEXT
윈도우: 12:00:00 ~ 12:00:59 → 카운터 키: rate:user123:202603281200
윈도우: 12:01:00 ~ 12:01:59 → 카운터 키: rate:user123:202603281201

Lua Script로 원자적 구현:

LUA
-- fixed_window.lua
-- KEYS[1]: 카운터 키 (예: rate:user123:202603281200)
-- ARGV[1]: 최대 허용 횟수
-- ARGV[2]: 윈도우 크기(초)

local current = redis.call('INCR', KEYS[1])

-- 첫 번째 요청이면 TTL 설정
if current == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2])
end

-- 제한 초과 여부 확인
if current > tonumber(ARGV[1]) then
    return 0  -- 거부
else
    return 1  -- 허용
end

구현이 간단하고 메모리도 적게 쓰지만, 치명적인 단점이 있습니다.

** 경계 버스트(Boundary Burst) 문제:**

PLAINTEXT
  윈도우 1 (12:00~12:01)     윈도우 2 (12:01~12:02)
  ─────────────────────────  ─────────────────────────
                   ▓▓▓▓▓▓▓▓  ▓▓▓▓▓▓▓▓
                   100건       100건
                   ◄──2초──►

  제한: 1분에 100건인데, 2초 사이에 200건이 허용됨!

12:00:59에 100건, 12:01:00에 100건이 들어오면 실질적으로 2초 사이에 200건이 통과합니다. 이 문제를 해결하는 것이 이후의 알고리즘들입니다.

Sliding Window Log

요청마다 타임스탬프를 Sorted Set에 기록하고, 현재 시각 기준으로 윈도우 범위 내의 요청 수를 세는 방식입니다.

** 동작 흐름:**

  1. 윈도우 밖의 오래된 요청 제거 (ZREMRANGEBYSCORE)
  2. 현재 요청의 타임스탬프 추가 (ZADD)
  3. 윈도우 내 요청 수 확인 (ZCARD)
  4. 제한 초과 시 방금 추가한 요청 제거

Lua Script:

LUA
-- sliding_window_log.lua
-- KEYS[1]: Sorted Set 키 (예: rate:user123)
-- ARGV[1]: 최대 허용 횟수
-- ARGV[2]: 윈도우 크기(밀리초)
-- ARGV[3]: 현재 타임스탬프(밀리초)

local window_start = tonumber(ARGV[3]) - tonumber(ARGV[2])

-- 1. 윈도우 밖의 오래된 요청 제거
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', window_start)

-- 2. 현재 윈도우 내 요청 수 확인
local count = redis.call('ZCARD', KEYS[1])

if count >= tonumber(ARGV[1]) then
    return 0  -- 거부
end

-- 3. 현재 요청 추가 (타임스탬프를 score와 member 모두에 사용)
redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])

-- 4. TTL 설정 (윈도우 크기 + 여유 시간)
redis.call('PEXPIRE', KEYS[1], ARGV[2])

return 1  -- 허용

** 장단점:**

  • 장점: 윈도우가 실시간으로 슬라이딩하므로 경계 버스트 문제가 없습니다. 가장 정확한 방식입니다.
  • 단점: 요청마다 Sorted Set에 멤버가 추가되므로, 트래픽이 높으면 메모리 사용량이 급증합니다. 분당 10만 건 제한이면 Sorted Set에 최대 10만 개의 멤버가 들어갑니다.

Sliding Window Counter

Fixed Window의 단순함과 Sliding Window Log의 정확성을 절충한 방식입니다. 이전 윈도우와 현재 윈도우의 카운트를 가중 평균으로 합산합니다.

** 핵심 아이디어:**

PLAINTEXT
이전 윈도우 (12:00~12:01): 84건
현재 윈도우 (12:01~12:02): 36건
현재 시각: 12:01:15 (현재 윈도우의 25% 경과)

가중 합산 = 이전 윈도우 × (1 - 경과 비율) + 현재 윈도우
         = 84 × 0.75 + 36
         = 63 + 36 = 99건

Lua Script:

LUA
-- sliding_window_counter.lua
-- KEYS[1]: 이전 윈도우 카운터 키
-- KEYS[2]: 현재 윈도우 카운터 키
-- ARGV[1]: 최대 허용 횟수
-- ARGV[2]: 윈도우 크기(초)
-- ARGV[3]: 현재 윈도우 시작 시각(Unix 초)
-- ARGV[4]: 현재 시각(Unix 초)

local prev_count = tonumber(redis.call('GET', KEYS[1]) or '0')
local curr_count = tonumber(redis.call('GET', KEYS[2]) or '0')

-- 현재 윈도우에서 경과한 비율 계산
local window_size = tonumber(ARGV[2])
local elapsed = tonumber(ARGV[4]) - tonumber(ARGV[3])
local weight = math.max(0, (window_size - elapsed) / window_size)

-- 가중 합산
local total = math.floor(prev_count * weight) + curr_count

if total >= tonumber(ARGV[1]) then
    return 0  -- 거부
end

-- 현재 윈도우 카운터 증가
redis.call('INCR', KEYS[2])
redis.call('EXPIRE', KEYS[2], window_size * 2)  -- 다음 윈도우에서도 참조하므로 2배

return 1  -- 허용

Fixed Window의 burst 문제를 상당 부분 해결하면서, Sorted Set 대신 String 카운터 2개만 사용하므로 메모리가 매우 효율적입니다. 완벽히 정확하지는 않지만, 실무에서는 이 정도 근사치면 충분한 경우가 많습니다.

Token Bucket

지금까지는 "시간당 N건"이라는 제한이었다면, Token Bucket은 평균 처리율을 유지하면서 일정 수준의 버스트를 허용하는 방식입니다.

핵심 개념:

  • ** 버킷 용량(capacity)**: 토큰이 최대로 쌓일 수 있는 수
  • ** 충전 속도(refill_rate)**: 초당 추가되는 토큰 수
  • ** 요청 시 토큰 소비 **: 토큰이 있으면 1개 소비 후 허용, 없으면 거부
PLAINTEXT
  버킷 용량: 10개   충전 속도: 2개/초

  ┌──────────┐
  │ ● ● ● ●  │  ← 현재 4개 토큰
  │ ● ● ●    │
  │          │
  └──────────┘

    2개/초 충전

  → 요청 1건 = 토큰 1개 소비
  → 요청 없으면 최대 10개까지 누적
  → 누적된 토큰으로 버스트 처리 가능

Lua Script:

LUA
-- token_bucket.lua
-- KEYS[1]: 버킷 키 (예: bucket:user123)
-- ARGV[1]: 버킷 최대 용량
-- ARGV[2]: 초당 충전 토큰 수
-- ARGV[3]: 현재 타임스탬프(밀리초)
-- ARGV[4]: 소비할 토큰 수 (보통 1)

local bucket_key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

-- 현재 버킷 상태 조회
local bucket = redis.call('HMGET', bucket_key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1])
local last_refill = tonumber(bucket[2])

-- 최초 요청이면 버킷을 가득 채움
if tokens == nil then
    tokens = capacity
    last_refill = now
end

-- 마지막 충전 이후 경과 시간 기반으로 토큰 충전
local elapsed = (now - last_refill) / 1000  -- 밀리초 → 초
local new_tokens = math.min(capacity, tokens + (elapsed * refill_rate))

-- 토큰 소비 시도
if new_tokens >= requested then
    new_tokens = new_tokens - requested
    -- 버킷 상태 갱신
    redis.call('HSET', bucket_key, 'tokens', new_tokens, 'last_refill', now)
    redis.call('EXPIRE', bucket_key, math.ceil(capacity / refill_rate) * 2)
    return 1  -- 허용
else
    -- 토큰 부족 — 충전 상태는 갱신하되 소비하지 않음
    redis.call('HSET', bucket_key, 'tokens', new_tokens, 'last_refill', now)
    redis.call('EXPIRE', bucket_key, math.ceil(capacity / refill_rate) * 2)
    return 0  -- 거부
end

Token Bucket은 API Gateway에서 가장 많이 채택하는 알고리즘입니다. AWS API Gateway, Stripe, GitHub API 모두 이 방식의 변형을 사용합니다. 평균 속도를 제어하면서도 순간적인 트래픽 급증을 유연하게 처리할 수 있기 때문입니다.

알고리즘 비교

기준Fixed WindowSliding Window LogSliding Window CounterToken Bucket
정확성낮음 (경계 burst)매우 높음높음 (근사)높음
** 메모리**매우 적음 (String 1개)큼 (요청마다 멤버 추가)적음 (String 2개)적음 (Hash 1개)
** 구현 복잡도**매우 낮음중간중간중간~높음
** 버스트 처리**경계에서 2배 허용정확히 제한거의 정확히 제한의도적으로 허용
** 대표 사용처**간단한 내부 API엄격한 제한 필요 시범용API Gateway

정리하자면, 간단한 내부 API는 Fixed Window로 충분하고, 정확한 제한이 필요하면 Sliding Window Log를, 메모리와 정확성을 적절히 절충하려면 Sliding Window Counter를, 버스트를 유연하게 허용하려면 Token Bucket을 선택합니다.

Spring Boot에서의 활용

Spring Cloud Gateway + Redis RateLimiter

Spring Cloud Gateway는 Redis 기반 Rate Limiter를 내장하고 있습니다. Token Bucket 알고리즘을 사용하며, 설정만으로 적용할 수 있습니다.

YAML
spring:
  cloud:
    gateway:
      routes:
        - id: api-route
          uri: http://localhost:8080
          predicates:
            - Path=/api/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10  # 초당 충전 토큰
                redis-rate-limiter.burstCapacity: 20  # 버킷 최대 용량
                key-resolver: "#{@userKeyResolver}"   # 사용자 식별 기준
JAVA
@Bean
public KeyResolver userKeyResolver() {
    // IP 주소 기준으로 Rate Limiting 적용
    return exchange -> Mono.just(
        exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()
    );
}

내부적으로 Redis Lua Script를 실행하며, replenishRateburstCapacity로 Token Bucket의 충전 속도와 버킷 용량을 조절합니다.

Bucket4j + Redis

Bucket4j는 Token Bucket 구현 라이브러리로, 다양한 백엔드(JCache, Redis, Hazelcast 등)를 지원합니다.

JAVA
@Bean
public ProxyManager<String> proxyManager(RedissonClient redissonClient) {
    // Redisson 기반 Redis 연동
    return RedissonBasedProxyManager.builderFor(redissonClient)
        .build();
}

public boolean tryConsume(String userId) {
    BucketConfiguration config = BucketConfiguration.builder()
        // 분당 100건, 최대 버스트 20건
        .addLimit(Bandwidth.builder()
            .capacity(20)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build())
        .build();

    Bucket bucket = proxyManager.builder()
        .build("rate:" + userId, () -> config);

    return bucket.tryConsume(1);  // 토큰 1개 소비 시도
}

Spring Cloud Gateway 없이 일반 Spring Boot 애플리케이션에서도 사용할 수 있어, 서비스 레이어에서 세밀한 Rate Limiting이 필요할 때 유용합니다.

분산 환경 주의사항

Clock Drift

Sliding Window 계열 알고리즘에서 타임스탬프 기반으로 윈도우를 계산할 때, 애플리케이션 서버 간 시각 차이가 문제가 될 수 있습니다.

  • 서버 A는 12:00:59, 서버 B는 12:01:01이라고 판단하면 다른 윈도우로 카운트됩니다
  • 해결: 타임스탬프를 애플리케이션 서버가 아닌 Redis 서버의 TIME 명령 으로 가져오거나, Lua Script 내에서 redis.call('TIME')을 사용합니다
LUA
-- Redis 서버 시각 사용
local time = redis.call('TIME')
local now_ms = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000)

Redis Cluster에서의 Key Routing

Redis Cluster는 키를 해시 슬롯으로 분산하므로, Rate Limiting에서 주의할 점이 있습니다.

  • Sliding Window Counter처럼 2개의 키(prev_window, curr_window)를 Lua Script에서 함께 다루려면, 두 키가 같은 슬롯에 있어야 합니다
  • 해시 태그 를 사용하여 같은 슬롯으로 라우팅합니다
PLAINTEXT
# 해시 태그 사용 — {user123} 부분만 해싱
rate:{user123}:prev
rate:{user123}:curr

# 이렇게 하면 두 키가 반드시 같은 슬롯에 위치

Token Bucket의 경우 Hash 하나에 모든 상태를 저장하므로, 단일 키로 처리되어 이 문제가 발생하지 않습니다.

정리

  • Rate Limiting 은 API 보호의 기본이며, Redis의 인메모리 속도와 원자적 연산이 이 용도에 최적입니다
  • Fixed Window 는 가장 단순하지만 경계 burst 문제가 있고, Sliding Window Log 는 가장 정확하지만 메모리를 많이 사용합니다
  • Sliding Window Counter 는 정확성과 효율성의 좋은 절충안이고, Token Bucket 은 버스트를 유연하게 허용하여 API Gateway에서 가장 널리 사용됩니다
  • 모든 구현에서 Lua Script를 통한 원자적 실행 이 핵심입니다. 조회와 갱신 사이에 다른 요청이 끼어드는 Race Condition을 반드시 방지해야 합니다
  • 분산 환경에서는 Clock Drift와 Redis Cluster의 키 라우팅을 고려해야 합니다
댓글 로딩 중...