[우테코] ip, 유저별 처리율 제한기 적용과 로드밸런서 적용기

찬디·2025년 8월 23일

우테코

목록 보기
15/19

개요

우아한테크코스에서 아맞다 서비스를 기획 및 개발중에 있습니다.

  • 아맞다는 조직단위 이벤트 리마인드 플랫폼입니다.

아맞다는 지난 데모데이때, 메인화면 api에 대해서 11만개의 리퀘스트를 받았습니다.

11만 리퀘스트가 왔을때

  • 모니터링 당시 가장 오래 걸리는 api가 0.5초, 그 외 메인 화면 api들을 합쳐서 약 1~2초 가량의 응답속도를 보이는 것을 확인할 수 있었습니다.

  • 각 api는 1초 미만들이지만, 메인 화면에 대해서는 1초가 넘어가는 상황입니다.


대시보드를 보자

  • 대시보드를 봤을때, 서버 그 자체에 부담이 많이 발생하지는 않았습니다.
  • 데이터베이스에도 부하는 크게 걸리지 않았습니다.
  • 하지만 쓰레드 풀이 매우 많이 할당되었습니다.
    • 단순 조회 요청을 매우 많이 받은 상황이었습니다.

해결 방법 모색

  • 우선, 현재 우리 서비스의 특성을 생각해보았습니다
  • 아맞다는 조직별로 이벤트를 관리해주는 서비스입니다.
    • 대부분의 기능은 로그인 이후에만 이루어지며, 각 유저는 식별이 가능했습니다.
  • 또한 11만 리퀘스트는 현실적으로 현재 서비스를 예상되는 사용자수가 아니었습니다.
    • 하지만 악의적인 유저로 인해 실제 유저가 불편함을 겪는 문제는 발생할 수 있습니다.

처리율 제한기 도입

  • 대부분의 기능은 로그인 이후에만 이루어지며, 각 유저는 식별이 가능했습니다.
  • 또한 11만 리퀘스트는 현실적으로 현재 서비스를 예상되는 사용자수가 아니었습니다.
    • 하지만 악의적인 유저로 인해 실제 유저가 불편함을 겪는 문제는 발생할 수 있습니다.
  • 위 사항들을 고려하여 ddos,dos 공격을 방어하기 위한 방어선을 구축하게 되었습니다.

  • dos 공격에 대해서는, 이전 포스팅에서 nginx의 rate limit 기능을 활용하여 DOS 공격에 대한 방어선을 구축하였습니다.
    참고

  • ddos 공격에도 어느정도 서비스 안정성을 고려할 방법을 찾아야 했습니다.

    • 저희는 그 중 처리율 제한기가 적합하다고 생각하였습니다.

처리율 제한기 말고 대안은 없는가?

  • 이미 잘 알려진 IP를 막아주는 WAF
    • nginx 단에서 이미 rate limit + 현재 기본적으로 설정되어있음
  • 캐싱으로 최대한 api들의 응답속도를 줄이는 방법
    • 현실적으로 모든 api를 캐싱하는 것은 불가능함
  • 정적 자원들을 CDN에 캐싱 등
    • 현재 정적 자원들을 크게 사용하는 로직 없음

여러가지 방법들로 어느정도 빠르게 응답을 내는 방법들 입니다.
하지만 순간적으로 몰리는 요청에 대한 근본적인 해결책은 되지않는다 판단하였습니다.

처리율 제한기란?

  • 대부분의 기능은 로그인 이후에만 이루어지며, 각 유저는 식별이 가능했습니다.
  • 저희는 유저 별로 식별 가능하니, 유저별로 1초내에 100번이상의 request를 보내면 악의적인 요청이라고 가정하는 방식을 채택하였습니다.

  • 또한 최근 1분내 요청에만 관심이 있으니 슬라이딩 윈도우가 적합하다고 생각하였습니다.

코드

필터단에서 인메모리 방식으로 구현된 코드입니다.

  • 추가적인 인프라 비용이 현재 예산을 벗어나고, 빠르게 구현할 필요가 있어 인메모리를 채택하였습니다.
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 2) // 필터에서 3번째로 실행됨
@RequiredArgsConstructor
public class SlidingWindowRateLimitFilter extends OncePerRequestFilter {

    private static final long WINDOW_MILLIS = 60_000;
    private static final int MAX_REQUESTS = 100;

    private final HeaderProvider headerProvider;
    private final JwtProvider jwtProvider;
    private final ObjectMapper objectMapper;

    private final Map<Long, Deque<Long>> requestLogs = new ConcurrentHashMap<>();

    @Override
    protected void doFilterInternal(
            final HttpServletRequest request,
            final HttpServletResponse response,
            final FilterChain filterChain
    ) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        Long memberId = extractMemberIdSafely(authorizationHeader);

        if (memberId == null) {
            filterChain.doFilter(request, response);
            return;
        }

        long now = Instant.now().toEpochMilli();
        Deque<Long> timestamps = requestLogs.computeIfAbsent(memberId, id -> new ConcurrentLinkedDeque<>());

        if (isRateLimited(timestamps, now)) {
            respondTooManyRequests(request, response, timestamps, now);
            return;
        }

        filterChain.doFilter(request, response);
    }

    private boolean isRateLimited(final Deque<Long> timestamps, final long now) {
        synchronized (timestamps) {
            while (!timestamps.isEmpty() && timestamps.peekFirst() < now - WINDOW_MILLIS) {
                timestamps.pollFirst();
            }

            if (timestamps.size() >= MAX_REQUESTS) {
                return true;
            }

            timestamps.addLast(now);
            return false;
        }
    }

    private void respondTooManyRequests(
            final HttpServletRequest request,
            final HttpServletResponse response,
            final Deque<Long> timestamps,
            final long now
    ) throws IOException {
        long retryAfterSeconds = calculateRetryAfterSeconds(timestamps, now);

        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
        problemDetail.setTitle("Too Many Requests");
        problemDetail.setDetail("요청이 너무 많습니다. 약 " + retryAfterSeconds + "초 후 다시 시도해 주세요.");
        problemDetail.setInstance(URI.create(request.getRequestURI()));

        response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfterSeconds));
        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        response.getWriter()
                .write(objectMapper.writeValueAsString(problemDetail));
    }

    private long calculateRetryAfterSeconds(final Deque<Long> timestamps, final long now) {
        synchronized (timestamps) {
            if (timestamps.isEmpty()) {
                return 1;
            }
            long oldest = timestamps.peekFirst();
            long retryMillis = (oldest + WINDOW_MILLIS) - now;
            return Math.max(1, (retryMillis + 999) / 1000);
        }
    }

    private Long extractMemberIdSafely(final String authorizationHeader) {
        try {
            String accessToken = headerProvider.extractAccessToken(authorizationHeader);

            return jwtProvider.parseAccessPayload(accessToken)
                    .getMemberId();
        } catch (Exception e) {
            return null;
        }
    }
}

로직 설명

Access Token 적용

  private Long extractMemberIdSafely(final String authorizationHeader) {
        try {
            String accessToken = headerProvider.extractAccessToken(authorizationHeader);

            return jwtProvider.parseAccessPayload(accessToken)
                    .getMemberId();
        } catch (Exception e) {
            return null;
        }
    }
  • access token을 파싱하여 멤버인지를 식별합니다.

슬라이드 윈도우 방식의 제한 및 응답

private boolean isRateLimited(final Deque<Long> timestamps, final long now) {
        synchronized (timestamps) {
            while (!timestamps.isEmpty() && timestamps.peekFirst() < now - WINDOW_MILLIS) {
                timestamps.pollFirst();
            }

            if (timestamps.size() >= MAX_REQUESTS) {
                return true;
            }

            timestamps.addLast(now);
            return false;
        }
    }

    private void respondTooManyRequests(
            final HttpServletRequest request,
            final HttpServletResponse response,
            final Deque<Long> timestamps,
            final long now
    ) throws IOException {
        long retryAfterSeconds = calculateRetryAfterSeconds(timestamps, now);

        ProblemDetail problemDetail = ProblemDetail.forStatus(HttpStatus.TOO_MANY_REQUESTS);
        problemDetail.setTitle("Too Many Requests");
        problemDetail.setDetail("요청이 너무 많습니다. 약 " + retryAfterSeconds + "초 후 다시 시도해 주세요.");
        problemDetail.setInstance(URI.create(request.getRequestURI()));

        response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(retryAfterSeconds));
        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
        response.getWriter()
                .write(objectMapper.writeValueAsString(problemDetail));
    }
  • 만약 최근 요청(1분내)이 100개가 넘어가면 이에 대한 ProblemDetail과 함께 결과값을 반환합니다.

이제 처리율 제한 로직이 잘 동작하는지, 부하테스트를 통해 확인해보겠습니다.

부하 테스트는 JMeter를 사용하였습니다.

처리율 제한 적용 이전 부하테스트 설계 및 결과

  • active user 300명을 가정, 각 유저가 500개의 요청을 보낸다고 가정
  • active user는 300명이지만 애플리케이션내에서 식별되는 유저는 한명(dos 공격과 유사)
    • 테스트 용이성을 위해 nginx단 rate limit은 꺼놓은 상태

Label# SamplesAverageMinMaxStd. DevError %ThroughputReceived KB/secSent KB/sec
get all events150000322142557273.820.00%481.9/sec118.09180.19
get organization150000286112510250.220.00%481.9/sec205.55161.42
TOTAL300000304112510257.720.00%963.5/sec323.57341.54
  • 메인페이지 기준 평균 0.6초가 출력되며 에러율은 0.00%입니다.

처리율 제한 적용 이후 부하 테스트 설계

  • active user 300명을 가정, 각 유저가 500개의 요청을 보낸다고 가정

  • active user는 300명이지만 애플리케이션내에서 식별되는 유저는 한명

  • 처리율 제한으로 인해 많은 요청이 429로 쳐내질 것이므로 응답속도가 향상될것으로 예상

처리율 제한 적용후 부하테스트 결과

Label# SamplesAverageMinMaxStd. DevError %ThroughputReceived KB/secSent KB/secAvg. Bytes
get all events150000121814347519.5199.91%1201.4/sec426.71470.49363.79
get organization150000121714315525.3899.88%1202.0/sec426.74469.61363.50
TOTAL300000121714347522.7699.89%2402.7/sec853.17974.01363.56
  • 15만개의 요청에 대해 동일한 유저의 요청이므로 모두 걸러지면서 에러율이 99.91%, 메인하면 기준 평균 응답속도는 0.2초로 약 50% 개선된 것을 확인할 수 있습니다.

  • 그러나 높아진 MAX 지표가 눈여겨 볼만합니다.

    • 이는 코드에서 존재하는 synchronized로 인한 락경합과 타임아웃의 부재로 보입니다.
    • 해당 문제들은 다음시간에 해결해보도록 하겠습니다!

성과

  • 악의적인 유저에 대해서는 빠르게 필터단에서 요청을 거부함으로써, 데이터베이스의 병목을 줄이고 정상적인 유저들이 커넥션을 오래 점유할 수 있게 되었다는 것을 의미합니다.
  • 도스와 유사한 공격에는 처리율 제한의 역할인, 비정상적인 요청에 대하여 빠르게 요청을 거부하는 역할을 충분히 하는 것으로 보입니다.
  • 이제 여러 유저를 가정으로 많은 요청이 들어왔을때를 테스트 해봅시다.

유저 10명으로 ddos 공격시 부하 테스트 설계

JMeter에서 여러 유저 설정하기

  • 토큰들을 많이 넣는 방식으로 여러 유저를 가정할 수 있습니다.
  • 테스트 그룹에서 다음과 같이 csv 헤더 파일을 추가합니다.
  • 헤더 파일에는 다음과 같이 토큰들을 넣어두었습니다.

부하 테스트 결과

  • active user(쓰레드 수)를 300으로 잡고, 멤버를 10명으로 돌렸을때 결과는 다음과 같습니다.
Label# SamplesAverageMinMaxStd. DevError %ThroughputReceived KB/secSent KB/secAvg. Bytes
get all events150000376024637645.7298.58%511.2/sec346.72175.64694.95
get organization150000163019478438.5998.52%511.4/sec312.88146.96626.55
TOTAL300000269024637562.1198.53%1022.3/sec659.43322.52660.59
  • 이전보다 에러율이 조금 낮아졌고, 그에 따른 평균 응답속도는 0.5초입니다.

여기까지의 성과

단순 도스 공격은 매우 빠르게 쳐내진다

  • nignx의 rate limit으로 단순 도스 공격은 빠르게 쳐내집니다.

이제, 디도스 공격을 할려면 우리 서비스에 로그인한 유저가 매우 많이 필요하다.

  • 이제 처리율 제한기의 도입으로인해 서비스에 ddos 공격을할려면 실제 구글 로그인을 통해 만들어진 유저를 만들어야합니다.
  • 이는 어느정도 ddos 공격을 억제하는 효과를 가져옵니다.

이제 ddos 공격을 하려면 그만큼의 구글 로그인 계정과, 그만큼의 ip가 필요합니다.

하지만, 정상적인 유저의 많은 요청은..?

  • 지금까지는 악의적인 유저를 방어하기 위한 처리율 제한기를 도입했지만, 실제 서비스에서는 매우 복잡한 성능저하 요인이 많을 것입니다.
  • 현재 서버에서 많은 알람을 보내고 있는 경우
    • 그 외에도 여러가지 기능이 동작하고 있는 경우, 많은 유저의 request를 처리하고 있는 경우
    • 위와 같은 복합 요인으로 인해 정상적인 유저가 서비스를 잘 활용하지 못할 수 있습니다.

테스트 : 500명의 유저가 300개의 request를 지속적으로 보내는 경우

Label# SamplesAverageMinMaxStd. DevError %ThroughputReceived KB/secSent KB/secAvg. Bytes
get all events163252583165211381830.480.00%60.7/sec26.7729.81440.02
get organization16183233842161311824.260.00%60.2/sec26.8019.68456.92
TOTAL32508246142211381882.350.00%120.6/sec49.472437.15

호기심에 정말 많은 부하를 걸기 위해 500명의 유저를 가정, 유저당 request를 300번 보내도록 테스트하였습니다.

  • Average가 5초까지 도달합니다.

    • 처리율 제한기에서 제한이 안되는 상황에는 RPS가 낮게 나옵니다.
  • active user 500명이 rts 300개의 요청을 처리하기에는 부하테스트시에 기기 사양이 부족한지 아니면 서버가 버티지 못하는것일지 너무 많은 시간이 걸려서 중간에 중지하였습니다.

  • 위 요청이 쓰기 요청이 없는 조회 요청만 존재하는 메인화면에 대한 부하테스트라는 것을 고려했을때, 많은 요청에 대해서 대비되지 않는 것을 확인할 수 있습니다.

스케일 아웃이 필요하다..

  • 이 글을 쓰는 시점에서 런칭데이는 얼마 남지 않았습니다.
  • 남은 시일내 더 이상의 애플리케이션단 최적화는 기대하기가 힘들었고, 스케일아웃으로 빠르게 해결할 때가 온 것이라고 판단하였습니다.

로드밸런스 및 인스턴스 2개 추가

nginx를 이미 사용하고 있으니, 여기에 로드밸런스 역할을 추가하였습니다.

nginx가 로드밸런스 역할처리

upstream backend_servers {
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

요청 ip에 대해 인스턴스를 맵핑하는 방법도 있지만, 우테코 내에서는 같은 ip를 사용하고 있기에 데모데이에 인스턴스 분산 효과를 누리지 못합니다.

  • 현재는 간단하게 서버에 몰리지 않게만 하면 되기 때문에 라운드로빈(기본값)을 선택하였습니다.

이제, 인스턴스를 3배로 늘려봅시다.

인스턴스 추가


위와 같이 동일한 역할을 하는 인스턴스를 3개 추가하였습니다.

300명의 유저, 500 request 부하테스트

부하 테스트 결과

또한 이전과 동일하게 약 300명의 멤버가 500개의 request를 보낸 경우에 15만개에 대한 요청 부하 테스트를 해보았습니다.

Label# SamplesAverageMinMaxStd. DevError %ThroughputReceived KB/secSent KB/secAvg. Bytes
get all events150000162020384782.1045.59%745.2/sec906.51159.571245.7
get organization150000127020392532.4444.56%745.4/sec958.17136.111316.3
TOTAL300000144020392689.2944.97%1490.2/sec1864.27295.621281.0
  • 메인화면 기준 평균 0.3 - 목표 RTS 1.0보다 약 3배 성능
  • error 율 46%
    • 요청이 너무 빠르게 처리되어서 처리율 제한기에서 처리되었습니다.

성과

  • 300명의 실제 유저가 메인페이지에 대해서 꾸준히 500개 요청을 보내도 끄떡없음!

이제 대비는 끝났습니다.
런칭데이때는 어떨까요?

런칭데이 후기

많은 회원가입과..


많은 이벤트들이 열렸고..


저희도 이벤트를 열었습니다!

잘버텨준 서버

마무리

gpt에게 모자이크해달라고 하니 뭔가뭔가임..

현재 방식의 한계

  • 현재 처리율 제한기는 인메모리로 구현됨
    • 로드밸런서를 적용하며 인스턴스가 3개가 되었기 때문에 이전보다 비효율적으로 요청을 걸러내게 됩니다.
    • 이를 해결하기 위해 다음에는 처리율 제한기를 분리할 예정입니다.
  • 처리율 제한기의 synchronized
    • 락 경합이 자주 발생함 - 부하테스트시에 MAX가 매우 높게 나타나는 현상으로 확인가능
      • 이를 막기 위해 redis 기반 처리율 제한기로 전환할 필요성
profile
깃허브에서 velog로 블로그를 이전했습니다.

1개의 댓글

comment-user-thumbnail
2025년 8월 23일

ㅋㅋㅋㅋㅋㅋㅋ 고생했다잉

답글 달기