개발 환경
Spring Boot : 3.3.0
Spring Cloud : 2023.0.2 (Spring Boot 3.X 버전과 호환)
spring-boot-starter-data-redis-reactive : 3.3.0
Redis : 7.4.1
일을 하던 중간에 서비스 과부하 방지를 위해 사용자별 API 요청에 대한 속도 조절 및 요청 제한 기능을 요청받아 구현하는 방식을 알아보던 중 API Rate Limiting 또는 처리율 제한 장치라고 불리는 것을 알게 되었다.
그렇게 일을 하면서 구현한 RateLimit는 bucket4j 라이브러리를 사용하여 OncePerRequestFilter를 상속받아 구현하였고 개인적으로 진행하고 있는 프로젝트(MSA)에도 적용하기 위해 알아보던 중 Spring Cloud에서는 API Rate Limiting 기능이 이미 구현되어 있다는 것을 알게 되었다.
API Rate Limiter란
이어서 API Rate Limiter란 API 요청 속도를 조절하여 대량의 패킷을 방지하는 기능으로 장점은 다음과 같다.
- 서비스 과부하 방지
특정 시간 동안 허용되는 API 요청 수를 제한하여 서버가 과부하되지 않도록 한다. - DDoS 공격 방어
대량의 요청이 단시간에 발생하는 DDoS(Distributed Denial of Service) 공격으로부터 서비스를 보호하는 데 유용하다. - 비용 관리
클라우드 기반 서비스인 경우 요청 수에 따라 요금이 부과될 수 있으므로 Rate Limiter를 사용하면 불필요한 비용을 줄일 수 있다고 한다.
API Rate Limter 구현 방식
위에 작성한 것처럼 Spring Cloud Gateway에서는 해당 기능을 이미 구현했기 때문에 이번 포스팅에서 다루지 않는 내용이 있을 것 같다.
API Rate Limiter에 대한 자세한 내용은 아래 링크를 확인하면 된다.
https://spring.io/blog/2021/04/05/api-rate-limiting-with-spring-cloud-gateway
API Rate Limiting with Spring Cloud Gateway
One of the imperative architectural concerns is to protect APIs and service endpoints from harmful effects, such as denial of service, cascading failure. or overuse of resources. Rate limiting is a technique to control the rate by which an API or a service
spring.io
- Token Bucket Algorithm
Spring Cloud Gateway에서는 API Rate Limiter 기능 구현 시 토큰 버킷 알고리즘을 사용했다.
토큰 버킷 알고리즘을 간단하게 정리하면 다음과 같다.
# 토큰 버킷 알고리즘
1. 아래 3가지 항목을 사전에 선정
- 바구니에 담을 수 있는 최대 토큰 개수
- 초마다 바구니에 넣는 토큰 개수
- 1개의 요청에 소비되는 토큰 개수
2. 요청이 들어오면 바구니에서 토큰을 꺼낸다.
2-1. 토큰이 꺼내진 경우 이어서 진행한다.
2-2. 토큰이 없는 경우 설정한 error code 반환(ex: 429 Too Many Requests)
1. Redis 다운로드
1-1. 윈도우용 Redis 설치 후 lua 스크립트 오류 발생
아래 링크는 윈도우 OS에 제공하는 가장 최신버전의 Redis를 다운받을 수 있는 링크이다.
그러나 필자는 아래 Redis 다운로드 후 Rate Limter 환경 세팅까지 했지만 Redis 구문 오류가 발생했다.
https://github.com/microsoftarchive/redis/releases
Releases · microsoftarchive/redis
Redis is an in-memory database that persists on disk. The data model is key-value, but many different kind of values are supported: Strings, Lists, Sets, Sorted Sets, Hashes - microsoftarchive/redis
github.com
오류가 발생한 원인을 확인해 보니 윈도우용 Redis(3.0.504)는 너무 예전 버전이어서 이미 구현된 API Rate Limter와 호환되지 않았다.
(2024.12.16 기준 Redis 7.4.x 버전이 최신 버전이다)
# 오류메시지
Caused by: io.lettuce.core.RedisCommandExecutionException: ERR Error running script (call to f_775635cca91092477cbcee85fd800accb6adc4b2): @user_script:1: user_script:1: attempt to call field 'replicate_commands' (a nil value)
at io.lettuce.core.internal.ExceptionFactory.createExecutionException(ExceptionFactory.java:147) ~[lettuce-core-6.3.2.RELEASE.jar:6.3.2.RELEASE/8941aea]
위 로그를 보니 lua 스크립트 실행 중에 오류가 발생했고 replicate_commands 함수를 사용할 수 없다는 내용으로 확인되었다.
그래도 혹시 몰라서 lua 스크립트가 정상적으로 실행되는지부터 확인하기 위해 CLI에서 테스트를 진행해 봤다.
역시나 잘되네용
이후 replicate_commands 함수에 대해 확인해 보니 Redis 6.0.0 버전부터 사용할 수 있다고 해서 docker에 Redis를 설치 후 진행해야 했다.
윈도우에서 docker를 사용하는 방법은 아래 포스팅을 참고하면 좋을 것 같다.
(WSL 설정 및 docker 설치 후 샘플 컨테이너 실행하는 내용이다)
https://jiji-gilog.tistory.com/16
[Docker] Windows의 WSL을 이용한 Linux 및 Docker 설치
나의 노트북은 윈도우 OS 기반의 LG gram 노트북이다. 추후 가벼운 프로젝트를 한 개 진행하려고 하는데 그때는 로컬이 아닌 linux 기반의 OS 위에 웹 애플리케이션을 배포하려고 한다. OS는 레드햇
jiji-gilog.tistory.com
1-2. docker에서 Redis 설치
# Redis 설치
1. docker pull redis // redis 이미지 다운로드
2. docker images // 설치된 image 목록 확인
3. docker run -p 6379:6379 --name redis redis // redis 컨테이너 생성 후 실행
4. docker ps -a // 실행된 컨테이너 확인
docker desktop을 사용하는 경우에는 아래 이미지처럼 컨테이너가 올라가 있는 것을 확인할 수 있다.
참고로 필자가 설치한 redis는 7.4.1 버전이다.
# Redis 버전 확인
1. 윈도우 커맨드에서 docker exec -it [서버이름] /bin/bash 명령어 입력
ex) docker exec -it redis /bin/bash
2. 리눅스에서 redis-cli --version 입력 또는 redis-cli(cli 접속) 입력 후 INFO server 입력
2. Redis 환경 설정
[ 패키지 구조 ]
[ Redis 관련 디펜던시 추가 ]
API Rate Limiter는 Redis를 사용하고 있고 API Gateway 서비스가 Netty 서버를 사용하기 때문에 아래 디펜던시를 추가해야야한다.
// Rate Limiter를 구현하기 위한 디펜던시
implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive'
아래 소스들은 Redis 환경설정과 관련된 Bean을 등록하는 소스(RedisConfig, RateLimterConfig)로 주석을 참고하면 될 것 같다.
[ RedisConfig ]
/**
* Redis 환경 설정
*/
@Slf4j
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
/**
* Redis 연결을 위한 'Connection' 생성합니다.
* @return RedisConnectionFactory
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(host, port);
}
/**
* ReactiveStringRedisTemplate Bean 생성
* - 비동기적인 Redis 연결을 위한 템플릿 생성
* @param redisConnectionFactory redis connection
* @return ReactiveStringRedisTemplate Bean
*/
@Bean
public ReactiveStringRedisTemplate reactiveStringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
return new ReactiveStringRedisTemplate((ReactiveRedisConnectionFactory) redisConnectionFactory);
}
}
[ RateLimterConfig ]
KeyResolver 구현한 부분을 보면 요청 헤더에서 X-USER-ID 꺼내오는 것을 확인할 수 있는데 해당 값은 아래 RateLimitFilter 파일에서 설명하며 해당 필터 이전에 인증 필터를 거치고 있어 인증 필터에서 X-USER-ID 헤더를 세팅한다.
/**
* API Rate Limiter 설정
*/
@Slf4j
@Configuration
public class RateLimiterConfig {
/** 초당 버킷에 추가되는 토큰 개수 */
private static final int REPLENISH_RATE = 1;
/** 버킷에 담을 수 있는 최대 토큰 개수 */
private static final int BURST_CAPACITY = 1;
/** API 요청 시 소비되는 토큰 개수 */
private static final int REQUESTED_TOKENS = 1;
/**
* RedisRateLimiter Bean 생성
* @return RedisRateLimiter Bean
*/
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(REPLENISH_RATE, BURST_CAPACITY, REQUESTED_TOKENS);
}
/**
* RequestRateLimiter Filter 기준 Bean 생성
* - 사용자 ID 기준으로 토큰 키를 세팅한다 .
* @return 사용자 ID
*/
@Bean
public KeyResolver userKeyResolver() {
return exchange -> {
ServerHttpRequest request = exchange.getRequest();
String userId = String.valueOf(request.getHeaders().get("X-USER-ID"));
log.debug("request userId : {}", userId);
return Mono.just(StringUtils.hasText(userId) ? userId : "Anonymous");
};
}
}
3. RateLimit filter 구현
위에서 설정한 Redis 및 RedisRateLimiter 정보들을 기반으로 filter를 구현하여 AuthorizationHeaderFilter 클래스에서 사용자 인증 후 사용자별로 API 요청 제한을 처리하는 소스이다.
@Slf4j
@Component
public class RedisRateLimitFilter extends AbstractGatewayFilterFactory<RedisRateLimitFilter.Config> {
private final KeyResolver userKeyResolver;
private final RedisRateLimiter redisRateLimiter;
public RedisRateLimitFilter(KeyResolver userKeyResolver, RedisRateLimiter redisRateLimiter) {
super(Config.class);
this.userKeyResolver = userKeyResolver;
this.redisRateLimiter = redisRateLimiter;
}
@Getter @Setter
public static class Config implements HasRouteId {
private String routeId;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
String routeId = config.getRouteId();
log.debug("routeId: {}", routeId);
return userKeyResolver.resolve(exchange)
.flatMap(key -> redisRateLimiter.isAllowed(routeId, key))
.flatMap(rateLimitResponse -> {
// Rate limit이 허용된 경우
if (rateLimitResponse.isAllowed()) {
log.debug("Rate limit pass. ");
return chain.filter(exchange);
}
log.warn("Rate limit exceeded. ");
// 허용되지 않은 경우 HTTP 429 error 발생
return Mono.error(new ResponseStatusException(HttpStatus.TOO_MANY_REQUESTS, "Rate limit exceeded"));
});
};
}
}
해당 필터는 기능 구현 후 테스트 하기 위해 임의로 작성하는 클래스이고 RedisRateLimiter는 이미 구현된 파일이 있기 때문에 작성하지 않고 yml 파일에 다음과 같이 작성해도 된다.
- 이미 구현되어 있는 RedisRateLimiter
4. RateLimit filter 적용
필자는 사용자별로 처리율을 제한하기 때문에 인증 필터를 거친 후 RedisRateLimitFilter를 적용했다.
(일하는 도중에 구현한 RateLimiter는 개발 중인 시스템이 독립망 환경이고 내부 사용자만 사용하기 때문에 사용자 ID가 아닌 IP를 기준으로 잡아 인증 필터 이전에 RateLimit 필터가 적용되도록 설정했다)
spring:
application:
name: apigateway-service
data:
redis:
host: 127.0.0.1
port: 6379
cloud:
gateway:
routes:
# 사용자 GET 요청
- id: user-service
uri: lb://USER-SERVICE
predicates:
- Path=/user-service/**
- Method=GET
filters:
- RemoveRequestHeader=Cookie
- RewritePath=/user-service/(?<segment>.*), /$\{segment}
- name: AuthorizationHeaderFilter
- name: RedisRateLimitFilter
# - name: RequestRateLimiter
# args:
# key-resolver: "#{@userKeyResolver}"
# redis-rate-limiter.replenishRate: 5 # 초당 10개씩 토큰 생성
# redis-rate-limiter.burstCapacity: 10 # 버킷 용량 (토큰 최대 10개)
# redis-rate-limiter.requestedTokens: 1 # 요청 시 1개의 토큰을 사용
API RateLimiting 기능 테스트
테스트는 POSTMAN으로 진행했다.
- 테스트 시나리오
RateLimiterConfig 클래스에서 아래 소스와 같이 1초에 2번 이상을 요청하면 429 에러가 발생하도록 설정했다.
/** 초당 버킷에 추가되는 토큰 개수 */
private static final int REPLENISH_RATE = 1;
/** 버킷에 담을 수 있는 최대 토큰 개수 */
private static final int BURST_CAPACITY = 1;
/** API 요청 시 소비되는 토큰 개수 */
private static final int REQUESTED_TOKENS = 1;
/**
* RedisRateLimiter Bean 생성
* @return RedisRateLimiter Bean
*/
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(REPLENISH_RATE, BURST_CAPACITY, REQUESTED_TOKENS);
}
- Blank collection 생성
- Run collection
- select Order
아래 이미지 좌측의 특정 아이템을 드래그앤드롭으로 가운데 영역으로 가져와도 되고 가운데 영역에 API 요청할 아이템을 설정한 후 우측 Iterations 항목을 10으로 설정한다.
- 테스트 결과
우측 영역에 429 Too Many Requests 에러가 발생한 것을 확인할 수 있다.
- 참고 레퍼런스
RequestRateLimiter GatewayFilter Factory :: Spring Cloud Gateway
The RequestRateLimiter GatewayFilter factory uses a RateLimiter implementation to determine if the current request is allowed to proceed. If it is not, a status of HTTP 429 - Too Many Requests (by default) is returned. This filter takes an optional keyReso
docs.spring.io
blog-spring-cloud-gateway/gateway/src/main/resources/application.yml at rate-limiting · Haybu/blog-spring-cloud-gateway
Contribute to Haybu/blog-spring-cloud-gateway development by creating an account on GitHub.
github.com
https://esssun.tistory.com/151
[프로젝트] 2. API Rate Limiter 도입 - Spring Cloud Gateway
트래픽 제한을 고려하게 된 이유오늘은 지난번에 소개 드린 "올봄" 프로젝트의 2. API Rate Limiter 도입을 진행해보려고 한다.이번에 개선할 프로젝트인 AI 기반 장년층 라이프 케어 서비스 "올봄"에
esssun.tistory.com
https://dgle.dev/RateLimiter1/
Request Rate Limiter를 만들어보자! 1편 | Dongle
Make Custom Rate Limiter With Spring Cloud Gateway 1
dgle.dev
'MSA' 카테고리의 다른 글
[MSA] Spring Cloud Gateway를 활용한 API Gateway 구현 (0) | 2024.05.31 |
---|---|
[MSA] Cloud Native Architecture, Application란 (0) | 2024.04.28 |