Request Rate Limiter (feat. Spring Cloud Gateway)

김건우·2025년 4월 4일

[TIL]

목록 보기
24/25

Rate Limiter

Rate Limiting 은 특정 시간 내에 트래픽의 처리율(rate)을 제어하기 위한 장치이며, 단위 시간 동안 얼마만큼의 실행을 허용할 것인지 제한하는 매커니즘을 가지고 있다.

서버가 제공할 수 있는 자원에는 한계가 있기 때문에 안정적으로 서비스를 제공하기 위해 사용하는 대표적인 혼잡 제어 기법이다.

DDos, Brute Force 공격등에 대해서 방어할 수 있고, 클라우드 환경의 종량제 요금을 사용하는 경우에 비용 절감이라는 이점을 얻을 수 있다. 또한, 특정 클라이언트가 서버의 자원을 독점하는 경우도 막을 수 있다.

Rate Limit 아키텍처

Rate Limiter 가 서버에게 요청이 가기전에 특정 알고리즘을 통해서 처리율을 제한하고, 초과하지 않은 요청이라면 실제 처리 서버에게 흘려보낸다.

이 Rate Limite 을 처리하는 위치는 Web Server, LoadBalancer, Gateway 등이 될 수 있다. 분산환경에서 구현은 정답은 없지만 대부분은 Redis를 활용하는 것 같다.

싱글스레드로 동작하기에 여러 요청이 들어왔을 때 원자적으로 동작하기에 동시성처리에 대한 문제를 크게 고려하지 않아도 되며, 인메모리 DB기에 빠르니까 사용하는 듯 하다.

실제 Spring-Cloud-Gateway 에서도 Redis를 활용한 Rate Limiter 구현을 제공해주니 말 다한듯..?

Rate Limit Algorithms

  1. Leaky Bucket
  2. Token Bucket
  3. Fixed Window Counter
  4. Sliding Window Log
  5. Sliding window counter

각 알고리즘에 대한 설명은 아래 링크에서 자세하게 설명되어있으며 예시 코드 또한 확인 가능하다.
https://www.mimul.com/blog/about-rate-limit-algorithm/

Spring Cloud Gateway 의 Rate Limit

실제 Rate Limit 를 쉽게 구현하도록 Spring-Cloud-Gateway 에서 제공해주고 있다.

미리 말하자면 Spring-Cloud-Gateway 에서는 Token Bucket 알고리즘을 사용하고있다.

하나하나씩 알아보자

RequestRateLimiterGatewayFilterFactory

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();
                        }
                    });
                }
            });
        };
    }
  1. KeyResolver 에서 key를 꺼내고
  2. Router의 id인 routeId와 추출한 key로 RateLimiter 를 통해 현재 요청을 처리해야 하는지 아닌지 응답값을 받는다.
  3. 응답값에 담긴 Rate Limit 관련 HTTP Header 값들을 ServerWebExchange의 ServerHttpResponse Header에 담는다.
  4. 요청을 허용하는 경우라면 다음 GatewayFilter로 요청을 넘겨 요청을 처리할 수 있도록 하고, 허용하지 않는 경우 상태코드와 함께 반환한다.

상당히 간단하게 이루어져 있다.

RateLimiter

이제 위에서 본 limiter.isAllowed() 의 RateLimiter 를 알아보자.

요청을 처리하는 Router의 ID와 요청의 key를 사용하여 해당 요청을 허용할지에 대한 응답을 리턴하는 역할을 한다.

인터페이스 형식으로 제공되어 RateLimiterFilter 별로 다양한 구현체로 활용할 수 있다.

앞서 Spring Cloud Gateway는 Redis를 사용한다고 하였는데,

Spring Cloud Gateway는 RateLimiter에 대한 구현체로 RedisRateLimiter를 제공하고 기본적으로 이를 사용한다.

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])

명령어를 읽어들인 후, 인자와 키를 변수에 매핑한다.

  • tokens_key : 토큰의 현재 수량을 저장하는 Redis 키
  • timestamp_key : 마지막으로 토큰을 갱신한 시간을 저장하는 Redis 키
  • rate : 초당 생성되는 토큰의 수 (속도)
  • capacity : 토큰 버킷의 최대 용량
  • now : 현재 시간 (밀리초 단위로 받음)
  • requested : 요청된 토큰의 수
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
  • fill_time : 버킷이 가득 차기까지 걸리는 시간
  • ttl : 버킷을 갱신하는 주기 (TTL)
    • 여기서는 fill_time * 2로 고정되어있다. 추가적으로 ttl 설정 변경이 필요하다면 이 부분을 고치면 된다.
  • now 값이 전달되지 않으면, Redis 서버의 시스템 시간을 가져온다.
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
  • 현재 토큰 수 (last_tokens)와 마지막 갱신 시간을 (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
  • 시간 차이 계산 : delta
  • 토큰 채우기 : 시간 차이에 비례하여 토큰을 채우고, 버킷의 용량을 초과하지 않도록 제한
  • 토큰 소모 : 요청된 토큰 수(requested)가 채워진 토큰 수(filled_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 }
  • Redis에 새 토큰 수 및 시간 저장
  • 결과 반환 : {요청 여부(1/0), 갱신된 후의 토큰 수}

해당 스크립트를 참고해서 나만의 토큰 버킷 알고리즘을 만들어 볼 수 있을 것 같다!

주의점

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 설정을 추가해주면 쉽게 사용할 수 있다.

spring-cloud-gateway 에서 어떻게 Redis 의존성이 추가된지 알까?

GatewayRedisAutoConfiguration.class 자동구성 클래스에서 RedisTemplate Bean 존재 여부에 따라 트리거 되도록 설정되어있다.

밑에 redisRequestRateLimiterScript() 메서드에서 확인가능하듯이 기본 lua 스크립트를 가져오고있다.

mvc 서버

@RestController
public class HelloController {

     @GetMapping("/hello")
     public String hello() {
         return "Hello, World!";
     }
}

간단한 "Hello, World!" 를 반환하는 mvc 서버 1대

gateway 서버

@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 파일 안에 다음 같은 정보를 적어주면 된다.

  • key-resolver : 우리가 선언한 bean이름
  • replenishRate : 초당 버킷 회복량
  • requestedTokens : 요청시에 소모되는 토큰의 개수
  • burstCapacity : 버킷의 담겨져있는 최대량

실행 결과

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

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

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

결론

  • spring-cloud-gateway 에선 토큰 버킷 알고리즘을 사용한 rate limiter 기능을 제공하고 있다.
  • 이는 redis 기반해서 제공하며, redis의 eval 커맨드는 lua 스크립트를 원자적으로 실행할 수 있다.
  • application.yaml 파일에서 쉽게 설정해줄 수 있다.
  • 시간이 나면 FQS 프로젝트를 리팩토링 하면서 권한 별 Rate Limit 를 달리해서 커스텀해보자 (추가 글 작성)
profile
공부 정리용

0개의 댓글