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 브라우저 응답 결과 확인

  • 성공한 응답 값

Bucket4j Sample TEST 결과 화면 (성공한 응답)

  • 실패한 경우 응답 값

Bucket4j Sample TEST 결과 화면 (실패한 응답)