Published on

Spring Boot 대용량 트래픽 대비 API Rate Limiting 설계

Authors

대용량 트래픽을 받는 API는 결국 두 가지와 싸웁니다. 하나는 정상 사용자의 급증(버스트), 다른 하나는 비정상 사용자의 과도한 호출(남용, 스크래핑, 크리덴셜 스터핑) 입니다. Rate Limiting은 이 둘을 같은 메커니즘으로 제어하면서도, 서비스 품질을 지키기 위한 가장 비용 효율적인 방어선입니다.

다만 Spring Boot에서 Rate Limiting을 “필터 하나로 429 내면 끝”으로 접근하면, 트래픽이 커질수록 다음 문제가 터집니다.

  • 서버가 여러 대로 늘어나면 인스턴스별 카운터가 달라져 제한이 무력화
  • Redis 장애나 네트워크 지연으로 요청 경로가 느려지거나 전체 장애로 전파
  • 사용자 단위, 토큰 단위, IP 단위, 엔드포인트 단위 정책이 섞이며 운영 복잡도 폭증
  • 429만 내보내고 재시도 정책이 없어서 클라이언트가 더 공격적으로 재시도(스파이크 증폭)

이 글은 Spring Boot 기준으로 정책 설계, 알고리즘 선택, 분산 구현, 장애 대응, 운영 지표까지 한 번에 정리합니다.

관련해서 외부 호출이 많은 서비스라면, 제한 이후의 재시도가 연쇄 장애를 만들 수 있습니다. 클라이언트 재시도 설계는 별도 글인 Spring Boot 3 Feign 타임아웃·재시도 함정 9가지도 같이 보는 것을 권합니다.

Rate Limiting의 목표를 먼저 정의하기

Rate Limiting은 “막는다”가 아니라 “서비스를 안정적으로 제공한다”가 목표입니다. 그래서 정책을 정하기 전에 아래를 문장으로 못 박아야 합니다.

  • 무엇을 보호할 것인가: DB 커넥션, 특정 API, 특정 외부 벤더 호출, 결제/인증 등
  • 누구를 기준으로 제한할 것인가: 사용자 ID, API 키, JWT subject, IP, 디바이스 ID
  • 어떤 품질을 보장할 것인가: 정상 사용자의 버스트 허용 여부, 엔드포인트별 비용 차등
  • 제한 시 UX: 429와 Retry-After 제공, 점진적 제한, 캡차/블록 전환

제한 키(Identity) 설계 체크리스트

현업에서 가장 흔한 실수는 “IP 기준으로만 제한”입니다. NAT, 모바일 통신사, 회사 프록시 환경에서는 한 IP에 수천 명이 붙을 수 있어 정상 사용자를 대량 차단할 수 있습니다.

권장 우선순위는 보통 다음과 같습니다.

  1. 인증된 요청: 사용자 ID 또는 API 키
  2. 비인증 요청: IP + User-Agent 해시(완벽하진 않지만 오탐 완화)
  3. 로그인/회원가입 같은 민감 API: IP와 계정 식별자를 함께 적용(계정별, IP별 이중 제한)

키 포맷 예시(인라인 코드는 백틱으로 감쌉니다): rl:{policy}:{endpoint}:{identity}

알고리즘 선택: Token Bucket vs Sliding Window

Rate Limiting 알고리즘은 여러 가지가 있지만, Spring Boot 대용량 트래픽에서 자주 쓰는 선택지는 사실상 아래 둘이 핵심입니다.

Token Bucket

  • 장점: 버스트를 자연스럽게 허용(토큰이 쌓였다가 한 번에 소비)
  • 단점: 구현이 조금 복잡(토큰 리필 계산)
  • 추천 상황: 사용자 경험이 중요하고, 순간 폭주를 어느 정도 허용해야 할 때

Sliding Window(특히 Sliding Window Log/Counter)

  • 장점: “최근 1분” 같은 정책을 직관적으로 맞추기 쉬움
  • 단점: 구현 방식에 따라 메모리/연산 비용 증가
  • 추천 상황: 정확한 윈도우 기반 제한이 필요하거나, 엔드포인트별로 강한 제어가 필요할 때

대부분의 API 게이트웨이/Redis 기반 구현에서는 Token Bucket 또는 Sliding Window Counter가 현실적인 타협점입니다.

어디에서 제한할 것인가: Edge, Gateway, App

Rate Limiting은 위치에 따라 성격이 달라집니다.

  • Edge(예: CDN, WAF): L3/L4 및 기본적인 IP/봇 차단. 비용 대비 효과가 큼.
  • Gateway(예: Spring Cloud Gateway, Nginx, Envoy): 라우팅 단에서 빠르게 차단. 서비스 공통 정책에 좋음.
  • Application(Spring Boot): 사용자/도메인 규칙을 반영한 정교한 제한. 가장 유연하지만 비용이 큼.

현실적인 권장 조합은 다음입니다.

  1. Edge에서 기초 방어(봇/국가/ASN 등)
  2. Gateway에서 서비스 공통 제한(예: API 키당 초당 N)
  3. Spring Boot에서 도메인 특화 제한(예: 결제 시도, 인증 코드 발송 등)

Spring Boot 애플리케이션 내부 구현 패턴

Spring Boot에서 애플리케이션 레벨 Rate Limiting은 보통 3가지 방식으로 들어갑니다.

  • Servlet Filter 또는 Spring MVC HandlerInterceptor
  • Spring AOP(어노테이션 기반)
  • WebFlux WebFilter

대용량 트래픽에서는 “가능한 앞단에서, 가능한 가볍게”가 원칙이므로, MVC라면 대체로 OncePerRequestFilter 또는 HandlerInterceptor가 적합합니다.

정책을 코드로 고정하지 말고 설정으로 분리하기

운영에서 정책은 반드시 바뀝니다. 릴리즈 없이 조정할 수 있도록 최소한 다음을 분리하세요.

  • 엔드포인트 그룹(예: auth, payment, public)
  • 그룹별 제한 값(초당, 분당)
  • 버스트 허용량
  • 제한 기준(사용자/키/IP)

Spring Boot @ConfigurationProperties로 받거나, 더 나아가 동적 설정(예: Config Server, DB)을 고려할 수 있습니다.

분산 환경의 정답: Redis 원자 연산 기반 제한

인스턴스가 여러 대면 로컬 메모리 카운터는 무력합니다. 결국 중앙 저장소가 필요하고, 가장 흔한 선택이 Redis입니다.

핵심은 “증가, 만료, 비교”를 원자적으로 처리하는 것입니다. 이를 위해 Redis Lua 스크립트를 사용합니다.

아래는 고정 윈도우 카운터(Fixed Window Counter) 예시입니다. 완벽히 매끄럽진 않지만 구현이 단순하고 성능이 좋습니다. 버스트 경계(윈도우가 바뀌는 순간)에서 약간의 허용이 생길 수 있습니다.

Redis Lua 스크립트 예시

-- KEYS[1] = rate limit key
-- ARGV[1] = window seconds
-- ARGV[2] = limit

local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end

if current > tonumber(ARGV[2]) then
  return {0, current}
end

return {1, current}

반환값에서 첫 번째 값이 1이면 허용, 0이면 차단 같은 식으로 사용합니다.

Spring Boot(Java)에서 Lua 호출 예시

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.time.Duration;
import java.util.List;

public class RedisFixedWindowRateLimiter {

    private final StringRedisTemplate redisTemplate;
    private final DefaultRedisScript<List> script;

    public RedisFixedWindowRateLimiter(StringRedisTemplate redisTemplate, String luaScript) {
        this.redisTemplate = redisTemplate;
        this.script = new DefaultRedisScript<>();
        this.script.setScriptText(luaScript);
        this.script.setResultType(List.class);
    }

    public Result allow(String key, Duration window, long limit) {
        List result = redisTemplate.execute(
                script,
                List.of(key),
                String.valueOf(window.toSeconds()),
                String.valueOf(limit)
        );

        long allowed = Long.parseLong(String.valueOf(result.get(0)));
        long current = Long.parseLong(String.valueOf(result.get(1)));
        return new Result(allowed == 1, current);
    }

    public record Result(boolean allowed, long current) {}
}

Spring MVC Filter 적용 예시

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.time.Duration;

public class RateLimitFilter extends OncePerRequestFilter {

    private final RedisFixedWindowRateLimiter limiter;

    public RateLimitFilter(RedisFixedWindowRateLimiter limiter) {
        this.limiter = limiter;
    }

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

        String identity = resolveIdentity(request); // userId 또는 apiKey 또는 ip
        String endpointGroup = resolveEndpointGroup(request);

        Duration window = Duration.ofSeconds(1);
        long limit = endpointGroup.equals("auth") ? 5 : 50;

        String key = "rl:" + endpointGroup + ":" + identity;

        var result = limiter.allow(key, window, limit);
        if (!result.allowed()) {
            response.setStatus(429);
            response.setHeader("Retry-After", "1");
            response.setContentType("application/json");
            response.getWriter().write("{\"message\":\"rate limited\"}");
            return;
        }

        filterChain.doFilter(request, response);
    }

    private String resolveIdentity(HttpServletRequest request) {
        // 예시: 인증된 경우 userId, 아니면 X-Api-Key, 아니면 IP
        String apiKey = request.getHeader("X-Api-Key");
        if (apiKey != null && !apiKey.isBlank()) return "key:" + apiKey;
        return "ip:" + request.getRemoteAddr();
    }

    private String resolveEndpointGroup(HttpServletRequest request) {
        String path = request.getRequestURI();
        if (path.startsWith("/api/auth")) return "auth";
        if (path.startsWith("/api/pay")) return "payment";
        return "public";
    }
}

이 방식의 장점은 단순함과 성능입니다. 단점은 다음과 같습니다.

  • 윈도우 경계에서 순간적으로 더 많이 통과할 수 있음
  • 엔드포인트별 비용 가중치(예: 결제는 10포인트, 조회는 1포인트)를 넣으려면 확장 필요

Token Bucket을 Redis로 구현할 때의 포인트

Token Bucket은 “마지막 리필 시각”과 “현재 토큰 수”를 저장해야 합니다. Redis에서는 보통 HASH에 저장하고 Lua로 원자 처리합니다.

  • 상태: tokens, lastRefillMs
  • 입력: capacity, refillTokensPerSec, nowMs, cost
  • 출력: 허용 여부, 남은 토큰, 다음 가능 시점

운영 관점에서 Token Bucket은 UX가 좋지만, 구현이 길어지고 검증 포인트가 늘어납니다. 초기에 Fixed Window로 시작하고, 버스트 UX가 중요해질 때 Token Bucket으로 옮기는 전략도 현실적입니다.

429 응답 설계: 클라이언트가 폭주를 증폭시키지 않게

서버가 429를 내도, 클라이언트가 즉시 재시도하면 오히려 트래픽이 더 늘어납니다. 따라서 다음을 권장합니다.

  • Retry-After 헤더를 반드시 제공
  • 가능하면 X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset 같은 힌트를 제공
  • 클라이언트는 지수 백오프와 지터를 적용

외부 연동이 많은 시스템에서는 재시도 정책이 장애를 키우는 경우가 많습니다. 특히 Feign 재시도는 설정 한 줄로 폭주를 만들 수 있으니, Spring Boot 3 Feign 타임아웃·재시도 함정 9가지에서 “재시도는 제한과 함께 설계해야 한다”는 관점을 같이 확인해보세요.

장애 대응: Redis가 느리거나 죽었을 때 어떻게 할 것인가

분산 Rate Limiting의 가장 큰 리스크는 Redis 장애가 곧바로 API 장애로 전파되는 것입니다. 선택지는 크게 3가지입니다.

Fail-open(허용)

  • Redis 장애 시 제한을 풀고 트래픽을 통과
  • 장점: 가용성 유지
  • 단점: 악성 트래픽에 취약, 백엔드가 터질 수 있음

Fail-closed(차단)

  • Redis 장애 시 429 또는 503으로 차단
  • 장점: 백엔드 보호
  • 단점: 정상 사용자도 영향, 가용성 저하

Hybrid(권장)

  • 핵심 API(결제/인증)는 fail-closed
  • 일반 조회 API는 fail-open 또는 완화된 로컬 제한으로 degrade

또한 Redis 호출에는 반드시 다음을 넣으세요.

  • 짧은 타임아웃(예: 수 ms 단위)
  • Bulkhead(동시 요청 제한) 또는 스레드 격리
  • 회로 차단기(서킷 브레이커)로 장애 전파 차단

Kubernetes에서 Redis/애플리케이션이 메모리 압박으로 재시작을 반복하면 제한 로직이 더 불안정해집니다. 운영 중 CrashLoopBackOffOOMKilled를 겪고 있다면 K8s CrashLoopBackOff·OOMKilled 원인과 해결처럼 리소스 안정화도 함께 진행하는 게 좋습니다.

엔드포인트 비용 기반 제한: “요청 수”가 아니라 “비용”을 제한하기

대용량 트래픽에서 더 정교한 방식은 엔드포인트별로 비용을 다르게 주는 것입니다.

  • GET /search는 1포인트
  • POST /payment/confirm는 20포인트
  • POST /auth/sms/send는 50포인트

이렇게 하면 단순히 초당 요청 수를 세는 것보다, 시스템의 병목(DB, 외부 호출, 암호화 비용)을 더 정확히 보호할 수 있습니다.

구현은 Lua 스크립트에서 INCRBY를 사용해 cost만큼 증가시키면 됩니다.

-- ARGV[1] = window seconds
-- ARGV[2] = limit
-- ARGV[3] = cost

local current = redis.call('INCRBY', KEYS[1], ARGV[3])
if current == tonumber(ARGV[3]) then
  redis.call('EXPIRE', KEYS[1], ARGV[1])
end

if current > tonumber(ARGV[2]) then
  return {0, current}
end

return {1, current}

멀티 레벨 제한: 사용자별 + IP별 + 전역 제한

실전에서는 하나의 제한만으로 부족합니다. 추천 조합은 다음입니다.

  • 사용자별 제한: API 키 또는 userId 기준
  • IP별 제한: 비인증 또는 공격 탐지용
  • 전역 제한: 특정 엔드포인트가 전체적으로 과열될 때

평가 순서는 보통 “가장 싸고 강한 것부터”입니다.

  1. 전역 제한으로 시스템 보호(가장 단순)
  2. IP 제한으로 악성 트래픽 억제
  3. 사용자 제한으로 공정성 보장

이때 중요한 것은, 여러 제한 중 하나라도 걸리면 429를 내되, 관측 가능하게 “어느 정책에 걸렸는지”를 로그/메트릭으로 남기는 것입니다.

관측(Observability): Rate Limiting은 지표가 전부다

Rate Limiting은 적용 순간부터 운영 지표가 없으면 “정상 사용자를 막는지” “공격을 막는지” 판단이 불가능합니다.

필수 지표:

  • rate_limit_allowed_total{policy,endpoint}
  • rate_limit_blocked_total{policy,endpoint}
  • Redis 호출 지연: p50, p95, p99
  • 429 응답 비율(전체 대비)
  • 상위 제한 대상 Top N(사용자/키/IP)

로그에는 다음을 구조화해서 남기면 좋습니다.

  • identity, endpointGroup, limit, windowSec, current, decision

성능 팁: Redis를 Rate Limiting 때문에 느리게 만들지 않기

  • 키 TTL은 짧게, 키 수 폭증을 막기
  • 해시태그를 써서 Redis Cluster에서 키가 분산되도록 설계(클러스터 사용 시)
  • Lua 스크립트는 짧고 단순하게 유지
  • 파이프라이닝은 도움이 되지만, 원자성이 필요하면 Lua가 우선
  • “정말 필요한 요청”에만 적용: 정적 리소스, 헬스체크 등은 제외

또한 애플리케이션이 스케일아웃하면서 네트워크 경로 문제가 생기면, 제한 로직이 아닌 곳에서 지연이 커져 전체가 느려질 수 있습니다. EKS 환경에서 네트워크 이슈를 겪고 있다면 EKS kube-proxy를 IPVS로 바꾼 뒤 통신 장애 복구처럼 인프라 레벨 점검도 병행하세요.

운영 적용 순서(권장 로드맵)

  1. 가장 위험한 엔드포인트(인증, 결제, SMS, 이메일 발송)에만 강한 제한 적용
  2. Redis 기반 분산 제한으로 전환(Lua 원자 처리)
  3. 429 응답 헤더와 클라이언트 백오프 정책 정비
  4. 관측 지표 대시보드 구축 및 임계치 알람 설정
  5. 비용 기반 제한, 멀티 레벨 제한으로 고도화
  6. Redis 장애 시 fail-open 또는 fail-closed 정책을 엔드포인트별로 분리

마무리

Spring Boot에서 Rate Limiting은 단순히 429를 반환하는 기능이 아니라, 공정성, 안정성, 장애 격리, 운영 가능성을 함께 설계하는 영역입니다. 특히 대용량 트래픽에서는 분산 환경에서 일관되게 동작하는 Redis 원자 연산, 장애 시나리오별 fail 전략, 그리고 429 이후의 클라이언트 재시도 제어가 핵심입니다.

처음부터 완벽한 알고리즘을 도입하기보다, “가장 위험한 API부터, 관측 가능하게, 장애 전파를 막는 방식으로” 단계적으로 확장하는 것이 장기적으로 가장 안전합니다.