Rate limiter 구현

김수호·2024년 11월 12일

📌개요

GDG마켓 플랫폼의 리스크관리 팀의 일원으로 api가 무방비하게 호출되는 상황을 막아야 한다.

 

📌 다양한 Rate limiter

1. Bucket4j

Bucket4j 는 주로 토큰 버킷 알고리즘을 기반으로 하는 Java 속도 제한 라이브러리로, IT 산업에서 속도 제한을 위한 사실상의 표준입니다.

[참고자료]
https://github.com/bucket4j/bucket4j
https://github.com/MarcGiffing/bucket4j-spring-boot-starter

 

2. Guava


Google의 Guava 라이브러리에서 제공하는 레이트 리미터로, 주로 간단한 경우에 사용하는 "토큰 버킷" 기반의 라이브러리입니다.

[참고자료]
https://github.com/google/guava

 

3. Resilience4j

함수형 프로그래밍을 위해 설계된 Java 라이브러리로, 서킷 브레이커, Rate Limiter, Retry 등을 사용한 고차 함수를 제공한다.

  • 서킷 브레이커는 시스템 내의 모듈이 일정 횟수 이상 실패했을 때, 그 모듈로의 요청을 차단하여 전체 시스템 장애를 방지하는 패턴입니다.
  • 레이트 리미터는 시스템의 특정 모듈이나 API에 대한 요청 수를 제한합니다.
  • Retry는 서비스 간의 통신이 일시적으로 실패할 수 있기 때문에, 실패 시 몇 번의 재시도를 수행하도록 설정하여 일시적 오류로 인한 장애를 줄일 수 있습니다.

[참고자료]
https://resilience4j.readme.io/

 

4. redis + Lua Script

레디스에 탑재된 Lua Script Engine을 통해서 서버에서 Lua Script를 실행하여 atomic 연산을 통해 api rate limiter에서 발생하는 race condition에 대해서 해결할 수 있다.
이를 통해서 MSA 환경에서도 자주 사용됩니다.

[참고자료]
https://dev.gmarket.com/69

 

📌프로젝트에 도입하기

 

1.의존성 추가

    implementation 'com.bucket4j:bucket4j-core:8.3.0'

 

2. Bucket4j 중요 3요소

Refill : 일정 시간마다 충전할 Token의 개수 지정
Bandwidth : Bucket의 총 크기를 지정
Bucket : 실제 트래픽 제어에 사용

// 공통된 Bucket 생성 메서드
    private Bucket createBucket(int capacity, Duration refillDuration) {
        Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillDuration));
        return Bucket.builder()
                .addLimit(limit)
                .build();
    }

[참고자료] https://www.baeldung.com/spring-bucket4j

 

3. 코드 작성

  • ip당 제한을 걸고 싶었다.
  • api 요청에 따라 제한을 다르게 걸고 싶었다.
  • 어노테이션으로 만들어 사용하기 편하게 만들고 싶었다.
@RestController
public class HelloController {

    @RateLimited(type = RequestType.LOGIN)
    @GetMapping(value = "/api/test")
    public ResponseEntity<String> test() {

        // API 호출시 토큰 1개를 소비
        return ResponseEntity.ok("Request successful!1");
    }

    @RateLimited(type = RequestType.QUERY)
    @GetMapping(value = "/api/test2")
    public ResponseEntity<String> test2() {

        // API 호출시 토큰 1개를 소비
        return ResponseEntity.ok("Request successful!2");
    }
}
@Configuration
public class BucketConfig {


    private final ConcurrentHashMap<String, Bucket> loginBuckets = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, Bucket> queryBuckets = new ConcurrentHashMap<>();


    public Bucket getBucketForLogin(String ip) {
        System.out.println("로그인 관련 요청 IP : " + ip);
        return loginBuckets.computeIfAbsent(ip, k -> createBucket(5, Duration.ofHours(1)));
    }

    public Bucket getBucketForQuery(String ip) {
        System.out.println("조회 관련 요청 IP : " + ip);
        return queryBuckets.computeIfAbsent(ip, k -> createBucket(5, Duration.ofSeconds(5)));
    }

    
    // 공통된 Bucket 생성 메서드
    private Bucket createBucket(int capacity, Duration refillDuration) {
        Bandwidth limit = Bandwidth.classic(capacity, Refill.intervally(capacity, refillDuration));
        return Bucket.builder()
                .addLimit(limit)
                .build();
    }
}

@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {


    private final BucketConfig bucketConfig;
    private final HttpServletRequest request;


    // @RateLimited 어노테이션이 붙은 메서드를 처리
    @Around("@annotation(rateLimited)")
    public Object applyRateLimit(ProceedingJoinPoint joinPoint, RateLimited rateLimited) throws Throwable {
        try {

            String ip = request.getRemoteAddr();


            Bucket bucket;
            if (rateLimited.type() == RequestType.LOGIN) {
                bucket = bucketConfig.getBucketForLogin(ip);
            } else {
                bucket = bucketConfig.getBucketForQuery(ip);
            }

            // rate limiting 처리
            if (bucket.tryConsume(1)) {  // 요청을 1개 토큰만 소비
                return joinPoint.proceed(); // 메서드 실행
            } else {
                return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many requests");
            }
        } catch (Exception e) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("Server error");
        }
    }


}
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimited {
    RequestType type();
}

public enum RequestType {
    LOGIN,
    QUERY
}

 

4. 결과

get요청 : http://127.0.0.1:8080/api/test (정상요청)

get요청 : http://127.0.0.1:8080/api/test (요청거부)

get요청 : http://127.0.0.1:8080/api/test2 (정상요청)
test에서는 거부가 되었지만 다른 api는 정상적으로 접근되도록 처리

get요청 : http://127.0.0.1:8080/api/test (정상요청)
ip를 192.168.1.1 로 변경시 테스트시 정상적인 접근되도록 처리
ip당 처리가 가능할 수 있게 했다.

profile
정답을 모르지만 답을 찾는 법을 알고, 그 답을 찾아낼 것이다. 그럼 괜찮지 않은가? -크리스 가드너-

0개의 댓글