[Spring] 필터에서 발생한 예외가 GlobalExceptionHandler로 처리되지 않는 이유?!

juhyeok01·2025년 1월 25일

Project

목록 보기
2/6

프로젝트를 진행하다가, 토큰 필터에서 발생한 예외가 GlobalExceptionHandler로 정의한 규격에 맞지 않게 return이 되는 것을 확인하였다. 포스트맨에서는 403 에러가 뜨고, 인텔리제이 로그에서 우리가 정의한 에러 메세지가 출력되는것을 확인하였다. 왜 이런 현상이 발생을 할까?



⭐서블릿 컨테이너와 Spring MVC 처리 흐름


1. 서블릿 컨테이너

  • 서블릿 컨테이너는 HTTP 요청과 응답을 처리하는 Java 웹 애플리케이션 서버
  • 요청이 들어오면 필터를 먼저 실행하고, 이후 서블릿을 실행한다.

2. 필터

  • 필터는 서블릿 컨테이너의 요청 처리 흐름에서 가장 먼저 실행되는 컴포넌트
  • 요청을 처리하거나 응답을 클라이언트로 반환하기 전에 조작
    • 요청의 헤더 또는 인증 토큰 검증
    • CORS 처리
    • 요청 로깅
  • 스프링 시큐리티의 필터(UsernamePasswordAuthentication, BasicAuthenticationFilter) 도 여기서 동작한다.
  • Spring MVC와 독립적으로 동작하며, 서블릿 컨테이너가 관리한다.

3. DispatcherServlet

  • Spring MVC의 핵심 서블릿으로, Spring 컨트롤러 요청을 라우팅한다.
  1. 요청이 필터를 지나서 DispatcherServlet에 도달
  2. 요청을 핸들러 매핑을 통해 적절한 컨트롤러로 전달
  3. 컨트롤러에서 처리 후 응답을 생성
  4. 응답을 다시 필터 체인으로 전달

4. 인터셉터

  • Spring MVC 레이어에 존재하며, 컨트롤러 실행 전후에 특정 로직을 실행할 수 있다.
  • 요청과 응답에 대해 추가 작업을 수행한다.
    • 컨트롤러가 실행되기 전 요청의 유효성 검증
    • 컨트롤러 실행 후, 응답에 헤더 추가
구분필터인터셉터
위치서블릿 컨테이너 레벨Spring MVC 레벨
적용 범위DispatcherServlet 이전DispatcherServlet 이후
사용 목적전역적인 요청/응답 처리컨트롤러 실행 전후의 요청/응답 처리



⭐필터와 GlobalExceptionHandler의 관계


1️⃣ 필터에서 예외 발생 시 DispatcherServlet으로 전달되지 않음

  • 필터에서 발생한 예외는 서블릿 컨테이너에서 처리되므로, Spring MVC의 DispatcherServlet까지 도달하지 않는다.
  • 따라서 GlobalExceptionHandler는 이러한 예외를 감지하거나 처리할 수 없다.

2️⃣ Spring MVC의 예외 처리 흐름

  • 예외가 DispatcherServlet 내부에서 발생하면, Spring MVC가 이를 ExceptionHandler 또는 @RestControllerAdvice로 전달한다.
  • 그러나 필터에서 발생한 예외는 DispatcherServlet 을 거치지 않으므로, MVC 예외 처리 메커니즘이 동작하지 않는다.


⭐필터 예외 처리 방법


💡Custom ExceptionHandlingFilter 구현

@Component
public class TokenExceptionHandlerFilter extends OncePerRequestFilter { // OncePerRequestFilter : 한 요청당 필터가 딱 한 번만 실행되도록 보장하는 추상 클래스

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try{
            filterChain.doFilter(request, response);

        }catch(BusinessException e){
            handleBusinessException(request,response,e);
        }

    }
    private void handleBusinessException(HttpServletRequest request, HttpServletResponse response, BusinessException e) throws IOException {
        ExceptionType exceptionType = e.getExceptionType();
        response.setStatus(exceptionType.getStatus().value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8"); // HttpServletResponse: ISO-8859-1 인코딩 사용하기 때문에 한글 출력을 위해 UTF-8 설정
        ResponseBody<Void> body= ResponseUtil.createFailureResponse(exceptionType);
        writeErrorResponse(response,body);
    }
    private void writeErrorResponse(HttpServletResponse response, ResponseBody<Void> body) throws IOException{
        ObjectMapper objectMapper = new ObjectMapper(); // Jackson ObjectMapper 인스턴스 생성
        String json = objectMapper.writeValueAsString(body);  // ResponseBody 객체를 JSON 문자열로 직렬화

        try (PrintWriter writer = response.getWriter()) { // PrintWriter 자원을 안전하게 닫기 위해 try-with-resource 사용
            writer.write(json);
            writer.flush();
        }
    }
}
  • OncePerRequestFilter: HTTP 요청당 딱 한 번만 실행되도록 보장하는 추상클래스이다.

  • doFilterInternal() : 요청을 필터 체인에서 다음 단계로 전달한다.

  • handleBusinessException

    • ExceptionType: 우리가 정의한 BusinessException에서 ExceptionType이라는 객체가 있다. 여기에는 다음 정보가 들어가있다.
      • HTTP 상태코드
      • 에러코드
      • 에러메세지
    • response
      • setStatus를 통해 클라이언트에 반환할 HTTP 상태 코드를 지정함
      • setContentType을 통해 응답 형식을 지정
      • HttpServletResponse는 기본적으로 ISO-8859-1 인코딩을 사용하기 때문에 한글 출력을 위해 UTF-8을 설정함
  • writeErrorResponse():

    • ObjectMapper
      • Jackson 라이브러리를 사용해 객체(ResponseBody)를 JSON 문자열로 변환
    • response.getWriter()
      • 클라이언트에 데이터를 보낼 출력 스트림을 가져옴
      • 여기서 데이터를 작성하면 클라이언트로 전달됨
    • try-with-resource
      • Printer은 스트림 객체이기 때문에 자원을 차지함. 이를 자동으로 닫아주지 않으면 리소스 낭비가 발생
      • 따라서 try-with-resource를 사용해서 PrintWriter 자원을 안전하게 닫음

💡WebSecurityConfig에서 필터 등록

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        TokenAuthenticationFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(tokenProvider, customUserDetailsService);
        http
                .csrf(AbstractHttpConfigurer::disable) //csrf 무시
                .cors(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(AUTH_WHITELIST).permitAll()
                        .anyRequest().denyAll()
                );
        // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
        http.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
        http.addFilterBefore(new TokenExceptionHandlerFilter(), TokenAuthenticationFilter.class);
        http.sessionManagement(sessionManagement->sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
        // FormLogin, BasicHttp 비활성화
        http.formLogin(AbstractHttpConfigurer::disable);
        http.httpBasic(AbstractHttpConfigurer::disable);
        return http.build();
    }


response 객체로 직접 리턴하나요? ResponseEntity를 사용하면 안 되나요?

ResponseEntity는 MVC 컨텍스트에서만 사용 가능

  • ResponseEntity는 Spring MVC에서 컨트롤러(@Controller, @RestController) 계층에서 HTTP 응답을 생성하고 반환할 때 사용하는 객체입니다.
  • 그러나 Filter는 Spring MVC 레이어의 이전 단계에서 동작합니다.
    • Filter는 서블릿 컨테이너(javax.servlet.Filter) 기반으로 작동하기 때문에, MVC와는 전혀 다른 레벨에서 동작합니다.
    • 이 단계에서는 HttpServletResponse를 직접 조작해서 응답을 만들어야 합니다.
  • 필터는 Spring MVC와 무관하게 요청과 응답을 처리하기 때문에, Spring의 ResponseEntity를 사용할 수 없습니다.

필터의 response 객체

  • 필터 체인에서 응답을 조작하려면, HttpServletResponse 객체를 직접 다뤄야 합니다.
  • 필터는 Spring MVC를 호출하기 전에 동작하기 때문에, 응답에 데이터를 작성하려면 response.getWriter().write()를 사용해 JSON 데이터를 직접 작성해야 합니다.



writeErrorResponse로 직렬화를 직접 하나요?

HttpServletResponse는 객체 직렬화를 지원하지 않음

  • HttpServletResponse는 단순히 HTTP 상태 코드, 헤더, 바디를 설정할 수 있는 서블릿 컨테이너 레벨의 객체입니다.
  • Spring MVC의 자동 직렬화 기능(예: Jackson ObjectMapper)은 컨트롤러(@RestController)에서 반환한 객체를 JSON으로 직렬화해서 응답 바디에 넣어줍니다.
    • 하지만 필터는 Spring MVC 계층과 독립적으로 동작하므로, 이런 자동 직렬화 기능을 사용할 수 없습니다.
  • 따라서, 필터에서 JSON 응답을 내려주고 싶다면, 직접 직렬화해서 response.getWriter().write()로 응답 바디에 넣어야 합니다.

Jackson 등으로 직렬화

  • 보통 ObjectMapper(Jackson)를 사용하여 객체를 JSON 문자열로 변환한 뒤, 이를 response의 출력 스트림에 작성합니다.
profile
백엔드 개발자를 지망하는 컴퓨터공학과 3학년 학생입니다 https://github.com/Juhye0k

0개의 댓글