RateLimiter 예제

RateLimiter 예제

이번 문서에서는 RateLimiter 예제를 작성하는 과정을 정리해봅니다. 이 예제에 대한 github 리포지터리는 아래의 링크를 참고해주시기 바랍니다.


Rate Limit, Token Bucket Algorithm

Spring Cloud Gateway 는 Router 기능, Rate Limit 을 모두 제공한다는 점에서 꽤 유용하고 다용도의 기능을 제공하는 것 같습니다. Spring Cloud Gateway 에서 제공하는 RateLimiter 의 기본 Rate Limit 정책은 Token Bucket Algorithm 입니다. Token Bucket Algorithm 에 대해서는 아래의 자료에 자세하게 설명되어 있습니다.


Rate Limiter 를 분산환경에서 적용시 일반적으로는 Redis 와 같은 data store 를 이용하게 됩니다. 이 경우 고민해야 할 점으로는 Race Condition 이 발생할 수 있다는 점 역시 어느 정도는 고민에 넣어두어야 할 것 같습니다. 따라서 이 경우 TTL 을 따로 지정해줘서 특정 키값의 만료를 지정한다는가 하는 설정을 따로 해두는 것을 권장합니다.

RFC6585 에서는 Rate Limit 적용 시 한계치에 도달했을 때 응답으로 "429 Too Many Request" 를 응답하도록 권고하고 있습니다. 그리고 아래의 정보들도 부가적으로 Header 에 같이 전달해주면 좋습니다.

  • RateLimit-Used : 기준 시간 단위 API 호출 수
  • RateLimit-Limit : 허용되는 요청의 최대 수
  • RateLimit-Remaining : 남은 요청 수
  • RateLimit-Reset : 요청 최대값이 재설정 될 때 까지의 시간

참고 : Naver Works Developers > 참고사항 > Rate Limits (opens in a new tab)


foobar-user

사용자 가입을 위한 서비스를 foobar-user 라는 모듈로 개발중이라고 하겠습니다. foobar-user 모듈의 내용들은 아래와 같습니다.

build.gradle.kts

// ...
 
repositories {
  mavenCentral()
}
 
extra["springCloudVersion"] = "2023.0.1"
 
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
  implementation("org.springframework.boot:spring-boot-starter-web")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
  testImplementation("io.projectreactor:reactor-test")
}
 
dependencyManagement {
  imports {
    mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
  }
}

HealthcheckController.java

간단한 HealthcheckController 코드입니다.

package io.chagchagchag.example.user.healthcheck;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
public class HealthcheckController {
  @GetMapping("/healthcheck/ready")
  public String getReady(){
    return "OK";
  }
}

동작 확인

curl 명령으로 아래의 명령을 수행해서 Web (8080) 에서 정상적으로 요청 수행이 되는지 테스트해봅니다.

$ curl localhost:8080/healthcheck/ready
OK

foobar-gateway

위에서 작성한 foobar-user 애플리케이션에 대해 gateway 역할을 수행하는 모듈입니다.

build.gradle.kts

// ...
 
repositories {
  mavenCentral()
}
 
extra["springCloudVersion"] = "2023.0.1"
 
dependencies {
  implementation("org.springframework.boot:spring-boot-starter-data-redis-reactive")
  implementation("org.springframework.cloud:spring-cloud-starter-gateway")
  compileOnly("org.projectlombok:lombok")
  annotationProcessor("org.projectlombok:lombok")
  testImplementation("org.springframework.boot:spring-boot-starter-test")
  testImplementation("io.projectreactor:reactor-test")
}
 
dependencyManagement {
  imports {
    mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
  }
}
 
// ... 

application.yml

yml 파일 내에서 spring.cloud.gateway.routes[0].filters 내에 정의한 RequestRateLimiter 에 대한 args 중 key-resolver 에 대한 값인 "#{@userIdAsKeyResolver}" 값은 직접 정의한 Bean 입니다. 뒤에서 설명합니다.

server:
  port: 9000
 
spring:
  data:
    redis:
      host: localhost
      port: 26379
      database: 0
  cloud:
    gateway:
      routes:
        - id: all
          uri: http://localhost:8080
          predicates:
            - Path=/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 20
                redis-rate-limiter.burstCapacity: 100
                redis-rate-limiter.requestedTokens: 3
                key-resolver: "#{@userIdAsKeyResolver}"

  • replenishRate : 초당 버킷 회복 량을 의미합니다. 위에서는 20 을 지정했는데 1초 내에 어떤 요청이 7회 발생하면 3x7 = 21 개의 토큰이 소모되며, 1초 뒤에 회복될때는 replenishRate 를 통해 다시 버킷이 30 으로 회복됩니다. (3은 requestedTokens 를 의미합니다.)
  • burstCapacitiy : 버킷에 담을 수 있는 최대 양을 의미합니다.
  • requestedTokens : 요청 발생 시마다 소모할 토큰의 갯수를 의미합니다. 3으로 설정했으므로 요청 하나당 3 의 토큰이 소모됩니다.
  • key-resolver : "#{@userIdKeyResolver}" 로 지정해줬는데, Java Config 로 미리 정의해둔 "userIdKeyResolver" 라는 이름의 Bean 을 의미하는 SPEL 표현식입니다.

UserIdKeyResolver

사용자가 Request Header 에 전달한 USER-ID 라고 하는 헤더 값을 추출하기 위해 ServerWebExchange 내의 request 내의 Header 내에서 USER-ID 에 대한 값을 추출합니다.

그리고 값이 정상일 경우 Mono<String> 을 return 하고 정상이 아닐 경우 Mono.error 를 리턴합니다.

package io.chagchagchag.example.gateway.resolver;
 
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
 
@Component("userIdAsKeyResolver")
public class UserIdKeyResolver implements KeyResolver {
  private final Logger logger = LoggerFactory.getLogger(UserIdKeyResolver.class);
  @Override
  public Mono<String> resolve(ServerWebExchange exchange) {
    final String userId = exchange.getRequest()
        .getHeaders()
        .getFirst("USER-ID");
 
    return Optional.ofNullable(userId)
        .map(Mono::justOrEmpty)
        .orElseGet(() -> {
          exchange.getResponse().setComplete();
          logger.debug(">>> 'USER-ID' is Empty");
          return Mono.error(new IllegalArgumentException("존재하지 않는 아이디입니다."));
        });
  }
}

동작 확인

  • 먼저 foobar-user 를 기동시킵니다.
  • 그리고 foobar-gateway 를 기동시킵니다.

curl 명령으로 아래의 명령을 수행해서 Gateway 에서 정상적으로 동작하는지 확인합니다.

$ curl -X GET localhost:9000/healthcheck/ready -H "Content-Type: application/json" -H "USER-ID:'A'"
OK

curl 명령어

주로 http 파일을 쓰거나 springdoc 를 사용하는 편이지만, 간단한 테스트는 curl 로만 수행하는게 오히려 더 편할 때가 많습니다. 그래서 막간을 이용해서 curl 명령어를 정리해봅니다.

curl 명령어 사용시 자주 사용하는 옵션은 3가지 입니다.

  • -d, --data : "data" 를 의미합니다.
  • -H, --header : "header" 를 의미합니다. 리퀘스트 전송시 헤더에 특정 값을 전달해야 할 경우 -H 를 지정해줍니다.
  • -X, --request : 사용할 요청 메서드(GET, POST, PUT 등)를 명시해주기 위해 사용하는 옵션입니다.

예를 들어 curl 명령어로 GET 요청을 하는데, 헤더에 USER-ID : A 를 전달해주려고 한다면 아래의 명령을 수행하면 됩니다.

$ curl -X GET localhost:8080/healthcheck/ready -H "Content-Type: application/json" -d "{"USER-ID": "A"}"
OK

e2e 테스트

먼저 테스트를 위해 application.yml 내의 설정을 아래와 같이 바꿔줍니다. 그리고 Gateway Application 을 재기동해줍니다.

server:
  port: 9000
 
spring:
  data:
    redis:
      host: localhost
      port: 26379
      database: 0
  cloud:
    gateway:
      routes:
        - id: all
          uri: http://localhost:8080
          predicates:
            - Path=/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 1
                redis-rate-limiter.burstCapacity: 10
                redis-rate-limiter.requestedTokens: 2
                key-resolver: "#{@userIdAsKeyResolver}"

burstCapacity 를 10초로 했고, requestedTokens 는 2로 설정해서 한 번의 요청에 토큰이 2개 소모되게끔 해주었습니다. 초당 버킷 회복량인 replenishRate 는 1 로 지정해서 버킷이 1초에 1정도가 회복되게끔 해줬습니다.

curl 요청 테스트 스크립트 작성

curl 요청을 0.5 초에 한번씩 30번 수행하는 스크립트입니다. replenishRate 는 1이므로 1초마다 1정도가 버킷에서 회복되고 requestesTokens 는 2 이므로 0.5초에 2번의 요청이 발생하면 4 개의 Token 이 1초에 한번씩 소모됩니다. 따라서 10 - 4 = 6 이 되고, 1초에 한번씩 버킷 사이즈는 6이 되었다가 다시 10으로 회복되는 방식입니다.

curl 스크립트는 아래와 같습니다.

for((i=0; i<30; i++)); do
  sleep .5
  curl -X GET localhost:9000/healthcheck/ready -H "Content-Type: application/json" -H "USER-ID:'A'";
done

결론적으로는 버킷이 1초에 2개의 요청을 수행하기에 총 15번의 요청이 성공하게 됩니다.

수행 결과

$ source curl-request-finite.sh 
OKOKOKOKOKOKOKOKOKOKOKOKOKOKOK