개발 환경
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
- 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
오류가 발생한 원인을 확인해 보니 윈도우용 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
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 에러가 발생한 것을 확인할 수 있다.
- 참고 레퍼런스
https://esssun.tistory.com/151
https://dgle.dev/RateLimiter1/
'MSA' 카테고리의 다른 글
[MSA] Spring Cloud Gateway를 활용한 API Gateway 구현 (0) | 2024.05.31 |
---|---|
[MSA] Cloud Native Architecture, Application란 (0) | 2024.04.28 |