
외부 API를 호출하는 배치 작업에서 외부 요청에 대한 Outbound Rate Limiting이 필요하여 Bucket4j를 활용하였던 사례를 정리해본다.
Rate Limiting은 일정 시간 허용되는 요청 수를 제한하는 것을 의미한다.
외부에 공개된 오픈 API의 경우 의도적인 트래픽 공격 등을 막기 위해서 시간 당 요청 수를 제한하고 있는 경우가 많은데, 이를 무시하고 요청을 지속적으로 보냈다가는 IP가 차단당하거나 일정 시간 동안 요청을 보내지 못하도록 제한이 걸릴 수 있으니 주의해야 한다.
필자의 경우에도 다양한 외부 API와 연동을 하는 배치에서 API마다 시간 당 요청 수가 제한되어 있었으며, 그 제한도 설정이 다 다른 상황이라 반복 작업을 할 때 이 제한이 걸리지 않도록 조정을 할 필요가 있어서 Rate Limiting과 관련된 라이브러리를 찾아보게 되었다.
Rate Limiting과 관련된 대표적인 라이브러리로는 Bucket4j, Resilience4j, Guava 등이 있는데, 필자는 다음과 같은 이유로 Bucket4j를 채택하였다.
1. 매우 세밀한 설정이 가능하다
토큰 기반 단순 / 경량화된 사용에 적합한 Guava는 그만큼 세밀한 설정이 불가능하고 Resilence4j는 Guava보다는 더 많은 기능을 제공하기는 하나 역시 Bucket4j에 비해서는 제한이 있다. Guava와 Resilience4j는 리필 전략이 제한적이지만 Bucket4j는 Greedy, Interval과 같이 다양한 리필 전략을 제공하고 있는 것을 예시로 들 수 있다.
필자가 Rate Limiting을 도입해야 하는 배치의 경우 다양한 종류의 외부 API와 연동이 되어 있었고 그만큼 외부 API마다 다른 설정 및 정책을 가져가야 했는데 유연하게 설정할 수 있는 Bucket4j를 사용하는 것이 바람직했다.
2. 분산 처리를 지원한다
Bucket4j의 경우 Redis, Hazelcast와 연계하여 분산 처리가 가능한 점도 매리트가 있었다. 배치의 경우 현재는 단일 배치로 돌리고 있지만 추후 작업량이 많아지면 분산 처리를 해야 할 가능성이 있는데, 이에 대해서도 Bucket4j로는 대응이 가능하다는 점이 인상 깊었다.
Bucket4j는 토큰 버킷 알고리즘 기반 Rate Limiting을 제공하는 라이브러리이다.
토큰 버킷 알고리즘의 주요 개념은 다음과 같다.

Bucket4j에서는 Bucket의 사이즈, 리필되는 토큰 수, 리필 주기, 리필 전략 등을 세부적으로 설정할 수 있으며 외부에서 오는 요청에 대한 Inbound Rate Limiting은 물론 외부로 보내는 요청에 대한 Outbound Rate Limiting도 가능하다.
build.gradle 또는 pom.xml에 다음 의존성을 추가한다.
implementation 'com.bucket4j:bucket4j-core:8.10.1'
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
Rate Limiting에 대한 설정을 할 수 있는 Bucket을 빈으로 등록한다.
이 때, 여러 개의 Bucket을 등록해야 한다면 다음과 같이 빈 이름을 지정한다.
@Configuration
public class BucketConfiguration {
private static final int A_CAPACITRY = 9;
private static final long A_SECONDS = 1L;
private static final long A_TOKENS = 9L;
private static final int B_CAPACITRY = 49;
private static final long B_SECONDS = 10L;
private static final long B_TOKENS = 49L;
private static final int C_CAPACITRY = 199;
private static final long C_SECONDS = 60L;
private static final long C_TOKENS = 199L;
@Bean(name = "bucketA")
public Bucket bucketA() {
return Bucket.builder()
.addLimit(limit ->
// 버킷의 사이즈는 9, Greedy 전략으로 1초마다 토큰 9개를 생성
limit.capacity(A_CAPACITY).refillGreedy(A_TOKENS, Duration.ofSeconds(A_SECOND)))
.build();
}
@Bean(name = "bucketB")
public Bucket bucketB() {
return Bucket.builder()
.addLimit(limit ->
// 버킷의 사이즈는 49, Greedy 전략으로 10초마다 토큰 49개를 생성
limit.capacity(B_CAPACITY).refillGreedy(B_TOKENS, Duration.ofSeconds(B_SECOND)))
.build();
}
@Bean(name = "bucketC")
public Bucket bucketC() {
return Bucket.builder()
.addLimit(limit ->
// 버킷의 사이즈는 199, Greedy 전략으로 60초마다 토큰 199개를 생성
limit.capacity(C_CAPACITY).refillGreedy(C_TOKENS, Duration.ofSeconds(C_SECOND)))
.build();
}
}
실제로 외부 API를 호출해야 하는 코드 상에서 버킷을 가져온 후 asBlocking().consume() 으로 블로킹 처리 및 토큰을 소모한다.
여기서 버킷에 잔여 토큰이 없을 경우, 스레드 블로킹이 발생하여 토큰이 다시 채워질 때까지 기다린다.
버킷을 여러 개 가져와야 할 경우 JSR 어노테이션인 @Resource 를 사용하여 Configuration에서 지정해준 버킷 이름으로 가져온다.
// 예시용 컴포넌트
@Component
public class ExternalApiHandler {
@Resource(name="bucketA")
private final Bucket bucketA;
@Resource(name="bucketB")
private final Bucket bucketB;
@Resource(name="bucketC")
private final Bucket bucketC;
public void executeA() {
try {
// 블로킹 방식으로 토큰 1개 소비
bucketA.asBlocking().consume(1);
// 외부 API 호출...
} catch (InterruptedException e) {
log.warn("bucket blocking error");
Thread.currentThread().interrupt();
}
}
public void executeB() {
try {
// 블로킹 방식으로 토큰 1개 소비
bucketB.asBlocking().consume(1);
// 외부 API 호출...
} catch (InterruptedException e) {
log.warn("bucket blocking error");
Thread.currentThread().interrupt();
}
}
public void executeC() {
try {
// 블로킹 방식으로 토큰 1개 소비
bucketC.asBlocking().consume(1);
// 외부 API 호출...
} catch (InterruptedException e) {
log.warn("bucket blocking error");
Thread.currentThread().interrupt();
}
}
}
https://github.com/bucket4j/bucket4j
https://www.researchgate.net/figure/Diagram-representing-token-bucket-algorithm_fig1_351452059
https://chatgpt.com/share/681c0664-a260-8008-af8e-a6a474d4640a