Redis와 Spring Cloud Gateway를 활용한 API 게이트웨이 Rate Limiter 설계

1. 개요

여러 클라이언트가 API를 동시에 호출할 때, 한 클라이언트의 오류나 의도적인 스팸은 모든 사용자의 백엔드를 마비시킬 수 있다. API 게이트웨이 계층의 Rate Limiter는 제한값을 초과한 요청을 차단하여 이를 방지한다.

이 글에서는 완전한 사례 연구를 다룬다. 요구사항 분석, 알고리즘 선택, 데이터 설계, Redis Lua 스크립트를 이용한 구현, Spring Cloud Gateway 통합까지 진행한다. 이를 통해 분산 rate limiter를 구축하고 동시성 문제, 시간 차이, hot key, Redis 장애에 대응하는 방법을 배울 수 있다.

2. 요구사항

2.1. 기능 요구사항

  • 테넌트의 API 키와 라우트별로 요청 제한. 예를 들어 테넌트 shop-a의 POST /orders는 초당 최대 100개 요청이 허용되며, 버스트는 200까지 가능하다.
  • 제한값을 초과한 요청은 429 Too Many Requests와 Retry-After 헤더를 받는다.
  • 모든 응답에는 X-RateLimit-Remaining 헤더가 포함되어 클라이언트가 자체 조절할 수 있다.
  • 규칙은 런타임 중에 변경할 수 있으며, 게이트웨이를 재배포할 필요가 없다.

2.2. 규모 및 제약 조건

  • 피크 시간에 약 50,000 RPS, 20개 게이트웨이 인스턴스, 10만 개 테넌트.
  • 각 요청에 추가되는 지연은 p99에서 2ms 이하여야 한다.
  • 제한값은 전체 클러스터에서 정확해야 하며, 각 인스턴스별로 따로 계산되지 않는다. 예를 들어 제한이 100/s라면, 전체 클러스터는 대략 100/s만 허용해야 한다.
  • Rate Limiter가 단일 실패 지점이 되어서는 안 된다. Redis에 장애가 발생해도 게이트웨이는 계속 서비스를 제공해야 한다.
  • 성능을 위해 작은 오차(몇 %)는 허용한다.

세 번째 제약은 각 인스턴스의 메모리에서 세는 방식을 배제한다. 공유 상태 저장소가 필요하다. Redis가 자연스러운 선택이다. 낮은 지연 시간과 Lua를 통한 원자적 작업을 제공하기 때문이다.

3. 알고리즘 선택

알고리즘 장점 단점
Fixed window 간단함, 하나의 INCR 윈도우 경계에서 버스트가 두 배로 증가
Sliding log 절대적으로 정확함 메모리 낭비, 각 타임스탬프 저장 필요
Sliding window counter 근사적으로 정확, 가벼움 버스트 표현이 어려움
Token bucket 자연스러운 버스트 지원, 2개 필드만 필요 토큰 충전 계산 필요

토큰 버킷을 선택한다. 각 버킷은 capacity(버스트 크기)와 refillPerSecond(초당 충전 속도)를 가진다. 각 요청은 한 개의 토큰을 사용한다. 토큰이 없으면 요청은 거부된다. 상태는 tokens와 ts(마지막 업데이트 시간) 두 개 값으로만 구성된다. 10만 개 테넌트에 여러 라우트를 곱해도 총 용량은 수십 MB에 불과하다.

4. 데이터 모델

4.1. SQL의 규칙 설정

규칙은 자주 변경되지 않으며 관리해야 하므로 PostgreSQL에 저장한다.

CREATE TABLE rate_limit_rule (
    id                 BIGSERIAL PRIMARY KEY,
    tenant_id          VARCHAR(64)  NOT NULL,
    route_id           VARCHAR(64)  NOT NULL,
    capacity           INT          NOT NULL CHECK (capacity > 0),
    refill_per_second  INT          NOT NULL CHECK (refill_per_second > 0),
    updated_at         TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (tenant_id, route_id)
);

INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);

게이트웨이는 각 요청마다 이 테이블을 읽지 않는다. 각 인스턴스는 모든 규칙을 메모리에 로드하고 주기적으로 새로 고친다. 예를 들어 30초마다 한 번. 다른 방법은 Kafka를 통해 rule-changed 이벤트를 수신하는 것이다. 핫 패스(hot path)는 절대 데이터베이스에 접근하지 않는다.

4.2. Redis의 버킷 상태

각 테넌트–라우트 쌍은 Redis hash이다.

Key:    rl:{shop-a}:create-order
Fields: tokens = 153.4
        ts     = 1760256000123   (ms, Redis 시간 기준)
TTL:    버킷이 가득 차고 오래 사용되지 않으면 자동으로 만료됨

{shop-a} 부분은 해시 태그이다. Redis Cluster에서 같은 테넌트의 모든 키는 같은 슬롯에 속한다. 나중에 한 스크립트에서 여러 버킷을 확인해야 할 때, 같은 노드에 위치할 것이다.

5. API와 처리 흐름

클라이언트가 보는 두 가지 응답 유형:

HTTP/1.1 200 OK
X-RateLimit-Remaining: 152

HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0

게이트웨이의 처리 흐름:

  1. X-Api-Key를 가져와 테넌트를 확인하고 매칭된 라우트에서 라우트 ID를 가져온다.
  2. 메모리 캐시에서 규칙을 조회한다. 규칙이 없으면 기본 규칙을 사용한다.
  3. Redis의 Lua 스크립트를 호출하여 한 번의 원자적 라운드 트립에서 토큰을 충전하고 가져온다.
  4. 허용되면 헤더를 추가하고 전달한다. 거부되면 429를 반환한다.
  5. Redis 오류나 타임아웃 시 로컬 limiter로 전환한다(섹션 7.3).

6. 구현

6.1. 의존성

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
    </dependency>
</dependencies>

6.2. Lua 스크립트 토큰 버킷

파일을 src/main/resources/scripts/token_bucket.lua에 저장:

local key      = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0   -- 초당 토큰 / ms

local t   = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)

local data   = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts     = tonumber(data[2]) or now

tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)

local allowed = 0
if tokens >= 1 then
  tokens = tokens - 1
  allowed = 1
end

redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)

return { allowed, math.floor(tokens) }

주요 특징:

  • redis.call('TIME')은 Redis의 시간을 가져오며, 게이트웨이의 시간이 아니다. 20개 인스턴스는 시간이 수십 ms 차이가 난다. 각 인스턴스에서 별도로 now를 보내면 토큰이 잘못 충전된다. Redis 7부터 스크립트는 기본적으로 효과 복제(effects replication)로 복제되므로, 쓰기 전에 TIME을 호출하는 것이 안전하다.
  • 읽기, 계산, 쓰기 전체가 원자적으로 실행된다. Redis가 스크립트를 단일 스레드로 실행하기 때문이다. 이것이 인스턴스 간 동시성 문제를 해결한다.
  • PEXPIRE는 버킷을 가득 채우는 데 필요한 시간의 두 배이다. 사용하지 않는 버킷은 자동으로 사라진다. 다시 나타나면 가득 차 있는 상태로 시작한다. 기대하는 의미와 정확히 일치한다.
  • 스크립트는 [allowed, remaining]을 반환한다. Lua가 반환할 때 실수를 정수로 변환하므로, 명확하게 하기 위해 미리 floor를 적용한다.

6.3. Java에서 스크립트 호출

public record RateLimitRule(int capacity, int refillPerSecond) {}

public record Decision(boolean allowed, long remaining) {}

@Component
public class RedisTokenBucket {

    private final ReactiveStringRedisTemplate redis;
    private final RedisScript<List> script =
            RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);

    public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
        this.redis = redis;
    }

    @SuppressWarnings("unchecked")
    public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
        String key = "rl:{" + tenant + "}:" + route;
        return redis.execute(script, List.of(key),
                        List.of(String.valueOf(rule.capacity()),
                                String.valueOf(rule.refillPerSecond())))
                .next()
                .map(r -> {
                    List<Long> res = (List<Long>) r;
                    return new Decision(res.get(0) == 1L, res.get(1));
                });
    }
}

Spring이 자동으로 스크립트의 SHA1을 계산하고 EVALSHA를 사용한다. Redis가 스크립트를 캐시하지 않았다면 Spring은 EVAL로 폴백한다. 따라서 첫 번째 호출만 전체 스크립트를 네트워크로 전송한다.

6.4. 게이트웨이의 Global Filter

@Component
public class RateLimitFilter implements GlobalFilter, Ordered {

    private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);

    private final RedisTokenBucket redisBucket;
    private final LocalFallbackLimiter fallback;
    private final RuleCache rules; // DB에서 주기적으로 로드한 Map<String, RateLimitRule>

    public RateLimitFilter(RedisTokenBucket redisBucket,
                           LocalFallbackLimiter fallback, RuleCache rules) {
        this.redisBucket = redisBucket;
        this.fallback = fallback;
        this.rules = rules;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
        if (tenant == null) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
        String routeId = route != null ? route.getId() : "default";
        RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);

        return redisBucket.tryAcquire(tenant, routeId, rule)
                .timeout(Duration.ofMillis(5))
                .onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
                .flatMap(decision -> {
                    var response = exchange.getResponse();
                    response.getHeaders().set("X-RateLimit-Remaining",
                            String.valueOf(decision.remaining()));
                    if (decision.allowed()) {
                        return chain.filter(exchange);
                    }
                    response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
                    response.getHeaders().set("Retry-After",
                            String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
                    return response.setComplete();
                });
    }

    @Override
    public int getOrder() {
        return -100; // 라우트 매칭 후, 라우팅 필터 전에 실행
    }
}

필터는 모든 라우트에서 실행된다. 5ms 타임아웃을 설정하여 느린 Redis 노드가 전체 게이트웨이의 지연을 증가시키지 않도록 한다. Redis 오류 시, onErrorResume은 로컬 limiter로 전환한다. capacity=2 규칙으로 curl로 빠르게 테스트:

for i in 1 2 3; do
  curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429

7. 어려운 부분과 해결책

7.1. 인스턴스 간의 동시성 문제

순진한 방법은 HGET을 호출하고 Java에서 계산한 다음 HSET을 호출하는 것이다. 두 인스턴스가 동시에 tokens=1을 읽고 모두 요청을 통과시킬 수 있다. Lua 스크립트는 이 race condition을 완전히 제거한다. WATCH/MULTI 접근도 정확하지만, 충돌 시 재시도해야 하고 더 많은 라운드 트립이 필요하다.

7.2. Hot Key와 트래픽 급증

큰 테넌트는 한 key에 20,000 RPS를 보낼 수 있다. 하나의 Redis 노드는 약 100,000 ops/s를 처리할 수 있지만, 그 key는 핫스팟이 될 것이다. 두 가지 해결책이 있다:

  • 배치 토큰 할당(로컬 사전 가져오기): 각 인스턴스는 미리 배치 토큰(예: 50개/회)을 요청하고 메모리에서 점진적으로 소비한다. Redis 호출 수가 약 50배 감소한다. 대신 총 제한값이 짧은 시간에 인스턴스 수 × 배치 크기까지 초과할 수 있다. 섹션 2.2의 “몇 % 오차” 요구사항을 감안하면 이는 허용 가능한 수준이다.
  • 버킷 분할(샤딩): 20,000/s 제한을 10개의 서브 버킷 rl:{shop-a}:create-order:0..9로 분리하고, 각각 2,000/s이다. 요청은 무작위로 서브 버킷을 선택한다. 이 키에 해시 태그를 제거하여 여러 노드에 분산시킨다.

합법적인 버스트는 이미 capacity가 흡수한다. 이것이 fixed window 대신 token bucket을 선택한 이유이다.

7.3. Redis 사용 불가: 제어된 Fail-Open

Fail-closed는 Redis가 죽으면 모든 요청을 거부하는 것이다. 부차적 인프라 장애를 전체 시스템 장애로 변환한다. 완전한 fail-open은 보호 계층을 잃는다. 중간 해결책은 인스턴스 수에 균등하게 분배된 제한값이 있는 로컬 limiter이다:

@Component
public class LocalFallbackLimiter {

    private final int instanceCount;
    private final LongSupplier clock;
    private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();

    public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
        this(instanceCount, System::currentTimeMillis);
    }

    LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
        this.instanceCount = instanceCount;
        this.clock = clock;
    }

    public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
        double cap = Math.max(1, rule.capacity() / (double) instanceCount);
        double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
        LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
                k -> new LocalBucket(cap, clock.getAsLong()));
        return b.take(cap, perMs, clock.getAsLong());
    }

    static final class LocalBucket {
        private double tokens;
        private long ts;

        LocalBucket(double tokens, long ts) {
            this.tokens = tokens;
            this.ts = ts;
        }

        synchronized Decision take(double cap, double perMs, long now) {
            tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
            ts = now;
            if (tokens >= 1) {
                tokens -= 1;
                return new Decision(true, (long) tokens);
            }
            return new Decision(false, 0);
        }
    }
}

각 인스턴스는 제한값의 1/instanceCount 부분을 유지한다. 클러스터 전체의 합계는 Redis 없이도 대략 정확하다. 프로덕션 환경에서는 Redis 호출을 Circuit Breaker(예: Resilience4j)로 래핑해야 한다. Redis가 다운될 때 Circuit Breaker는 각 요청마다 5ms 타임아웃을 낭비하지 않도록 도움을 준다.

7.4. 다중 지역 일관성

게이트웨이가 두 지역에서 실행되는 경우, 각 요청마다 지역 간 Redis 동기화는 2ms 요구사항을 위반한다. 실용적인 방법은 트래픽 비율에 따라 지역별로 제한값을 분할하는 것이다. 예: 70/30. 그리고 한 지역에서는 테넌트가 일찍 차단될 수 있지만 다른 지역에는 여유가 있을 수 있음을 인정한다. 더 최적화가 필요하면 백그라운드 작업이 실제 메트릭을 기반으로 몇 분마다 비율을 조정할 수 있다.

8. 테스트

Token bucket 로직은 실제 시간과 독립적으로 테스트되어야 한다. LocalFallbackLimiter가 LongSupplier를 받으므로, AtomicLong으로 시간을 앞당길 수 있다:

class LocalFallbackLimiterTest {

    @Test
    void shouldRejectWhenBucketEmptyAndRefillOverTime() {
        AtomicLong now = new AtomicLong(0);
        var limiter = new LocalFallbackLimiter(1, now::get);
        var rule = new RateLimitRule(2, 1); // 버스트 2, 초당 1 토큰 충전

        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
        assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();

        now.addAndGet(1_000); // 1초 후 1 토큰 추가

        Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
        assertThat(d.allowed()).isTrue();
        assertThat(d.remaining()).isZero();
    }
}

이 테스트는 세 가지 동작을 확인한다: 최대 capacity까지 버스트 허용, 제한을 초과한 요청은 거부됨, 토큰은 시간에 따라 정확히 충전됨. Lua 스크립트의 경우 redis:7 이미지를 사용하는 Testcontainers로 통합 테스트를 작성해야 한다. 1,000개의 병렬 요청을 보내고 허용된 요청 수가 capacity를 초과하지 않는지 확인한다.

주의: 일반적인 실수:

  • 게이트웨이의 System.currentTimeMillis()를 스크립트의 now로 사용하면 인스턴스 간 시간 차이로 토큰이 잘못 충전된다.
  • 키에 TTL을 설정하지 않으면 Redis가 테넌트 수에 따라 점차 팽창한다.
  • NAT나 로드 밸런서 뒤의 IP로 클라이언트를 식별하면 전체 회사가 같은 버킷을 공유한다.
  • 게이트웨이의 rate limiter를 서비스 자체의 과부하 방지 메커니즘(bulkhead, load shedding)으로 사용해서는 안 된다. 게이트웨이는 요청의 수만 알지 어떤 요청이 무거운지는 모른다.

9. 결론

이 글에서는 요구사항부터 구현까지 API 게이트웨이용 rate limiter를 설계했다. Token bucket은 버스트를 깔끔하게 처리한다. Redis의 Lua 스크립트는 원자적 라운드 트립으로 동시성 문제를 해결한다. Redis에서 시간을 가져오면 인스턴스 간의 시간 차이 문제가 해결된다.

실제 어려운 부분은 알고리즘 외부에 있다. Hot key는 배치 토큰 할당이나 버킷 분할이 필요하다. Redis 장애는 로컬 limiter를 사용한 제어된 fail-open이 필요하다. 다중 지역 시스템은 엄격한 동기화 대신 제한값 분할이 필요하다. 각 지점에서 정확성을 약간 타협하여 낮은 지연 시간과 가용성을 얻으며, 처음부터 설정한 제약을 충족한다.

system-designrate-limitingredisspring-cloud-gatewayjava