SpringBoot에서 유량제어하기(Bucket4j를 활용한 레이트 리밋 구현하기)
롤 해 듀오 프로젝트에서는 라이엇 API와 통신해야 했는데, 라이엇은 API 남용을 방지하고 모든 개발자에게 공정한 사용을 보장하기 위해 엄격한 사용량 제한을 적용하고 있습니다.
외부 API와 통합할 때 특히 엄격한 사용량 제한이 있는 경우, 애플리케이션에서 적절한 레이트 리밋(Rate Limit) 구현은 필수적입니다.
이 블로그 포스트에서는 Bucket4j를 사용한 레이트 리밋 구현 과정을 공유하고, Resilience4j와 같은 대안 대신 Bucket4j를 선택한 이유, 그리고 단순한 접근 방식에서 Redis를 활용한 더 정교한 큐 기반 시스템으로 발전한 단계별 구현 과정을 상세히 설명하겠습니다.
레이트 리밋 과제
라이엇 API는 다음과 같은 여러 사용량 제한을 적용합니다:
- 초당 20개 요청
- 2분당 100개 요청
이를 초과하면 429 (Too Many Requests) 에러가 발생하며, 심한 경우 일시적으로 API 접근이 차단될 수 있습니다.
따라서 애플리케이션 레벨에서 이를 제어하는 것이 필수적이었습니다.
- 라이엇의 사용량 제한을 초과하지 않기
- 사용량 제한 오류를 적절하게 처리하기
- 애플리케이션 성장에 따라 확장 가능하기
적절한 레이트 리밋 라이브러리 선택하기
여러 레이트 리밋 라이브라리 중에서 Bucket4j와 Resilience4j 중 고민을 하였습니다.
Resilience4j
- 장점: 서킷 브레이커, 재시도, 타임아웃 등 다양한 장애 회복 패턴 제공
- 단점: 필요한 것은 레이트 리밋인데 너무 무거움
Guava RateLimiter
- 장점: 구글이 만든 안정적인 라이브러리
- 단점: 분산 환경 지원이 약함
Bucket4j
- 장점:
- 토큰 버킷 알고리즘에 특화된 경량 라이브러리
- 직관적이고 간단한 API
- 분산 환경 지원 (Redis, Hazelcast, Ignite 등)
- Spring Boot 친화적
- 단점: 레이트 리밋만 제공 (오히려 장점)
프로젝트에서는 레이트 리밋에만 집중된 솔루션이 필요했고, 다른 장애 회복성 패턴은 필요하지 않았기 때문에 토큰 버킷 알고리즘에 특화된 Bucket4j를 선택했습니다. 또한 추후 Redis를 활용한 분산 환경으로 확장할 계획이었기 때문에 Bucket4j의 분산 시스템 지원도 큰 장점이었습니다.
Bucket4j 기본 개념
Bucket4j는 토큰 버킷(Token Bucket) 알고리즘을 구현합니다:
- 버킷에는 정해진 수의 토큰이 들어있음
- API 호출 시 토큰을 소비
- 시간이 지나면 토큰이 다시 채워짐
- 토큰이 없으면 요청을 거부하거나 대기
구현 과정
1단계 : 의존성 추가
의존성 주입
implementation 'com.bucket4j:bucket4j-core:8.10.1'
2단계 : Bucket4j 설정하기
먼저 토큰 버킷 알고리즘을 구현하기 위해 Bucket4j 설정부터 시작했습니다. 라이엇 API의 제한 사항에 맞게 버킷을 구성했습니다.
Config 설정
@Configuration
public class RateLimitConfig {
@Bean
public Bucket riotApiBucket() {
return Bucket.builder()
.addLimit(
Bandwidth.builder()
.capacity(100)
.refillGreedy(100, Duration.ofMinutes(2)) // 2분에 100개
.build()
)
.build();
}
}
- capacity(100): 버킷이 담을 수 있는 최대 토큰 수
- refillGreedy(100, Duration.ofMinutes(2)): 2분마다 100개의 토큰을 한 번에 채움
- 초당 제한도 추가할 수 있지만, 2분 제한만으로도 충분하다고 판단
3단계 : RateLimiterManager 만들기
다음으로, 레이트 리밋 로직을 추상화하고 애플리케이션 전체에서 일관되게 사용할 수 있는 매니저 클래스를 구현했습니다.
@Component
@RequiredArgsConstructor
public class RateLimiterManager {
private final Bucket riotApiBucket;
public boolean tryConsume() {
return riotApiBucket.tryConsume(1);
}
public ConsumptionProbe probe() {
return riotApiBucket.tryConsumeAndReturnRemaining(1);
}
}
tryConsume() 메서드는 단순히 토큰을 소비할 수 있는지 확인 가능
ConsumptionProbe는 다음과 같은 유용한 정보를 제공합니다:
- 토큰 소비 성공 여부
- 남은 토큰 수
- 다음 토큰이 사용 가능해질 때까지 대기 시간
4단계 : 서비스 레이어에서 활용
RateLimiterManager의 probe 메서드를 호출하여 ConsumptionProbe 객체를 활용해 토큰이 있는지 확인하고 있으면 API 요청을 처리하고, 없을 경우 대기시간을 가져와서 그 이후 처리하는 것으로 활용
// 레이트 리미터 확인
ConsumptionProbe probe = rateLimiterManager.probe();
if (probe.isConsumed()) {
// 토큰이 있으면 요청 처리
isRateLimited = false;
String serializedRequest = queue.poll();
if (serializedRequest != null) {
processRequest(serializedRequest);
}
} else {
// 레이트 리밋에 걸렸을 때 잠시 대기
long waitTimeMs = probe.getNanosToWaitForRefill() / 1_000_000;
5단계 : 레이트 리밋 예외 처리하기
레이트 리밋에 도달했을 때 적절한 예외를 발생시키고 처리하는 메커니즘을 구현했습니다.
public class InternalRateLimitException extends RuntimeException {
public InternalRateLimitException(String message) {
super(message);
}
}
그리고 이 예외를 처리하기 위한 전역 예외 핸들러를 구현했습니다:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InternalRateLimitException.class)
public ResponseEntity<Map<String, Object>> handleInternalRateLimit(InternalRateLimitException ex) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body(Map.of(
"message", ex.getMessage(),
"code", "INTERNAL_RATE_LIMIT",
"timestamp", Instant.now().toString()
));
}
}
Bucket4j의 Rate Limiter를 적용하면서 배운점
1. Riot API의 정책에 맞게 적절한 용량을 선택해야한다.
2. 리필 전략 선택:
- refillGreedy: 한 번에 모든 토큰 리필
- refillIntervally: 주기적으로 일정량씩 리필
라이엇 API는 2분에 100개라서 Greedy 방식을 선택
결론
Bucket4j를 사용하여 라이엇 API의 레이트 리밋을 효과적으로 관리할 수 있었습니다.
토큰 버킷 알고리즘의 직관적인 개념과 Bucket4j의 간단한 API 덕분에 구현이 쉬웠고, 안정적인 API를 구축할 수 있었습니다.
레이트 리밋은 단순해 보이지만, 외부 API와의 안정적인 통합을 위해서는 필수적인 요소입니다.
하지만! Bucket4j의 레이트 리밋을 적용했다고 안전한 것이 아니라 방지를 해주는 역할만 해주는 것이고 외부 API 호출이 누락되지 않기 위해서 큐 기반으로 호출하는 과정을 적용했습니다.
다음 포스트에서는 이 레이트 리밋 시스템을 Redis 기반 큐 시스템과 통합하여 더욱 정교한 API 호출 관리 시스템을 구축한 과정을 공유하겠습니다.