Rate Limiting 은 특정 시간 내에 트래픽의 처리율(rate)을 제어하기 위한 장치이며, 단위 시간 동안 얼마만큼의 실행을 허용할 것인지 제한하는 매커니즘을 가지고 있다.
서버가 제공할 수 있는 자원에는 한계가 있기 때문에 안정적으로 서비스를 제공하기 위해 사용하는 대표적인 혼잡 제어 기법이다.
DDos, Brute Force 공격등에 대해서 방어할 수 있고, 클라우드 환경의 종량제 요금을 사용하는 경우에 비용 절감이라는 이점을 얻을 수 있다. 또한, 특정 클라이언트가 서버의 자원을 독점하는 경우도 막을 수 있다.

Rate Limiter 가 서버에게 요청이 가기전에 특정 알고리즘을 통해서 처리율을 제한하고, 초과하지 않은 요청이라면 실제 처리 서버에게 흘려보낸다.
이 Rate Limite 을 처리하는 위치는 Web Server, LoadBalancer, Gateway 등이 될 수 있다. 분산환경에서 구현은 정답은 없지만 대부분은 Redis를 활용하는 것 같다.
싱글스레드로 동작하기에 여러 요청이 들어왔을 때 원자적으로 동작하기에 동시성처리에 대한 문제를 크게 고려하지 않아도 되며, 인메모리 DB기에 빠르니까 사용하는 듯 하다.
실제 Spring-Cloud-Gateway 에서도 Redis를 활용한 Rate Limiter 구현을 제공해주니 말 다한듯..?
각 알고리즘에 대한 설명은 아래 링크에서 자세하게 설명되어있으며 예시 코드 또한 확인 가능하다.
https://www.mimul.com/blog/about-rate-limit-algorithm/
실제 Rate Limit 를 쉽게 구현하도록 Spring-Cloud-Gateway 에서 제공해주고 있다.
미리 말하자면 Spring-Cloud-Gateway 에서는 Token Bucket 알고리즘을 사용하고있다.
하나하나씩 알아보자


Config를 통해 KeyResolver와 RateLimiter의 bean을 주입받을 수 있다. 만약 Config에 KeyResolver와 RateLimiter 설정이 없다면 기본으로 주입받은 bean인 defaultRateLimiter, defaultKeyResolver를 사용한다.
이 클래스에선 apply 메서드가 핵심이다.
public GatewayFilter apply(Config config) {
// ...
return (exchange, chain) -> {
return resolver.resolve(exchange).defaultIfEmpty("____EMPTY_KEY__").flatMap((key) -> {
// ...
return limiter.isAllowed(routeId, key).flatMap((response) -> {
Iterator var4 = response.getHeaders().entrySet().iterator();
while(var4.hasNext()) {
Map.Entry<String, String> header = (Map.Entry)var4.next();
exchange.getResponse().getHeaders().add((String)header.getKey(), (String)header.getValue());
}
if (response.isAllowed()) {
return chain.filter(exchange);
} else {
ServerWebExchangeUtils.setResponseStatus(exchange, config.getStatusCode());
return exchange.getResponse().setComplete();
}
});
}
});
};
}
상당히 간단하게 이루어져 있다.
이제 위에서 본 limiter.isAllowed() 의 RateLimiter 를 알아보자.
요청을 처리하는 Router의 ID와 요청의 key를 사용하여 해당 요청을 허용할지에 대한 응답을 리턴하는 역할을 한다.

인터페이스 형식으로 제공되어 RateLimiterFilter 별로 다양한 구현체로 활용할 수 있다.
앞서 Spring Cloud Gateway는 Redis를 사용한다고 하였는데,
Spring Cloud Gateway는 RateLimiter에 대한 구현체로 RedisRateLimiter를 제공하고 기본적으로 이를 사용한다.
핵심적으로 살펴볼 것은 RateLimiter 인터페이스의 기능을 구현한 isAllowed 메서드이다.

실제 여기서 Token Buket 알고리즘을 사용해서 처리율 제한이 일어난다.
redis의 eval 커맨드로 lua 스크립트를 실행하고있다.
여기서 사용하는 스크립트는 다음 위치에 저장되어있다.

redis.replicate_commands()
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)
-- for testing, it should use redis system time in production
if now == nil then
now = redis.call('TIME')[1]
end
--redis.log(redis.LOG_WARNING, "rate " .. ARGV[1])
--redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2])
--redis.log(redis.LOG_WARNING, "now " .. now)
--redis.log(redis.LOG_WARNING, "requested " .. ARGV[4])
--redis.log(redis.LOG_WARNING, "filltime " .. fill_time)
--redis.log(redis.LOG_WARNING, "ttl " .. ttl)
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
last_tokens = capacity
end
--redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens)
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
last_refreshed = 0
end
--redis.log(redis.LOG_WARNING, "last_refreshed " .. last_refreshed)
local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
new_tokens = filled_tokens - requested
allowed_num = 1
end
--redis.log(redis.LOG_WARNING, "delta " .. delta)
--redis.log(redis.LOG_WARNING, "filled_tokens " .. filled_tokens)
--redis.log(redis.LOG_WARNING, "allowed_num " .. allowed_num)
--redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens)
if ttl > 0 then
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
end
-- return { allowed_num, new_tokens, capacity, filled_tokens, requested, new_tokens }
return { allowed_num, new_tokens }
이 스크립트 내부에서 application.yml 에 설정한 redis-rate-limiter.~~ 값을 사용해 Token Bucket 알고리즘을 적용한다.
한 뭉태기씩 이해해보자
redis.replicate_commands()
local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
명령어를 읽어들인 후, 인자와 키를 변수에 매핑한다.
local fill_time = capacity / rate
local ttl = math.floor(fill_time * 2)
-- for testing, it should use redis system time in production
if now == nil then
now = redis.call('TIME')[1]
end
local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
last_tokens = capacity
end
--redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens)
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
last_refreshed = 0
end
local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
new_tokens = filled_tokens - requested
allowed_num = 1
end
if ttl > 0 then
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
end
-- return { allowed_num, new_tokens, capacity, filled_tokens, requested, new_tokens }
return { allowed_num, new_tokens }
해당 스크립트를 참고해서 나만의 토큰 버킷 알고리즘을 만들어 볼 수 있을 것 같다!
lua 스크립트 내에서
if ttl > 0 then
redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)
end
setex 커맨드를 사용중인데, 공식문서 를 살펴보면

Redis 버전 2.6.12 이후 부터 deprecated 되었다고 한다.
학교 선배가 해당 spring-cloud-gateway 의 토큰 버킷 알고리즘을 활용해 개발한 것을 본 적 있었는데, 그 때 말한 문제가 이거였구나..
여튼 Redis 버전을 잘 확인하고, 이후 버전이라면 set 명령어를 사용하는게 좋아보인다!
dependencies {
//...
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
//...
}
이렇게 기본 gateway starter 와 redis 설정을 추가해주면 쉽게 사용할 수 있다.

GatewayRedisAutoConfiguration.class 자동구성 클래스에서 RedisTemplate Bean 존재 여부에 따라 트리거 되도록 설정되어있다.
밑에 redisRequestRateLimiterScript() 메서드에서 확인가능하듯이 기본 lua 스크립트를 가져오고있다.
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "Hello, World!";
}
}
간단한 "Hello, World!" 를 반환하는 mvc 서버 1대
@Configuration
public class CustomUserKeyResolver {
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("userId"));
}
}
userId 파싱해서 전달하는 KeyResolver
server:
port: 8080
spring:
data:
redis:
host: localhost
port: 6379
cloud:
gateway:
routes:
- id: api_server
uri: http://localhost:8081
predicates:
- Path=/**
filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@userKeyResolver}"
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 10
redis-rate-limiter.requestedTokens: 5
그리고 application.yaml 파일 안에 다음 같은 정보를 적어주면 된다.

그에 따라서 10개 용량의 버킷에 한번 요청하면 5개의 토큰을 요청했으며, 남은 토큰은 5개, 초당 1개씩 회복하는 것을 알 수 있다.

여러번 요청해보면 토큰 2개가 남았는데, 5개를 요청했을 때 429 상태코드로 오류를 내뱉게 된다.

실제 기본으로 사용하면 Redis에 다음과 같은 형식으로 저장된다.
남은 토큰수와 마지막 요청 시각을 저장하고, 요청시마다 업데이트된다.
ttl 이 지나면 자동으로 null 로 바뀐다.