1. 개요

이 예제에서는 Bucket4j 를 사용하여 Spring REST API를 제한하는 방법에 중점을 둘 것 입니다.

API 속도 제한을 살펴보고 Bucket4j에 대해 학습한 다음 Spring 애플리케이션에서 속도 제한 REST API의 몇 가지 방법을 살펴보겠습니다.

2. API 속도 제한

속도 제한은 API에 대한 액세스를 제한 하는 전략 입니다. 클라이언트가 특정 시간 프레임 내에 만들 수 있는 API 호출 수를 제한합니다. 이는 의도하지 않은 악의적인 남용으로부터 API를 보호하는 데 도움이 됩니다.

속도 제한은 종종 IP 주소를 추적하거나 API 키 또는 액세스 토큰과 같은 보다 비즈니스별 방식으로 API에 적용됩니다. API 개발자로서 클라이언트가 한도에 도달하면 몇 가지 옵션이 있습니다.

  • 남은 기간이 경과할 때까지 요청 대기
  • 요청을 즉시 허용하지만 이 요청에 대해 추가 요금을 부과합니다.
  • 요청 거부(HTTP 429 너무 많은 요청)

3. Bucket4j 속도 제한 라이브러리

3.1. Bucket4j란?

Bucket4j는 토큰 버킷 알고리즘 을 기반으로 하는 Java 속도 제한 라이브러리입니다 . Bucket4j는 독립 실행형 JVM 애플리케이션 또는 클러스터 환경에서 사용할 수 있는 스레드로부터 안전한 라이브러리입니다. 또한 JCache(JSR107) 사양을 통해 메모리 내 또는 분산 캐싱을 지원합니다.

3.2. 토큰 버킷 알고리즘

API 속도 제한의 맥락에서 알고리즘을 직관적으로 살펴보겠습니다.

보유할 수 있는 토큰의 수로 용량이 정의되는 버킷이 있다고 가정합니다. 소비자가 API 엔드포인트에 액세스하려고 할 때마다 버킷에서 토큰을 가져와야 합니다. 사용 가능한 경우 버킷에서 토큰을 제거하고 요청을 수락합니다. 반대로 버킷에 토큰이 없으면 요청을 거부합니다.

요청이 토큰을 소비함에 따라 버킷의 용량을 초과하지 않도록 일정한 비율로 토큰을 보충하고 있습니다 .

분당 100개의 요청 속도 제한이 있는 API를 고려해 보겠습니다. 용량이 100이고 재충전 속도가 분당 100인 버킷을 만들 수 있습니다.

주어진 시간에 사용 가능한 토큰보다 적은 70개의 요청을 수신하는 경우 다음 1분이 시작될 때 토큰을 30개만 더 추가하여 버킷을 최대 용량으로 가져옵니다. 반면에 40초 안에 모든 토큰을 소진하면 양동이를 다시 채우기 위해 20초 동안 기다려야 합니다.

4. Bucket4j 시작하기

4.1. 메이븐 구성

pom.xml 에 bucket4j 의존성을 추가하여 시작하겠습니다 .

<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>4.10.0</version>
</dependency>

4.2. 술어

Bucket4j를 사용하는 방법을 살펴보기 전에 일부 핵심 클래스와 이들이 토큰 버킷 알고리즘의 공식 모델에서 다양한 요소를 나타내는 방법에 대해 간략하게 설명하겠습니다.

Bucket 인터페이스는 최대 용량의 토큰 버킷을 나타냅니다 . 토큰 소비를 위한 tryConsume 및  tryConsumeAndReturnRemaining 과 같은 메서드를 제공합니다 . 이러한 메서드  는 요청이 제한을 준수하고 토큰이 소비된 경우 소비 결과를 true 로 반환합니다.

Bandwidth 클래스는 버킷의 한계를 정의하므로 버킷의 핵심 빌딩 블록입니다 . Bandwidth 를 사용 하여 버킷의 용량과 리필 속도를 구성합니다.

Refill 클래스는 토큰 이  버킷에 추가되는 고정 비율을 정의하는 데 사용됩니다. 주어진 기간에 추가될 토큰 수로 속도를 구성할 수 있습니다. 예를 들어 초당 10개의 버킷 또는 5분당 200개의 토큰 등입니다.

BuckettryConsumeAndReturnRemaining 메서드 는 ConsumptionProbe 를 반환합니다 . ConsumptionProbe 에는 소비 결과와 함께 남은 토큰과 같은 버킷 상태 또는 요청된 토큰을 버킷에서 다시 사용할 수 있을 때까지 남은 시간이 포함됩니다.

4.3. 기본 사용법

몇 가지 기본 속도 제한 패턴을 테스트해 보겠습니다.

분당 10개의 요청 속도 제한에 대해 용량이 10이고 분당 10개의 토큰 재충전 속도가 있는 버킷을 생성합니다.

Refill refill = Refill.intervally(10, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(10, refill);
Bucket bucket = Bucket4j.builder()
    .addLimit(limit)
    .build();

for (int i = 1; i <= 10; i++) {
    assertTrue(bucket.tryConsume(1));
}
assertFalse(bucket.tryConsume(1));

Refill.intervally 는 시간 창의 시작 부분에서 버킷을 다시 채웁니다. 이 경우 분 시작 부분에서 10개의 토큰입니다.

다음으로 리필 작동을 살펴보겠습니다.

2초당 토큰 1개의 리필 속도를 설정하고 속도 제한을 준수하도록 요청을 제한합니다 .

Bandwidth limit = Bandwidth.classic(1, Refill.intervally(1, Duration.ofSeconds(2)));
Bucket bucket = Bucket4j.builder()
    .addLimit(limit)
    .build();
assertTrue(bucket.tryConsume(1));     // first request
Executors.newScheduledThreadPool(1)   // schedule another request for 2 seconds later
    .schedule(() -> assertTrue(bucket.tryConsume(1)), 2, TimeUnit.SECONDS); 

분당 10개의 요청 속도 제한이 있다고 가정합니다. 동시에 처음 5초 동안 모든 토큰을 소진하는 급증을 피하고 싶을 수도 있습니다 . Bucket4j를 사용 하면 동일한 버킷에 여러 제한( Bandwidth )을 설정할 수 있습니다. 20초 시간 창에서 5개의 요청만 허용하는 또 다른 제한을 추가해 보겠습니다.

Bucket bucket = Bucket4j.builder()
    .addLimit(Bandwidth.classic(10, Refill.intervally(10, Duration.ofMinutes(1))))
    .addLimit(Bandwidth.classic(5, Refill.intervally(5, Duration.ofSeconds(20))))
    .build();

for (int i = 1; i <= 5; i++) {
    assertTrue(bucket.tryConsume(1));
}
assertFalse(bucket.tryConsume(1));

5. Bucket4j를 사용하여 Spring API 속도 제한

Bucket4j를 사용하여 Spring REST API에서 속도 제한을 적용해 보겠습니다.

5.1. 면적 계산기 API

간단하지만 매우 인기 있는 면적 계산기 REST API를 구현할 것입니다. 현재 크기가 주어진 직사각형의 면적을 계산하고 반환합니다.

@RestController
class AreaCalculationController {

    @PostMapping(value = "/api/v1/area/rectangle")
    public ResponseEntity<AreaV1> rectangle(@RequestBody RectangleDimensionsV1 dimensions) {
        return ResponseEntity.ok(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
    }
}

API가 실행 중인지 확인합니다.

$ curl -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" \
    -d '{ "length": 10, "width": 12 }'

{ "shape":"rectangle","area":120.0 }

5.2. 속도 제한 적용

이제 순진한 속도 제한을 도입하여 API가 분당 20개의 요청을 허용하도록 하겠습니다. 즉, API는 1분의 시간 창에서 이미 20개의 요청을 받은 경우 요청을 거부합니다.

버킷 을 생성 하고 제한 (대역폭) 을 추가하도록 컨트롤러 를 수정해 보겠습니다 .

@RestController
class AreaCalculationController {

    private final Bucket bucket;

    public AreaCalculationController() {
        Bandwidth limit = Bandwidth.classic(20, Refill.greedy(20, Duration.ofMinutes(1)));
        this.bucket = Bucket4j.builder()
            .addLimit(limit)
            .build();
    }
    //..
}

이 API에서는 tryConsume 메서드를 사용하여 버킷에서 토큰을 소비하여 요청이 허용되는지 여부를 확인할 수 있습니다 . 한도에 도달하면 HTTP 429 너무 많은 요청 상태로 응답하여 요청을 거부할 수 있습니다.

public ResponseEntity<AreaV1> rectangle(@RequestBody RectangleDimensionsV1 dimensions) {
    if (bucket.tryConsume(1)) {
        return ResponseEntity.ok(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
    }

    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
# 21st request within 1 minute
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 429

5.3. API 클라이언트 및 요금제

이제 API 요청을 제한할 수 있는 순진한 속도 제한이 있습니다. 다음으로 비즈니스 중심의 요금 제한에 대한 요금제를 소개합니다.

요금제는 API로 수익을 창출하는 데 도움이 됩니다. API 클라이언트에 대해 다음과 같은 계획이 있다고 가정해 보겠습니다.

  • 무료: API 클라이언트당 시간당 요청 20개
  • 기본: API 클라이언트당 시간당 요청 40개
  • 전문가: API 클라이언트당 시간당 요청 100개

각 API 클라이언트는 각 요청과 함께 전송해야 하는 고유한 API 키를 받습니다 . 이를 통해 API 클라이언트와 연결된 요금제를 식별할 수 있습니다.

각 요금제에 대한 속도 제한( Bandwidth )을 정의해 보겠습니다.

enum PricingPlan {
    FREE {
        Bandwidth getLimit() {
            return Bandwidth.classic(20, Refill.intervally(20, Duration.ofHours(1)));
        }
    },
    BASIC {
        Bandwidth getLimit() {
            return Bandwidth.classic(40, Refill.intervally(40, Duration.ofHours(1)));
        }
    },
    PROFESSIONAL {
        Bandwidth getLimit() {
            return Bandwidth.classic(100, Refill.intervally(100, Duration.ofHours(1)));
        }
    };
    //..
}

그런 다음 지정된 API 키에서 요금제를 해결하는 방법을 추가해 보겠습니다.

enum PricingPlan {
    
    static PricingPlan resolvePlanFromApiKey(String apiKey) {
        if (apiKey == null || apiKey.isEmpty()) {
            return FREE;
        } else if (apiKey.startsWith("PX001-")) {
            return PROFESSIONAL;
        } else if (apiKey.startsWith("BX001-")) {
            return BASIC;
        }
        return FREE;
    }
    //..
}

다음으로 각 API 키에 대한 버킷 을 저장하고 속도 제한을 위해 버킷 을 검색해야 합니다.

class PricingPlanService {

    private final Map<String, Bucket> cache = new ConcurrentHashMap<>();

    public Bucket resolveBucket(String apiKey) {
        return cache.computeIfAbsent(apiKey, this::newBucket);
    }

    private Bucket newBucket(String apiKey) {
        PricingPlan pricingPlan = PricingPlan.resolvePlanFromApiKey(apiKey);
        return Bucket4j.builder()
            .addLimit(pricingPlan.getLimit())
            .build();
    }
}

이제 API 키당 버킷의 메모리 내 저장소가 있습니다. PricingPlanService 를 사용하도록 컨트롤러 를 수정해 보겠습니다 .

@RestController
class AreaCalculationController {

    private PricingPlanService pricingPlanService;

    public ResponseEntity<AreaV1> rectangle(@RequestHeader(value = "X-api-key") String apiKey,
        @RequestBody RectangleDimensionsV1 dimensions) {

        Bucket bucket = pricingPlanService.resolveBucket(apiKey);
        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            return ResponseEntity.ok()
                .header("X-Rate-Limit-Remaining", Long.toString(probe.getRemainingTokens()))
                .body(new AreaV1("rectangle", dimensions.getLength() * dimensions.getWidth()));
        }
        
        long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
        return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill))
            .build();
    }
}

변경 사항을 살펴보겠습니다. API 클라이언트는 X-api-key 요청 헤더와 함께 API 키를 보냅니다. PricingPlanService 를 사용하여  이 API 키에 대한 버킷을 가져오고 버킷에서 토큰을 소비하여 요청이 허용되는지 확인합니다.

API의 클라이언트 경험을 향상시키기 위해 다음과 같은 추가 응답 헤더를 사용하여 속도 제한에 대한 정보를 보냅니다.

  • X-Rate-Limit-Remaining : 현재 시간 창에 남아 있는 토큰 수
  • X-Rate-Limit-Retry-After-Seconds : 버킷이 다시 채워질 때까지 남은 시간(초)

ConsumptionProbe  메서드 getRemainingTokensgetNanosToWaitForRefill 을 호출하여 각각 버킷에 남아 있는 토큰 수와 다음 리필까지 남은 시간을 가져올 수 있습니다. getNanosToWaitForRefill 메서드는 토큰을 성공적으로 사용할 수 있는 경우 0을 반환합니다 .

API를 호출해 보겠습니다.

## successful request
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 11
{"shape":"rectangle","area":120.0}

## rejected request
$ curl -v -X POST http://localhost:9001/api/v1/area/rectangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "length": 10, "width": 12 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 583

5.4. Spring MVC 인터셉터 사용

이제 높이와 밑면이 주어진 삼각형의 면적을 계산하고 반환하는 새 API Endpoints을 추가해야 한다고 가정합니다.

@PostMapping(value = "/triangle")
public ResponseEntity<AreaV1> triangle(@RequestBody TriangleDimensionsV1 dimensions) {
    return ResponseEntity.ok(new AreaV1("triangle", 0.5d * dimensions.getHeight() * dimensions.getBase()));
}

결과적으로 새 엔드포인트도 속도 제한해야 합니다. 이전 엔드포인트에서 속도 제한 코드를 복사하여 붙여넣기만 하면 됩니다. 또는 Spring MVC의 HandlerInterceptor 를 사용하여 비즈니스 코드에서 속도 제한 코드를 분리할 수 있습니다 .

RateLimitInterceptor 를 만들고 preHandle 메서드 에서 속도 제한 코드를 구현해 보겠습니다.

public class RateLimitInterceptor implements HandlerInterceptor {

    private PricingPlanService pricingPlanService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) 
      throws Exception {
        String apiKey = request.getHeader("X-api-key");
        if (apiKey == null || apiKey.isEmpty()) {
            response.sendError(HttpStatus.BAD_REQUEST.value(), "Missing Header: X-api-key");
            return false;
        }

        Bucket tokenBucket = pricingPlanService.resolveBucket(apiKey);
        ConsumptionProbe probe = tokenBucket.tryConsumeAndReturnRemaining(1);
        if (probe.isConsumed()) {
            response.addHeader("X-Rate-Limit-Remaining", String.valueOf(probe.getRemainingTokens()));
            return true;
        } else {
            long waitForRefill = probe.getNanosToWaitForRefill() / 1_000_000_000;
            response.addHeader("X-Rate-Limit-Retry-After-Seconds", String.valueOf(waitForRefill));
            response.sendError(HttpStatus.TOO_MANY_REQUESTS.value(),
              "You have exhausted your API Request Quota"); 
            return false;
        }
    }
}

마지막으로 InterceptorRegistry 에 인터셉터를 추가해야 합니다 .

public class AppConfig implements WebMvcConfigurer {
    
    private RateLimitInterceptor interceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(interceptor)
            .addPathPatterns("/api/v1/area/**");
    }
}

RateLimitInterceptor 는  영역 계산 API Endpoints에 대한 각 요청을 가로챕니다.

새로운 Endpoints을 사용해 보겠습니다.

## successful request
$ curl -v -X POST http://localhost:9001/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 15, "base": 8 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 9
{"shape":"triangle","area":60.0}

## rejected request
$ curl -v -X POST http://localhost:9001/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 15, "base": 8 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 299
{ "status": 429, "error": "Too Many Requests", "message": "You have exhausted your API Request Quota" }

끝난 것 같습니다. Endpoints을 계속 추가할 수 있으며 인터셉터는 각 요청에 대해 속도 제한을 적용합니다.

6. Bucket4j 스프링 부트 스타터

Spring 애플리케이션에서 Bucket4j를 사용하는 또 다른 방법을 살펴보겠습니다. Bucket4j Spring Boot Starter 는 Spring Boot 애플리케이션 속성 또는 구성을 통해 API 속도 제한을 달성하는 데 도움이 되는 Bucket4j에 대한 자동 구성을 제공합니다.

Bucket4j 스타터를 애플리케이션에 통합하면 애플리케이션 코드 없이 완전히 선언적인 API 속도 제한 구현을 갖게 됩니다 .

6.1. 속도 제한 필터

이 예제에서는 속도 제한을 식별하고 적용하기 위한 키로 요청 헤더 X-api-key 의 값을 사용했습니다.

Bucket4j Spring Boot Starter는 속도 제한 키를 정의하기 위한 몇 가지 미리 정의된 구성을 제공합니다.

  • 순진한 속도 제한 필터(기본값)
  • IP 주소로 필터링
  • 표현식 기반 필터

표현식 기반 필터는 SpEL(Spring Expression Language) 을 사용합니다 . SpEL은 IP 주소( getRemoteAddr() ), 요청 헤더( getHeader('X-api-key') ) 등에서 필터 표현식을 작성하는 데 사용할 수 있는 HttpServletRequest 와 같은 루트 개체에 대한 액세스를 제공합니다 .

라이브러리는 필터 표현식에서 사용자 정의 클래스도 지원하며 이에 대해서는 문서 에서 설명합니다 .

6.2. 메이븐 구성

pom.xml 에 bucket4j-spring-boot-starter 의존성을 추가하여 시작하겠습니다 .

<dependency>
    <groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
    <artifactId>bucket4j-spring-boot-starter</artifactId>
    <version>0.2.0</version>
</dependency>

우리는 이전 구현에서 API 키(소비자)당 버킷 을 저장하기 위해 메모리 내 맵 을 사용했습니다. 여기에서 Spring의 캐싱 추상화를 사용하여 Caffeine 또는 Guava 와 같은 메모리 내 저장소를 구성할 수 있습니다 .

캐싱 의존성을 추가해 보겠습니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>javax.cache</groupId>
    <artifactId>cache-api</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.8.2</version>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>jcache</artifactId>
    <version>2.8.2</version>
</dependency>

참고: Bucket4j의 캐싱 지원을 준수하기 위해 jcache 의존성도 추가했습니다.

구성 클래스에 @EnableCaching 어노테이션을 추가하여 캐싱 기능 을 활성화해야 합니다 .

6.3. 애플리케이션 구성

Bucket4j 스타터 라이브러리를 사용하도록 애플리케이션을 구성해 보겠습니다. 먼저 API 키와 버킷 인메모리 를 저장하도록 Caffeine 캐싱을 구성 합니다.

spring:
  cache:
    cache-names:
    - rate-limit-buckets
    caffeine:
      spec: maximumSize=100000,expireAfterAccess=3600s

다음으로 Bucket4j 를 구성 해 보겠습니다.

bucket4j:
  enabled: true
  filters:
  - cache-name: rate-limit-buckets
    url: /api/v1/area.*
    strategy: first
    http-response-body: "{ \"status\": 429, \"error\": \"Too Many Requests\", \"message\": \"You have exhausted your API Request Quota\" }"
    rate-limits:
    - expression: "getHeader('X-api-key')"
      execute-condition: "getHeader('X-api-key').startsWith('PX001-')"
      bandwidths:
      - capacity: 100
        time: 1
        unit: hours
    - expression: "getHeader('X-api-key')"
      execute-condition: "getHeader('X-api-key').startsWith('BX001-')"
      bandwidths:
      - capacity: 40
        time: 1
        unit: hours
    - expression: "getHeader('X-api-key')"
      bandwidths:
      - capacity: 20
        time: 1
        unit: hours

그래서 우리는 방금 무엇을 구성 했습니까?

  • bucket4j.enabled=true – Bucket4j 자동 구성 활성화
  • bucket4j.filters.cache-name –  캐시에서 API 키에 대한 버킷 가져오기
  • bucket4j.filters.url – 속도 제한을 적용하기 위한 경로 표현식을 나타냅니다.
  • bucket4j.filters.strategy=first – 일치하는 첫 번째 속도 제한 구성에서 중지
  • bucket4j.filters.rate-limits.expression – SpEL(Spring Expression Language)을 사용하여 키 검색
  • bucket4j.filters.rate-limits.execute-condition – SpEL을 사용하여 속도 제한을 실행할지 여부를 결정합니다.
  • bucket4j.filters.rate-limits.bandwidths – Bucket4j 속도 제한 매개변수를 정의합니다.

PricingPlanServiceRateLimitInterceptor 를 순차적으로 평가되는 속도 제한 구성 List으로 교체했습니다 .

시도해 봅시다:

## successful request
$ curl -v -X POST http://localhost:9000/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 20, "base": 7 }'

< HTTP/1.1 200
< X-Rate-Limit-Remaining: 7
{"shape":"triangle","area":70.0}

## rejected request
$ curl -v -X POST http://localhost:9000/api/v1/area/triangle \
    -H "Content-Type: application/json" -H "X-api-key:FX001-99999" \
    -d '{ "height": 7, "base": 20 }'

< HTTP/1.1 429
< X-Rate-Limit-Retry-After-Seconds: 212
{ "status": 429, "error": "Too Many Requests", "message": "You have exhausted your API Request Quota" }

7. 결론

이 기사에서는 속도 제한 Spring API에 Bucket4j를 사용하는 여러 가지 접근 방식을 시연했습니다. 자세한 내용은 공식 문서 를 확인하십시오 .

평소와 같이 모든 예제의 소스 코드는 GitHub 에서 사용할 수 있습니다 .

REST footer banner