- Published on
Spring Boot 3에서 429 폭증 - RateLimiter 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버를 운영하다 보면 어느 날 갑자기 429 응답이 폭증하는 순간이 옵니다. 문제는 429 자체가 “정상 동작”일 수도 “장애 전조”일 수도 있다는 점입니다. 예를 들어 외부 API 호출량이 순간적으로 늘어 upstream에서 429를 뿌리는 경우도 있고, 반대로 우리 서비스가 내부적으로 동시성 제어를 못해 폭주를 유발하고, 그 결과로 클라이언트 재시도가 더 큰 폭주를 만드는 경우도 흔합니다.
이 글은 Spring Boot 3 기준으로 다음을 목표로 합니다.
- 429 폭증을 “원인별로” 분해해서 진단한다
- Resilience4j
RateLimiter를 이용해 서버 인바운드/아웃바운드 모두에 제한을 건다 - 429 응답 정책(헤더, 메시지, 로깅)과 관측(Micrometer)까지 포함해 운영 가능한 형태로 만든다
또한 429 재시도/백오프는 RateLimiter만으로 끝나지 않는 경우가 많으니, 외부 호출 재시도 설계는 아래 글도 함께 보시면 연결이 잘 됩니다.
429 폭증의 전형적인 패턴 5가지
1) 클라이언트 재시도 폭주(Thundering Herd)
- 타임아웃 또는 5xx가 발생
- 클라이언트가 즉시 재시도(또는 짧은 고정 딜레이)
- 순간 QPS가 수배로 늘어 429가 더 많이 발생
핵심은 “재시도는 트래픽을 줄이지 않는다”는 점입니다. 재시도는 반드시 지수 백오프와 지터를 가져야 하고, 서버가 429를 주면 Retry-After를 존중해야 합니다.
2) 특정 엔드포인트만 핫스팟
전체 트래픽은 비슷한데 /login, /search, /payments/confirm 같은 일부만 폭발합니다. 이때 전역 RateLimit 하나로 막으면 정상 트래픽까지 타격을 줍니다. 엔드포인트/키 기반으로 쪼개야 합니다.
3) 외부 API 429를 우리 서버가 증폭
예: 우리 서버가 외부 API를 호출하는데 upstream이 429를 반환. 그런데 우리 서버는 요청을 계속 받아서 큐잉 없이 즉시 외부 호출을 시도하고, 실패하면 재시도까지 하며 upstream을 더 때립니다.
해결은 “아웃바운드 제한”입니다. 즉, 우리 서버에서 외부 API에 나가는 호출 자체를 RateLimiter로 제한해야 합니다.
4) 비정상/악성 트래픽
봇, 스캐너, 크리덴셜 스터핑 등. 이 경우 IP 단위 제한, 토큰 단위 제한, CAPTCHA/차단 정책이 필요할 수 있습니다.
5) 내부 락/병목으로 처리량 급락
처리량이 떨어지면 동일 트래픽에서도 대기열이 쌓이고 타임아웃이 늘며, 재시도가 겹쳐 429/5xx가 같이 튀는 형태로 보입니다. 특히 DB 락이나 잘못된 경계 설계로 병목이 생기면 “제한을 걸어도” 근본 해결이 안 됩니다.
Spring Boot 3에서 RateLimiter를 어디에 걸 것인가
실무에서는 보통 3군데 중 하나(혹은 조합)에 겁니다.
- 인바운드(우리 API로 들어오는 요청)
- 목적: 우리 서버 보호, 핫스팟 엔드포인트 보호
- 아웃바운드(외부 API로 나가는 호출)
- 목적: upstream 429 방지, 비용/쿼터 보호
- 내부 리소스(특정 서비스 메서드, DB 접근, 특정 락 구간)
- 목적: 내부 병목 구간을 보호하고 큐잉 대신 빠른 실패로 전환
이번 글은 Spring MVC 기준으로 인바운드와 아웃바운드를 모두 다룹니다.
의존성: Resilience4j RateLimiter + Micrometer
Gradle 예시입니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.github.resilience4j:resilience4j-spring-boot3'
implementation 'io.github.resilience4j:resilience4j-micrometer'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
운영에서는 반드시 Actuator와 metrics를 같이 켜서 “제한이 실제로 얼마나 걸리는지” 봐야 합니다.
RateLimiter 기본 개념(운영 관점)
Resilience4j RateLimiter는 토큰 버킷과 유사하게 “주기당 허용량”을 관리하며, 초과 시 즉시 실패하거나 일정 시간 대기 후 실패하도록 설정할 수 있습니다.
핵심 파라미터는 다음 두 가지입니다.
limitForPeriod: 한 주기당 허용 호출 수limitRefreshPeriod: 주기 길이timeoutDuration: 토큰을 기다릴 최대 시간(대기 허용)
운영 팁:
- 인바운드 API는 보통
timeoutDuration을0에 가깝게 두고 “빠른 429”가 낫습니다(서버 스레드 점유를 줄임). - 아웃바운드 호출은
timeoutDuration을 약간 허용해도 됩니다. 다만 대기열이 길어지면 전체 응답 시간이 늘어 타임아웃과 재시도를 유발할 수 있으니 상한을 명확히 둡니다.
설정: application.yml로 RateLimiter 정의
resilience4j:
ratelimiter:
instances:
inboundLogin:
limitForPeriod: 20
limitRefreshPeriod: 1s
timeoutDuration: 0ms
outboundPartnerApi:
limitForPeriod: 50
limitRefreshPeriod: 1s
timeoutDuration: 200ms
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
여기서 중요한 점은 “전역 1개”로 시작하지 말고, 최소한 인바운드/아웃바운드를 분리하는 것입니다. 핫스팟이 발생하면 인스턴스를 더 쪼개는 방식이 운영에 유리합니다.
인바운드 429: HandlerInterceptor로 엔드포인트 보호
Spring MVC에서 가장 직관적인 접근은 HandlerInterceptor로 요청을 가로채고, 특정 조건에서 RateLimiter를 적용하는 것입니다.
1) Interceptor 구현
package com.example.ratelimit;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final RateLimiter inboundLoginLimiter;
private final ObjectMapper objectMapper;
public RateLimitInterceptor(RateLimiter inboundLoginLimiter, ObjectMapper objectMapper) {
this.inboundLoginLimiter = inboundLoginLimiter;
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String path = request.getRequestURI();
String method = request.getMethod();
// 예시: 로그인 엔드포인트만 보호
if ("POST".equalsIgnoreCase(method) && "/api/login".equals(path)) {
try {
// 토큰이 없으면 RequestNotPermitted 발생
inboundLoginLimiter.acquirePermission();
return true;
} catch (RequestNotPermitted ex) {
write429(response, Duration.ofSeconds(1));
return false;
}
}
return true;
}
private void write429(HttpServletResponse response, Duration retryAfter) throws Exception {
response.setStatus(429);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// 클라이언트가 백오프할 수 있도록 힌트 제공
response.setHeader("Retry-After", String.valueOf(retryAfter.toSeconds()));
Map<String, Object> body = Map.of(
"code", "TOO_MANY_REQUESTS",
"message", "Rate limit exceeded. Please retry later.",
"retryAfterSeconds", retryAfter.toSeconds()
);
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}
포인트:
- 429를 반환할 때
Retry-After를 같이 주면, 잘 만든 클라이언트는 이를 보고 재시도 간격을 늘립니다. timeoutDuration을0ms로 두면 대기하지 않고 즉시 429로 떨어져 서버 자원을 보호합니다.
2) Interceptor 등록
package com.example.ratelimit;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RateLimitInterceptor rateLimitInterceptor;
public WebConfig(RateLimitInterceptor rateLimitInterceptor) {
this.rateLimitInterceptor = rateLimitInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor);
}
}
3) RateLimiter Bean 주입
Resilience4j Spring Boot 3 스타터를 쓰면 설정 기반으로 RateLimiterRegistry가 구성됩니다.
package com.example.ratelimit;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RateLimiterConfig {
@Bean
public RateLimiter inboundLoginLimiter(RateLimiterRegistry registry) {
return registry.rateLimiter("inboundLogin");
}
@Bean
public RateLimiter outboundPartnerApiLimiter(RateLimiterRegistry registry) {
return registry.rateLimiter("outboundPartnerApi");
}
}
인바운드 제한에서 자주 하는 실수
전역 제한 1개로 다 막기
전역 제한은 장애 시 “전체 429”로 보이게 만들고, 정상 기능까지 같이 죽입니다. 최소한 다음은 분리하세요.
- 인증/로그인
- 검색/리스트
- 결제/주문
IP 기반 제한을 애플리케이션에서만 처리
프록시/게이트웨이 레벨(예: CDN, WAF, API Gateway, Ingress)에서 1차로 걸러야 애플리케이션이 덜 고생합니다. 애플리케이션 RateLimiter는 “서비스 로직 보호”에 집중하는 편이 효율적입니다.
아웃바운드 429: RestClient 호출에 RateLimiter 적용
Spring Boot 3에서는 RestClient가 많이 쓰입니다. 외부 API 호출을 감싸 RateLimiter를 적용하면 upstream 쿼터를 안정적으로 지킬 수 있습니다.
1) RestClient Bean
package com.example.http;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class HttpClientConfig {
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder.build();
}
}
2) 외부 호출 서비스에 RateLimiter 적용
package com.example.partner;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiter;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.function.Supplier;
@Service
public class PartnerApiClient {
private final RestClient restClient;
private final RateLimiter outboundLimiter;
public PartnerApiClient(RestClient restClient, RateLimiter outboundPartnerApiLimiter) {
this.restClient = restClient;
this.outboundLimiter = outboundPartnerApiLimiter;
}
public PartnerResponse fetchSomething(String id) {
Supplier<PartnerResponse> supplier = () ->
restClient.get()
.uri("https://partner.example.com/api/resource/{id}", id)
.retrieve()
.body(PartnerResponse.class);
Supplier<PartnerResponse> limited = io.github.resilience4j.ratelimiter.RateLimiter
.decorateSupplier(outboundLimiter, supplier);
try {
return limited.get();
} catch (RequestNotPermitted ex) {
// 우리 쪽에서 먼저 제한: upstream에 불필요한 호출을 보내지 않음
throw new PartnerRateLimitedException("Outbound rate limit exceeded", ex);
}
}
}
주의: 위 코드 블록에서 import가 과도하게 반복되지 않도록 실제 코드에서는 정리하세요. 핵심은 RateLimiter.decorateSupplier(...)로 외부 호출을 감싸는 패턴입니다.
이렇게 하면 upstream 429가 오기 전에 우리 서비스가 스스로 속도를 조절할 수 있습니다. 특히 파트너 API가 “분당 쿼터”를 가지는 경우 효과가 큽니다.
429 응답을 ‘정책’으로 만들기: 메시지, 헤더, 로깅
429는 단순히 상태 코드만 던지면 끝이 아니라, 클라이언트가 행동을 바꿀 수 있도록 정보를 줘야 합니다.
Retry-After: 초 단위 또는 HTTP-date- 응답 바디: 에러 코드, 재시도 권장 시간, 요청 식별자
- 로깅: 제한 발생 시점에 사용자/클라이언트/엔드포인트를 구조화 로그로 남기기
예: Spring MVC 전역 예외 처리로 RequestNotPermitted를 429로 변환해 통일할 수 있습니다.
package com.example.web;
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RequestNotPermitted.class)
public ResponseEntity<Map<String, Object>> handleRateLimit(RequestNotPermitted ex) {
return ResponseEntity.status(429)
.header("Retry-After", "1")
.body(Map.of(
"code", "TOO_MANY_REQUESTS",
"message", "Rate limit exceeded. Please retry later."
));
}
}
관측: RateLimiter가 실제로 걸리는지 확인하기
운영에서 가장 흔한 실패는 “설정은 했는데, 언제 얼마나 제한되는지 모른다”입니다.
Resilience4j는 Micrometer 연동을 통해 지표를 노출할 수 있습니다. Actuator와 Prometheus 엔드포인트를 열어두면 다음을 확인할 수 있습니다.
- 허용된 호출 수
- 거부된 호출 수
- 대기 시간(설정에 따라)
Prometheus를 쓴다면 /actuator/prometheus를 스크랩하고, Grafana에서 ratelimiter 관련 메트릭을 대시보드로 구성하세요.
운영 팁:
- 429 비율이 올라갈 때, “거부 수 증가”와 “응답 시간 증가”가 같이 보이면 병목이 먼저일 가능성이 큽니다.
- 거부 수만 증가하고 응답 시간은 안정적이면, 제한이 의도대로 작동하는 신호일 수 있습니다.
실전 튜닝 가이드: 숫자를 어떻게 잡을까
1) 먼저 처리량을 측정한다
- 엔드포인트별 P95/P99 응답 시간
- DB/외부 API 호출 시간
- 인스턴스 수와 스레드 풀 크기
2) 제한은 “보호 목표”로 잡는다
예를 들어 로그인 엔드포인트가 DB와 암호화 비용으로 무겁다면, 서버 1대가 안정적으로 처리 가능한 QPS가 30이라면 limitForPeriod를 20부터 시작합니다.
3) timeoutDuration은 신중히
대기 시간을 주면 순간 버스트를 흡수할 수 있지만, 대기가 길어지면 결국 타임아웃과 재시도를 부릅니다. 인바운드는 0ms 또는 매우 짧게, 아웃바운드는 짧은 완충 정도만 허용하는 식이 실전에서 안전합니다.
RateLimiter만으로 부족한 경우: Bulkhead, TimeLimiter, Retry 조합
429 폭증은 종종 다음과 같이 엮여 있습니다.
- 처리량 저하(스레드 고갈)
->타임아웃 증가->재시도 증가->429 증가
이때는 RateLimiter만 두면 “거절은 잘 되는데 원인 해결은 안 되는” 상태가 됩니다. 보통은 다음 조합이 안정적입니다.
Bulkhead: 동시 실행 수 제한(스레드/커넥션 보호)TimeLimiter: 외부 호출 상한 시간 강제Retry: 429/5xx에 대한 지수 백오프 + 지터
특히 504/타임아웃이 같이 보이면, 네트워크/로드밸런서/업스트림 병목도 함께 점검해야 합니다.
체크리스트: 429 폭증을 멈추는 순서
- 429가 “어디서” 발생하는지 분리
- 우리 서버 인바운드에서 429를 내는가
- upstream이 429를 내고 우리는 전달만 하는가
- 핫스팟 엔드포인트를 분리 제한
- 전역 1개 제한을 피하고 인스턴스를 쪼갠다
- 아웃바운드 호출을 제한
- upstream 쿼터를 초과하지 않도록 우리 쪽에서 속도를 조절
- 재시도 정책 점검
- 즉시 재시도 금지
Retry-After존중- 지수 백오프 + 지터
- 병목(락/DB/외부 의존성) 제거
- 제한은 보호 장치이지 근본 치료가 아니다
마무리
Spring Boot 3에서 429 폭증을 다룰 때 핵심은 “RateLimiter를 어디에, 어떤 단위로, 어떤 정책으로 걸 것인가”입니다. 인바운드는 빠른 실패로 서버를 보호하고, 아웃바운드는 upstream 쿼터를 지키며, 관측 지표로 실제 효과를 확인해야 합니다. 마지막으로 429는 종종 재시도/타임아웃/병목과 연결되어 폭증하므로, 제한과 함께 백오프 및 병목 제거까지 한 세트로 가져가야 운영에서 안정화됩니다.