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 limiting using the Token Bucket algorithm (opens in a new tab)
- Bucket4j - Token bucket 알고리즘을 이용한 Rate limit 라이브러리를 알아보자!! (opens in a new tab)
- 만약 Rate Limit 을 더 효율적으로 하고 싶을 경우 참고해서 튜닝이 이뤄진 Rate Limit 서비스를 만들 수 있을 것 같아서 추가해둔 자료입니다.
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