Rate Limiting이 필요한 이유

크롤러 봇 하나가 우리 API를 초당 500번 호출했다. 서버가 뻗었고, 실제 사용자들은 '앱이 안 돼요'라고 난리가 났다. Rate Limiting이 없는 API는 문 안 잠근 집이나 마찬가지라는 걸, 그날 뼈저리게 느꼈다.

저희 팀에서는 외부 파트너사에 API를 제공하면서 특정 클라이언트가 초당 수천 건의 요청을 보내 전체 서비스에 영향을 주는 사고를 겪었고, 그 이후 체계적인 Rate Limiting을 도입했습니다.

Rate Limiting 알고리즘 비교

1. Fixed Window Counter

가장 단순한 방식으로, 고정된 시간 윈도우(예: 1분) 내의 요청 수를 카운트합니다. 구현이 간단하지만 윈도우 경계에서 트래픽이 집중되는 문제(경계 문제)가 있습니다. 예를 들어 분당 100회 제한에서 0:59에 100회, 1:00에 100회를 보내면 2초 사이에 200회 요청이 통과합니다.

2. Sliding Window Log

각 요청의 타임스탬프를 모두 기록하고, 현재 시점에서 윈도우 크기만큼 이전의 요청만 카운트합니다. 정확하지만 메모리 사용량이 높아 대규모 트래픽에서는 비효율적입니다.

3. Token Bucket

일정한 속도로 토큰이 버킷에 추가되고, 요청마다 토큰을 소비합니다. 버스트 트래픽을 허용하면서도 평균 처리율을 제한할 수 있어 가장 널리 사용됩니다.

4. Sliding Window Counter

Fixed Window와 Sliding Window Log의 장점을 결합한 방식입니다. 이전 윈도우의 카운트와 현재 윈도우의 카운트를 가중 평균하여 더 정확한 제한을 제공합니다.

Spring Boot에서 Bucket4j 적용

Token Bucket 알고리즘을 구현한 Bucket4j 라이브러리를 Spring Boot Filter로 적용하는 방법입니다.

// build.gradle
dependencies {
    implementation 'com.bucket4j:bucket4j-core:8.7.0'
    implementation 'com.bucket4j:bucket4j-redis:8.7.0'
}
@Component
@Order(1)
public class RateLimitFilter extends OncePerRequestFilter {

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

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        String clientId = resolveClientId(request);
        Bucket bucket = buckets.computeIfAbsent(
            clientId, this::createBucket);

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            response.addHeader("X-Rate-Limit-Remaining",
                String.valueOf(probe.getRemainingTokens()));
            chain.doFilter(request, response);
        } else {
            long waitSeconds = probe.getNanosToWaitForRefill()
                / 1_000_000_000;
            response.addHeader("X-Rate-Limit-Retry-After",
                String.valueOf(waitSeconds));
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType("application/json");
            response.getWriter().write(
                "{\"error\":\"Rate limit exceeded\","
                + "\"retryAfter\":" + waitSeconds + "}");
        }
    }

    private Bucket createBucket(String clientId) {
        return Bucket.builder()
            .addLimit(Bandwidth.classic(
                100, Refill.greedy(100, Duration.ofMinutes(1))))
            .addLimit(Bandwidth.classic(
                1000, Refill.greedy(1000, Duration.ofHours(1))))
            .build();
    }

    private String resolveClientId(HttpServletRequest request) {
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey != null) return "key:" + apiKey;

        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null) return "ip:" + forwarded.split(",")[0];

        return "ip:" + request.getRemoteAddr();
    }
}

API 등급별 Rate Limit 차등 적용

파트너사나 요금제에 따라 다른 Rate Limit을 적용하는 것이 일반적입니다.

public enum ApiTier {
    FREE(60, 1000),       // 분당 60, 시간당 1000
    STANDARD(300, 10000), // 분당 300, 시간당 10000
    PREMIUM(1000, 50000); // 분당 1000, 시간당 50000

    private final int perMinute;
    private final int perHour;

    public Bucket createBucket() {
        return Bucket.builder()
            .addLimit(Bandwidth.classic(
                perMinute,
                Refill.greedy(perMinute, Duration.ofMinutes(1))))
            .addLimit(Bandwidth.classic(
                perHour,
                Refill.greedy(perHour, Duration.ofHours(1))))
            .build();
    }
}

분산 환경에서의 Rate Limiting

서버가 여러 대인 분산 환경에서는 인메모리 Rate Limiting으로는 부족합니다. Redis를 활용한 중앙 집중식 Rate Limiting이 필요합니다. Bucket4j-Redis를 사용하면 기존 코드를 최소한으로 변경하면서 분산 Rate Limiting을 구현할 수 있습니다.

@Bean
public ProxyManager<String> proxyManager(
        LettuceBasedProxyManager.LettuceBasedProxyManagerBuilder builder) {
    return builder.build();
}

// 분산 버킷 생성
BucketConfiguration config = BucketConfiguration.builder()
    .addLimit(Bandwidth.classic(100,
        Refill.greedy(100, Duration.ofMinutes(1))))
    .build();

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

클라이언트 친화적인 응답 설계

Rate Limit에 걸린 클라이언트가 적절히 대응할 수 있도록 표준 HTTP 헤더를 제공하는 것이 중요합니다. X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset 헤더를 모든 응답에 포함하고, 429 응답 시 Retry-After 헤더로 재시도 시점을 안내합니다.

마무리

Rate Limiting은 서비스 안정성의 핵심 요소입니다. Token Bucket 알고리즘을 기본으로, 비즈니스 요구사항에 맞는 등급별 차등 적용, 그리고 분산 환경 대응까지 고려하면 견고한 Rate Limiting 체계를 구축할 수 있습니다.

← 목록으로 다음 글 →
Jaeseong
Jaeseong

10년차 풀스택 개발자. Spring Boot, Flutter, AI 등 실무 경험을 기록합니다.

GitHub →

💬 댓글