2025. 3. 23. 11:31ㆍBackend/Spring

📝 최근 짧은 시간에 엄청나게 많은 클라이언트의 요청을 (많은 트래픽) 경험하게 되었고, 이로 인해 문제점이 발생하게 되었습니다. 언제든지 과도한 트래픽이 발생할 수 있기 때문에, 이를 대처하고자 방법을 찾다 발견한 것이 'Rate Limiter'였습니다! 따라서 관련 내용을 공부 후 정리하여 기억하고, 비슷한 상황을 만났을 때 빠르게 대처하고자 글을 작성합니다.
이 글을 Windows 11, Spring Boot 3.4.3, Java 17 기준으로 작성되었습니다. (개인 test용)
😂 Problem

위의 그림처럼, 평상시에 클라이언트는 접근 가능한 url(https://ip:port/api)를 사용하여 특정 api 요청을 하게 됩니다. 그렇게 되면 특정 서비스에서 cache, 외부 api, DB 등을 이용하여 데이터를 확보한 뒤, 클라이언트와 합의된 형식으로 응답하게 됩니다.

그런데 문제는 서비스가 견딜 수 있는 요청이 아닌, 과도한 요청이 발생하게 될 때 발생합니다. 예를 들어, redis는 싱글 스레드라 요청이 한 번에 한 번씩 발생할 수 있거나, 커넥션 수가 정해져 있는 DB의 경우 커넥션 수과 초과될 수 있거나, 과도한 트래픽을 방지하기 위해 외부 API에서 특정 조건으로 요청량을 제한하고 있을 수 있습니다. 이 경우에 서비스 응답 시간이 과도하게 지연되거나 연관 서비스들이 마비되는 등 문제점이 발생할 수 있습니다. 더군다나 악의적인 목적으로 짧은 시간 내에 엄청나게 많은 요청을 하는 DDos 인 경우에는, 서비스가 마비됩니다.
따라서 이런 문제를 해결하기 위하여, 과도한 트래픽이 발생했을 때 어떻게 대처할지 고민을 해야 했습니다.
📝 How?
처리율 제한 장치
구글링으로 방법을 찾아보고, 공통으로 다루고 있는 책도 있었기에 해당 책을 구매하였습니다.
이 때 발견한 방법은 '처리율 제한 장치 (Rate Limiter)'입니다.
네트워크 시스템에서 처리율 제한 장치(rate limiter)는
클라이언트 또는 서비스가 보내는 트래픽의 처리율(rate)을 제어하기 위한 장치이다.
- 가상 면접 사례로 배우는 대규모 시스템 설계 기초, p.51 중에서
정말 제가 만난 상황을 정말 다루기에 좋은 방법이었습니다. 트래픽을 일정 비율로 제어할 수 있다면, 이후 caching 처리, 외부 API 연동, DB 연동 과정에서 생길 문제들을 사전에 차단할 수 있는 좋은 방법이라고 생각했습니다.
처리율 제한 장치를 구현할 수 있는 여러 알고리즘이 존재하는데, 제가 참고하기로 다짐한 알고리즘은 '토큰 버킷 알고리즘'입니다. AWS에서 API Gateway에서 토큰 버킷 알고리즘을 사용하여 API에 대한 요청을 제한하는 것으로 알려져 있습니다.
토큰 버킷 알고리즘이란?

Bucket에 토큰을 담을 수 있는데, 주기적으로 Token이 채워집니다. 클라이언트를 특정 서비스로 요청했을 때, Token을 부여받게 되는데요. 이 Token이 없는 경우, 클라이언트가 원하는 응답이 아닌 429(too many requests)와 같은 응답을 받게 됩니다.
마치 일정 시간 몇 명 입장할 수밖에 없는 전시회를 떠올리면 좋습니다. 1시간마다 100명 입장할 수 있는 전시회에서, 대기줄에서 기다리다가 입장권(token)을 부여받게 되면 입장하게 되는 건데요.
여기서 중요한 점은 'Bucket의 크기'와 'Token이 어떤 주기로 얼마나 채워질 것인가'라는 점입니다.각 서비스의 트래픽에 맞춰, Bucket의 크기와 Token 공급률(refill rate)을 설정하면 됩니다.
🦜Solution
처리율 제한 장치를 구현하는 방법은 다양합니다.
- 구현 방법 : 하드 코딩 vs 라이브러리 이용
- 서버 차이 : 단순 서버 vs 분산 서버
- 장치 위치 : (Spring 기준) Filter vs Interceptor vs AOP
저는 여기서 Bucket4J라는 라이브러리를 사용하기로 했고, 분산 서버를 가정하여 Redis를 이용하기로 했으며, Dispatcher Servlet을 호출하기 전에 사용되는 Filter에 장치를 두기로 생각했습니다.
라이브러리 : Bucket4J

토큰 버킷 알고리즘을 구현하는 라이브러리가 Guava, Resillience4J, RateLimitJ, Bucket4J가 있습니다. 여기서 단순 test 로 사용하기에 무거운 Guava, Resillience4J 및 업데이트 되지 않는 RateLimitJ가 아닌 Bucket4J를 사용하기로 했습니다.
Bucket4j 8.9.0 Reference
Question: Why does bucket invoke the listener on the client-side instead of the server-side in case of distributed scenario? What do I need to do if I need an aggregated stat across the whole cluster? Answer: Because of a planned expansion to non-JVM back-
bucket4j.com
Bucket4J 라이브러리 문서에서는, 해당 라이브러리를 토큰 버킷 알고리즘에 주로 기반한 Java 처리율 제한 장치 라이브러리로 소개하고 있습니다.
Bucket4j is a Java rate-limiting library that is mainly based on the token-bucket algorithm,
which is by the de-facto standard for rate-limiting in the IT industry.
- https://bucket4j.com/8.9.0/toc.html#about-bucket4j
build.gradle
dependencies {
//rate limit : bucket4j
implementation 'com.bucket4j:bucket4j-core:8.9.0'
implementation 'com.bucket4j:bucket4j-redis:8.9.0'
Filter
filter를 택한 이유는, 프로젝트의 수문장 역할을 하는 filter에서 요청들을 거르면 비즈니스 로직이나 주변부 코드를 거치지 않기 때문입니다.
@Slf4j
@Component
public class RateLimitFilter implements Filter {
private final ProxyManager<String> proxyManager;
private final Map<String, Bucket> bucketPool = new ConcurrentHashMap<>();
@Autowired
public RateLimitFilter(ProxyManager<String> proxyManager) {
//Redis 저장된 Bucket을 생성/조회/복구 관리
this.proxyManager = proxyManager;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String address = httpServletRequest.getRemoteAddr();
//bucket 설정
Supplier<BucketConfiguration> bucketConfiguration = getBucketConfiguration();
//요청마다 Bucket 생성 O
//Bucket bucket = proxyManager.builder().withRecoveryStrategy(RecoveryStrategy.RECONSTRUCT).build(address, bucketConfiguration);
//요청마다 Bucket 생성 X
Bucket bucket = bucketPool.computeIfAbsent(address,
//1) redis : proxyManager 활용 - RecoveryStrategy.RECONSTRUCT : Redis에서 기존 bucket 정보 불러옴
k -> proxyManager.builder().withRecoveryStrategy(RecoveryStrategy.RECONSTRUCT).build(address, bucketConfiguration));
//2) jvm : 메모리 활용
//k -> Bucket.builder().addLimit(getBandwidthIntervally()).build());
//경쟁 조건(race condition)과 동기화 문제 해결
ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(1);
if (consumptionProbe.isConsumed()) {
filterChain.doFilter(servletRequest, servletResponse);
} else {
HttpServletResponse httpServletResponse = makeRateLimitResponse(servletResponse, consumptionProbe);
}
}
//Bucket 설정 : 토큰 개수, Refill 전략
public Supplier<BucketConfiguration> getBucketConfiguration() {
return () -> BucketConfiguration.builder().addLimit(getBandwidthIntervally()).build();
}
//Bucket 내 token 활용 - interval 전략
public Bandwidth getBandwidthIntervally() {
//return Bandwidth.builder().capacity(50).refillIntervally(1, Duration.ofSeconds(10L)).build();
return Bandwidth.builder().capacity(50).refillIntervally(1, Duration.ofSeconds(60L)).build();
}
//bucket 내 token 활용 - greedy 전략
public Bandwidth getBandwidthGreedy() {
return Bandwidth.builder().capacity(50).refillGreedy(1, Duration.ofSeconds(10L)).build();
}
//token 이 없다면 : 429 응답
private HttpServletResponse makeRateLimitResponse(ServletResponse servletResponse, ConsumptionProbe probe) throws IOException {
HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
httpResponse.setContentType("text/plain");
//1) http 헤더에 Rate-limit 정책 알림
httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill()));
//2) http 헤더에 429 추가
httpResponse.setStatus(429);
httpResponse.getWriter().append("Too many requests");
return httpResponse;
}
}
FilterConfig : Filter 를 Bean으로 등록
@Configuration
public class FilterConfig {
private final RateLimitFilter rateLimitFilter;
private static final String[] PATHS = { "/rate/test", "/rate/test2", "/rate/test3" };
public FilterConfig(RateLimitFilter rateLimitFilter) {
this.rateLimitFilter = rateLimitFilter;
}
@Bean
public FilterRegistrationBean<RateLimitFilter> filterBean() {
FilterRegistrationBean<RateLimitFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(rateLimitFilter); //필터 설정
registrationBean.setOrder(1); //필터 순위
registrationBean.addUrlPatterns("/*"); //전체 API 엔드포인트 호출 허용
//registrationBean.setUrlPatterns(Arrays.asList(PATHS)); //특정 API 엔드포인트 호출 허용
return registrationBean;
}
}
여러 조건을 filter에 설정할 수 있는데, ip 기반으로 설정하였습니다. 특정 ip에서 과도한 트래픽을 호출할 것을 가정하였습니다.
RedisConfig : Redis 연결 및 관리 위해 Bean으로 등록
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
//redis 클라이언트 설정
//@Bean(destroyMethod = "shutdown")
@Bean
public RedisClient redisClient() {
return RedisClient.create(
RedisURI.builder()
.withHost(host)
.withPort(port)
.build());
}
//Bucket4j가 Redis 같은 외부 저장소를 사용하여 Rate Limiting 데이터를 관리할 수 있도록 도와주는 인터페이스
@Bean
public ProxyManager<String> proxyManager(RedisClient redisClient) {
//redis 연결 세션
StatefulRedisConnection<String, byte[]> connection =
redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
//bucket 관리
return LettuceBasedProxyManager
.builderFor(connection)
//.withExpirationStrategy(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(1L)))
.withExpirationStrategy(ExpirationAfterWriteStrategy.fixedTimeToLive(Duration.ofSeconds(60L))) //TTL 무제한 증가 방지
.build();
}
}
Test-Controller
@Slf4j
@RestController
@Tag(name = "Rate Limiter - Bucket4J + Redis API", description = "Bucket4J + Redis Test API")
@RequestMapping("/rate")
public class RateController {
@Operation(summary = "test1")
@GetMapping("/test")
public ResponseEntity<?> getTestRate() {
/* 응답 interval 두기 */
log.info("==== test_1 API 호출");
return ResponseEntity.ok("test response ok");
}
😊Test
테스트를 위하여 postman의 Runner를 이용하기로 했습니다.

제가 filter와 controller 등에 로그를 따로 추가해뒀었고, local에서 test하며 확인할 수 있었던 log입니다.

보시는 것처럼 token이 다 소진된 이후에는 요청이 거절됩니다.

이후 확인할 수 있는 것처럼, 429 응답을 받게 됩니다. 추가로 응답 헤더에는 X-Rate-Limit-Retry-After-Seconds가 등장함을 확인할 수 있었습니다.


Redis에서도 다음과 같은 내용을 확인할 수 있었는데요
실행 중일 때는 key(ip)를 확인할 수 있었고, value도 확인 가능했습니다. 추가로 ttl 설정도 확인했는데요!

이후 특정 시간이 지난 경우에는 삭제가 되었음을 확인하였습니다.

References
1. 가상 면접 사례로 배우는 대규모 시스템 설계 기초, 알렉스 쉬, 인사이트, p.51~p.75
2. API Gateway의 처리량 향상을 위해 Rest API에 대한 요청을 제한할 수 있습니다.
4. Bucket4J
5. 처리율 제한장치 적용
6. Bucket4J 기본 개념 (Spring boot Rate Limiter)
'Backend > Spring' 카테고리의 다른 글
| [Error/Spring/SSL] PKIX path building failed (0) | 2025.05.31 |
|---|---|
| [Spring/AI] Spring AI를 활용하여 가볍게 영작하기 (0) | 2025.03.24 |
| [Spring/PB] Google Protocol Buffers (Protobuf) (0) | 2025.03.16 |
| [Spring Cloud] Spring Cloud Data Flow (local_ver) (0) | 2025.02.17 |
| [Spring] JSON : Jackson, Gson (0) | 2025.01.07 |