Book Review
[Book Review] 가상 면접 사례로 배우는 대규모 시스템 설계 기초. 4장
EndiYou
2024. 12. 16. 10:00
4장. 처리율 제한 장치의 설계
시스템이 처리할 수 있는 최대 요청량을 제한해 성능을 관리하고, 과부하를 방지해 리소스를 효율적으로 사용할 수 있게 하는 기술이나 장치
1. 처리율 제한 장치의 장/단점
1.1 처리율 제한 장치의 장점
- DDoS와 같은 악의적인 트래픽을 차단해 시스템을 보호하고 정상적인 사용자의 접근 보장
- 사용량에 따른 과금 서비스(클라우드, API) 사용 시 불필요한 요청을 제한하고 비용 절감
- Bot, 오남용으로 인한 과도한 요청 차단을 통해 시스템 리소스 효율 관리, 성능 보장
- 예측 가능한 부하 관리를 통해 시스템의 안정성, 가용성 확보
- 급격한 트래픽 증가에 대비가 가능해지고 시스템 다운타임 최소화
1.2 처리율 제한 장치의 단점
- 설정에 따라 정상적인 사용자의 요청도 차단될 수 있음
- 트래픽 패턴이 급변하는 환경에서는 유연하게 대응하기 어렵고 적용하기 어려움
- 적절한 임계값 설정, 지속적인 모니터링이 필요해 관리 부담 증가
- 다양한 클라이언트, API에 대한 개별적인 설정이 필요할 수 있어 복잡성 증가
- 모든 요청에 대해 처리율 확인, 제어하는 과정에서 약간의 성능 저하 발생
- 다수의 서버, 마이크로 서비스 환경에 일관된 처리율 제한 적용의 어려움
2. 처리율 제한 알고리즘
2.1 토큰 버킷 (Token Bucket)
- 토큰 용량을 지정하고 요청이 처리될 때마다 토큰을 하나씩 소비
- 일정 시간이 지나면 토큰이 다시 채워지는 방식
2.2 누출 버킷 (Leaky Bucket)
- Queue 시스템 방식
- FIFO 방식으로 들어온 순서대로 요청 처리
- Queue의 버퍼가 가득차 있으면 신규 요청은 폐기 처리
2.3 고정 윈도 카운터 (Fixed Window Counter)
- Window 단위 별로 처리 가능한 요청의 수량을 지정하는 방식
- Window는 시간(타임라인)을 고정된 간격으로 나눈 하나의 시간 단위
- Window의 경계 값에 트래픽이 몰릴 경우 할당된 양보다 많은 요청을 처리하는 경우 발생
2.4 이동 윈도 로그 (Sliding Window Log)
- 요청의 타임스탬프 값을 로그에 기록 후 Throttling을 넘기지 않은 경우 시스템에 전달해 요청 처리
- 정확한 처리율 제한이 가능하지만, 로그 유지에 따른 메모리 사용량 증가
2.5 이동 윈도 카운터 (Sliding Window Counter)
- 직전 1분, 현재 1분 동안 받은 요청 개수를 모두 더하고, 윈도우 사이즈에 겹치는 비율을 곱하여 나온 결과가 처리율 제한을 넘기지 않는지 확인하는 방식
- 고정 윈도와 이동 윈도 로그의 장점을 결합한 방식
- 메모리 효율성과 정확성 사이의 좋은 균형을 제공
3. Rate-Limit Architecture Best Practice
- 알고리즘 선택 기준
- 트래픽 체증에 민감하지 않다면 Token Bucket
- 이 외에는 Fixed Windows or Sliding Window 알고리즘 선택
- 처리율 제한기 구성 위치
- 프로그래밍 언어 효율이 높아 서버에 구성해도 성능에 영향이 없다면 Application 서버에 직접 구성
- 반대의 경우 Middleware 서버를 별도 구성
- 처리율 제한 제품 선택
- 상용 제품 사용 시 제약 사항이 발생
- 직접 처리율 제한 장치를 만들면 자율적으로 알고리즘부터 선택해서 구성 가능
- 직접 제한 장치를 만들 인력과 시간이 없다면 상용 제품 선택
- 카운팅 대상: 추적 대상 선정(사용자 ID, IP Address, API EndPoint..)
- 카운터 데이터 저장소: 메모리 상에 위치 시킬 수 있는 Cache server (Redis..) 사용 권장
- 처리율 제한 규칙: 카운터의 값의 한도를 하루/분당/초당 등 최대치 수준 설계
- 제한 트래픽 처리 방법
- 카운터의 최대 값을 넘겼을 경우 어떻게 처리할 것인지 설계
- HTTP Response 429 반환, 트래픽 폐기 처리, Queue 시스템 등록 등
- 분산처리 환경의 '경쟁 조건' 고려: 요청 동시 처리 방지를 위해 Lua Script, Sorted set 방식 선택
- 동기화 이슈
- 처리율 제한 장치를 여러 대로 구성했을 경우 장치 간 카운터 정보 동기화
- Redis 이용 다중화 설계
- 성능 최적화: 처리율 제한 장치가 여러 데이터센터를 지원할 수 있도록 구성
- 모니터링: 구성한 알고리즘, 규칙이 효율적인지 모니터링을 통해 지속적인 수정, 개선 필요
- Throttling 구현 방식 선택
- 임계치를 넘기지 못하는 Hard한 방식
- 어느정도 초과를 허용하는 Soft 방식
- 서버 리소스에 여유가 있을 때는 임계치 초과를 허용하는 Elastic & Dynamic 방식
- OSI 7 계층별 처리율 제한: 계층마다 제한 장치를 둘 수지, 특정 계층에만 구성할지 선택
- 처리율 제한을 회피하는 방법
- 클라이언트 설계 단계부터 처리율 제한 임계치를 초과하지 않도록 시스템 설계
- 클라이언트 캐시 도입, 예외 & 에러 처리 코드 등
4. 처리율 제한 장치 구현 테스트
3.1 Java Late-Limiting Library 'Bucket4j'
Java로 작성된 Token Bucket 알고리즘 기반 처리율 제한(rate-limiting) Library
3.1.1 Bucket4j 주요 구성요소
- Refill : 일정 시간마다 몇 개의 Token을 충전할지 지정하는 Class
- Bandwidth : Bucket의 크기를 지정하는 Class
- Bucket : Refill, Bandwidth를 이용해 처리율 제한을 위한 Bucket을 생성하기 위한 Class
3.1.2 Dependency 추가 (build.gradle)
implementation group: 'com.github.vladimir-bukhtoyarov', name: 'bucket4j-core', version: '7.0.0'
3.1.3 JAVA Sample 코드
package com.example.springbucket4j;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.Refill;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Timestamp;
import java.time.Duration;
@Slf4j
@RestController
@RequestMapping(value = "/")
@SuppressWarnings("unchecked")
public class SpringBucket4JController{
// 5초 마다 2개의 토큰을 채운다.
Refill refill = Refill.intervally(2, Duration.ofSeconds(5));
// 5개 사이즈의 Bucket을 만들고, refill 클래스에 지정된 조건에 맞춰 토큰을 리필한다.
Bandwidth limit = Bandwidth.classic(5, refill);
Bucket bucket = Bucket.builder().addLimit(limit).build();
private Timestamp time;
@GetMapping(value = "/simple-test")
public ResponseEntity<Object> useToken() throws Exception{
if(bucket.tryConsume(1)) {
log.info(" Success, bucket AvailableToken:"+ bucket.getAvailableTokens());
return ResponseEntity.status(HttpStatus.OK).build();
}
else {
log.info(" Failed, bucket AvailableToken:"+ bucket.getAvailableTokens());
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
}
}
@GetMapping(value = "/simple-header")
public ResponseEntity header() {
if(bucket.tryConsume(1)) {
MultiValueMap<String, String> header = new LinkedMultiValueMap<>();
String availToken = String.valueOf(bucket.getAvailableTokens());
header.add("X-ratelimit-Remaining", availToken);
header.add("X-ratelimit-testResult", "Success!!");
return new ResponseEntity(header, HttpStatus.OK);
}
else {
MultiValueMap<String, String> header = new LinkedMultiValueMap<>();
String availToken = String.valueOf(bucket.getAvailableTokens());
header.add("X-ratelimit-Remaining", availToken);
header.add("X-ratelimit-testResult", "Failed!!");
return new ResponseEntity(header, HttpStatus.TOO_MANY_REQUESTS);
}
}
}
3.1.3 브라우저 응답 결과 확인
- 성공한 응답 값
- 실패한 경우 응답 값