Spring Bucket4j Rate Limiting

왜 Bucket4j인가?

Spring Cloud Gateway의 RequestRateLimiter는 Redis 기반으로 동작하지만, 모놀리식 Spring Boot 애플리케이션에서는 과한 설정이다. Bucket4jToken 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 심화

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux