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를 사용하여 일정 시간마다 주어진 만큼 요청을 할 수 있도록 함.
- 새로운 Bucket 생성
- 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. 결과

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