1. Rate Limiting이란?

Rate Limiting(속도 제한)은 특정 시간 내에 시스템으로 들어오는 요청 수를 제어하는 기법이다. 네트워크, API, 애플리케이션 레벨에서 광범위하게 사용된다.

왜 필요한가?

1) DDoS / 브루트포스 공격 방어

공격자가 초당 수만 건의 요청을 보내 서버를 마비시키는 공격을 차단한다. Rate Limiting이 없으면 단 몇 대의 클라이언트가 전체 서비스를 다운시킬 수 있다.

공격자         서버
  │ ──── 10,000 req/s ────► │  ← 서버 다운
  │                          │

Rate Limiting 적용 후
  │ ──── 10,000 req/s ────► [Rate Limiter] ──── 100 req/s ────► │
  │                              │
  │ ◄──── 429 Too Many Requests ─┘

2) 비용 보호

클라우드 환경에서 무제한 트래픽은 곧 무제한 비용이다. AWS API Gateway, OpenAI API 등 외부 서비스 호출에 Rate Limiting을 걸면 예상치 못한 폭탄 청구서를 방지할 수 있다.

3) 공정한 리소스 분배

특정 사용자가 시스템 자원을 독점하지 못하도록 막아 모든 사용자에게 균등한 서비스 품질을 보장한다. 멀티테넌트(Multi-tenant) SaaS 환경에서 필수적이다.

4) 서비스 안정성 (Cascading Failure 방지)

업스트림 서비스가 트래픽 급증을 견디지 못하면 의존하는 모든 서비스가 연쇄적으로 장애를 일으킨다. Rate Limiting은 이 연쇄 장애(Cascading Failure)를 방어하는 첫 번째 방어선이다.


Rate Limiting 적용 레벨

레벨 위치 도구 특징
인프라 레벨 L4/L7 로드밸런서, 방화벽 Nginx, HAProxy, AWS WAF 가장 빠름. 앱 코드 진입 전 차단
API Gateway 레벨 API Gateway AWS API Gateway, Kong, Istio 서비스 간 공통 정책 적용
애플리케이션 레벨 서버 코드 내부 Bucket4j, Resilience4j 세밀한 비즈니스 로직 적용 가능
클라이언트 레벨 SDK, 클라이언트 Guava RateLimiter 호출자 자체 제어

각 레벨을 중첩 적용(Defense in Depth)하는 것이 실무 표준이다.

Internet
   │
[Nginx/WAF]          ← 인프라 레벨 (초당 IP당 요청 제한)
   │
[API Gateway]        ← Gateway 레벨 (API Key별 제한)
   │
[Spring App]         ← 애플리케이션 레벨 (비즈니스 로직 기반 세밀 제한)
   │
[DB / External API]  ← 클라이언트 레벨 (Guava 등으로 외부 호출 제어)

2. Rate Limiting 알고리즘

2-1. Fixed Window Counter (고정 윈도우 카운터)

시간을 고정된 윈도우(예: 1분 단위)로 나누고, 각 윈도우 안에서 카운터를 증가시킨다.

시간축
│ 0s         60s        120s       180s
│  [  윈도우1  ][  윈도우2  ][  윈도우3  ]
│  req:95     req:100    req:80
│             ↑ 한도 초과 → 429

경계 문제(Boundary Burst)

시간축
│          59s    60s    61s
│  윈도우1  │      │  윈도우2
│           │ 100req│100req │
│           └──────┴──────┘
│                 ↑ 1초 안에 200 req 가능!

59초에 100건, 61초에 100건을 보내면 실제로는 2초 안에 200건이 처리된다. 이것이 Fixed Window의 치명적 단점이다.

장점

  • 구현이 매우 단순하다
  • 메모리 사용량이 적다 (윈도우당 카운터 1개)
  • Redis INCR + EXPIRE로 원자적 구현 가능

단점

  • 윈도우 경계에서 2배 버스트가 허용된다
  • 트래픽이 균일하지 않을 때 실제 제한이 느슨해진다

구현 예시

@Component
public class FixedWindowRateLimiter {
    private final ConcurrentHashMap<String, AtomicInteger> counters = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, Long> windowStart = new ConcurrentHashMap<>();
    private final int limit = 100;
    private final long windowMs = 60_000L; // 1분

    public boolean allowRequest(String key) {
        long now = System.currentTimeMillis();
        windowStart.putIfAbsent(key, now);
        counters.putIfAbsent(key, new AtomicInteger(0));

        long start = windowStart.get(key);
        if (now - start >= windowMs) {
            // 새 윈도우 시작
            windowStart.put(key, now);
            counters.put(key, new AtomicInteger(1));
            return true;
        }

        int count = counters.get(key).incrementAndGet();
        return count <= limit;
    }
}

2-2. Sliding Window Log (슬라이딩 윈도우 로그)

각 요청의 타임스탬프를 전부 기록하고, 현재 시각 기준 과거 N초 내의 요청 수를 카운팅한다.

현재시각: 1:00:30

슬라이딩 윈도우 [0:59:30 ~ 1:00:30]
┌─────────────────────────────────────┐
│ 0:59:31 │ 0:59:45 │ 1:00:10 │ 1:00:25 │  → 4건
└─────────────────────────────────────┘
                                      ↑ 새 요청 → 5건 (한도 5라면 허용)

현재시각: 1:00:32
슬라이딩 윈도우 [0:59:32 ~ 1:00:32]
┌─────────────────────────────────────┐
│ 0:59:45 │ 1:00:10 │ 1:00:25 │ 1:00:30 │  → 4건 (0:59:31 만료)
└─────────────────────────────────────┘

장점

  • 경계 문제가 없다. 어느 시점에서도 정확히 N초 내 요청 수를 센다
  • 가장 정확한 알고리즘

단점

  • 모든 요청 타임스탬프를 저장해야 하므로 메모리 사용량이 요청 수에 비례한다
  • 트래픽이 많을 때 메모리 폭발 위험
  • Redis Sorted Set 사용 시 고트래픽에서 성능 저하

Redis Sorted Set 구현

@Component
public class SlidingWindowLogRateLimiter {
    private final RedisTemplate<String, String> redisTemplate;
    private final int limit = 100;
    private final long windowMs = 60_000L;

    public SlidingWindowLogRateLimiter(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public boolean allowRequest(String key) {
        long now = System.currentTimeMillis();
        long windowStart = now - windowMs;

        // Lua 스크립트로 원자적 처리
        String script =
            "redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])\n" +
            "local count = redis.call('ZCARD', KEYS[1])\n" +
            "if count < tonumber(ARGV[2]) then\n" +
            "  redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3])\n" +
            "  redis.call('EXPIRE', KEYS[1], ARGV[4])\n" +
            "  return 1\n" +
            "end\n" +
            "return 0";

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        Long result = redisTemplate.execute(
            redisScript,
            Collections.singletonList("ratelimit:" + key),
            String.valueOf(windowStart),
            String.valueOf(limit),
            String.valueOf(now),
            String.valueOf(windowMs / 1000 + 1)
        );
        return Long.valueOf(1L).equals(result);
    }
}

2-3. Sliding Window Counter (슬라이딩 윈도우 카운터)

Fixed Window와 Sliding Window Log의 절충안이다. 이전 윈도우의 카운터와 현재 윈도우의 카운터를 가중 평균으로 합산한다.

이전 윈도우 (0:59 ~ 1:00)  현재 윈도우 (1:00 ~ 1:01)
┌────────────────────────┐  ┌────────────────────────┐
│     count: 80          │  │     count: 30          │
└────────────────────────┘  └────────────────────────┘
              현재 시각: 1:00:45 (현재 윈도우 75% 경과)

예상 요청 수 = 80 × (1 - 0.75) + 30
           = 80 × 0.25 + 30
           = 20 + 30
           = 50건 → 한도 100 미만이므로 허용

장점

  • 메모리 사용량이 적다 (윈도우당 카운터 2개)
  • 경계 문제를 상당히 완화한다
  • 실무에서 Redis + Lua로 구현하기 쉽다

단점

  • 정확히 100% 정밀하지는 않다 (통계적 근사)
  • 트래픽이 완전히 균일하다는 가정에 기반
@Component
public class SlidingWindowCounterRateLimiter {
    private final RedisTemplate<String, String> redisTemplate;
    private final int limit = 100;
    private final long windowMs = 60_000L;

    public boolean allowRequest(String key) {
        long now = System.currentTimeMillis();
        long currentWindowStart = (now / windowMs) * windowMs;
        long prevWindowStart = currentWindowStart - windowMs;
        double elapsed = (double)(now - currentWindowStart) / windowMs;

        String currentKey = "ratelimit:" + key + ":" + currentWindowStart;
        String prevKey = "ratelimit:" + key + ":" + prevWindowStart;

        String script =
            "local prev = tonumber(redis.call('GET', KEYS[1])) or 0\n" +
            "local curr = tonumber(redis.call('GET', KEYS[2])) or 0\n" +
            "local estimate = prev * (1 - tonumber(ARGV[1])) + curr\n" +
            "if estimate < tonumber(ARGV[2]) then\n" +
            "  redis.call('INCR', KEYS[2])\n" +
            "  redis.call('EXPIRE', KEYS[2], ARGV[3])\n" +
            "  return 1\n" +
            "end\n" +
            "return 0";

        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        Long result = redisTemplate.execute(
            redisScript,
            Arrays.asList(prevKey, currentKey),
            String.format("%.4f", elapsed),
            String.valueOf(limit),
            String.valueOf((windowMs / 1000) * 2)
        );
        return Long.valueOf(1L).equals(result);
    }
}

2-4. Token Bucket (토큰 버킷)

버킷에 일정 속도로 토큰이 채워지고, 요청이 올 때마다 토큰을 소비한다. 버킷이 가득 차면 새 토큰은 버려진다.

        [토큰 생성기]
         1 token/sec
              │
              ▼
     ┌────────────────┐
     │  ●●●●●●●●●●   │  ← 버킷 (최대 10 토큰)
     │  ●●●●●●●●●●   │
     └────────────────┘
              │
     요청 도착 시 토큰 소비
              │
              ▼
     [처리됨] 또는 [429: 토큰 없음]

버스트 허용: 버킷이 가득 찼을 때 순간적으로 10 req 처리 가능

실제 동작 흐름

t=0s:  버킷 10/10 토큰
t=0s:  요청 5건 → 토큰 5개 소비 → 버킷 5/10
t=1s:  토큰 1개 추가 → 버킷 6/10
t=1s:  요청 7건 → 6개 처리 + 1건 거부 → 버킷 0/10
t=2s:  토큰 1개 추가 → 버킷 1/10

장점

  • 버스트 트래픽 허용 (버킷이 가득 찰 때까지 쌓인 토큰으로 순간 처리)
  • AWS API Gateway, Stripe, GitHub API가 이 방식 사용
  • 구현이 비교적 단순

단점

  • 버킷 크기와 충전 속도 두 파라미터를 튜닝해야 한다
  • 분산 환경에서 토큰 동기화가 필요하다

2-5. Leaky Bucket (누출 버킷)

요청을 큐(버킷)에 넣고 일정한 속도로만 처리한다. 버킷(큐)이 가득 차면 새 요청을 거부한다.

요청 입력 (불규칙)
  │││ │ ││││ │ │
  ▼▼▼ ▼ ▼▼▼▼ ▼ ▼
┌─────────────────┐
│  큐 (Leaky Bucket) │  ← 최대 100개
│  ●●●●●●●●●●    │
└─────────────────┘
         │
         │ 일정한 속도로 처리 (예: 10 req/s)
         │ ●  ●  ●  ●  ●
         ▼
     [처리됨]

Token Bucket vs Leaky Bucket 비교

Token Bucket:
  ┌─┐ ┌─┐ ┌─┐ ┌─┐    ← 버스트 허용
  └─┘ └─┘ └─┘ └─┘
처리: ||||    ||||    ← 순간 몰려서 처리

Leaky Bucket:
  | | | | | | | | |  ← 균일한 처리
처리: | | | | | | |  ← 항상 일정 간격

장점

  • 출력 속도가 완전히 일정하다 → 다운스트림 서비스 보호에 유리
  • Nginx의 기본 방식

단점

  • 버스트를 완전히 허용하지 않으므로 정상적인 트래픽 급증에도 응답이 느려진다
  • 큐 대기 시 레이턴시 증가

알고리즘 비교 표

알고리즘 구현 복잡도 메모리 사용 버스트 허용 정확도 대표 사용처
Fixed Window ★☆☆☆☆ 매우 낮음 경계에서 2× 낮음 단순 시스템
Sliding Window Log ★★★★☆ 높음 (요청수 비례) 없음 매우 높음 정확성 중요한 API
Sliding Window Counter ★★★☆☆ 낮음 거의 없음 높음 실무 표준
Token Bucket ★★★☆☆ 낮음 허용 높음 AWS, Stripe, GitHub
Leaky Bucket ★★★☆☆ 중간 (큐) 불허 높음 Nginx, 균일 처리 필요 시

3. 직접 구현 (Java / Spring)

3-1. 인메모리 Token Bucket 구현

단일 JVM 환경에서 사용할 수 있는 Thread-safe Token Bucket 구현이다.

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public class TokenBucket {
    private final long capacity;       // 버킷 최대 용량
    private final long refillRate;     // 초당 토큰 충전량
    private final AtomicLong tokens;
    private volatile long lastRefillTime;

    public TokenBucket(long capacity, long refillRate) {
        this.capacity = capacity;
        this.refillRate = refillRate;
        this.tokens = new AtomicLong(capacity);
        this.lastRefillTime = System.currentTimeMillis();
    }

    public synchronized boolean tryConsume(long tokensToConsume) {
        refill();
        if (tokens.get() >= tokensToConsume) {
            tokens.addAndGet(-tokensToConsume);
            return true;
        }
        return false;
    }

    private void refill() {
        long now = System.currentTimeMillis();
        long elapsed = now - lastRefillTime;
        long tokensToAdd = (elapsed / 1000) * refillRate;

        if (tokensToAdd > 0) {
            long newTokens = Math.min(capacity, tokens.get() + tokensToAdd);
            tokens.set(newTokens);
            lastRefillTime = now;
        }
    }
}

@Component
public class InMemoryTokenBucketRateLimiter {
    // 사용자별 버킷 관리
    private final ConcurrentHashMap<String, TokenBucket> buckets = new ConcurrentHashMap<>();
    private final long capacity = 100L;
    private final long refillRate = 10L; // 초당 10 토큰 충전

    public boolean allowRequest(String userId) {
        TokenBucket bucket = buckets.computeIfAbsent(
            userId,
            k -> new TokenBucket(capacity, refillRate)
        );
        return bucket.tryConsume(1);
    }
}

메모리 누수 방지: 오래된 버킷을 정리하는 스케줄러를 함께 운용해야 한다.

@Scheduled(fixedDelay = 3600_000) // 1시간마다
public void evictStaleBuckets() {
    // TTL 기반 제거 로직 추가
    buckets.entrySet().removeIf(entry -> isStale(entry.getValue()));
}

3-2. Redis 기반 Sliding Window Counter 구현

분산 환경에서 사용하는 Redis + Lua 스크립트 기반 구현이다.

@Component
public class RedisRateLimiter {

    private final RedisTemplate<String, String> redisTemplate;

    // Lua 스크립트: 원자적으로 카운터 확인 + 증가
    private static final String SLIDING_WINDOW_SCRIPT =
        "local now = tonumber(ARGV[1])\n" +
        "local window = tonumber(ARGV[2])\n" +
        "local limit = tonumber(ARGV[3])\n" +
        "local key = KEYS[1]\n" +
        "\n" +
        "-- 만료된 요청 제거\n" +
        "redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window)\n" +
        "\n" +
        "-- 현재 요청 수 확인\n" +
        "local count = redis.call('ZCARD', key)\n" +
        "\n" +
        "if count < limit then\n" +
        "  -- 현재 요청 추가 (score = timestamp, member = timestamp+random)\n" +
        "  redis.call('ZADD', key, now, now .. '-' .. math.random(100000))\n" +
        "  redis.call('EXPIRE', key, math.ceil(window / 1000) + 1)\n" +
        "  return {1, limit - count - 1}\n" +
        "end\n" +
        "\n" +
        "return {0, 0}";

    private final DefaultRedisScript<List> script;

    public RedisRateLimiter(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.script = new DefaultRedisScript<>(SLIDING_WINDOW_SCRIPT, List.class);
    }

    public RateLimitResult checkLimit(String key, int limit, long windowMs) {
        long now = System.currentTimeMillis();

        @SuppressWarnings("unchecked")
        List<Long> result = (List<Long>) redisTemplate.execute(
            script,
            Collections.singletonList("rl:" + key),
            String.valueOf(now),
            String.valueOf(windowMs),
            String.valueOf(limit)
        );

        boolean allowed = result != null && result.get(0) == 1L;
        long remaining = result != null ? result.get(1) : 0L;

        return new RateLimitResult(allowed, remaining, limit, windowMs);
    }
}

public record RateLimitResult(
    boolean allowed,
    long remaining,
    int limit,
    long windowMs
) {}

3-3. Spring Filter로 Rate Limiting 적용

@Component
@Order(1)
public class RateLimitFilter implements Filter {

    private final RedisRateLimiter rateLimiter;

    public RateLimitFilter(RedisRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        String clientKey = resolveClientKey(httpRequest);
        RateLimitResult result = rateLimiter.checkLimit(clientKey, 100, 60_000L);

        // 표준 헤더 설정
        httpResponse.setHeader("X-RateLimit-Limit", String.valueOf(result.limit()));
        httpResponse.setHeader("X-RateLimit-Remaining", String.valueOf(result.remaining()));
        httpResponse.setHeader("X-RateLimit-Reset",
            String.valueOf(System.currentTimeMillis() / 1000 + 60));

        if (!result.allowed()) {
            httpResponse.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            httpResponse.setHeader("Retry-After", "60");
            httpResponse.setContentType("application/json");
            httpResponse.getWriter().write("""
                {
                  "error": "Too Many Requests",
                  "message": "Rate limit exceeded. Please retry after 60 seconds.",
                  "retryAfter": 60
                }
                """);
            return;
        }

        chain.doFilter(request, response);
    }

    private String resolveClientKey(HttpServletRequest request) {
        // API Key 우선, 없으면 IP
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null && !apiKey.isBlank()) {
            return "apikey:" + apiKey;
        }
        // X-Forwarded-For 헤더 처리 (프록시 뒤에 있을 경우)
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null) {
            return "ip:" + forwarded.split(",")[0].trim();
        }
        return "ip:" + request.getRemoteAddr();
    }
}

3-4. Spring Interceptor로 어노테이션 기반 적용

엔드포인트별로 다른 Rate Limit을 적용하는 어노테이션 기반 방식이다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    int limit() default 100;
    long windowMs() default 60_000L;
    String keyPrefix() default "";
}

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final RedisRateLimiter rateLimiter;

    public RateLimitInterceptor(RedisRateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;
        }

        RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
        if (rateLimit == null) {
            return true;
        }

        String prefix = rateLimit.keyPrefix().isBlank()
            ? handlerMethod.getMethod().getName()
            : rateLimit.keyPrefix();

        String clientIp = getClientIp(request);
        String key = prefix + ":" + clientIp;

        RateLimitResult result = rateLimiter.checkLimit(key, rateLimit.limit(), rateLimit.windowMs());

        response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimit.limit()));
        response.setHeader("X-RateLimit-Remaining", String.valueOf(result.remaining()));

        if (!result.allowed()) {
            response.setStatus(429);
            response.setHeader("Retry-After", String.valueOf(rateLimit.windowMs() / 1000));
            response.getWriter().write("{\"error\":\"Too Many Requests\"}");
            return false;
        }

        return true;
    }

    private String getClientIp(HttpServletRequest request) {
        String forwarded = request.getHeader("X-Forwarded-For");
        return (forwarded != null) ? forwarded.split(",")[0].trim() : request.getRemoteAddr();
    }
}

// 사용 예시
@RestController
@RequestMapping("/api")
public class UserController {

    @RateLimit(limit = 5, windowMs = 60_000L, keyPrefix = "login")
    @PostMapping("/login")
    public ResponseEntity<String> login(@RequestBody LoginRequest req) {
        // 로그인 로직
        return ResponseEntity.ok("OK");
    }

    @RateLimit(limit = 1000, windowMs = 3600_000L, keyPrefix = "search")
    @GetMapping("/search")
    public ResponseEntity<List<String>> search(@RequestParam String q) {
        // 검색 로직
        return ResponseEntity.ok(List.of());
    }
}

4. 라이브러리 & 프레임워크

4-1. Bucket4j

Token Bucket 알고리즘 기반의 Java 전용 Rate Limiting 라이브러리다. 로컬(in-memory)과 분산(Redis, Hazelcast, Infinispan) 모드를 모두 지원한다.

의존성 추가

<!-- Bucket4j Core -->
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.10.1</version>
</dependency>

<!-- Redis 분산 지원 -->
<dependency>
    <groupId>com.bucket4j</groupId>
    <artifactId>bucket4j-redis</artifactId>
    <version>8.10.1</version>
</dependency>

인메모리 사용 예시

import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import java.time.Duration;

@Service
public class Bucket4jRateLimiterService {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    private Bucket createBucket() {
        // 1분에 100개, 최대 버스트 200개 허용
        Bandwidth limit = Bandwidth.classic(
            100,
            Refill.greedy(100, Duration.ofMinutes(1))
        );
        Bandwidth burst = Bandwidth.classic(
            200,
            Refill.intervally(200, Duration.ofMinutes(1))
        );
        return Bucket.builder()
            .addLimit(limit)
            .addLimit(burst)
            .build();
    }

    public boolean tryConsume(String userId) {
        Bucket bucket = buckets.computeIfAbsent(userId, k -> createBucket());
        return bucket.tryConsume(1);
    }

    // 블로킹 방식: 토큰이 생길 때까지 대기
    public void consumeBlocking(String userId) throws InterruptedException {
        Bucket bucket = buckets.computeIfAbsent(userId, k -> createBucket());
        bucket.asBlocking().consume(1);
    }
}

Redis 분산 모드

@Configuration
public class Bucket4jRedisConfig {

    @Bean
    public ProxyManager<String> proxyManager(RedissonClient redissonClient) {
        return Bucket4jRedisson.casBasedBuilder(redissonClient)
            .build();
    }
}

@Service
public class DistributedBucket4jService {

    private final ProxyManager<String> proxyManager;

    public DistributedBucket4jService(ProxyManager<String> proxyManager) {
        this.proxyManager = proxyManager;
    }

    public boolean tryConsume(String userId) {
        BucketConfiguration config = BucketConfiguration.builder()
            .addLimit(Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))))
            .build();

        Bucket bucket = proxyManager.builder()
            .build(userId, () -> config);

        return bucket.tryConsume(1);
    }
}

Spring Boot Starter 사용 시 자동 설정 (application.yml)

bucket4j:
  enabled: true
  filters:
    - cache-name: buckets
      url: /api/.*
      rate-limits:
        - bandwidths:
            - capacity: 100
              time: 1
              unit: minutes
          cache-key: "getRemoteAddr()"

4-2. Resilience4j RateLimiter

Resilience4j는 Circuit Breaker, Retry, Rate Limiter, Bulkhead 등 다양한 안정성 패턴을 제공하는 라이브러리다. Rate Limiter는 Semaphore 기반으로 동작한다.

의존성 추가

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>

application.yml 설정

resilience4j:
  ratelimiter:
    instances:
      backendA:
        limit-for-period: 100          # 갱신 주기당 허용 요청 수
        limit-refresh-period: 1s       # 갱신 주기 (1초)
        timeout-duration: 0s           # 토큰 대기 시간 (0 = 즉시 실패)
      userLoginEndpoint:
        limit-for-period: 5
        limit-refresh-period: 1m
        timeout-duration: 500ms        # 500ms 대기 후 실패

어노테이션 기반 사용

@Service
public class UserService {

    @RateLimiter(name = "userLoginEndpoint", fallbackMethod = "loginFallback")
    public String login(String userId, String password) {
        // 로그인 처리
        return "success";
    }

    public String loginFallback(String userId, String password, RequestNotPermitted e) {
        return "Too many login attempts. Please try again later.";
    }
}

Circuit Breaker + Rate Limiter 조합

@Service
public class PaymentService {

    private final RateLimiter rateLimiter;
    private final CircuitBreaker circuitBreaker;

    public PaymentService(RateLimiterRegistry rateLimiterRegistry,
                          CircuitBreakerRegistry circuitBreakerRegistry) {
        this.rateLimiter = rateLimiterRegistry.rateLimiter("payment");
        this.circuitBreaker = circuitBreakerRegistry.circuitBreaker("payment");
    }

    public String processPayment(PaymentRequest request) {
        // Rate Limiter → Circuit Breaker 순서로 적용 (바깥에서 안으로)
        Supplier<String> decoratedSupplier = RateLimiter
            .decorateSupplier(rateLimiter,
                CircuitBreaker.decorateSupplier(circuitBreaker,
                    () -> doProcessPayment(request)));

        return Try.ofSupplier(decoratedSupplier)
            .recover(RequestNotPermitted.class, e -> "Rate limit exceeded")
            .recover(CallNotPermittedException.class, e -> "Circuit is open")
            .get();
    }

    private String doProcessPayment(PaymentRequest request) {
        // 실제 결제 처리
        return "payment-id-12345";
    }
}

4-3. Spring Cloud Gateway RateLimiter

Spring Cloud Gateway에서 기본 제공하는 RequestRateLimiter 필터로, Redis 기반 Token Bucket을 사용한다.

의존성 추가

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

application.yml 설정

spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/users/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10    # 초당 토큰 충전량
                redis-rate-limiter.burstCapacity: 20    # 버킷 최대 용량
                redis-rate-limiter.requestedTokens: 1   # 요청당 소비 토큰
                key-resolver: "#{@userKeyResolver}"     # 키 결정 Bean

Key Resolver 구현

@Configuration
public class RateLimiterConfig {

    // IP 기반 키
    @Bean
    public KeyResolver ipKeyResolver() {
        return exchange -> Mono.just(
            Objects.requireNonNull(
                exchange.getRequest().getRemoteAddress()
            ).getAddress().getHostAddress()
        );
    }

    // 사용자 ID 기반 키 (인증 헤더에서 추출)
    @Bean
    public KeyResolver userKeyResolver() {
        return exchange -> {
            String userId = exchange.getRequest().getHeaders()
                .getFirst("X-User-Id");
            return Mono.just(userId != null ? userId : "anonymous");
        };
    }

    // API Key 기반 키
    @Bean
    public KeyResolver apiKeyResolver() {
        return exchange -> {
            String apiKey = exchange.getRequest().getHeaders()
                .getFirst("X-API-Key");
            if (apiKey == null) {
                return Mono.just("no-api-key");
            }
            return Mono.just(apiKey);
        };
    }
}

커스텀 Rate Limiter (엔드포인트별 차등 제한)

@Component
public class CustomRedisRateLimiter extends AbstractRateLimiter<CustomRedisRateLimiter.Config> {

    @Data
    public static class Config {
        private int replenishRate;
        private int burstCapacity;
    }

    // 경로별 설정 커스터마이징
    @Override
    public Mono<Response> isAllowed(String routeId, String id) {
        // Redis Lua 스크립트 실행
        // ...
        return Mono.just(new Response(true, Map.of(
            "X-RateLimit-Remaining", "50"
        )));
    }
}

4-4. Guava RateLimiter

Google Guava 라이브러리의 RateLimiter단일 JVM 환경에서 간편하게 사용할 수 있는 Token Bucket 구현이다. 분산 환경에서는 사용할 수 없다.

두 가지 모드

import com.google.common.util.concurrent.RateLimiter;

// 1. SmoothBursty: 버스트 허용 (기본값)
// 초당 10개 처리 허용, 최대 1초치 버스트 저장
RateLimiter burstyLimiter = RateLimiter.create(10.0);

// 2. SmoothWarmingUp: 워밍업 후 최대 속도 도달 (cold start 시뮬레이션)
// 초당 10개, 5초의 워밍업 기간
RateLimiter warmingLimiter = RateLimiter.create(10.0, 5, TimeUnit.SECONDS);

사용 예시

@Service
public class ExternalApiService {

    // 외부 API 호출을 초당 5회로 제한
    private final RateLimiter limiter = RateLimiter.create(5.0);

    public String callExternalApi(String param) {
        // 블로킹: 토큰이 생길 때까지 대기 (최대 100ms)
        if (!limiter.tryAcquire(100, TimeUnit.MILLISECONDS)) {
            throw new RateLimitExceededException("External API call rate limit exceeded");
        }

        // 외부 API 호출
        return restTemplate.getForObject("https://api.example.com/data?q=" + param, String.class);
    }

    public String callWithBlock(String param) {
        // 블로킹: 토큰이 생길 때까지 무조건 대기
        double waitTime = limiter.acquire();
        log.debug("Waited {}s for rate limiter", waitTime);

        return restTemplate.getForObject("https://api.example.com/data?q=" + param, String.class);
    }
}

SmoothBursty vs SmoothWarmingUp 동작 차이

SmoothBursty (create(10.0)):
t=0s:  10개 즉시 처리 가능 (버스트)
t=1s:  10개 즉시 처리 가능
t=1.5s: 5개 즉시 처리 가능

SmoothWarmingUp (create(10.0, 5s)):
t=0s:  1개 처리 (워밍업 시작)
t=1s:  3개 처리
t=3s:  7개 처리
t=5s:  10개 처리 (최대 속도 도달)

4-5. Nginx Rate Limiting

인프라 레벨에서 가장 먼저 차단하는 Nginx의 ngx_http_limit_req_module이다.

http {
    # Rate Limit Zone 정의
    # 키: $binary_remote_addr (클라이언트 IP)
    # 메모리: 10MB (약 16만 IP 저장 가능)
    # 속도: 초당 10 요청
    limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

    # API Key 기반 제한
    limit_req_zone $http_x_api_key zone=apikey_limit:10m rate=100r/s;

    server {
        listen 80;

        location /api/ {
            # burst: 최대 20개 요청을 큐에 보관 (Leaky Bucket 방식)
            # nodelay: 큐에 있는 요청을 바로 처리 (큐 대기 없이)
            limit_req zone=api_limit burst=20 nodelay;

            # 429 상태 코드 반환 (기본은 503)
            limit_req_status 429;

            proxy_pass http://backend;
        }

        location /api/login {
            # 로그인은 더 엄격하게: 초당 1 요청, 버스트 5
            limit_req zone=api_limit burst=5;
            limit_req_status 429;

            proxy_pass http://backend;
        }

        # 429 에러 페이지 커스터마이징
        error_page 429 /rate_limit.json;
        location = /rate_limit.json {
            default_type application/json;
            return 429 '{"error":"Too Many Requests","message":"Please slow down"}';
        }
    }
}

Nginx 설정 설명

limit_req_zone [key] zone=[name]:[size] rate=[N]r/[s|m]
                                              └ r/s = 초당, r/m = 분당

limit_req zone=[name] burst=[N] [nodelay]
                       └ 버스트 허용량 (큐 크기)
                                  └ nodelay: 버스트 요청 즉시 처리 (지연 없이)
                                    없으면:  rate에 맞게 지연 후 처리

라이브러리 비교 표

라이브러리 알고리즘 분산 지원 성능 Spring Boot 통합 난이도 추천 상황
Bucket4j Token Bucket Redis, Hazelcast, Infinispan 매우 빠름 Spring Boot Starter ★★★☆☆ 분산 환경 범용
Resilience4j Semaphore 없음 (단일 JVM) 빠름 Spring Boot Starter ★★☆☆☆ 안정성 패턴 통합
Spring Cloud Gateway Token Bucket (Redis) Redis 빠름 내장 ★★★☆☆ API Gateway
Guava RateLimiter Token Bucket 없음 (단일 JVM) 매우 빠름 없음 ★☆☆☆☆ 단일 JVM, 외부 API 호출
Nginx Leaky Bucket 없음 극도로 빠름 없음 ★★☆☆☆ 인프라 레벨 1차 차단

5. 분산 환경에서의 Rate Limiting

왜 단일 서버 Rate Limiting이 부족한가?

클라이언트
    │
    ├──► [서버1: 카운터=50] ← 각 서버가 독립적으로 카운터 관리
    ├──► [서버2: 카운터=50]
    └──► [서버3: 카운터=50]

한도: 100 req/min 설정 → 실제: 150 req/min 허용 (서버 3대 × 50)

로드밸런서가 요청을 분산시키므로, 각 서버의 인메모리 카운터는 전체 요청 수를 반영하지 못한다.


Redis 기반 중앙 집중식 Rate Limiting

클라이언트
    │
    ├──► [서버1] ──┐
    ├──► [서버2] ──┼──► [Redis: 공유 카운터] ← 단일 진실의 원천
    └──► [서버3] ──┘

모든 서버가 Redis의 동일한 카운터를 읽고 쓴다. 정확하지만 Redis가 병목이 될 수 있다.

Lua 스크립트로 Race Condition 방지

Redis는 단일 스레드로 명령을 처리하고, Lua 스크립트는 원자적으로 실행된다. 따라서 별도의 락 없이 Race Condition을 방지할 수 있다.

-- 원자적 카운터 확인 + 증가 스크립트
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])

local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
    return 0  -- 거부
end

local new_value = redis.call('INCR', key)
if new_value == 1 then
    -- 첫 번째 요청: TTL 설정
    redis.call('EXPIRE', key, expire)
end

if new_value > limit then
    -- 동시 요청 경쟁으로 한도 초과 시 롤백
    redis.call('DECR', key)
    return 0  -- 거부
end

return 1  -- 허용

로컬 + 글로벌 하이브리드 방식

Redis 호출을 줄이면서도 정확도를 유지하는 하이브리드 방식이다.

요청 도착
    │
    ▼
[로컬 캐시 확인] ── 명백히 한도 초과 ──► 즉시 거부 (Redis 호출 없음)
    │
    │ 불확실
    ▼
[Redis 원자적 확인] ── 허용/거부 결정
    │
    ▼
[로컬 카운터 동기화] ← 주기적으로 Redis와 싱크
@Component
public class HybridRateLimiter {

    private final RedisRateLimiter redisLimiter;
    // 로컬 추정치: 실제보다 낮게 설정 (예: 전체 한도의 80%)
    private final ConcurrentHashMap<String, AtomicInteger> localCounters = new ConcurrentHashMap<>();
    private final int localThreshold = 80; // 로컬 한도
    private final int globalLimit = 100;   // 전체 한도

    public boolean allowRequest(String key) {
        // 1단계: 로컬 카운터로 빠른 사전 거부
        AtomicInteger local = localCounters.computeIfAbsent(key, k -> new AtomicInteger(0));
        if (local.get() >= localThreshold) {
            // 로컬 한도 초과 → Redis 확인 (더 정확한 판단)
            boolean redisResult = redisLimiter.checkLimit(key, globalLimit, 60_000L).allowed();
            if (!redisResult) return false;
            local.set(0); // 리셋 (윈도우 갱신)
        }

        local.incrementAndGet();
        return true;
    }
}

Race Condition 처리 상세

문제 상황

서버1: GET counter = 99  ←┐
서버2: GET counter = 99  ←┘ 동시 읽기
서버1: SET counter = 100 ← 둘 다 100으로 설정 → 200번째 요청이 허용됨!
서버2: SET counter = 100

Lua 스크립트로 해결

서버1: EVAL script → Redis 원자적으로 99→100 (허용)
서버2: EVAL script → Redis 원자적으로 100→101 감지 후 롤백 (거부)

Redis Lua 스크립트는 원자적 CAS(Compare-And-Swap)처럼 동작하므로, 별도의 분산 락 없이 정확한 카운팅이 가능하다.


6. HTTP 표준 헤더

RFC 6585과 IETF 드래프트에서 정의하는 Rate Limiting 관련 표준 헤더다.

요청 허용 시 응답 헤더

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 73
X-RateLimit-Reset: 1746094800
Content-Type: application/json
헤더 의미 예시
X-RateLimit-Limit 현재 윈도우의 최대 요청 허용 수 100
X-RateLimit-Remaining 현재 윈도우에서 남은 요청 가능 수 73
X-RateLimit-Reset 윈도우가 리셋되는 시각 (Unix timestamp) 1746094800

429 Too Many Requests 응답

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1746094800
Retry-After: 47
Content-Type: application/json

{
  "error": "Too Many Requests",
  "message": "Rate limit exceeded",
  "limit": 100,
  "retryAfter": 47,
  "resetAt": "2026-05-01T12:00:00Z"
}
헤더 의미
Retry-After N초 후 재시도하라는 권고 (초 단위 또는 HTTP 날짜)

Spring에서 표준 헤더 구현

@RestControllerAdvice
public class RateLimitExceptionHandler {

    @ExceptionHandler(RateLimitExceededException.class)
    public ResponseEntity<ErrorResponse> handleRateLimit(
            RateLimitExceededException e,
            HttpServletRequest request) {

        long resetTime = System.currentTimeMillis() / 1000 + 60;
        long retryAfter = 60;

        HttpHeaders headers = new HttpHeaders();
        headers.set("X-RateLimit-Limit", String.valueOf(e.getLimit()));
        headers.set("X-RateLimit-Remaining", "0");
        headers.set("X-RateLimit-Reset", String.valueOf(resetTime));
        headers.set("Retry-After", String.valueOf(retryAfter));

        ErrorResponse body = new ErrorResponse(
            "Too Many Requests",
            "Rate limit exceeded. Retry after " + retryAfter + " seconds.",
            retryAfter,
            Instant.ofEpochSecond(resetTime).toString()
        );

        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .headers(headers)
            .body(body);
    }
}

public record ErrorResponse(
    String error,
    String message,
    long retryAfter,
    String resetAt
) {}

7. 실무 설계 패턴

7-1. 사용자 식별 방식별 Rate Limiting

@Component
public class RateLimitKeyResolver {

    public String resolve(HttpServletRequest request) {
        // 1순위: JWT에서 사용자 ID 추출
        String jwt = extractJwt(request);
        if (jwt != null) {
            String userId = jwtService.extractUserId(jwt);
            return "user:" + userId;
        }

        // 2순위: API Key
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null && !apiKey.isBlank()) {
            return "apikey:" + apiKey;
        }

        // 3순위: IP 주소 (인증되지 않은 요청)
        String forwarded = request.getHeader("X-Forwarded-For");
        String ip = (forwarded != null)
            ? forwarded.split(",")[0].trim()
            : request.getRemoteAddr();
        return "ip:" + ip;
    }
}

식별 방식 비교

방식 장점 단점 사용 시점
IP 기반 인증 없이 적용 가능 NAT 뒤 다수 사용자 동일 IP 비인증 엔드포인트
API Key 기반 클라이언트 앱별 구분 키 노출 위험 B2B API
User ID 기반 가장 정확한 사용자 구분 인증 필요 로그인 필요 서비스

7-2. 엔드포인트별 차등 Rate Limit

@Configuration
public class RateLimitRules {

    @Bean
    public Map<String, RateLimitConfig> rateLimitConfigs() {
        Map<String, RateLimitConfig> configs = new HashMap<>();

        // 로그인: 매우 엄격 (브루트포스 방지)
        configs.put("/api/auth/login", new RateLimitConfig(5, Duration.ofMinutes(1)));

        // 회원가입: 엄격
        configs.put("/api/auth/register", new RateLimitConfig(3, Duration.ofHours(1)));

        // 비밀번호 초기화: 엄격
        configs.put("/api/auth/password-reset", new RateLimitConfig(3, Duration.ofHours(1)));

        // 일반 API: 보통
        configs.put("/api/**", new RateLimitConfig(100, Duration.ofMinutes(1)));

        // 검색: 다소 느슨
        configs.put("/api/search", new RateLimitConfig(30, Duration.ofMinutes(1)));

        // 파일 업로드: 제한적
        configs.put("/api/files/upload", new RateLimitConfig(10, Duration.ofHours(1)));

        return configs;
    }
}

public record RateLimitConfig(int limit, Duration window) {}

7-3. 티어별 Rate Limit (Free / Pro / Enterprise)

public enum UserTier {
    FREE(100, Duration.ofHours(1)),
    PRO(10_000, Duration.ofHours(1)),
    ENTERPRISE(1_000_000, Duration.ofHours(1));

    private final int requestLimit;
    private final Duration window;

    UserTier(int requestLimit, Duration window) {
        this.requestLimit = requestLimit;
        this.window = window;
    }

    public int getRequestLimit() { return requestLimit; }
    public Duration getWindow() { return window; }
}

@Component
public class TieredRateLimiter {

    private final RedisRateLimiter redisLimiter;
    private final UserService userService;

    public RateLimitResult checkTieredLimit(String userId, HttpServletRequest request) {
        UserTier tier = userService.getUserTier(userId);

        String key = "tier:" + tier.name().toLowerCase() + ":user:" + userId;
        return redisLimiter.checkLimit(
            key,
            tier.getRequestLimit(),
            tier.getWindow().toMillis()
        );
    }
}

티어별 Rate Limit 정책 예시

┌────────────┬──────────────┬──────────────┬────────────────────┐
│ 티어       │ 요청 한도    │ 윈도우       │ 비고               │
├────────────┼──────────────┼──────────────┼────────────────────┤
│ Free       │ 100 req      │ 시간당       │ 무료 사용자        │
│ Pro        │ 10,000 req   │ 시간당       │ 월 $29             │
│ Enterprise │ 1,000,000 req│ 시간당       │ 커스텀 계약        │
│ Internal   │ 무제한       │ -            │ 내부 서비스        │
└────────────┴──────────────┴──────────────┴────────────────────┘

7-4. Graceful Degradation (큐잉으로 부드러운 처리)

429를 즉시 반환하는 대신, 요청을 큐에 넣어 처리 능력이 생기면 처리하는 방식이다.

@RestController
public class SearchController {

    private final BlockingQueue<SearchRequest> queue = new LinkedBlockingQueue<>(1000);
    private final SearchService searchService;

    @PostMapping("/api/search")
    public ResponseEntity<?> search(@RequestBody SearchRequest req) {
        if (isRateLimited(req.userId())) {
            // 즉시 거부 대신 큐잉 시도
            boolean queued = queue.offer(req);
            if (queued) {
                return ResponseEntity.accepted()
                    .body(new QueuedResponse("Request queued", estimateWaitTime()));
            } else {
                // 큐도 가득 찬 경우에만 429
                return ResponseEntity.status(429)
                    .header("Retry-After", "30")
                    .body(new ErrorResponse("Service busy. Please retry later."));
            }
        }

        return ResponseEntity.ok(searchService.search(req));
    }

    @Scheduled(fixedDelay = 100) // 100ms마다 큐 처리
    public void processQueue() {
        SearchRequest req = queue.poll();
        if (req != null) {
            searchService.searchAsync(req);
        }
    }

    private long estimateWaitTime() {
        return (long) queue.size() * 100; // 대략적인 대기 시간 (ms)
    }
}

8. 극한 시나리오

8-1. Redis 장애 시 Rate Limiter 동작

Redis 장애는 Rate Limiter를 완전히 무력화시킬 수 있다. 두 가지 전략이 있다.

Fail-Open (장애 시 허용)

public boolean allowRequest(String key) {
    try {
        return redisLimiter.checkLimit(key, limit, windowMs).allowed();
    } catch (RedisConnectionException e) {
        log.warn("Redis unavailable, failing open for key: {}", key);
        // Redis 장애 시 모든 요청 허용 → 서비스 가용성 우선
        return true;
    }
}
  • 장점: 서비스가 중단되지 않는다
  • 단점: 장애 시 Rate Limiting 효과 없음 → 공격에 취약

Fail-Close (장애 시 거부)

public boolean allowRequest(String key) {
    try {
        return redisLimiter.checkLimit(key, limit, windowMs).allowed();
    } catch (RedisConnectionException e) {
        log.error("Redis unavailable, failing closed for key: {}", key);
        // Redis 장애 시 모든 요청 거부 → 보안 우선
        return false;
    }
}
  • 장점: 장애 시에도 서버를 보호한다
  • 단점: 정상 트래픽도 차단됨 → 서비스 중단

실무 권장: 로컬 캐시 폴백

@Component
public class ResilientRateLimiter {

    private final RedisRateLimiter redisLimiter;
    // Redis 장애 시 폴백용 로컬 카운터
    private final ConcurrentHashMap<String, AtomicInteger> localFallback = new ConcurrentHashMap<>();
    // Redis 장애 감지 상태
    private volatile boolean redisHealthy = true;

    public boolean allowRequest(String key) {
        if (redisHealthy) {
            try {
                return redisLimiter.checkLimit(key, 100, 60_000L).allowed();
            } catch (RedisConnectionException e) {
                log.error("Redis connection failed, switching to local fallback");
                redisHealthy = false;
                alertOps("Redis rate limiter down - using local fallback");
            }
        }

        // 로컬 폴백: 한도를 절반으로 낮춰서 부분적 보호
        return localFallbackCheck(key, 50);
    }

    @Scheduled(fixedDelay = 5_000) // 5초마다 Redis 복구 확인
    public void checkRedisHealth() {
        try {
            redisTemplate.opsForValue().get("health-check");
            if (!redisHealthy) {
                log.info("Redis recovered, switching back to Redis rate limiter");
                redisHealthy = true;
                localFallback.clear();
            }
        } catch (Exception e) {
            // Redis 여전히 장애
        }
    }

    private boolean localFallbackCheck(String key, int limit) {
        AtomicInteger counter = localFallback.computeIfAbsent(key, k -> new AtomicInteger(0));
        return counter.incrementAndGet() <= limit;
    }
}

8-2. 시간 동기화 문제 (NTP Drift)

Fixed Window, Sliding Window 알고리즘은 시스템 시계에 의존한다. NTP 동기화 오차가 수 초 발생하면 윈도우 경계 계산이 틀어진다.

문제 예시

서버1 시각: 1:00:00.000  ← NTP 정확
서버2 시각: 1:00:02.500  ← NTP 2.5초 느림

윈도우 경계(1:00:00)에서:
- 서버1: 새 윈도우 시작 → 카운터 초기화
- 서버2: 아직 이전 윈도우 → 카운터 계속 증가
→ 실제 허용 요청 수가 의도보다 많아짐

대응 방법

// Redis 서버 시간을 신뢰의 원천으로 사용
public long getServerTime() {
    // Redis TIME 명령으로 Redis 서버의 Unix timestamp 가져오기
    List<Long> time = redisTemplate.execute(
        (RedisCallback<List<Long>>) connection -> connection.serverCommands().time()
    );
    // [seconds, microseconds]
    return time.get(0) * 1000 + time.get(1) / 1000;
}
  • 모든 서버가 Redis의 시간을 기준으로 윈도우를 계산하면 NTP Drift의 영향을 제거할 수 있다
  • 추가 Redis 호출 비용이 있으므로, 윈도우 시작 시에만 호출하는 방식으로 최적화한다

8-3. 분산 환경에서 정확도 vs 성능 트레이드오프

        정확도
          │
          │  Sliding Window Log (Redis)
          │         ●
          │
          │  Sliding Window Counter (Redis)
          │              ●
          │
          │  Fixed Window (Redis)
          │                    ●
          │
          │  Local InMemory (각 서버 독립)
          │                         ●
          └─────────────────────────────► 성능 (낮은 레이턴시)
         낮음                          높음
접근 방식 정확도 레이턴시 추가 적합한 상황
로컬 인메모리 낮음 0ms 단일 서버, 내부 서비스
Redis Fixed Window 중간 ~1ms 빠른 응답 필요
Redis Sliding Window Counter 높음 ~1-2ms 실무 표준
Redis Sliding Window Log 매우 높음 ~2-5ms 정확도 최우선
로컬+Redis 하이브리드 높음 ~0.5ms 고성능 + 정확도 균형

8-4. Hot Key 문제

인기 API 엔드포인트나 유명 사용자의 Rate Limit 키가 Redis의 특정 슬롯에 집중되면 Hot Key 문제가 발생한다.

문제 상황

Redis Cluster
┌─────────┐  ┌─────────┐  ┌─────────┐
│ 노드 A  │  │ 노드 B  │  │ 노드 C  │
│ 부하 5% │  │ 부하 95%│  │ 부하 5% │
└─────────┘  └─────────┘  └─────────┘
                  ↑
        rl:popular-endpoint 키가 노드 B에만 집중

해결책 1: 키 샤딩 (Key Sharding)

public String shardedKey(String key, int shards) {
    // 키를 N개의 샤드로 분산
    int shard = Math.abs(key.hashCode() % shards);
    return key + ":shard:" + shard;
}

public boolean allowRequest(String key) {
    // 샤드 4개로 분산
    String shardKey = shardedKey(key, 4);
    // 전체 한도 100을 샤드 수로 나눠서 각 샤드에 적용
    return redisLimiter.checkLimit(shardKey, 100 / 4, windowMs).allowed();
}

해결책 2: 로컬 캐시 선처리

@Component
public class HotKeyAwareRateLimiter {

    // 인기 키 목록 (운영 중 동적으로 감지 가능)
    private final Set<String> hotKeys = ConcurrentHashMap.newKeySet();
    private final ConcurrentHashMap<String, AtomicInteger> localCounters = new ConcurrentHashMap<>();

    public boolean allowRequest(String key) {
        if (hotKeys.contains(key)) {
            // Hot Key: 로컬에서 먼저 빠르게 처리
            AtomicInteger local = localCounters.computeIfAbsent(key, k -> new AtomicInteger(0));
            int count = local.incrementAndGet();
            if (count > 20) { // 로컬 소프트 한도 초과 시 Redis 확인
                local.set(0);
                return redisLimiter.checkLimit(key, 100, windowMs).allowed();
            }
            return true;
        }
        return redisLimiter.checkLimit(key, 100, windowMs).allowed();
    }
}

해결책 3: Redis Cluster의 해시 태그 회피

// 나쁜 예: 같은 해시 태그 → 같은 슬롯에 집중
String badKey = "{popular-endpoint}:user:" + userId;

// 좋은 예: 사용자 ID를 해시 기준으로 → 자연스러운 분산
String goodKey = "rl:" + userId + ":popular-endpoint";

정리

Rate Limiting은 단순한 카운터처럼 보이지만, 실무에서는 알고리즘 선택 → 분산 처리 → 장애 대응 → 성능 튜닝까지 고려해야 하는 깊이 있는 주제다.

핵심 선택 가이드

단일 JVM + 간단한 제한
    └─► Guava RateLimiter 또는 Resilience4j

단일 JVM + 정교한 Token Bucket
    └─► Bucket4j (InMemory)

분산 환경 + 범용
    └─► Bucket4j (Redis) 또는 직접 구현 (Redis Lua)

API Gateway 레벨
    └─► Spring Cloud Gateway RequestRateLimiter

인프라 레벨 1차 방어
    └─► Nginx limit_req

극한 정확도 (금융, 결제)
    └─► Redis Sliding Window Log + Lua 스크립트

Redis 장애 시 Fail-Open + 로컬 폴백을 기본 전략으로, Hot Key는 키 샤딩 + 로컬 캐시로 대응하고, 모든 Rate Limit 응답에는 표준 헤더(X-RateLimit-*, Retry-After)를 반드시 포함하는 것이 실무 표준이다.

카테고리:

업데이트: