Project/Distance

[Distance] API 요청 과부하로부터 서비스를 보호하자.

EJUN 2025. 3. 11. 23:14

 

Distance서비스를 운영하면서 사실 마냥 순탄치는 않았습니다.

서비스를 운영하면서 겪었던 문제 중 하나인 "Postman을 통한 무한 API요청"을 효과적으로 차단한 경험을 공유하려 합니다.

 

저희 서비스는 축제 시즌에 배포된 서비스인데 00대학교에 배포 중 갑자기 오후 11시쯤에 Slack으로 알림하나가 날라왔습니다.

서비스 중 발생한 크리티컬 에러발생!

이 에러는 저희 서비스에 굉장한 크리티컬한 에러였습니다.

사용자가 Distance서비스에 신규 가입을 하려면 반드시 SMS인증을 통해 가입을 할 수 있는데 그 인증 API가 일일 사용량 초과가 된 것입니다.

갑자기 증가한 실패요청

분명 일일 한도가 약 390건 정도가 남아있었는데 이게 한 순간에 사라진게 이상하게 생각해 발송내역을 보니 한 번호가 391건의 요청을 했던 것을 확인할 수 있었습니다.

 

알고보니 Postman과 같은 API테스트도구를 활용해 요청을 보냈던 것입니다.

 

그래서 일단 급한대로 클라이언트의 IP를 확인해 동일한 IP로 하루에 3번 이상 접속하면 더 이상 SMS API를 요청할 수 없게 임시로 막아두었습니다.

@Transactional
    public void saveIp(HttpServletRequest request){
        String memberIpAddr= ipGenerator.generateMemberIp(request);

        if(!ipValidator.isExistMemberIp(memberIpAddr)){
            ipSaver.saveMemberIp(memberIpAddr);
        } else{
            ipUpdater.increaseIpCount(memberIpAddr);
        }

        if(ipReader.getMemberIpCount(memberIpAddr)>3){
            throw new DistanceException(ErrorCode.TOO_MANY_REQUEST);
        }

    }

위의 코드와 같이 HttpServletRequest의 getRemoteAddr()를 활용하여 클라이언트의 IP를 확인한 후 검증을 통해 SMS발송을 할 수 있도록 로직을 구현했습니다.

 

하지만 위의 코드는 말 그대로 "임시"조치였다.
위의 로직의 가장 큰 문제점은 바로

클라이언트가 IP를 변경하면서 접근하면 막을 수 없다.

라는 큰 단점을 지니고 있었다.

 

저는 웹/앱 둘 다 개발을 해보았지만 웹은 개발자 모드를 통해 Request URL을 알 수 있다는 점이었다.

늘 전 이걸 어떻게 막을 수 있을까에 대한 고민을 항상해왔습니다..

 

그러던 중 Spring의 Dispatcher-Servlet구조에 대해 공부를 하면서 호기심이 생기면서 이렇게 막아보면 되지 않을까? 라는 호기심이

들어서 공부하던 것을 미뤄두고 바로 코드와 이야기를 해보았습니다.

 

우선 간단하게 Dispatch-Servlet에 대해 설명을 해보겠습니다.

Dispatcher-Servlet

흐름도

위의 사진처럼 Dispatcher-Servlet는 Spring Context에서 가장 먼저 요청을 받는 Front Controller라고 부른다.

Dispatcher-Servlet의 동작방식은

1. 클라이언트의 요청을 보냄
2. DispatcherServlet이 요청을 수신
3. HandlerMapping을 사용하여 컨트롤러 탐색
4. HandlerAdapter를 사용하여 컨트롤러 실행
5. 컨트롤러 실행
7. ViewResolver 또는 HttpMessageConverter를 통해 응답 변환
8. 클라이언트에 최종 응답 반환

크게 이런과정으로 동작을 하고 자세한 건 다음 포스팅에서 상세히 설명하겠다.


 

다시 본론으로 돌아와서 IP만으로 요청을 막는 건 말이 안된다고 확신했고 생각한 다른 방법은 Spring context로 들어오는 요청 자체를 막는 방법이었다.

 

여기서 또 하나는 그럼 어떻게 Postman요청을 구분할 수 있을까?였다..

그런던 중 개발자 모드에 가면 "Origin"이라는 것을 확인할 수 있었다.

origin

Origin헤더는 Protocol + Domain + Port로 구성되어있다.

 

사실 처음부터 이 생각을 한 건 아니었다.

처음엔 WebFilter를 통해서 사용자가 입력한 Request값을 변환하려고 하였다.

예를 들어
Client : { "email" : "wnstjr120422@naver.com } 으로 보내면
HttpServletRequestWrapper()를 통해서 요청 온 request값을
Server : { "userEmail" : "wnstjr120422@naver.com" } 으로 서버에 반환하려고 하였다.

하지만 곰곰히 생각해보니 이 방식도 사용자가 개발자모드에서 조회하면 충분히 알고 사실 크게 달라지는게 없어보였다.

 

그러다가 문득,, "어 그럼 특정 Origin으로만 요청이 들어왔을 때만 요청을 허락해주면 되지않을까?" 라는 생각이 들었다.

 

그래서 바로 Filter를 통해서 접근을 제한하는 방법이다.

@WebFilter("/*")
@Component
public class WebConfig implements Filter {

    private static final String ALLOWED_ORIGIN = "...";
    private static final String ALLOWED_ORIGIN_LOCAL = "...";
    private static final String ORIGIN = "Origin";

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
        FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) servletRequest;
        HttpServletResponse httpResponse = (HttpServletResponse) servletResponse;
        String origin = httpRequest.getHeader(ORIGIN);
        if (origin == null && !httpRequest.getRequestURL().toString()
            .startsWith(ALLOWED_ORIGIN_LOCAL)) {
            httpResponse.sendError(...);
            return;
        }

        if (origin != null && !origin.equals(ALLOWED_ORIGIN)) {
            httpResponse.sendError(...);
            return;
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }
}

위의 코드를 보면 @WebFilter는 Servlet필터를 선언하는 어노테이션으로 모든 요청을 가로채 검사하도록 설정한 것입니다.

이 Filter 또한 Dispatcher-Servlet앞단에서 동작하니까 당연히 @Component로 등록을 하지 않아도 된다 생각했지만 큰 오산이었습니다.

그 이유를 알아보니 Servlet Filter는 서블릿 컨테이너(Tomcat, Jetty 등)에 의해 실행되는데 Spring boot에선 이를 자동으로 감지하지 않기 때문에 컴포넌트로 등록을 해주던가 FilterRegistrationBean을 등록해서 수동으로 필터를 등록해야했습니다....

 

이제 등록까지 한 후 httpRequest.getHeader에서 "Origin"에 해당하는 값을 가져온 후 null 검사를 했습니다.

 

이 부분이 핵심부분인데 Postman과 같은 API테스트도구로 접근하면 Origin이 null로 잡히기 때문에 요청이 차단당하면서 에러를 뱉게됩니다.

 

이런식으로 origin의 null여부를 통해 차단하고 프론트 도메인만 허용하여 직접 백엔드 API를 호출해서 요청하는 방법을 차단하였습니다.

 

만약 if절의 모든 조건을 통과하면 "filterChain.doFilter(servletRequest, servletResponse);"을 통해서 요청을 컨트롤러에 전달하도록 구현했습니다.

 

<전체 흐름 요약>
1. 클라이언트가 API 요청을 보냄
2. Filter가 요청을 가로챔
3. Origin 헤더를 확인
없으면 localhost 요청인지 확인 localhost 요청이 아니면 에러 반환
4. Origin이 특정 도메인이 아니면 차단
5. 모든 검사를 통과하면 요청을 컨트롤러(@RestController)로 전달

 

마지막으로 로컬환경과 dev환경에서 테스트를 해본결과

Local 환경
dev 환경

 

원하던 대로 정상적으로 차단되는것을 확인할 수 있었습니다.

 

Spring에 대해서 공부를 하다보면서 몰랐던 새로운 점들을 하나씩 알아가는과정이 정말 흥미있게 다가온거 같습니다.