본문 바로가기
Java & Kotlin/Spring

Request Rate Limiting with Spring Cloud Gateway - 3. RateLimiterFilter 파헤치기

by devson 2022. 2. 8.

이전 포스팅에서 Spring Cloud Gateway에서 제공하는 RateLimiterFilter를 사용하여 Request Rate Limiting 기능을 적용하는 방법에 대해 알아보았다.

 

이번 포스팅에서는 내부적으로 어떤 식으로 RateLimiterFilter가 Request Rate Limiting 기능을 하는지에 대해 파헤쳐보도록하자.

 

전체 코드는 여기에서 확인 가능하다.


RateLimiterFilter

application.yml 에서 설정한 RateLimiterFilterRequestRateLimiterGatewayFilterFactory 를 살펴보면 된다.

이 필터의 apply 메서드가 리턴하는 GatewayFilter 코드를 보면 전체적인 틀을 파악할 수 있다.

 

RequestRateLimiterGatewayFilterFactory Config를 통해 KeyResolver와 RateLimiter의 bean을 주입받을 수 있다.

만약에 Config에 KeyResolver와 RateLimiter 설정이 없다면 기본으로 주입받은 bean인 defaultRateLimiter, defaultKeyResolver를 사용한다.

package org.springframework.cloud.gateway.filter.factory;

@ConfigurationProperties("spring.cloud.gateway.filter.request-rate-limiter")
public class RequestRateLimiterGatewayFilterFactory
        extends AbstractGatewayFilterFactory<RequestRateLimiterGatewayFilterFactory.Config> {

    // ...

    // 기본으로 사용하는 RateLimiter
    private final RateLimiter defaultRateLimiter;
    // 기본으로 사용하는 KeyResolver
    private final KeyResolver defaultKeyResolver;

    // ...

    public static class Config implements HasRouteId {

        private KeyResolver keyResolver;

        private RateLimiter rateLimiter;

        private HttpStatus statusCode = HttpStatus.TOO_MANY_REQUESTS;

        private Boolean denyEmptyKey;

        private String emptyKeyStatus;

        private String routeId;
        
        // getter & setters
    }
}

 

RateLimiterFilter가 처리하는 Rate Limiting 기능에 대한 흐름의 핵심 99, 112~124 라인인데 이 부분을 살펴보자.

 

- 99 라인: KeyResolver를 통해 해당 요청에 대해 key를 추출한다.

- 112 라인: Router의 ID인 routeId와 99 라인에서 추출한 key를 사용하여 RateLimiter를 통해 현재 요청을 허용해야하는지 아닌지에 대한 응답값을 받는다.

- 114~116 라인: 112 라인에서 받은 응답값에 담긴 Rate Limit 관련 HTTP Header 값들을 ServerWebExchangeServerHttpResponse Header에 담는다.

- 118~120 라인: 요청을 허용하는 경우라면 다음 GatewayFilter로 요청을 넘겨 요청을 처리할 수 있도록 한다.

- 112~123 라인: 요청을 허용하지 않는 경우라면 응답코드를 설정하고 해당 요청에대해 응답을 한다.

 

보다 자세한 내용과 설정에 대한 설명은 건너뛰겠지만 코드를 조금만 읽다보면 파악할 수 있을 정도로 RateLimiterFilter 자체는 매우 간단하다.

 

RateLimiter

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

인터페이스로 제공되어 이 인터페이스를 구현하여 RateLimiterFilter 별로 다양하게 구현체를 적용할 수 있다. 

 

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

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

그럼 RedisRateLimiter는 어떤 식으로 요청 처리율을 제한하는지에 대해 알아보자.

 

RedisRateLimiter

RedisRateLimiter 를 보면 내부는 꽤나 복잡하지만 핵심적으로 살펴볼 것은 RateLimiter 인터페이스의 기능을 구현 isAllowed 메서드이다.

 

그런데 이전에 Token Bucket Algorithm을 사용한다고 하였는데 코드를 보아도 관련된 코드는 보이지도 않고,

Request Rate Limiting 관련된 코드도 보이지 않는 것 같다.

어디서 이를 처리하는 것일까?

 

답은 Lua 스크립트에서이다.

256 라인을 보면 RedisTemplate을 통해 script를 실행하는데 

 

이 때 기본으로 사용되는 Lua script가 request_rate_limiter.lua 이다. 

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)

--redis.log(redis.LOG_WARNING, "rate " .. ARGV[1])
--redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2])
--redis.log(redis.LOG_WARNING, "now " .. ARGV[3])
--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 Algorithm을 적용하는 것이다.

코드에서 파악할 수 있듯 Redis를 통해 buckettoken을 관리하고 해당 bucket, token 관련 key에 대해 value와 TTL을 설정하게된다.

 

여기서 토큰은 Redis를 사용하면 저장하는 것은 이해되지만 Lua 스크립트는 왜 사용할까에 대해 의문이 들 수 있을 것이다.

그 이유는 개인적으로 2가지의 의미가 있을 것이라고 추측해본다.

  1. Race Condition
    Token Bucket Algorithm을 적용하기 위해서는 bucket에 token이 얼마나 들어있는지를 확인하고, bucket에 든 token을 줄이는 일련의 과정이 필요한데
    이러한 과정 동안에 다른 동일한 Client로부터 동일 요청이 들어올 경우 Race Condition으로 인해 제대로 된 처리가 되지 않을 수 있다.
    Redis는 command 처리에 대해 single thread로 처리하기 때문에 하나의 command는 원자성을 가지며,
    그렇기 때문에 Lua 스크립트를 통해 Race Condition에 대한 문제를 해결할 수 있다.
  2. 응답 지연 최소화
    API Gateway에서 Request Rate Limiting 기능 추가로 인해 응답 지연을 최소화하기 위해 Lua 스크립트를 사용할 수 있다.
    Lua 스크립트를 사용하게되면 해당 스크립트 코드가 Redis 내에서 실행되기 때문에 Network I/O를 줄일 수 있다.

 

Spring Cloud Gateway에서 이렇게 기본적인 Request Rate Limiting 기능에 대해 인터페이스와 그에 대한 구현체를 이렇게 제공하니 쉽게 적용할 수 있지만,

RedisRateLimiter를 사용할 때 한 가지 주의해야할 점이 있다.

RedisRateLimiter의 258~262 라인을 살펴보면 RedisTemplate을 통해 스크립트를 실행했을 때 오류가 난 경우에 대해 어떻게 처리하는 지를 확인 할 수 있는데,

아래 코드를 보면 알 수 있듯, 오류가 나더라도 오류가 나지 않은 것 처럼 처리된다는 것이다.

 

만약 Redis 인스턴스가 장애 등으로 인해 API Gateway 서버에서 접속을 할 수 있는 상태가 아니라고 하자.

그러면 아래와 같이 오류가 나는 것을 확인할 순 있지만 따로 오류 응답을 하진 않고 정상적으로 처리된 것 처럼 나오게된다.

 

특히 로그 레벨이 debug이기 때문에 실 운영 환경에서 Redis 관련 오류를 알아차리기 힘들 수 있다.

만일 RedisRateLimiter를 사용하는 경우 이 부분에 대해 꼭 인지를 하길 바란다.

 


포스팅 시리즈를 통해 Spring Cloud Gateway로 Request Rate Limiting 기능을 적용하는 방법에 대해 알아보았다.

해당 기능에 대한 코드가 그렇게 복잡하지 않기 때문에 조금만 살펴보면 customizing을 하는 것도 그다지 어려운 작업이 되지 않을 것이다.

댓글