콘텐츠로 이동

Bucket4j 요청 제한: 버킷보다 먼저 호출자 식별 기준을 정하라

API Gateway 요청 제한 작업대에 허용 요청, 429 응답, Redis, Caffeine 캐시가 배치된 3D 일러스트
요청 제한기는 토큰 버킷을 어디에 저장할지보다 누구의 버킷에서 토큰을 차감할지 먼저 정해야 합니다.

공개 API에 요청 제한기를 붙일 때는 토큰 버킷부터 떠올리기 쉽습니다. 그러나 운영 환경에서 먼저 어긋나는 지점은 버킷 자료구조가 아니라 호출자 식별 기준입니다. remoteAddress를 그대로 쓰면 프록시 뒤의 모든 사용자가 한 사람처럼 보일 수 있고, X-Forwarded-For를 무조건 신뢰하면 공격자가 헤더를 바꿔 할당량을 우회할 수 있습니다.

bluetape4k-workshop/ratelimit에는 이 문제를 단계별로 볼 수 있는 네 가지 예제가 있습니다. 한 글에 다 설명하려면 각 예제의 역할을 먼저 나눠야 합니다.

예제이 글에서의 역할핵심 포인트
bucket4j-caffeine-web단일 JVM 기준 예제Caffeine JCache에 버킷을 두고 WebMVC 엔드포인트를 제한한다
bucket4j-redis분산 버킷 기준 예제Redis/Lettuce에 버킷 상태를 두고 WebFlux 엔드포인트를 제한한다
bucker4j-bluetape4k-webfluxbluetape4k 제한기 API 예제DistributedRateLimiter로 사용자 토큰 기반 버킷을 적용한다
bucket4j-advanced본문 중심 예제IP·사용자·복합 키를 분리하고 프록시 신뢰와 응답 헤더를 다룬다

그래서 이 글의 중심은 bucket4j-advanced입니다. Caffeine과 Redis는 저장소 선택을 보여주고, bluetape4k WebFlux 예제는 기본 API 사용법을 보여줍니다. 하지만 실제 운영에서 자주 터지는 문제는 “저장소가 Redis냐 Caffeine이냐”보다 “같은 버킷으로 묶으면 안 되는 요청을 묶었는가”에 가깝습니다.

클라이언트, 프록시, 호출자 식별 전략, WebFilter, Redis 또는 Caffeine 버킷 저장소, 핸들러와 429 응답 경로를 보여주는 Bucket4j 요청 제한 구조도
IP·사용자·복합 키는 서로 다른 호출자 식별 계약입니다. Redis와 Caffeine은 이 계약에 따른 토큰 상태를 저장합니다.

요청 제한을 단순히 “요청 수를 세는 기능”으로 보면 서로 다른 설계 책임이 한데 섞입니다. 실제로는 세 가지 질문을 따로 답해야 합니다.

질문잘못 답했을 때 생기는 문제
어떤 식별자로 버킷을 나눌 것인가?NAT 뒤의 정상 사용자까지 함께 차단하거나 공격자가 헤더를 바꿔 우회한다
버킷 상태를 어디에 둘 것인가?인스턴스가 여러 대일 때 할당량이 인스턴스마다 따로 계산된다
제한 결과를 클라이언트에 어떻게 알릴 것인가?클라이언트가 재시도 시점을 알 수 없어 요청을 더 증폭시킨다

그림을 코드로 풀면 두 단계입니다. 먼저 Bucket4j 설정에서 “어떤 버킷 정책을 어떤 제한기 빈에 묶을지”를 정합니다. 그다음 WebFilter가 요청에서 호출자 식별자를 추출해 버킷 키를 만들고, 그 키로 토큰 하나를 소비할 수 있는지 확인합니다.

Bucket4j 설정은 버킷 정책과 제한기 빈을 분리한다

섹션 제목: “Bucket4j 설정은 버킷 정책과 제한기 빈을 분리한다”

bucket4j-advancedRateLimitConfig는 Redis 프록시 관리자를 하나 만들고, IP·사용자·복합 전략별로 BucketConfiguration, AsyncBucketProxyProvider, DistributedSuspendRateLimiter를 따로 등록합니다.

RateLimitConfig, BucketConfiguration, AsyncBucketProxyProvider, DistributedSuspendRateLimiter, SuspendRateLimiter, IpRateLimitWebFilter, RequestUtils, RateLimitResult 관계를 보여주는 Spring 설정 클래스 다이어그램
Spring 설정은 정책·공급자·제한기 빈을 분리하며, WebFilter는 RateLimitResult만 보고 HTTP 응답을 결정합니다. 실선은 인터페이스 구현, 점선은 생성·사용·반환 관계입니다.

클래스 다이어그램에서 RateLimitConfig는 세 가지 전략에 같은 구성의 빈을 만드는 조립 클래스입니다. BucketConfiguration은 할당량 정책이고, AsyncBucketProxyProvider는 키별 원격 버킷을 찾는 공급자입니다. DistributedSuspendRateLimiterSuspendRateLimiter 구현체이며, HTTP 필터는 이 구현체를 주입받아 RateLimitResult만 해석합니다. 즉 Spring 빈 조립과 HTTP 처리 정책 사이의 경계는 consume(key, 1) 호출 하나로 좁아집니다.

@Bean
fun proxyManager(lettuceClient: RedisClient): LettuceBasedProxyManager<ByteArray> {
return lettuceBasedProxyManagerOf(lettuceClient) {
ClientSideConfig.getDefault()
.withExpirationAfterWriteStrategy(
ExpirationAfterWriteStrategy
.basedOnTimeForRefillingBucketUpToMax(Duration.ofSeconds(90))
)
.withExecutionStrategy(ExecutionStrategy.background(VirtualThreadExecutor))
}
}
@Bean
@Qualifier("ipBucketConfiguration")
fun ipBucketConfiguration(): BucketConfiguration = bucketConfiguration {
addLimit { it.capacity(20).refillIntervally(20, Duration.ofSeconds(10)) }
addLimit { it.capacity(100).refillGreedy(10, Duration.ofMinutes(1)) }
}
@Bean
@Qualifier("ipBucketProxyProvider")
fun ipBucketProxyProvider(
proxyManager: ProxyManager<ByteArray>,
@Qualifier("ipBucketConfiguration") ipBucketConfiguration: BucketConfiguration,
): AsyncBucketProxyProvider =
AsyncBucketProxyProvider(proxyManager.asAsync(), ipBucketConfiguration)
@Bean
@Qualifier("ipRateLimiter")
fun ipRateLimiter(
@Qualifier("ipBucketProxyProvider") ipBucketProxyProvider: AsyncBucketProxyProvider,
): DistributedSuspendRateLimiter =
DistributedSuspendRateLimiter(ipBucketProxyProvider)

핵심은 Redis 설정과 버킷 정책, 제한기 빈을 한 덩어리로 뭉개지 않는다는 점입니다. Redis는 토큰 상태를 저장하는 공용 저장소이고, ipBucketConfiguration은 “IP 버킷은 10초에 20개, 1분에 100개”라는 정책입니다. ipRateLimiter는 그 정책을 실제로 적용하는 진입점입니다. 사용자·복합 전략도 같은 구조를 쓰되 용량만 다르게 둡니다.

bluetape4k RateLimiter는 Bucket4j 검사 결과를 결과 객체로 감싼다

섹션 제목: “bluetape4k RateLimiter는 Bucket4j 검사 결과를 결과 객체로 감싼다”

bluetape4k-bucket4jRateLimiterSuspendRateLimiter는 Bucket4j를 직접 노출하지 않고, consume(key, numToken) 하나로 즉시 소비 결과를 돌려주는 퍼사드입니다.

interface SuspendRateLimiter<K> {
suspend fun consume(key: K, numToken: Long = 1): RateLimitResult
}

consume은 토큰 재충전을 기다리는 API가 아닙니다. 지금 토큰을 소비할 수 있는지 즉시 판단하고, 그 결과를 RateLimitResult로 돌려줍니다.

val result = rateLimiter.consume("ip:203.0.113.10", 1)
when {
result.isConsumed -> chain.filter(exchange).awaitSingleOrNull()
result.isRejected -> {
exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS
Mono.empty<Void>().awaitSingleOrNull()
}
result.isError -> chain.filter(exchange).awaitSingleOrNull() // 예제 정책: 실패 시 허용
}

RateLimitResult는 세 상태를 갖습니다.

상태의미HTTP 필터에서의 사용
CONSUMED토큰 소비 성공핸들러로 요청을 넘긴다
REJECTED토큰 부족429Retry-After를 쓴다
ERROR버킷 공급자 또는 Redis 처리 실패호출자가 예외를 처리할 때 적용할 정책의 근거

분산 코루틴 구현인 DistributedSuspendRateLimiterAsyncBucketProxyProvider에서 키별 버킷 프록시를 찾고, tryConsumeAndReturnRemaining(numToken)ConsumptionProbe를 기다린 뒤 RateLimitResult로 변환합니다. availableTokens를 한 번 더 읽지 않고 소비 시도와 남은 토큰 확인을 하나의 프로브로 끝냅니다.

val bucketProxy = asyncBucketProxyProvider.resolveBucket(key)
val probe = bucketProxy.tryConsumeAndReturnRemaining(numToken).await()
return toRateLimitResult(probe, numToken)

따라서 워크숍 필터가 알아야 할 것은 Bucket4j 내부 타입이 아니라 RateLimitResult입니다. 다만 현재 bucket4j-advanced 필터는 consume이 예외를 던지면 요청을 통과시키는 실패 시 허용(fail-open) 정책을 사용합니다. 결과 상태와 예외 경로를 같은 정책으로 오해하지 않아야 합니다.

extractIp로 만든 식별자가 허용 판단의 키가 된다

섹션 제목: “extractIp로 만든 식별자가 허용 판단의 키가 된다”

요청이 /api/anonymous로 들어오면 IpRateLimitWebFilter가 먼저 실행됩니다. 여기서 RequestUtils.extractIp(exchange, trustProxy)로 호출자 식별자를 정하고, ip:<address> 형태의 버킷 키를 만든 뒤 rateLimiter.consume(key, 1L)을 호출합니다.

val ip = RequestUtils.extractIp(exchange, trustProxy)
val effectiveIp = ip?.takeIf { it.isNotBlank() } ?: "unknown"
val key = "ip:$effectiveIp"
val result = rateLimiter.consume(key, 1L)
exchange.response.headers.set(
HeaderConstants.X_RATELIMIT_REMAINING,
result.availableTokens.toString()
)
if (result.isConsumed) {
chain.filter(exchange).awaitSingleOrNull()
} else {
val retryAfterSecs = result.retryAfter
?.let { TimeUnit.NANOSECONDS.toSeconds(it.toNanos()).coerceAtLeast(1) }
?: 1L
exchange.response.headers.set(HeaderConstants.RETRY_AFTER, retryAfterSecs.toString())
exchange.response.statusCode = HttpStatus.TOO_MANY_REQUESTS
Mono.empty<Void>().awaitSingleOrNull()
}

여기서 허용 여부는 result.isConsumed로 판단합니다. 토큰 하나를 소비할 수 있으면 핸들러로 넘기고, 소비하지 못하면 핸들러를 호출하지 않은 채 429 Too Many RequestsRetry-After를 반환합니다. 반면 consume 호출 자체에서 예외가 발생하면 바깥 catch가 요청을 통과시킵니다. 이것이 현재 예제가 구현한 실패 시 허용 경계입니다.

그 앞단의 식별자 추출은 아래처럼 프록시 신뢰 설정에 따라 달라집니다.

fun extractIp(exchange: ServerWebExchange, trustProxy: Boolean = false): String? {
val request = exchange.request
if (trustProxy) {
val forwardedFor = request.headers.getFirst(HeaderConstants.X_FORWARDED_FOR)
if (!forwardedFor.isNullOrBlank()) {
return forwardedFor.split(",").first().trim()
}
val realIp = request.headers.getFirst(HeaderConstants.X_REAL_IP)
if (!realIp.isNullOrBlank()) {
return realIp.trim()
}
}
return request.remoteAddress?.address?.hostAddress
}

WebFlux와 WebMVC에서 달라지는 부분은 정책이 아니라 요청 API입니다. WebFlux는 ServerWebExchange에서 exchange.request.headersexchange.request.remoteAddress를 읽습니다. 같은 규칙을 Servlet/WebMVC 필터나 인터셉터에서 쓴다면 HttpServletRequest#getHeader()request.remoteAddr를 사용합니다.

fun extractIp(request: HttpServletRequest, trustProxy: Boolean = false): String? {
if (trustProxy) {
val forwardedFor = request.getHeader(HeaderConstants.X_FORWARDED_FOR)
if (!forwardedFor.isNullOrBlank()) {
return forwardedFor.split(",").first().trim()
}
val realIp = request.getHeader(HeaderConstants.X_REAL_IP)
if (!realIp.isNullOrBlank()) {
return realIp.trim()
}
}
return request.remoteAddr
}

두 코드의 의미는 같습니다. trustProxy=false이면 프레임워크가 직접 확인한 상대 주소를 쓰고, trustProxy=true일 때만 프록시 헤더를 식별자 후보로 인정합니다. 차이는 WebFlux가 리액티브 서버 요청 모델을 쓰고, WebMVC가 Servlet 요청 모델을 쓴다는 점입니다.

즉 “요청 제한을 적용한다”는 말은 단순히 Bucket4j를 켜는 것이 아닙니다. 이 예제에서는 extractIp로 식별자를 결정하고, 그 식별자를 버킷 키로 바꾼 뒤 consume 결과가 허용인지 거부인지 판단합니다. 프록시 신뢰 설정을 잘못 잡으면 Bucket4j 설정이 아무리 좋아도 엉뚱한 사람의 토큰을 차감합니다.

Caffeine과 Redis의 차이는 저장소 경계다

섹션 제목: “Caffeine과 Redis의 차이는 저장소 경계다”

bucket4j-caffeine-web은 단일 JVM에서 시작 구성을 보기 좋습니다. 예제 테스트 프로필은 /hello를 5회/10초, /world를 10회/10초로 제한하고, 토큰이 소진되면 429를 검증합니다. 로컬 Servlet 애플리케이션이나 단일 인스턴스 서비스라면 이 정도 예제가 가장 빠르게 이해됩니다.

반대로 bucket4j-redis는 WebFlux + Redis/Lettuce 조합입니다. 여러 애플리케이션 인스턴스가 같은 Redis 버킷 상태를 공유하므로, “Pod A에는 아직 토큰이 남았고 Pod B에서는 모두 소진했다” 같은 상태 분리가 줄어듭니다.

선택맞는 상황조심할 점
Caffeine 로컬 버킷단일 JVM, 로컬 개발, 시작 구성 확인인스턴스가 늘어나면 할당량도 인스턴스 수만큼 늘어난다
Redis 분산 버킷여러 인스턴스, 게이트웨이·서비스 확장Redis 장애 시 실패 시 허용·거부 정책을 정해야 한다

여기까지는 저장소 문제입니다. 하지만 운영 장애는 여기서 끝나지 않습니다. 저장소를 Redis로 바꿔도 키를 잘못 잡으면 여전히 엉뚱한 사람을 막습니다.

키는 엔드포인트 성격에 맞춰야 한다

섹션 제목: “키는 엔드포인트 성격에 맞춰야 한다”

bucket4j-advanced는 세 필터를 경로 접두사로 분리합니다.

엔드포인트키 형식버킷
GET /api/anonymous/helloip:<address>20 tokens / 10s, 100 tokens / min
GET /api/authenticated/hellouser:<userId>50 tokens / 10s, 200 tokens / min
GET /api/sensitive/hellocombined:<ip>:<userId>10 tokens / 10s, 50 tokens / min

익명 엔드포인트는 IP 버킷이 자연스럽습니다. 로그인 사용자는 X-User-ID 같은 인증 식별자가 더 낫습니다. 민감한 작업은 IP와 userId를 함께 묶어 “한 IP 뒤의 여러 사용자”와 “한 사용자의 여러 접속 위치”를 더 세밀하게 다룰 수 있습니다.

val key = "combined:$effectiveIp:$userId"
val result = rateLimiter.consume(key, 1L)

이 한 줄이 단순해 보여도, 그 앞에는 정책 결정이 있습니다. userId가 없으면 combined 필터는 400을 냅니다. 사용자 기반 필터는 X-User-ID가 없으면 401을 냅니다. “헤더가 없으면 원격 주소를 대체값으로 사용”하는 방식은 편하지만, 인증 엔드포인트 에서는 잘못된 식별자를 정상처럼 취급할 수 있습니다.

프록시 뒤에 있는 서비스에서 remoteAddress만 보면 대부분 프록시나 부하 분산기 주소가 나옵니다. 그렇다고 무조건 X-Forwarded-For를 믿으면 더 큰 문제가 생깁니다. 클라이언트가 직접 보낸 X-Forwarded-For를 그대로 믿으면 공격자는 자기 IP를 매 요청마다 바꾼 것처럼 꾸밀 수 있습니다.

bucket4j-advanced는 기본값을 ratelimit.trust-proxy=false로 둡니다. 이 경우 X-Forwarded-ForX-Real-IP는 무시하고 TCP 원격 주소를 사용합니다. trust-proxy=true일 때만 X-Forwarded-For의 첫 번째 IP를 클라이언트 IP로 봅니다.

앞에서 본 WebFlux/WebMVC 코드 모두 이 정책은 같습니다. 이 설정은 “기능 옵션”보다 “보안 경계”에 가깝습니다. 애플리케이션 앞단에 신뢰할 수 있는 역방향 프록시가 있고, 외부 클라이언트가 프록시 헤더를 직접 주입할 수 없을 때만 켜야 합니다.

응답 헤더는 클라이언트와의 운영 계약이다

섹션 제목: “응답 헤더는 클라이언트와의 운영 계약이다”

요청 제한기가 429만 반환하면 클라이언트는 언제 다시 시도해야 하는지 모릅니다. 그래서 bucket4j-advanced는 남은 토큰과 재시도 시간을 헤더로 드러냅니다.

헤더의미
X-RateLimit-Remaining이번 요청 처리 뒤 남은 토큰 수
X-RateLimit-Reset재설정까지 남은 초. 현재 필터는 계산값을 넣고, README는 예약된 헤더라는 성격을 함께 설명한다
Retry-After429 Too Many Requests일 때 클라이언트가 기다려야 할 초

기본 bucker4j-bluetape4k-webflux 예제는 X-Bluetape4k-Remaining-Token을 사용합니다. bucket4j-advanced는 글에서 보여주기 좋게 표준에 가까운 X-RateLimit-RemainingRetry-After로 정리했습니다.

bucket4j-advanced를 실행한 뒤에는 아래 요청으로 세 정책을 바로 확인할 수 있습니다.

Terminal window
# IP-based anonymous endpoint
curl -i http://localhost:8080/api/anonymous/hello
# userId-based authenticated endpoint
curl -i -H "X-User-ID: alice" \
http://localhost:8080/api/authenticated/hello
# combined IP + userId endpoint
curl -i -H "X-User-ID: alice" \
http://localhost:8080/api/sensitive/hello
# ignored unless ratelimit.trust-proxy=true
curl -i -H "X-Forwarded-For: 203.0.113.10, 10.0.0.7" \
http://localhost:8080/api/anonymous/hello

테스트도 같은 방향을 검증합니다. IP 버킷은 토큰 소진 뒤 429Retry-After를 확인하고, 사용자 버킷은 다른 userId가 서로 독립적인지 확인합니다. 복합 버킷은 userId가 없으면 400, 할당량이 소진되면 429를 냅니다.

코루틴과 WebFlux를 사용해도 요청 제한의 운영 계약은 달라지지 않습니다. 다만 비동기 필터 안에서 토큰 소비, 헤더 기록, 실패 시 허용 정책이 한 흐름에 섞이므로 결과 상태와 예외 경계를 구분해 읽어야 합니다.

이 워크숍 예제들은 역할을 나눠서 봐야 좋습니다.

  • bucket4j-caffeine-web: 로컬 버킷의 가장 작은 시작 예제
  • bucket4j-redis: 분산 버킷의 시작 예제
  • bucker4j-bluetape4k-webflux: bluetape4k DistributedRateLimiter 사용법
  • bucket4j-advanced: 운영에 중요한 호출자 식별, 프록시 신뢰, 응답 헤더 계약

요청 제한기를 붙일 때는 “Redis를 쓸까?”보다 먼저 “누구의 요청으로 볼 것인가?”를 정해야 합니다. 버킷은 그다음입니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.