[프로젝트] Rate limit 핸들링을 통한 무분별한 API 호출 방지하기

조찬영·2023년 10월 17일
0

들어가기 앞서

프로젝트를 진행하면서 구현하게 되는 대부분은 특정 서비스를 수행하기 위한 api 설계가 대부분이었던 것 같습니다. (개발 레벨)

하지만 현업에 계신 개발자님들의 이야기를 종종 듣고 나면,
실제 서비스에서 개발만큼 중요한 부분이 운영적인 부분이라고 느꼈습니다.

개발한 애플리케이션이 항상 의도대로 동작한다고 단정 지을 수 없고
유저의 예측할 수 없는 여러 동작에 대한 경우의 수를 세놓고 그에 대한 대비책을 만드는 것이 훌륭한 개발자로 도달하기 위해 필요되는 역량이라고 생각했습니다.

그래서 무엇을 구현할 것인가? 🤔

우선 프로젝트에 구현하려고 하는 부분은 다음과 같습니다.

  • Rate limit Handling
    • 초당 요청할 수 있는 api 호출을 제한한다.
  • 에러 모니터링
    • 지정된 rate limit threshold 초과시 알림 메시지

그리고 이를 프로젝트 세팅에 맞게 구체화하여 다음과 같은 요구사항을 만들었습니다.

1초에 10번 이상의 api 호출 시 1회의 rate limit 발생으로 간주하며,
만약 1시간 안에 10번 이상의 rate limit을 발생 시킨 클라이언트가 있다면
해당 클라이언트에 대한 알림 메시지를 서버측에 발송합니다.

뿐만아니라 조금 더 응용한다면 더 많은 서비스 및 시스템을 구축할 수 있습니다.

(유저의 레벨에 따른 번역 기능 횟수 제한하기, 500 에러 발생시 알림등..)



1. Rate limit?

1.1 Rate limit 이란 무엇이며 왜 필요한 것일까?


rate limit이란 시스템의 소비 가능한 리소스의 속도 및 수량을 제한하는 기술로 ,

일정 시간 동안 애플리케이션에 대한 요청의 수를 제한하는 것입니다.


1.2 Rate limit 으로 해결할 수 있는 문제들

rate limit은 다음과 같은 상황에서 사용이 용이합니다.

  • api 남용 및 많은 사용량 처리

    • 실수 또는 의도적으로 클라이언트가 api에 많은 양의 요청을 보낼 경우 프로그램이 영향을 받을 수 있는 상황
  • Dos 공격 방지

    • 악의적인 공격자가 대규모 요청으로 시스템을 마비시키려는 DoS 공격을 수행할 수 있는 상황

    rate limit은 클라이언트의 요청을 제한함으로서 이 같은 문제를 해결하거나 완화할 수 있습니다.



2. Bucket4j 라이브러리 활용하기

먼저 rate limit 을 구현하기 위해서 저는 Bucket4j 라이브러리를 활용하기로 결정했습니다.

2.1 Bucket4j?

  • Java rate limit 을 지원하는 라이브러리로서 고효율적인 rate limit을 구성하고 있습니다.
  • 동시성 제어를 지원하며, 다양한 저장소를 지원합니다.
  • 독립 실행형 JVM 애플리케이션 또는 클러스터 환경에서 사용할 수 있는 스레드로부터 안전한 라이브러리이며 또한 JCache(JSR107) 사양을 통해 메모리 내 또는 분산 캐싱을 지원합니다.

2.2 의존성 주입

Spring boot 에서 bucket4j 를 활용하기 위해서는 다음과 같은 의존성 주입이 필요합니다.

implementation group: 'com.github.vladimir-bukhtoyarov', name: 'bucket4j-core', version: '7.0.0'


3. Rate limit Handling

이제 본격적으로 구현을 해보겠습니다.

3.1 RateLimitConstants

먼저 rate limit 관련한 bucket 생성에 필요한 상수에 대해서 별도의 클래스로 관리하였습니다.

RateLimitBucketConstants


/**
 * @apiNote # BUCKET_CAPACITY : 버킷의 총 크기 (용량)
 *          # BUCKET_TOKENS : 시간당 버킷안에 충전되는 토큰의 수
 *          # CALLS_IN_SECONDS : 버킷 충전 시간
 *          # REQUEST_COST_IN_TOKENS : 1회 요청당 소비되는 토큰 수
 */
public class RateLimitBucketConstants {

    public static final Long BUCKET_CAPACITY = 10L;
    public static final Long BUCKET_TOKENS = 10L;
    public static final Duration CALLS_IN_SECONDS = Duration.ofSeconds(1);
    public static final Integer REQUEST_COST_IN_TOKENS = 1;

}

해당 상수에 대한 설정이 반영된다면 bucket에 1초에 10번 토큰이 채워지며
토큰의 수는 10을 초과할 수 없게 됩니다.

3.2 Client Ip 호출하기

로그인/ 비로그인을 포함한 rate limit 체크를 하기 위해서는 클라이언트의 IP 정보가 필요하다고 생각하여 다음과 같이 클라이언트의 IP에 대한 유틸 클래스를 생성하였습니다.

ClientIpUtil

public class ClientIpUtil {
    private static final String[] IP_HEADER_CANDIDATES = {
            "X-Forwarded-For",
            "Proxy-Client-IP",
            "WL-Proxy-Client-IP",
            "HTTP_X_FORWARDED_FOR",
            "HTTP_X_FORWARDED",
            "HTTP_X_CLUSTER_CLIENT_IP",
            "HTTP_CLIENT_IP",
            "HTTP_FORWARDED_FOR",
            "HTTP_FORWARDED",
            "HTTP_VIA",
    };

    /**
     * @param request current HTTP request
     * @return (String) client Ip
     * @apiNote 'X-Forwarded-For' 참고: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
     */
    public static String getClientIp(HttpServletRequest request) {
        return Arrays.stream(IP_HEADER_CANDIDATES)
                .map(request::getHeader)

                .filter(ipAddress -> ipAddress != null && !ipAddress.isEmpty() && !"unknown".equalsIgnoreCase(ipAddress))
                .map(ipAddress -> ipAddress.split(",")[0])
                .findFirst()

                .orElseGet(request::getRemoteAddr);
    }
}

IP 추출에 대해서는 해당 레퍼런스 를 참조했습니다.


3.3 RateLimitingInterceptor

rate limit 을 체크할 수 있는 RateLimitingInterceptor 을 만들어 주었습니다.

package com.sns.yourconnection.common.interceptor;

import static com.sns.yourconnection.utils.constants.RateLimitBucketConstants.*;

...(생략)

@Component
@RequiredArgsConstructor
@Slf4j
public class RateLimitingInterceptor implements HandlerInterceptor {

    private final Map<String, Bucket> cache = new ConcurrentHashMap<>();
    private final RateLimitService rateLimitService;

    @Override
    public boolean preHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler) {
        String clientIp = ClientIpUtil.getClientIp(request);
        
        Bucket bucket = cache.computeIfAbsent(clientIp, key -> newBucket());
        ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(
            REQUEST_COST_IN_TOKENS);
        
        if (isRateLimitExceeded(request, response, clientIp, consumptionProbe)) {
            return false;
        }
        return true;
    }

    /**
     * @apiNote rate limit 발생여부에 따른 각각의 success, error response 를 생성 및 반환 합니다. *
     * 'rateLimitService.isLimitReachedThreshold(...)' 특정 IP에 대한 rate limit 허용치 초과 여부를 check 합니다.*
     */
    private boolean isRateLimitExceeded(HttpServletRequest request, HttpServletResponse response,
        String clientIp, ConsumptionProbe consumptionProbe) {

        if (!consumptionProbe.isConsumed()) {
            float waitForRefill =
                RateLimitRefillChecker.getRoundedSecondsToWaitForRefill(consumptionProbe);
            
            RateLimitResponse.errorResponse(
                response, BUCKET_CAPACITY, CALLS_IN_SECONDS, waitForRefill);

            log.warn(
                "rate limit exceeded for client IP :{}  Refill in {} seconds  Request "
                    + "details: method = {} URI = {}",
                clientIp, waitForRefill, request.getMethod(), request.getRequestURI());

            // 만약에 1시간에 10번 이상의 Rate Limit 에러를 발생시키는 유저가 있다면 메시지 알림.
            rateLimitService.isLimitReachedThreshold(clientIp);
            return true;
        }
        
        RateLimitResponse.successResponse(
            response, consumptionProbe.getRemainingTokens(), BUCKET_CAPACITY, CALLS_IN_SECONDS);

        log.info("remaining token: {}", consumptionProbe.getRemainingTokens());
        return false;
    }

    private Bucket newBucket() {
        return Bucket.builder()
            .addLimit(Bandwidth.classic(
                BUCKET_CAPACITY, Refill.intervally(
                    BUCKET_TOKENS, CALLS_IN_SECONDS)))
            .build();
    }
}

인터셉터를 사용한 이유

  • 컨트롤러 메소드가 실행되기 전에 rate limit 발생 여부를 체크해야 하므로 때문에 인터셉터를 활용했습니다.

ConcurrentHashMap 사용 이유

  • rate limit발생시 클라이언트에 대한 버킷 토큰을 카운트하고 소비시키기 위해서는 Thread-safe 해야 했습니다.
  • Java의 ConcurrentHashMap은 동시성(Concurrency) 환경에서 안전하게 사용할 수 있는 해시 맵 구현이며 여러 스레드가 동시에 맵에 접근하더라도 일관된 상태를 유지할 수 있습니다.

ConcurrentHashMap에 대해서는 [ConcurrentHashMap 동기화 방식] 를 참조했습니다.

(상황에 따라서는 ConcurrentHashMap 대신에 Redis를 활용해 보는 것도 가능할 것 같습니다.)


버킷 생성 및 소비

  Bucket bucket = cache.computeIfAbsent(clientIp, key -> newBucket());
        ConsumptionProbe consumptionProbe = bucket.tryConsumeAndReturnRemaining(
            REQUEST_COST_IN_TOKENS);
  • clientIp 에 대한 버킷을 불러오고 버킷을 REQUEST_COST_IN_TOKENS 만큼 consume 시킵니다.
  • 만약 clientIp 에 대한 버킷이 존재하지 않다면 새로운 버킷을 생성합니다.

RateLimit 발생 여부 체크

private boolean isRateLimitExceeded(HttpServletRequest request, HttpServletResponse response,
        String clientIp, ConsumptionProbe consumptionProbe) {

        if (!consumptionProbe.isConsumed()) {
        
           ...(생략)

            // 만약에 1시간에 10번 이상의 Rate Limit 에러를 발생시키는 유저가 있다면 알림 메시지
            rateLimitService.isLimitReachedThreshold(clientIp);
            return true;
        }
  • 버킷에 더 이상 남아있는 토큰의 수가 없을때 (모든 버킷을 소비했을 때)
    해당 조건식 if (!consumptionProbe.isConsumed())을 만족하게 됩니다.

  • rateLimitService 서비스 레이어를 통해 해당 클라이언트가 1시간에 10번 이상의 rate limit을 발생 시켰는지 확인합니다.


3.4 RateLimitService

다음과 같이 서비스 레이어를 만들어 주었습니다.

RateLimitService

@Slf4j
@Service
@RequiredArgsConstructor
public class RateLimitService {

    private Map<String, RequestInfo> rateLimitErrorCounter = new ConcurrentHashMap<>();
    private final NotificationService notificationService;

    public boolean isLimitReachedThreshold(String clientIp) {
        RequestInfo requestInfo = rateLimitErrorCounter.computeIfAbsent(clientIp,
            key -> new RequestInfo());

        if (!isApplyHandling(clientIp, requestInfo)) {
            requestInfo.saveCount();
            return false;
        }
        return true;
    }

    private boolean isApplyHandling(String clientIp, RequestInfo requestInfo) {
        if (requestInfo.isWithinTimeWindow()) {
            int count = requestInfo.incrementAndGetCount();

            log.info("[Rate limit count] client IP : {}  limit count  : {} ", clientIp, count);

            checkAndResetIfLimitExceeded(clientIp, requestInfo, count);
            return true;
        }
        return false;
    }

    private void checkAndResetIfLimitExceeded(String clientIp, RequestInfo requestInfo, int count) {
        if (count >= 10) {
            requestInfo.resetCount();
            
            notificationService.sendMessage(
                String.format(" Rate limit is occurred 10 or more times for this client IP: %s",
                    clientIp));
        }
    }

    private static class RequestInfo {

        private AtomicInteger count = new AtomicInteger(0);
        private LocalDateTime lastRequestTime;

        public int incrementAndGetCount() {
            return this.count.incrementAndGet();
        }

        public boolean isWithinTimeWindow() {
            LocalDateTime now = LocalDateTime.now();
            if (lastRequestTime == null || ChronoUnit.HOURS.between(lastRequestTime, now) >= 1) {
                lastRequestTime = now;
                return false;
            }
            return true;
        }

        public void saveCount() {
            count.set(1);
            lastRequestTime = LocalDateTime.now();
        }

        public void resetCount() {
            count.set(0);
            lastRequestTime = LocalDateTime.now();
        }
    }
}
  • 역시나 클라이언트에 대한 카운트 원자성을 보장 받기 위해서 ConcurrentHashMap 을 활용했습니다.

  • 최초 rate limit 발생 후 1시간이 지났다면 해당 클라이언트에 대한 카운트를 초기화하기 위해서 카운트와 마지막 발생 시간의 정보를 담고 있는RequestInfo 객체를 내부 클래스로 생성해 주었습니다.

  • 만약 클라이언트가 1시간 안에 10번 이상의 rate limit을 발생시켰다면 notificationService 를 통해 알림 메시지를 전송합니다.

(notificationService 에 대해서는 조금 뒤에 다루겠습니다.)


3.5 RateLimitResponse

rate limit 관련되서 조금 더 세분화 된 response 가 필요하다고 생각하여
RateLimitResponse 클래스를 생성해 주었습니다.

public class RateLimitResponse {

    /**
     * @apiNote 'X-RateLimit-RetryAfter', 'X-RateLimit-Limit', 'X-RateLimit-Remaining' 참고:
     * https://sendbird.com/docs/chat/v3/platform-api/application/understanding-rate-limits/rate-limits
     * @apiNote `Retry-After` 참고:
     * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
     */
    public static void successResponse(HttpServletResponse response,
        long remainingTokens, Long bucketCapacity, Duration callsInSeconds) {

        response.setHeader("X-RateLimit-Remaining",
            Long.toString(remainingTokens));

        response.setHeader("X-RateLimit-Limit",
            bucketCapacity + ";w=" + callsInSeconds.getSeconds());
    }

    public static void errorResponse(HttpServletResponse response,
        Long bucketCapacity, Duration callsInSeconds, float waitForRefill) {

        response.setHeader("X-RateLimit-RetryAfter",
            Float.toString(waitForRefill));

        response.setHeader("X-RateLimit-Limit",
            bucketCapacity + ";w=" + callsInSeconds.getSeconds());

        response.setStatus(ErrorCode.TOO_MANY_REQUESTS.getHttpStatus().value());
    }
}

관련된 response 메시지는 다음의 레퍼런스를 참고했습니다.



4. Notification Service 구현

이제 에러에 대한 알림 메시지를 전송할 notificationService 를 구현해 보겠습니다.

4.1 알림 메시지 전송하기

NotificationService

public interface NotificationService {

    void sendMessage(String message);
}

저는 텔레그램을 통해 알림 메시지를 전송하게 만들었습니다.
(텔레그램에 대한 구체적인 세팅을 생략하겠습니다.)

TelegramService

@Slf4j
@Service
@RequiredArgsConstructor
public class TelegramService implements NotificationService {

    private final TelegramProperties properties;
    private final Environment environment;
    private final RestTemplate restTemplate;

    @Override
    public void sendMessage(String message) {
        message = environment.getProperty("spring.config.activate.on-profile") + message;

        try {
            sendTelegram(properties, message);
            log.info(message);

        } catch (Exception e) {
            throw new TelegramException(ErrorCode.TELEGRAM_SEND_ERROR);
        }
    }

    private void sendTelegram(TelegramProperties properties, String message) {
        try {
            final String url = properties.getUrl();
            final String chatId = properties.getChatId();
            final HttpHeaders headers = new HttpHeaders();

            HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();

            CloseableHttpClient httpClient = getHttpClient();
            requestFactory.setHttpClient(httpClient);

            headers.set("Accept", MediaType.APPLICATION_JSON_VALUE);
            UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url)
                .queryParam("chat_id", chatId)
                .queryParam("parse_mode", "HTML")
                .queryParam("disable_web_page_preview", "true")
                .queryParam("text", message);
            final HttpEntity<?> entity = new HttpEntity<>(headers);

            restTemplate.exchange(
                builder.build()
                    .encode()
                    .toUri(), HttpMethod.GET, entity, String.class);

        } catch (Exception e) {
            throw new TelegramException(ErrorCode.TELEGRAM_SEND_ERROR);
        }
    }

    private CloseableHttpClient getHttpClient() {
        CloseableHttpClient httpClient = HttpClients.custom()
            .setSSLHostnameVerifier(
                new NoopHostnameVerifier())
            .build();
        return httpClient;
    }

}

이렇게 Properties 값으로 세팅한 텔레그램 채팅 방에 에러 발생시 관련 메시지를 전송하도록 만들어 주었습니다.


결과값 확인

이후 의도적으로 rate limit을 발생시켰고 결과를 확인했습니다.

  • api response header

  • 텔레그램 알림 메시지


끝마치며

이로서 Rate limit Handling 및 모니터링을 구현해 보았습니다.😃

긴 글 읽어주셔서 감사합니다 :)
프로젝트에 전체 코드는 [프로젝트 깃 허브 링크]에서 확인하실 수 있습니다.
개선점이나 더 좋은 것들이 있다면 같이 공유해요!

profile
보안/응용 소프트웨어 개발자

0개의 댓글