이 튜토리얼에서는 확장된(스케일 아웃된) 서비스에서 속도 제한(Rate Limiting)을 구현하는 방법을 알아봅니다. 구현에는 Bucket4J 라이브러리를 사용하고, 분산 캐시로는 Redis를 활용합니다.
속도 제한은 왜 필요할까요?
먼저 기본 개념부터 살펴보며 속도 제한이 왜 필요한지 이해하고, 이번 튜토리얼에서 사용할 도구들을 소개하겠습니다.
무제한 요청의 문제점
Twitter API 같은 공개 API가 사용자에게 시간당 무제한 요청을 허용한다면 어떤 일이 벌어질까요?
- 서버 리소스 고갈
- 서비스 품질 저하
- DoS(서비스 거부) 공격에 취약해짐
결과적으로 서비스가 느려지거나 아예 사용할 수 없는 상황이 발생할 수 있으며, 예상치 못한 추가 비용이 발생할 수도 있습니다.
속도 제한이 주는 이점
첫째, 속도 제한은 DoS 공격을 방지할 수 있습니다. 여기에 중복 제거(deduplication) 메커니즘이나 API 키를 결합하면 DDoS(분산 서비스 거부) 공격까지 예방하는 데 도움이 됩니다.
둘째, 트래픽 규모를 파악하는 데 유용합니다. 공개 API에서는 특히 중요한 부분으로, 자동화 스크립트와 연계해 서비스를 모니터링하고 자동으로 확장하는 데 활용할 수 있습니다.
셋째, 등급 기반 요금제(tier-based pricing)를 구현하는 데 사용할 수 있습니다. 이 요금 모델은 사용자가 더 높은 요청 한도를 위해 비용을 지불하는 방식으로, Twitter API가 대표적인 예입니다.
토큰 버킷(Token Bucket) 알고리즘
토큰 버킷은 속도 제한을 구현할 때 사용할 수 있는 알고리즘입니다. 간단히 설명하면 다음과 같이 동작합니다.
- 특정 용량(토큰 수)을 가진 버킷을 생성합니다.
- 요청이 들어오면 버킷을 확인합니다. 충분한 용량이 있으면 요청을 통과시키고, 그렇지 않으면 거부합니다.
- 요청이 허용되면 용량(토큰)이 차감됩니다.
- 일정 시간이 지나면 용량이 다시 충전됩니다.
분산 시스템에서 토큰 버킷 구현하기
분산 시스템에서 토큰 버킷 알고리즘을 구현하려면 분산 캐시가 필요합니다.
캐시는 버킷 정보를 저장하는 키-값 저장소(key-value store) 역할을 합니다. 여기서는 Redis 캐시를 사용해 구현하겠습니다.
내부적으로 Bucket4j는 Java JCache API의 어떤 구현체든 연결(plug-in)할 수 있습니다. 이번 튜토리얼에서는 Redis의 Redisson 클라이언트를 구현체로 사용합니다.
프로젝트 구현
서비스 구축에는 Spring Boot 프레임워크를 사용합니다.
우리 서비스는 다음 컴포넌트들로 구성됩니다.
- 간단한 REST API
- 서비스에 연결된 Redis 캐시 – Redisson 클라이언트 사용
- REST API를 감싸는 Bucket4J 라이브러리
- Bucket4J를 JCache 인터페이스에 연결하고, 백그라운드 구현체로 Redisson 클라이언트 사용
먼저 모든 요청에 대해 API 속도 제한을 적용하는 방법을 배우고, 이후 사용자별 또는 요금제별로 더 복잡한 속도 제한을 구현하는 방법을 알아보겠습니다.
프로젝트 설정부터 시작해 보겠습니다.
의존성 설치
pom.xml(또는 build.gradle) 파일에 아래 의존성을 추가합니다.
<dependencies>
<!-- To build the Rest API -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redisson Starter = Spring Data Redis starter(excluding other clients) and Redisson client -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.0</version>
</dependency>
<!-- Bucket4J starter = Bucket4J + JCache -->
<dependency>
<groupId>com.giffing.bucket4j.spring.boot.starter</groupId>
<artifactId>bucket4j-spring-boot-starter</artifactId>
<version>0.5.2</version>
</dependency>
</dependencies>캐시 설정
먼저 Redis 서버를 실행해야 합니다. 로컬 머신의 6379 포트에서 Redis 서버가 실행 중이라고 가정하겠습니다.
수행해야 할 작업은 두 가지입니다.
- 애플리케이션에서 해당 서버로의 연결 생성
- JCache가 Redisson 클라이언트를 구현체로 사용하도록 설정
Redisson 공식 문서는 일반 Java 애플리케이션에서 이를 구현하는 간결한 단계를 제공합니다. 우리는 동일한 단계를 Spring Boot 환경에서 구현할 것입니다.
먼저 코드를 살펴보겠습니다. 필요한 빈(bean)들을 생성하기 위한 Configuration 클래스를 만듭니다.
@Configuration
public class RedisConfig {
@Bean
public Config config() {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
return config;
}
@Bean
public CacheManager cacheManager(Config config) {
CacheManager manager = Caching.getCachingProvider().getCacheManager();
cacheManager.createCache("cache", RedissonConfiguration.fromConfig(config));
return cacheManager;
}
@Bean
ProxyManager<String> proxyManager(CacheManager cacheManager) {
return new JCacheProxyManager<>(cacheManager.getCache("cache"));
}
}이 코드는 무엇을 하는 걸까요?
- 연결 생성에 사용할 설정 객체를 만듭니다.
- 설정 객체로 캐시 매니저를 생성합니다. 내부적으로 Redis 인스턴스와의 연결이 만들어지고, "cache"라는 이름의 해시(hash)가 생성됩니다.
- 캐시에 접근하는 데 사용될 프록시 매니저를 생성합니다. 애플리케이션이 JCache API를 통해 캐싱하려는 모든 데이터는 Redis 인스턴스의 "cache" 해시 안에 저장됩니다.
API 작성
간단한 REST API를 만들어 보겠습니다.
@RestController
public class RateLimitController {
@GetMapping("/user/{id}")
public String getInfo(@PathVariable("id") String id) {
return "Hello " + id;
}
}https://localhost:8080/user/1 URL로 API를 호출하면 Hello 1 응답을 받게 됩니다.
Bucket4J 설정
속도 제한을 구현하려면 Bucket4J를 설정해야 합니다. 다행히 스타터(starter) 라이브러리 덕분에 보일러플레이트 코드를 직접 작성할 필요가 없습니다.
또한 이 라이브러리는 이전 단계에서 생성한 ProxyManager 빈을 자동으로 감지하여 버킷 캐싱에 활용합니다.
우리가 해야 할 일은 이 라이브러리를 앞서 만든 API에 맞게 설정하는 것입니다.
역시 여러 가지 방법이 있습니다.
스타터 라이브러리에 정의된 프로퍼티(property) 기반 설정을 사용할 수 있습니다.
모든 사용자 또는 게스트 사용자 전체에 대한 속도 제한처럼 단순한 경우에는 가장 편리한 방식입니다.
하지만 사용자별 속도 제한처럼 더 복잡한 기능을 구현하려면 커스텀 코드를 작성하는 것이 좋습니다.
여기서는 사용자별(per-user) 속도 제한을 구현해 보겠습니다. 각 사용자의 속도 제한 정보가 데이터베이스에 저장되어 있고, 사용자 ID로 조회할 수 있다고 가정합니다.
단계별로 코드를 작성해 보겠습니다.
버킷 생성하기
시작하기 전에 버킷이 어떻게 생성되는지 살펴보겠습니다.
Refill refill = Refill.intervally(10, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(10, refill);
Bucket bucket = Bucket4j.builder()
.addLimit(limit)
.build();- Refill – 버킷이 얼마나 자주 리필(refill)되는지를 나타냅니다.
- Bandwidth – 버킷이 가진 대역폭입니다. 즉, 리필 주기당 허용되는 요청 수입니다.
- Bucket – 위 두 파라미터로 구성되는 객체입니다. 추가로 버킷에 남아 있는 토큰 수를 추적하는 토큰 카운터를 유지합니다.
이것을 기본 빌딩 블록으로 삼고, 우리 사용 사례에 맞게 몇 가지를 변경해 보겠습니다.
ProxyManager로 버킷 생성 및 캐싱하기
ProxyManager는 버킷을 Redis에 저장하기 위해 만든 것입니다. 버킷이 한 번 생성되면 Redis에 캐시되며, 다시 생성할 필요가 없습니다.
이를 위해 Bucket4j.builder()를 proxyManager.builder()로 대체합니다. ProxyManager가 버킷을 캐싱하고 중복 생성하지 않도록 처리해 줍니다.
ProxyManager의 빌더는 두 개의 파라미터를 받습니다. 버킷이 캐싱될 때 기준이 되는 키(key)와, 버킷 생성에 사용할 설정 객체(configuration object)입니다.
구현 방법을 살펴보겠습니다.
@Service
public class RateLimiter {
//autowiring dependencies
public Bucket resolveBucket(String key) {
Supplier<BucketConfiguration> configSupplier = getConfigSupplierForUser(key);
// Does not always create a new bucket, but instead returns the existing one if it exists.
return buckets.builder().build(key, configSupplier);
}
private Supplier<BucketConfiguration> getConfigSupplierForUser(String key) {
User user = userRepository.findById(userId);
Refill refill = Refill.intervally(user.getLimit(), Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(user.getLimit(), refill);
return () -> (BucketConfiguration.builder()
.addLimit(limit)
.build());
}
}주어진 키에 해당하는 버킷을 반환하는 메서드를 만들었습니다. 다음 단계에서 이를 실제로 활용하는 방법을 살펴보겠습니다.
토큰 소비 및 속도 제한 적용하기
요청이 들어오면 해당 사용자의 버킷에서 토큰 하나를 소비하려 시도합니다.
이때 버킷의 tryConsume() 메서드를 사용합니다.
@GetMapping("/user/{id}")
public String getInfo(@PathVariable("id") String id) {
// gets the bucket for the user
Bucket bucket = rateLimiter.resolveBucket(id);
// tries to consume a token from the bucket
if (bucket.tryConsume(1)) {
return "Hello " + id;
} else {
return "Rate limit exceeded";
}
}tryConsume() 메서드는 토큰 소비에 성공하면 true, 실패하면 false를 반환합니다.
서비스 테스트하기
자동화 테스트 기법을 활용해 테스트할 수 있습니다. 예를 들어 JUnit을 사용할 수 있습니다. getInfo() 메서드를 여러 번 호출하고 응답이 올바른지 검증하는 테스트 케이스를 작성해 보겠습니다.
ID가 1인 사용자는 분당 10건의 요청 제한이 있고, ID가 2인 사용자는 분당 20건의 요청 제한이 있다고 가정합니다.
두 사용자 모두 11번의 요청을 보내고, ID가 1인 사용자의 요청은 실패하고 ID가 2인 사용자의 요청은 성공하는지 확인합니다.
@Test
public void testGetInfo() {
// calls the method 10 times for user 1
for (int i = 0; i < 10; i++) {
rateLimiter.getInfo(1));
rateLimiter.getInfo(2));
}
// verifies that the response is rate limited for user 1
assertEquals("Rate limit exceeded", rateLimiter.getInfo(1));
// verifies that the response is successful for user 2
assertEquals("Hello 2", rateLimiter.getInfo(2));
}테스트를 실행하면 정상적으로 통과하는 것을 확인할 수 있습니다.
마무리
이번 튜토리얼에서는 Spring Boot 애플리케이션에서 Bucket4j와 Redis를 활용해 속도 제한기를 만드는 방법을 다뤘습니다. 또한 JCache와 함께 Redisson 클라이언트를 설정하고, 이를 통해 버킷을 캐싱하는 방법도 살펴보았습니다.
마지막으로 특정 사용자의 요청에 속도 제한을 적용할 수 있는 간단한 속도 제한기를 구현했습니다.
이 튜토리얼이 도움이 되었기를 바랍니다. 읽어주셔서 감사합니다!