[로아투두] bucket4j를 이용해 트래픽 요청 제한하기

2025. 2. 27. 11:44·Project/LOATODO

1. 서론

최근 로아투두 사용자가 많아지면서 서버의 많은 부하가 걸렸다.

패치날인 수요일 저녁같은 경우에는 감당하기 어려울 정도...

 

그러다가 2월 26일 오후 4시정도에 카톡이 많이와서 확인해보니

로아투두 서버가 터졌다는 사용자들의 톡을 보게되었다.

 

JDBC Connection이 부족해서 서버가 재실행된거 같은데...

왜...???

 

Connection 연결 개수 모니터링

 

Connection 대기가 갑작스럽게 200개 가까이 늘어났다.

근데... 나는 설정에 최대를 30개를 줬는데 왜 10개밖에 Active가 안된거지..?

 

Request Count Increase 모니터링

 

음.....

 

원인 파악: 특정 API가 Connection을 많이 물고 있어서 알림을 체크하는 API 요청이 Connection을 가져오지 못해서 서버가 터졌다.

근데 문제는... 그 특정 API가 뭔지 모른다... 그 전 로그를 봐도

 

 

딱히... 눈에 띄는건 안보이는데... 추후에 ECS들어가서 제대로 확인이 필요할 것 같다.

 

 

2. bucket4j로 트래픽 제한하기

그럼 일단 한 사용자가 많은 요청을 보내는것을 제한하고자 한다.

 

0) 설계 내용

1. IP 기반으로 트래픽을 제한한다.

2. GET, OPTIONS Mapping은 제한하지 않는다.

3. 짧은 제한(초 단위), 긴 제한(분 단위) 두 가지 경우를 생각한다.

 

1) 라이브러리

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

 

2) IpRateLimitingFilter

@Component
public class IpRateLimitingFilter implements Filter {

    // IP별로 버킷을 저장하는 ConcurrentHashMap
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        // GET 요청과 OPTIONS 요청은 Rate Limiting을 적용하지 않음
        if ("GET".equals(request.getMethod()) || "OPTIONS".equals(request.getMethod())) {
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }

        String clientIp = getClientIp(request);
        Bucket bucket = buckets.computeIfAbsent(clientIp, this::createNewBucket);

        // 토큰이 있으면 요청 허용, 없으면 429 응답
        if (!bucket.tryConsume(1)) {
            throw new RateLimitExceededException("많은 요청이 있습니다. 잠시후 다시 요청해주세요.");
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    // 1. 일반 사용자를 위한 요청 제한: 5초 3회
    // 2. 공격 방지를 위한 제한: 1분 30회
    private Bucket createNewBucket(String key) {
        return Bucket4j.builder()
                .addLimit(Bandwidth.classic(3, Refill.greedy(3, Duration.ofSeconds(5))))
                .addLimit(Bandwidth.classic(30, Refill.greedy(30, Duration.ofMinutes(1))))
                .build();
    }


    // 클라이언트 IP 추출
    private String getClientIp(HttpServletRequest request) {
        String xfHeader = request.getHeader("X-Forwarded-For");
        return xfHeader != null ? xfHeader.split(",")[0] : request.getRemoteAddr();
    }
}

 

  • doFilter 메서드
    • getClientIp(request): 클라이언트 IP 주소 추출
    • bucket
      • buckets.computeIfAbsent(clientIp, this::createNewBucket): 클라이언트 IP가 존재하지 않으면 createNewBucket 메서드를 통해 새 Bucket을 생성하고, 이미 존재하면 해당 IP의 Bucket을 가져옴.
      • bucket.tryConsume(1): Bucket에서 1개의 토큰을 소비하고, 소비가 불가능하면 RateLimitExceededException을 던짐.
  • createNewBucket 메서드
    • 새로운 Bucket 생성 
      • 일반 사용자 요청 제한: 5초에 3번의 요청만 허용
      • 공격 방지 요청 제한: 1분에 30번의 요청만 허용
    • Bandwidth.classic을 사용하여 각각의 제한을 설정하고, Refill.greedy를 사용하여 일정 시간마다 주어진 만큼 요청을 할 수 있도록 함.
  • getClientIp 메서드
    • 클라이언트 IP 주소를 추출하는 메서드
    • X-Forwarded-For 헤더를 확인하여 클라이언트의 실제 IP를 얻음.
    • 헤더가 없으면 request.getRemoteAddr()를 사용하여 요청을 보낸 IP를 반환
  • 예외처리
    • RateLimitExceededException
    • 상태코드: 429

 

3) RateLimitExceededException

@Getter
public class RateLimitExceededException extends RuntimeException {

    public RateLimitExceededException(String message) {
        super(message);
    }
}
  • RuntimeException으로 구현한 커스텀 Exception
  • 해당 Excpetion은 사람마다 다르겠지만 나는 JwtFilter에서 잡았다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException{
    String token = parseBearerToken(request);
    SecurityContext securityContext = SecurityContextHolder.createEmptyContext();

    try {
        if (token != null && !token.equalsIgnoreCase("null")) {
            processToken(request, response, securityContext, token);
        } else {
            processNoToken(request, response, securityContext);
        }
        SecurityContextHolder.setContext(securityContext);
        filterChain.doFilter(request, response);
    } catch (ServletException e) {
        log.error("Auth Error = {}", e.getMessage());
        sendErrorResponse(response, AUTH_ERROR_MESSAGE);
    } catch (RateLimitExceededException e) {
        sendRateLimitResponse(response, e.getMessage());
    }
}

 

 

3. 결과

 

여러번 요청을 보내면 에러 메시지가 나오게 수정하였다.

저작자표시 (새창열림)
'Project/LOATODO' 카테고리의 다른 글
  • [로아투두] 로그 저장 최적화 작업
  • [로아투두] 에러 로그 출력 변경
  • [로아투두] 2025년 기획
  • [LOATODO] 회원가입 후 캐릭터 등록 테스트 코드 작성 + 리팩토링
마볼링
마볼링
개발과 게임에 관한 내용을 읽기 쉽게 정리합니다.
  • 마볼링
    게임을 좋아하는 개발자의 블로그
    마볼링
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Project
        • LOATODO
        • 인스타그램 클론코딩(중단)
      • Language
        • Java
        • PHP
        • Javascript
      • Framework & Library
        • Spring
        • Vue
      • Computer Science
        • Web
        • Linux
      • CodingTest
        • Algorithm
        • Kotlin으로 푼 코딩 테스트
        • Java로 푼 코딩 테스트
        • Sorting & Thinking
        • BFS
      • 책&강의 정리
      • 정보처리기사
      • 개인
        • 팰월드(PALWORLD)
        • 마인크래프트
  • 블로그 메뉴

    • 링크

      • GitHub
      • Threads
    • 공지사항

    • 인기 글

    • 태그

      네트워크
      아크 서바이벌
      로아투두
      LoaTodo
      codingtest
      프로그래머스
      php
      Database
      error
      오블완
      JPA
      springboot
      java
      Spring
      코딩테스트
      이터널 모드
      CS
      티스토리챌린지
      운영체제
      jsp
    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.1
    마볼링
    [로아투두] bucket4j를 이용해 트래픽 요청 제한하기
    상단으로

    티스토리툴바