트래픽 제한을 고려하게 된 이유
오늘은 지난번에 소개 드린 "올봄" 프로젝트의 2. API Rate Limiter 도입을 진행해보려고 한다.
이번에 개선할 프로젝트인 AI 기반 장년층 라이프 케어 서비스 "올봄"에서는 챗봇, 지도, ToDo, 게임, 일자리 총 5가지 기능이 있다.
이 중에서도 챗봇 기능에선 Langchain을 사용하여 LLM의 한 종류인 GPT-4 기반의 챗봇을 제공한다.
사용자들은 LLM 기반의 챗봇을 통해 일상 대화, 노인 편의 기능 및 유용 정보를 제공 받을 수 있다.
올봄은 장년층의 삶에 활기를 불어넣자는 목적으로 기획된 공익 서비스로, 현재까진 별도의 비즈니스 모델이 없다.
서비스 규모가 좀 더 커진다면 장년층을 대상으로 한 수익화 방안을 모색할 계획이 있지만, 지금까지는 수익화 전략이 따로 마련되어 있지 않은 상황이다.
챗봇 기능 운영을 위해서는 GPT-4 API 호출이 필요한데, 호출 한 건당 약 50-60원의 비용이 발생한다.
현재 올봄은 초기 사용자 확보를 위해 당분간 이 비용을 운영 측에서 부담하는 방식으로 서비스를 제공하고 있다.
그러나 악의적인 사용자가 나타나 챗봇에 지속적으로 요청을 보낼 경우, 예상치 못한 높은 GPT 사용 비용이 발생할 수 있다.
따라서, 사용자들의 요청 횟수에 제한을 두는 것이 필요하다고 판단했다.
API Rate Limiter
스프링에서 API Rate Limiter를 구현할 수 있는 방법은 여러가지가 있다.
그 중에서도, 나는 추후 확장성을 고려하여 메인 스프링 부트 서버 앞단에 Spring Cloud Gateway를 두어 API Rate Limiter를 구현하기로 하였다.
API Rate Limiter 코드
RateLimiterConfig.java
@Configuration
public class RateLimiterConfig {
private static final String MEMBER_ID_ATTRIBUTE = "MEMBER_ID";
@Bean
public KeyResolver memberIdKeyResolver() {
return exchange -> {
Long memberId = exchange.getAttribute(MEMBER_ID_ATTRIBUTE);
if (memberId != null) {
return Mono.just(memberId.toString()); // MemberId를 기반으로 Rate Limiting 적용
} else {
return Mono.just("anonymous"); // MemberId가 없는 경우 기본값 사용
}
};
}
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(1, 1, 1);
}
}
RequestRateLimitFilter.java
@Component
@Slf4j
public class RequestRateLimitFilter extends AbstractGatewayFilterFactory<RequestRateLimitFilter.Config> {
private final KeyResolver memberIdKeyResolver;
private final RedisRateLimiter defaultRateLimiter;
public RequestRateLimitFilter(KeyResolver memberIdKeyResolver, RedisRateLimiter redisRateLimiter) {
super(Config.class);
this.memberIdKeyResolver = memberIdKeyResolver;
this.defaultRateLimiter = redisRateLimiter;
}
@Override
public GatewayFilter apply(Config config) {
GatewayFilter filter = (exchange, chain) -> {
KeyResolver keyResolver = getOrDefault(config.keyResolver, memberIdKeyResolver);
RedisRateLimiter rateLimiter = getOrDefault(config.rateLimiter, defaultRateLimiter);
String routeId = config.getRouteId();
System.out.println("routeId = " + routeId);
return keyResolver.resolve(exchange)
.flatMap(key -> rateLimiter.isAllowed(routeId, key))
.flatMap(rateLimitResponse -> {
if (rateLimitResponse.isAllowed()) {
return chain.filter(exchange); // Rate limit이 허용된 경우
} else {
log.warn("Rate limit exceeded for key: ");
// TooManyRequestException 발생
return Mono.error(new TooManyRequestsException(AuthErrorCode.TOO_MANY_REQUESTS));
}
});
};
return filter;
}
private <T> T getOrDefault(T configValue, T defaultValue) {
return configValue != null ? configValue : defaultValue;
}
@Getter
@Setter
public static class Config implements HasRouteId {
private KeyResolver keyResolver;
private RedisRateLimiter rateLimiter;
private String routeId;
}
}
TooManyException.java
public class TooManyRequestsException extends AllbomException{
public TooManyRequestsException(ErrorCode errorCode) {
super(errorCode);
}
}
application.yml
server:
port: 8081
spring:
application:
name: allbom-gateway
cloud:
gateway:
routes:
- id: api-server
uri: http://localhost:8080
predicates:
- Path=/**
filters:
- name: RequestRateLimitFilter
data:
redis:
host: localhost
port: 6379
실행 결과
spring cloud gateway 서버의 port번호인 8081을 통해 API 엔드포인트로 요청을 보냈다.
위에서 봤듯이, RedisRateLimiter(1, 1, 1)은 초당 1개의 요청을 허용하고, 그 이상은 429 에러를 발생시킨다. 즉, 주어진 시간 내에 요청 수가 해당 설정을 초과했기 때문에, 429 에러가 난 것이다.
위에서 Error를 Custom한대로, 너무 많은 요청이 들어오면 클라이언트에게 429에러를 반환하고, 적절한 메시지를 전달하는 것을 확인할 수 있다.
개선된 아키텍처
API Rate Limiter을 구현하기 위해 메인 애플리케이션 앞단에 Spring Cloud Gateway 서버를 추가하였다.
즉, Spring Cloud Gateway는 이 아키텍처에서 클라이언트 요청의 진입점이다.
RedisRateLimiter는 사용자 또는 클라이언트별로 개별적인 버킷이 주어진다. 즉, 각 사용자마다 별도의 버킷이 할당되며, 이 버킷을 통해 요청 제한이 관리된다. 이를 통해 동일한 API에 대한 요청이라 하더라도, 각 사용자의 요청 빈도가 별도로 제한된다.
Spring Cloud Gateway와 RedisRateLimiter를 함께 사용할 때, 요청을 보낸 사용자의 고유한 식별자(예: IP 주소, API 키, 사용자 ID)를 기준으로 각 사용자에게 별도의 버킷이 할당된다. 이 과정에서 HTTP 헤더의 Bearer JWT 토큰을 디코딩하여 사용자 고유의 멤버 ID를 추출하고, 이를 기준으로 요청 제한을 적용하였다.
Gateway 서버에서 이미 JWT 디코딩을 통해 멤버 ID를 추출하므로, 이 값을 메인 서버에 헤더로 전달하여 추가적인 처리를 간소화하고, 효율적으로 활용할 수 있도록 변경하였다.
이를 통해 사용자의 요청 수를 효과적으로 제한하고, 서비스의 안정성을 유지하면서 악의적인 요청으로 인한 서버 과부하를 방지할 수 있게 되었다. 이 Gateway 서버는 요청의 분배, 필터링, 그리고 인증과 같은 추가적인 기능도 수행하여 전체 아키텍처의 유연성과 확장성을 높여준다.
참고 문헌
https://pgmjun.tistory.com/m/153
https://dkswnkk.tistory.com/m/732
https://bossm0n5t3r.github.io/posts/spring-boot-bucket4j/
https://dkswnkk.tistory.com/m/732
https://devs0n.tistory.com/70?category=865955
https://woooongs.tistory.com/56
https://dgle.dev/RateLimiter1/