[Spring/Architecture] Rate Limiter (처리율 제한 장치) - too many requests

2025. 3. 23. 11:31Backend/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에 대한 요청을 제한할 수 있습니다.

3. Bucket4J를 사용하해 유량제어 하기

4. Bucket4J

5. 처리율 제한장치 적용

6. Bucket4J 기본 개념 (Spring boot Rate Limiter)

7. [Spring] Bucket4J를 이용하여 트래픽 제어하기

8. [서버] 사용자 ID로 처리율 제한하기 - Bucket4J, Spring Boot