개요
스마트폰 어플에서 우다다다 버튼을 누르다보면 가끔 아래와 같은 메세지를 마주할 수 있다.
이런 기능들은 대부분의 사이트에서 만나볼 수 있는데, 보통 서버 안정을 위해 필수적으로 탑재하는 기능이다. 전문 용어로 처리율 제한 장치 (= Rate limiter = API Throttling)라고 부르는 이 친구, 내가 만들어 볼 순 없을까?
처리율 제한 장치와 더불어 Spring Boot에서 처리율 제한 기능을 구현할 수 있는 다양한 Java 라이브러리도 알아보자! ⚡️
본 포스팅은 구현보다는 방법의 종류에 대해 전반적으로 알아보는 것을 목적으로 합니다 :)
실습은 Java11 / Spring Boot 2.7.6 / Gradle 8 환경에서 진행합니다.
처리율 제한 장치 (Rate Limiter = API Throttling)
처리율 제한 장치는 클라이언트 또는 서비스가 보내는 트래픽 처리율을 제한하기 위한 장치이다. 보통은 트래픽이 과도하게 높아진 경우, 로드밸런서를 이용해 서버에 적절히 부하를 분산하는 방법을 사용하지만 그럼에도 처리율 제한은 필요하다.
왜 처리율 제한 장치가 필요할까?
- 디도스 공격에 의한 자원 고갈 (resource starvation)을 방지하기 위함
- 처리율을 제한함으로써 서버를 많지 않게 두거나 or 우선 순위가 높은 API에 더 많은 자원을 할당하는 방식으로 서버 리소스 절감
- 잘못된 이용 패턴으로 인해 유발된 트래픽을 막아 불필요한 서버 과부하를 방지 가능
👉 즉, 그럴리 없는 요청이 과도하게 몰릴 경우 잘못된 요청일 가능성을 고려하기 위해 사용한다고 보면 되겠다. 처리율 제한 기능의 구현 예시로는 아래와 같은 친구들이 있다.
- 사용자는 초당 2회 이상 새 글을 올릴 수 없다
- 같은 IP 주소로는 하루에 10개 이상의 계정을 생성할 수 없다
- 동일 계정으로 주 5회 이상의 리워드를 요청할 수 없다
처리율은 어디서 제어되어야 할까?
처리율 제한 장치는 클라이언트, 미들웨어, 서버 중 한 곳에 위치할 수 있다. 각각의 장단점은 다음과 같다.
위치 | 장점 | 단점 |
클라이언트측 | 딱히 없음 | - 위변조가 쉽다. - 모든 클라이언트의 구현을 통제하기 어렵다. |
미들웨어측 | API gateway가 지원해주기에 비교적 구현이 쉽다. | - 미들웨어를 커스텀하기 어려울 수 있다. - SPOF : 미들웨어에 문제 생기면 전체 처리율 제한 처리에도 문제가 생긴다. |
서버측 | 자유롭게 처리율 제한 알고리즘을 선택할 수 있다. | - 처리율 제한 장치 구현 인력이 추가로 필요하다. - 사용 중인 PGL이 서버측 구현을 지원할 만큼 효율이 높은지 검토가 필요하다. |
만약 내가 MSA 환경이라 이미 API gateway를 사용 중이라면, 또는 처리율 제한 장치를 직접 구현할 리소스가 충분하지 않다면 미들웨어측에 구현하는 것이 제일 좋아 보인다 🤔
처리율 제한 알고리즘?
처리율 제한 알고리즘에는 여러 가지 종류가 있다. 하나씩 알아보자.
이름 | 토큰 버킷 알고리즘 | 누출 버킷 알고리즘 |
동작 원리 | 요청을 처리할 때마다 버킷에서 토큰을 꺼내 사용하는 방식. 토큰이 없다면 요청은 버려진다 (overflow) |
큐에 자리가 있는지 확인한 후, 빈 자리가 있으면 요청을 추가한다. 큐가 가득 찼다면 요청은 버려진다 (overflow) |
특징 | 간단하고, 세간의 이해도도 높아 폭넓게 사용되는 중 | 요청 처리율이 고정된 FIFO 큐로 구현 |
장점 | - 구현 쉽고 메모리 효율적 - 짧은 시간에 집중되는 트래픽 커버 가능 - 버킷에 토큰이 남았다면 무조건 요청이 시스템에 전달된다 |
- 큐 크기가 제한적이기에 메모리 효율적 - 처리율이 고정되어 안정적 출력 가능 |
단점 | - 버킷 크기와 토큰 공급률을 정하기 까다롭다 (IP 주소마다 제한할 것인지, 초당 요청을 제한할 것인지에 따라 버킷 관리 달라짐) |
- 단시간에 많은 트래픽이 몰릴 경우, 이전 요청을 처리하지 못함에 따라 새 요청이 버려질 수 있다. - 버킷 크기와 처리율을 관리하기 힘들다. |
이름 | 고정 윈도 카운터 알고리즘 | 이동 윈도 로깅 알고리즘 | 이동 윈도 카운터 알고리즘 |
동작 원리 | 타임라인을 고정 간격의 윈도로 나누고, 윈도마다 카운터를 붙인다. 카운터가 임계에 도달하면 요청은 새 윈도가 열릴 때까지 버려진다 (overflow) |
요청의 타임스탬프를 이용해, 새 요청이 오면 만료된 타임스탬프는 제거하고 신규 요청의 타임스탬프를 로그에 추가한다. 로그의 크기가 임계치에 닿으면 요청은 버려진다 (oveflow) |
현재 1분 간의 요청 수 + 직전 1분 간의 요청수 * 이동 윈도와 직전 1분이 겹치는 비율 을 이용해 현재 윈도의 요청 수를 계산한다. |
특징 | - | 고정 윈도의 윈도 부근 트래픽 문제 해결 | 고정 윈도 카운터 + 이동 윈도 로깅 |
장점 | - 메모리 효율적 - 특정 패턴의 트래픽을 처리하기에 적합 |
- 모든 순간에서 윈도의 허용 요청 개수가 시스템 처리 한도를 만족한다. | - 버스트 트래픽에 대응하기 좋다. - 메모리 효율적 |
단점 | - 윈도 경계 부근에 일시적인 트래픽이 발생할 경우, 기대했던 처리 한도를 초과할 수 있다. | - 거부된 요청의 타임스탬프도 보관해야 하므로 메모리가 많이 필요하다. | - 직전 시간대 요청이 균등하게 분포되어 있다고 가정하기에 비교적 느슨하다. |
(더 있을 수도 있어요)
카운터 관리 방법과 관련 HTTP header
위 알고리즘들이 모두 추적 대상별 카운터를 생성하고, 카운터가 임계치를 넘어설 경우 요청을 거부하는 매커니즘을 기반으로 동작한다. 따라서 카운터를 어디에 보관하는 것이 적절한가에 대해서도 고민해 볼 필요가 있다.
보통 뭔가를 저장한다고 하면 DB를 떠올리기 쉽지만, 카운터가 실시간으로 집계되어야 하는 상황을 고려했을 때 디스크에 접근해야 하는 DB는 처리율을 제한하기에는 너무 느리다. 따라서 메모리 기반 저장 장치를 사용하는 것이 적합하겠다. 보통 Redis가 많이 사용된다 😎
클라이언트가 요청을 보내면 -> 미들웨어는 Redis를 이용해 한도를 검사하고 -> 요청을 처리하는 방식이다. 이 과정에서 한도가 초과되어 버려지는 Request는 HTTP Header를 통해 클라이언트에게 overflow 되었다고 전달된다. 이를 관리하는 HTTP Header에는 다음과 같은 종류가 있다.
X-Ratelimit-Limit | X-Ratelimit-Remaining | X-Ratelimit-Retry-After | X-Ratelimit-Reset |
X-Daily-Requests-Left |
클라이언트가 보낼 수 있는 API별 총 요청 한도. | 남은 API 요청 횟수. 이 횟수 만큼 추가 요청을 보낼 수 있다. | 다음 API 요청을 시도하기 전에 대기해야 하는 시간 | 요청 최댓값이 재설정 될 때까지의 시간 | API 요청에서 사용 가능한 남은 일일 요청 횟수 |
와! 지금까지 처리율 제한 장치에 대해 간단하게 알아보았다. 분산 환경에서는 어떻게 구현할 것인지, 성능 최적화는 어떻게 수행할 것인지, 클라이언트는 어떻게 처리율 제한을 회피할 수 있는지 등 상세 설계에 대해서도 고민해 볼 것들이 많지만 오늘의 포스팅은 Spring Boot에서의 처리율 제한에 초점을 맞췄기에 적당히 생략하겠다. 만약 처리율 제한 장치에 대해 더 자세히 알아보고 싶으신 분이 계시다면 이 책을 추천한다 🚀
Bucket4j
토큰 버킷 알고리즘을 기반으로 하는 Java 속도 제한 라이브러리
공식 github repo에 들어가면 보이는 댕멋있는 문구 >>> 절대 타협하지 않는 정밀도 <<< floats나 doubles가 아닌 integer 기반으로만 수행되어, 반올림으로 부터 안전하다고 한다. 동시에 lock-free한 구현으로 멀티 스레딩 환경에서의 확장성이 우수하며, lock이 필요한 경우에 사용할 수 있는 추가적인 동시성 전략도 제공한다고 하니 일단 굉장히 탄탄해 보인다 👀
// gradle
implementation 'com.bucket4j:bucket4j-core:8.3.0'
// maven For java 11+
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.3.0</version>
</dependency>
// maven For java 8
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j_jdk8-core</artifactId>
<version>8.3.0</version>
</dependency>
의존성 추가는 언제나 그렇듯, 자신이 사용하는 환경에 맞추어 gradle 혹은 maven에 위 의존성을 추가해주면 된다. Java 버전에 따라 아주 조금 다르게 생겼으니 주의하자.
Bucket4j에서 메인으로 사용하는 Class는 다음과 같다.
Refill | Bandwidth | Bucket |
일정 시간마다 충전할 Token의 개수 지정 | Bucket의 총 크기를 지정 | 실제 트래픽 제어에 사용 |
@RestController
public class TestController {
private final Bucket bucket;
public TestController() {
// 충전 간격을 10초로 지정하며, 한 번 충전할 때마다 2개의 토큰을 충전한다.
Refill refill = Refill.intervally(2, Duration.ofSeconds(10));
// Bucket의 총 크기는 3
Bandwidth limit = Bandwidth.classic(3, refill);
// 총 크기는 3이며 10초마다 2개의 토큰을 충전하는 Bucket
this.bucket = Bucket.builder()
.addLimit(limit)
.build();
}
@GetMapping(value = "/api/test")
public ResponseEntity<String> test() {
if (bucket.tryConsume(1)) {
return ResponseEntity.ok("success!");
}
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
}
요청을 보내면 success를 리턴하는 API를 만들었다. 요청이 들어올 때마다 버킷에서 토큰을 하나씩 소비하며, 만약 토큰이 더이상 없다면 429 exception을 리턴한다. 실행해보자.
postman이나 swagger로 실행하면 몇 번 실행했는지 확인할 수 없으니, curl을 이용해서 API를 호출했다. 3번째까지는 정상적으로 실행되지만, 버킷의 사이즈인 3을 초과한 4번째 호출부터는 429 응답이 날아온다.
공식 문서의 quick start를 보면 enum을 이용해 초당 요청율 기반으로 요금제를 매기거나, Interceptor를 만들거나, IP 주소를 기반으로 처리율을 제한하는 등 다양한 예제가 나와있다. 좀 더 처리율 제한을 커스텀하고 싶다면 참고하자.
Guava - RateLimiter
토큰 버킷 알고리즘을 기반으로 구현된 Guava 라이브러리 제공 클래스
guava는 구글이 개발한 오픈소스 라이브러리다. 일단 구글이 만들었다는 점에서 뭔가 신뢰가 간다ㅋㅋㅋ guava 자체는 처리율 제한 목적으로 나온 라이브러리가 아니지만, guava가 제공하는 기능 중 처리율 제한 기능이 존재한다.
// gradle
implementation 'com.google.guava:guava:31.1-jre'
// maven
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
@RestController
public class TestController {
private static RateLimiter rateLimiter;
public TestController() {
// 0.1초에 한 번만 호출 가능
rateLimiter = RateLimiter.create(0.1);
}
@GetMapping(value = "/api/test")
public ResponseEntity<String> test() {
// 아직 호출 할 수 있다면 성공
if (rateLimiter.tryAcquire()) {
return ResponseEntity.ok("success!");
}
// 호출할 수 없다면 429 리턴
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
}
bucket4j와 굉장히 비슷하게 사용할 수 있다. RateLimiter를 원하는 요청 수 만큼 초기화해주고, tryAcquire()를 이용해 체크해주자.
마찬가지로, 허용된 임계치를 초과하는 요청이 들어오면 429 Too Many Request를 리턴하는 모습이다.
RateLimitJ
이동 윈도 알고리즘을 기반으로 하는 Java 속도 제한 라이브러리
이번엔 이동 윈도 (sliding window) 알고리즘을 사용하는 라이브러리다. 단, 레포 readme를 보면 더 이상 지원하지 않는 프로젝트이니 Bucket4j를 사용하라는 권고 사항이 적혀있다. 레퍼런스도 딱히 없으니, 단순히 궁금해서 or 사이드 프로젝트 중 다양한 라이브러리를 써보고 싶다면 공부 목적으로 사용해봐도 좋겠지만 회사 등 중요한 프로젝트를 할 때는 피하는 게 좋겠다.
(생략)
Resilience4j
함수형 프로그래밍을 위해 설계된 Java 라이브러리로, 서킷 브레이커, Rate Limiter, Retry 등을 사용한 고차 함수를 제공한다.
일단 구글에 Resilience4j를 검색하면 서킷 브레이커 < 를 제공하는 라이브러리라는 게시글이 대다수다. 서킷 브레이커는 대충 한 모듈이 장애가 나도 다른 모듈은 멀쩡해야 한다는 MSA 회복성 패턴 중 하나인데, 이에 대해 언급하기엔 본 포스팅과 너무 동떨어지는 것 같아 생략하겠다.
resilience4j는 서킷 브레이커를 포함한 다양한 기능을 제공해주는 함수형 프로그래밍 지향 라이브러리인데, 개중 Rate Limiter도 있어서 우리가 써먹기 딱 좋아 보이더라 ^__^ 공식 문서를 참고해보면, 임계치를 넘어서는 초과 요청을 아예 거부하거나, 혹은 나중에 실행하기 위해 대기열에 저장하는 두 가지 접근 방식을 제공한다고 한다.
참고로 resilience4j 2는 java 17 이상 부터만 사용할 수 있다고 하니 참고하자!
// gradle
implementation 'io.github.resilience4j:resilience4j-spring-boot2:1.7.0'
implementation 'org.springframework.boot:spring-boot-starter-aop:2.7.2'
// maven
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot</artifactId>
<version>1.7.0</version>
</dependency>
resilience4j-spring-boot2 1.7.0 version을 사용해야지만 아래에 나오는 @RateLimiter를 사용할 수 있었다 🤔 공식문서를 덜 읽었나..? resilience4j나 resilience4j-ratelimiter로는 실행이 안되더라.
지금까지의 라이브러리들은 의존성만 추가해주면 되었지만, resilience4j의 rate limiter은 조금의 옵션 설정이 필요하다. 아래와 같은 옵션들을 이용해 cycle에 대한 정책을 잡아주어야 한다.
timeoutDuration | limitRefreshPeriod | limitForPeriod |
default 5s | default 500 ns | default 50 |
스레드가 rateLimit에 대한 접근 허가를 얻기 위해 대기하는 시간 | limitForPeriod가 갱신되는 시간 | limitRefreshPeriod 동안 호출할 수 있는 횟수 |
이 친구들을 application.yml 등 프로필 (properties or yml)파일에 아래와 같이 간단하게 적용할 수 있다.
// application.yml
resilience4j:
ratelimiter:
instances:
user-throttling:
limitForPeriod: 1
limitRefreshPeriod: 10s
timeoutDuration: 0
instances에 여러개의 throttling 설정을 넣을 수도 있다. 위는 10초에 1개의 요청만 처리하도록 하는 설정이다.
@GetMapping(value = "/api/test")
@RateLimiter(name = "rateLimiterApi", fallbackMethod = "rateLimiterFallback")
public ResponseEntity<String> test() {
return ResponseEntity.ok("success");
}
public ResponseEntity<String> rateLimiterFallback(Throwable t) {
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.set("Retry-After", "10s");
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.headers(responseHeaders)
.body("짧은 시간에 너무 많은 요청을 보냈습니다. 10초 후에 재시도하세요.");
}
이후 어노테이션을 이용해 설정한 rate limiter를 API에 적용할 수 있다. 지금까지의 다른 라이브러리들과는 달리, @RateLimiter 어노테이션의 fallbackMethod를 이용해 임계치를 초과했을 때의 처리 메소드를 분리할 수 있었다.
마무리
오늘은 처리율 제한 장치의 기본 개념과 다양한 처리율 제한 자바 라이브러리를 알아봤다. 사실 Redis와 AOP를 이용해 직접 기능을 구현하는 걸 해보고 싶었는데, 생각보다 라이브러리가 이것 저것 있어서 다양하게 쓰다보니 좀... 기가 빠졌어요... API gateway가 해준다잖어... 😇
OSI 계층 어디서든 처리율을 제한할 수 있다고 하니, 다음 기회에는 계층별 처리율 제한 방법도 알아보자! 🙌
레퍼런스
- 가상 면접 사례로 배우는 대규모 시스템 설계 기초 [Chapter 4 / 처리율 제한 장치의 설계] : https://www.yes24.com/Product/Goods/102819435
- bucket4j quickstart : https://bucket4j.com/8.3.0/toc.html#quick-start-examples
- guava ratelimiter : https://github.com/google/guava/blob/master/guava/src/com/google/common/util/concurrent/RateLimiter.java
- detailed explanation of guava ratelimiter's throttling mechanism : https://www.alibabacloud.com/blog/detailed-explanation-of-guava-ratelimiters-throttling-mechanism_594820
- ratelimitj : https://github.com/mokies/ratelimitj
- resilience4j : https://resilience4j.readme.io/
- [MSA] spring boot에서 resilience4j 사용해보자 : https://sabarada.tistory.com/206
'Develop > Spring' 카테고리의 다른 글
[JPA] 영속성 전이 (CASCADE)와 고아 객체에 대하여 (0) | 2023.09.16 |
---|---|
[Spring Boot] Auto Increment PK Id를 노출하지 않으면서 API에 활용하는 방법 (4) | 2023.06.28 |
[Spring Boot] Jsoup으로 OG태그 메타 데이터 크롤링하기 (1) | 2023.06.14 |
[Spring Boot] count를 구현하는 5가지 방법 (2) | 2023.05.24 |
[Spring Boot] ConstraintValidator를 이용해 나만의 validator annotation 만들기 (1) | 2023.05.15 |