왜 Bucket4j인가?
Spring Cloud Gateway의 RequestRateLimiter는 Redis 기반으로 동작하지만, 모놀리식 Spring Boot 애플리케이션에서는 과한 설정이다. Bucket4j는 Token Bucket 알고리즘을 Java로 구현한 라이브러리로, 로컬 메모리부터 Redis/Hazelcast 분산 환경까지 동일한 API로 Rate Limiting을 적용할 수 있다.
핵심 장점은 세 가지다. 첫째, JCache(JSR-107) 호환으로 Caffeine, Redis, Hazelcast 등 다양한 백엔드를 교체할 수 있다. 둘째, 다중 Bandwidth 설정으로 “초당 10회 + 분당 100회” 같은 복합 제한을 하나의 버킷으로 처리한다. 셋째, Spring Boot Starter가 있어 어노테이션 기반으로 간편하게 적용할 수 있다.
의존성 설정
// build.gradle.kts
dependencies {
// Bucket4j 코어
implementation("com.bucket4j:bucket4j-core:8.10.1")
// Spring Boot Starter (필터 자동 설정)
implementation("com.bucket4j:bucket4j-spring-boot-starter:0.12.3")
// JCache + Caffeine (로컬 캐시)
implementation("javax.cache:cache-api:1.1.1")
implementation("com.github.ben-manes.caffeine:caffeine:3.1.8")
implementation("com.github.ben-manes.caffeine:jcache:3.1.8")
// 분산 환경: Redis 사용 시
// implementation("com.bucket4j:bucket4j-redis:8.10.1")
// implementation("io.lettuce:lettuce-core:6.3.2.RELEASE")
}
Token Bucket 알고리즘 이해
Rate Limiting 알고리즘 중 Token Bucket은 가장 유연한 선택이다. 버킷에 일정 속도로 토큰이 채워지고, 요청마다 토큰을 소비한다. 토큰이 없으면 요청을 거부한다.
| 알고리즘 | 특징 | 버스트 허용 |
|---|---|---|
| Fixed Window | 시간 윈도우 단위 카운팅 | 윈도우 경계에서 2배 허용 |
| Sliding Window | 이동 윈도우 카운팅 | ❌ 균일 분산 |
| Token Bucket | 토큰 충전/소비 | ✅ 제어된 버스트 |
| Leaky Bucket | 고정 속도 유출 | ❌ 완전 균일 |
Token Bucket의 강점은 버스트를 허용하면서도 평균 속도를 제한할 수 있다는 것이다. 예를 들어 “분당 60회 제한이지만, 순간적으로 10개까지 버스트 가능”이라는 정책을 자연스럽게 표현할 수 있다.
기본 사용: 프로그래매틱 API
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
@Service
public class RateLimiterService {
// 단일 Bandwidth: 분당 60회
public Bucket createSimpleBucket() {
Bandwidth limit = Bandwidth.classic(60,
Refill.greedy(60, Duration.ofMinutes(1)));
return Bucket.builder()
.addLimit(limit)
.build();
}
// 다중 Bandwidth: 초당 10회 + 분당 100회
public Bucket createMultiBandwidthBucket() {
Bandwidth perSecond = Bandwidth.classic(10,
Refill.greedy(10, Duration.ofSeconds(1)));
Bandwidth perMinute = Bandwidth.classic(100,
Refill.greedy(100, Duration.ofMinutes(1)));
return Bucket.builder()
.addLimit(perSecond) // 순간 버스트 제한
.addLimit(perMinute) // 전체 속도 제한
.build();
}
// 사용
public boolean tryConsume(Bucket bucket) {
return bucket.tryConsume(1); // 토큰 1개 소비 시도
}
// 대기 가능한 소비 (블로킹)
public void consumeWithWait(Bucket bucket) throws InterruptedException {
bucket.asBlocking().consume(1); // 토큰 생길 때까지 대기
}
}
Refill 전략: greedy vs intervally
// greedy: 토큰을 가능한 빨리 채움 (부드러운 충전)
// 분당 60개 → 1초마다 1개씩 채움
Bandwidth greedy = Bandwidth.classic(60,
Refill.greedy(60, Duration.ofMinutes(1)));
// intervally: 주기마다 한꺼번에 채움 (계단식 충전)
// 1분마다 60개를 한번에 채움
Bandwidth intervally = Bandwidth.classic(60,
Refill.intervally(60, Duration.ofMinutes(1)));
// intervallyAligned: 정각에 맞춰 충전 (과금 주기 정렬)
// 매시 정각에 1000개 충전
Instant firstRefillTime = Instant.now()
.truncatedTo(ChronoUnit.HOURS)
.plus(1, ChronoUnit.HOURS);
Bandwidth aligned = Bandwidth.classic(1000,
Refill.intervallyAligned(1000,
Duration.ofHours(1), firstRefillTime, true));
greedy는 API Rate Limiting에, intervally는 과금 기반 할당량(월 1000회 API 호출)에 적합하다.
Spring Boot Starter로 필터 자동 적용
# application.yml
spring:
cache:
cache-names:
- rate-limit-buckets
caffeine:
spec: maximumSize=100000,expireAfterAccess=3600s
bucket4j:
enabled: true
filters:
# API 엔드포인트별 Rate Limiting
- cache-name: rate-limit-buckets
url: /api/.*
http-response-body: '{"error":"Too Many Requests","message":"요청 한도를 초과했습니다"}'
rate-limits:
- cache-key: getRemoteAddr() # IP 기반 제한
bandwidths:
- capacity: 100
time: 1
unit: minutes
refill-speed: greedy
# 로그인 엔드포인트 강화 제한 (Brute Force 방지)
- cache-name: rate-limit-buckets
url: /api/auth/login
http-response-body: '{"error":"Too Many Requests","message":"잠시 후 다시 시도해주세요"}'
rate-limits:
- cache-key: getRemoteAddr()
bandwidths:
- capacity: 5
time: 1
unit: minutes
refill-speed: intervally
사용자 등급별 Rate Limiting
@Component
public class PlanBasedRateLimiter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
public Bucket resolveBucket(String apiKey, UserPlan plan) {
return buckets.computeIfAbsent(apiKey,
key -> createBucketForPlan(plan));
}
private Bucket createBucketForPlan(UserPlan plan) {
return switch (plan) {
case FREE -> Bucket.builder()
.addLimit(Bandwidth.classic(10,
Refill.greedy(10, Duration.ofMinutes(1))))
.addLimit(Bandwidth.classic(100,
Refill.greedy(100, Duration.ofHours(1))))
.build();
case STANDARD -> Bucket.builder()
.addLimit(Bandwidth.classic(100,
Refill.greedy(100, Duration.ofMinutes(1))))
.addLimit(Bandwidth.classic(5000,
Refill.greedy(5000, Duration.ofHours(1))))
.build();
case ENTERPRISE -> Bucket.builder()
.addLimit(Bandwidth.classic(1000,
Refill.greedy(1000, Duration.ofMinutes(1))))
.build();
};
}
}
인터셉터에서 적용
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
private final PlanBasedRateLimiter rateLimiter;
private final ApiKeyService apiKeyService;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
String apiKey = request.getHeader("X-Api-Key");
if (apiKey == null) {
response.setStatus(401);
return false;
}
UserPlan plan = apiKeyService.getPlan(apiKey);
Bucket bucket = rateLimiter.resolveBucket(apiKey, plan);
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
// 남은 토큰 수를 헤더로 전달
response.setHeader("X-Rate-Limit-Remaining",
String.valueOf(probe.getRemainingTokens()));
return true;
}
// 제한 초과: 재시도 시간 안내
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000;
response.setHeader("Retry-After", String.valueOf(waitSeconds));
response.setStatus(429);
response.getWriter().write(
"{"error":"Rate limit exceeded","retryAfter":" + waitSeconds + "}");
return false;
}
}
Redis 분산 Rate Limiting
서버가 여러 대라면 로컬 메모리 버킷은 의미가 없다. Bucket4j의 Redis 프록시를 사용하면 분산 환경에서 정확한 Rate Limiting이 가능하다.
@Configuration
public class DistributedRateLimitConfig {
@Bean
public ProxyManager<String> lettuceProxyManager(
RedisConnectionFactory connectionFactory) {
StatefulRedisConnection<String, byte[]> connection =
RedisClient.create(RedisURI.create("redis://localhost:6379"))
.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));
return LettuceBasedProxyManager.builderFor(connection)
.withExpirationStrategy(
ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(
Duration.ofMinutes(10)))
.build();
}
}
@Service
public class DistributedRateLimiter {
private final ProxyManager<String> proxyManager;
public boolean tryConsume(String key, int tokens) {
BucketConfiguration config = BucketConfiguration.builder()
.addLimit(Bandwidth.classic(100,
Refill.greedy(100, Duration.ofMinutes(1))))
.build();
Bucket bucket = proxyManager.builder()
.build(key, () -> config);
return bucket.tryConsume(tokens);
}
}
AOP 기반 메서드 레벨 Rate Limiting
// 커스텀 어노테이션
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
int capacity() default 10;
int refillTokens() default 10;
int refillSeconds() default 60;
String key() default ""; // SpEL 표현식
}
// AOP Aspect
@Aspect
@Component
public class RateLimitAspect {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint pjp,
RateLimit rateLimit) throws Throwable {
String key = resolveKey(pjp, rateLimit);
Bucket bucket = buckets.computeIfAbsent(key, k ->
Bucket.builder()
.addLimit(Bandwidth.classic(rateLimit.capacity(),
Refill.greedy(rateLimit.refillTokens(),
Duration.ofSeconds(rateLimit.refillSeconds()))))
.build()
);
if (!bucket.tryConsume(1)) {
throw new RateLimitExceededException("요청 한도 초과");
}
return pjp.proceed();
}
}
// 사용 예시
@RestController
public class SmsController {
@PostMapping("/api/sms/send")
@RateLimit(capacity = 3, refillTokens = 3, refillSeconds = 60,
key = "#request.phoneNumber") // 전화번호당 분당 3회
public ResponseEntity<Void> sendSms(@RequestBody SmsRequest request) {
smsService.send(request);
return ResponseEntity.ok().build();
}
}
모니터링: Micrometer 연동
@Component
public class RateLimitMetrics {
private final MeterRegistry meterRegistry;
public void recordConsumption(String endpoint, boolean consumed) {
Counter.builder("rate_limit_requests")
.tag("endpoint", endpoint)
.tag("result", consumed ? "allowed" : "rejected")
.register(meterRegistry)
.increment();
}
// Grafana 대시보드에서:
// rate(rate_limit_requests_total{result="rejected"}[5m])
// → 분당 거부된 요청 수 모니터링
}
Bucket4j는 단순한 Rate Limiter를 넘어 정교한 트래픽 제어 도구다. 로컬 메모리로 시작해서 Redis 분산 환경으로 확장할 수 있고, 다중 Bandwidth로 복합 정책을 표현할 수 있다. API 플랫폼을 운영한다면, 사용자 등급별 Rate Limiting과 Retry-After 헤더 제공은 필수 요구사항이다.
관련 글: NestJS Throttler Rate Limiting | Spring Cloud Gateway 심화