Bucket4j와 Redis를 연동한 분산 환경의 고성능 트래픽 제어 시스템 구축

마이크로서비스 아키텍처(MSA)와 같이 여러 대의 서버로 구성된 분산 환경에서는 개별 서버 단위의 로컬 제한만으로는 전체 시스템의 부하를 정밀하게 관리하기 어렵습니다. 모든 인스턴스가 동일한 상태를 공유하며 일관된 제한 정책을 적용하기 위해 분산 트래픽 제한(Distributed Rate Limiting)이 필요합니다. 이를 위해 Java 진영의 대표적인 토큰 버킷 알고리즘 라이브러리인 Bucket4j와 고성능 인메모리 저장소인 Redis를 조합하는 최적의 방법을 살펴봅니다.

Bucket4j의 Redis 클라이언트 지원 체계

Bucket4j는 다양한 Redis 클라이언트를 통해 데이터 정합성을 보장하며, 동시성 문제를 해결하기 위해 Compare-and-Swap(CAS) 메커니즘을 사용합니다. 현재 지원되는 주요 라이브러리는 다음과 같습니다.

Redis 클라이언트 비동기 지원 클러스터 지원
Jedis 지원 안 함 지원
Lettuce 지원 지원
Redisson 지원 지원
Vert.x Redis Client 지원 지원

Jedis를 활용한 분산 버킷 구현

가장 널리 사용되는 동기식 클라이언트인 Jedis를 사용하여 기본적인 분산 제한 환경을 구축하는 방법입니다.

// Redis 연결 설정
JedisPool redisPool = new JedisPool("127.0.0.1", 6379);

// Redis 기반 ProxyManager 생성
JedisBasedProxyManager<String> proxyManager = Bucket4jJedis.casBasedBuilder(redisPool)
    .expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofMinutes(5)))
    .keyMapper(Mapper.STRING)
    .build();

// 트래픽 정책 정의: 30초당 500개 토큰 생성
BucketConfiguration rateLimitPolicy = BucketConfiguration.builder()
    .addLimit(limit -> limit.capacity(500).refillGreedy(500, Duration.ofSeconds(30)))
    .build();

// 특정 식별자(예: 서비스명)로 버킷 획득
Bucket serviceBucket = proxyManager.getProxy("payment-service", () -> rateLimitPolicy);

// 요청 처리 시 토큰 소비
if (serviceBucket.tryConsume(1)) {
    executeBusinessLogic();
} else {
    handleRateLimitExceeded();
}

Redisson 기반의 비동기 트래픽 제어

높은 동시성이 요구되는 환경에서는 Redisson의 비동기 기능을 활용하여 I/O 블로킹을 최소화할 수 있습니다.

// Redisson 클라이언트 초기화
Config redisConfig = new Config();
redisConfig.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(redisConfig);

// 비동기 처리를 위한 ProxyManager 구성
RedissonBasedProxyManager<String> asyncManager = 
    Bucket4jRedisson.casBasedBuilder(redissonClient.getCommandExecutor())
        .withClientSideConfig(ClientSideConfig.getDefault()
            .withExpirationAfterWriteStrategy(
                ExpirationAfterWriteStrategy.fixedTimeToLive(Duration.ofMinutes(10))))
        .build();

핵심 설정 및 최적화 기법

1. 만료 전략(Expiration Strategy)

Redis 내의 데이터가 무한히 쌓이는 것을 방지하기 위해 TTL(Time-To-Live) 설정이 중요합니다.

  • basedOnTimeForRefillingBucketUpToMax: 버킷이 완전히 리필되는 시점을 계산하여 자동으로 만료 시간을 설정하는 권장 방식입니다.
  • fixedTimeToLive: 지정된 절대 시간 이후에 데이터를 삭제합니다.
  • none: 만료를 설정하지 않고 데이터를 영구 보관합니다.

2. 성능 향상을 위한 클라이언트 측 최적화

네트워크 왕복 시간을 줄이기 위해 ClientSideConfig를 통해 예측 기반의 최적화나 배칭(Batching)을 적용할 수 있습니다.

ClientSideConfig optimizationConfig = ClientSideConfig.getDefault()
    .withOptimization(Optimizations.PREDICTIVE) // 예측 최적화 활성화
    .withOptimization(Optimizations.batching() // 일괄 처리 설정
        .withBatchSize(50)
        .withMaxDelay(Duration.ofMillis(20)));

JedisBasedProxyManager<String> optimizedManager = Bucket4jJedis.casBasedBuilder(redisPool)
    .withClientSideConfig(optimizationConfig)
    .build();

실전 응용 시나리오

API 게이트웨이 레벨의 사용자별 제한

API 키를 기반으로 사용자마다 독립적인 트래픽 할당량을 부여하는 예시입니다.

public void handleRequest(String apiKey) {
    // 사용자별 고유 키 생성
    String bucketKey = "quota:" + apiKey;
    
    // 분산 환경에서 버킷 가져오기
    Bucket userBucket = proxyManager.getProxy(bucketKey, () -> 
        BucketConfiguration.builder()
            .addLimit(limit -> limit.capacity(100).refillIntervally(100, Duration.ofHours(1)))
            .build());

    if (userBucket.tryConsume(1)) {
        // 정상 처리
    } else {
        // 429 Too Many Requests 응답
    }
}

모니터링 및 상태 확인

Bucket4j는 단순히 토큰을 소비하는 것뿐만 아니라, 현재 남은 토큰 수나 리필까지 남은 시간 등 상세한 진단 정보를 제공합니다.

VerboseResult<ConsumptionProbe> status = bucket.asVerbose().tryConsumeAndReturnRemaining(1);

if (status.isConsumed()) {
    long remaining = status.getValue().getRemainingTokens();
    System.out.println("현재 사용 가능한 토큰: " + remaining);
} else {
    long waitNanos = status.getValue().getNanosToWaitForRefill();
    System.out.println("대기 필요 시간(ns): " + waitNanos);
}

Redis와 Bucket4j의 결합은 복잡한 설정 없이도 강력한 분산 제어 계층을 제공합니다. 특히 CAS 기반의 원자적 연산을 사용하기 때문에 데이터 충돌 걱정 없이 안정적으로 대규모 트래픽을 관리할 수 있다는 것이 큰 장점입니다.

태그: Bucket4j Redis RateLimiting java jedis

9월 11일 19:40에 게시됨