이론 편에서는 Rate Limiter의 다섯 가지 알고리즘을 공부했었다. 이번 글에서는 SpringBoot에서 Rate Limiter를 지원하는 라이브러리를 비교해 보고, 한 가지를 사용해서 샘플 코드를 작성해 보겠다.
Rate Limiting 라이브러리
Spring에서 처리율 제한 기능을 지원하는 라이브러리들의 장단점을 살펴보고, 내 서비스에 어울리는 라이브러리를 골라보자
- Guava - Rate Limiter
- 구글이 개발한 오픈소스 라이브러리로, Token Bucket 알고리즘을 기반으로 동작
- 장점
- 사용이 쉽고 직관적이며 안정적임
- 단점
- 동시성 제어가 약하고 분산 시스템에 부적합
- 기본적인 rate limiting 기능만을 제공함
- RateLimitJ
- Sliding Window 알고리즘 기반
- github readme를 보면, 더 이상 지원하지 않으므로 대신해서 Bucket4j를 사용하라고 명시되어 있음
- Bucket4j
- Token Bucket 알고리즘 기반
- 장점
- 멀티스레딩 환경에서 확장성이 우수하고 높은 동시성을 지원
- Rate Limiting만을 위해 개발되었기에 다른 라이브러리에 비해 가벼움
- 다양한 refill 전략을 제공
- 로컬메모리 외에도 JDBC, Redis등과 같은 분산 환경의 DB도 지원
- 단점
- api와 구성이 복잡할 수 있음
- Resilience4j
- 함수형 프로그래밍을 위해 설계된 라이브러리
- 장점
- Circuit Breaker, Retry, Bulkhead와 같은 다양한 기능을 제공하여 msa 환경에서 다양한 회복 패턴을 설정할 수 있음
- 또한 요청이 임계치를 넘겼을 때 단순히 거부하는 기능뿐만 아니라, 나중에 실행하기 위해 대기열에 저장하는 두 가지 접근방식을 제공함
- 단점
- Resilience4j 2는 java 17 이상부터 사용 가능
- Rate Limiting만을 위한 라이브러리가 아니기에 단순 Rate Limiting만을 적용하려면 오버헤드가 될 수 있음
내가 개발 중인 서비스는 'Java17, SpringBoot3, 모놀로식 아키텍처, 낮은 트래픽'이라는 특징을 가지고 있다.
더 이상 지원을 하지 않는 RateLimitJ를 제외하고 라이브러리들을 비교해 봤을 때, 다양한 Rate Limiting 기능을 제공하고 모놀로식 아키텍처에서 가볍게 사용할 수 있으며 여러 DB와의 연동도 지원하는 Bucket4j를 사용하기로 결심했다.
Resilience4j의 대기열 기능이 탐나기는 했지만.. 모놀로식 아키텍처에서 사용하기에는 너무 헤비하기에 나중에 MSA 프로젝트를 하게 된다면 적용해 보기로 하자.
Bucket4j
Bucket4j에서는 4개의 Refill 전략이 있지만, 크게 Greedy와 Intervally로 분류할 수 있다.
- Greedy Refill
- 가능한 한 빨리 토큰을 refill 하는 방식
- 전체 duration과 refill 할 토큰 수를 정하면, duration/refill 시간 동안 하나씩 토큰이 생성된다
- 따라서 전체 duration동안 설정한 토큰이 하나씩 refill 된다
- Intervally Refill
- 설정한 시간 간격이 끝날 때까지 기다린 후, 한 번에 전체 토큰을 refill 하는 방식
- duration이 지나면, 설정한 개수만큼 토큰이 한 번에 refill 된다
다른 두 방식은 refill이 처음 시작되는 시간을 자세하게 설정할 수 있는 방식인 IntervallyAligned와, 첫 refill 시점에서 토큰의 초기 수량을 동적으로 조절할 수 있는 RefillIntervallyAlignedWithAdaptiveInitialTokens 방식이다. 두 방식 모두 Intervally 방식이라 생각하면 된다.
구현 방식도 Controller에서 bucket을 관리하는 방법, filter를 구현하는 방법, interceptor를 사용하는 방법 등 여러 가지가 존재하지만 이번 글에서는 Bucket4j Reference에 있는 예시처럼 filter를 사용해서 두 가지 전략을 구현해 보겠다.
Sample Code
1. Dependencies
가장 최신 버전은 8.13.1 이지만, 의존성 추가를 해보면 찾을 수 없다기에 8.10.1 버전을 사용하였다.
2. Controller
컨트롤러에서는 Greedy 방식을 사용할 api, Interval 방식을 사용할 api, 그리고 rate limiting을 적용하지 않은 api를 정의했다.
3. Throttling Filter
먼저 코드에서는 Filter를 implements 해서 구현했는데, 하나의 요청에 대해 filter가 중복 실행되는 것을 방지하기 위해 OncePerRequestFilter를 extends 해서 사용하는 것을 권장한다.
filter는 크게 doFilter, bucket 생성, api 토큰 사용 부분으로 구성되어 있다.
doFilter
doFilter 메서드에서는 request의 uri를 확인하여 rate limit이 걸려있는 api인지 확인하고, 토큰을 사용하는 단순한 로직이 구현되어 있다
bucket 생성
bucket 생성은 Greedy bucket 생성과 Interval bucket 생성으로 분리했다. api에서 사용하는 bucket 종류에 따라서 다른 메서드를 호출하게 된다
api 토큰 사용
api 토큰 사용 메서드에서는 bucket에서 하나의 토큰을 consume 하고 ConsumptionProbe 객체를 받아온다.
이때 tryConsumeAndReturnRemaining()을 보면, 인자로 받아온 tokensToConsume이 0 이하라면 예외처리를 하는 checkTokensToConsume()을 통해 유효성 검증을 진행한다.
이후 tryConsumeAndReturnRemainingTokensImpl()을 통해 ConsumtionProbe를 가져오고, 토큰이 정상적으로 consume 되었다면 true인 consumed 필드를 확인해서 그 결과에 따라 다음 filter로 넘어가든지 예외처리를 진행한다.
만약 probe 객체의 consumed == true라면 다음 filter로 넘어가고 bucket에서 현재 남은 토큰 수를 return 해준다.
그렇지 않다면 예외처리를 하고 클라이언트에 probe 객채에서 refill까지 남은 시간을 가져와서 넘겨준다.
Rate Limiting 결과
Greedy Bucket과 Inteval Bucket 모두 3 개의 토큰 용량을 가지고 있고, 9초 동안 3개의 토큰이 refill 되도록 설정하였다
1. Greedy
토큰이 남아있는 경우, 응답이 정상적으로 반환되고 로그에는 현재 남은 토큰 수가 출력된다.
토큰이 없는 경우, 응답과 서버 로그를 보면 알 수 있듯이 9초 이후에 3개가 한 번에 채워지는 것이 아니라, 3초마다 1개씩 생성되는 것을 알 수 있다.
2. Interval
마찬가지로 토큰이 남아있는 경우, 응답이 정상적으로 반환되고 로그에 현재 남아있는 토큰 수가 출력된다.
하지만 Greedy와 다르게 9초마다 토큰이 refill 되고, 토큰이 0개 -> 3개로 한 번에 refill 되는 것을 알 수 있다
다음에는 실제 서비스에서, 하루에 n번의 제한이 걸려있는 api에 redis를 사용해서 rate limiting을 구현해 보겠다.
Reference
- RateLimiting 라이브러리 비교: https://medium.com/@jaewook.lee1618/spring-boot-rate-limiting-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%B9%84%EA%B5%90-829562a9405a
- Bucket4j로 트래픽 제한하기(Redis & MariaDB): https://dkswnkk.tistory.com/732
- Bucket4j 8.10.1 Reference: https://bucket4j.com/8.10.1/toc.html#returning-tokens-back-to-bucket
- ratelimitj github: https://github.com/mokies/ratelimitj
- resilience4j github: https://github.com/resilience4j/resilience4j