GRIT에서 JWT 인증을 구현하던 중, 만료된 토큰으로 요청했을 때 프론트엔드와 약속한 공통 에러 응답이 내려오지 않는 문제가 있었다. 예상으로는 @RestControllerAdvice가 예외를 잡아서 JSON 형태의 에러 응답을 내려줄 것이라고 생각했다.
하지만 실제로는 커스텀 에러 응답 대신 톰캣의 기본 500 에러 화면이 반환됐다. 인증 실패 상황인데도 응답 형식이 일관되지 않았고, 프론트엔드에서는 에러를 공통 방식으로 처리하기 어려웠다.
원인은 예외가 발생한 위치였다. 토큰 검증은 스프링 MVC 계층의 컨트롤러 안에서 일어난 것이 아니라, 그 앞단인 서블릿 필터 영역에서 일어나고 있었다. 이 글은 JWT 인증 필터에서 발생한 예외를 어디에서 처리할지 정리한 기록이다.

처음 보인 문제
컨트롤러 안에서 발생한 예외는 @RestControllerAdvice가 잡을 수 있다. 하지만 JWT 검증 필터는 DispatcherServlet에 도달하기 전에 실행된다.
즉, 제가 기대한 예외 처리 경로와 실제 예외 발생 위치가 달랐다.
이 문제를 겪고 나서 먼저 확인한 것은 다음과 같았다.
- 예외가 어느 계층에서 발생하는가
@RestControllerAdvice까지 도달하는 흐름인가- Spring Security가 제공하는 인증 실패 처리 지점은 어디인가
- 커스텀 응답 포맷을 어디에서 만들어야 계층이 덜 섞이는가
문제는 단순히 예외를 잡느냐가 아니었다. 어느 계층에서 발생한 예외를 어느 계층에서 처리할 것인가의 문제였다.
처음 검토한 해결
자주 보이는 해결책 중 하나는 서블릿 필터에서 터진 예외를 HandlerExceptionResolver를 통해 스프링 내부의 컨트롤러 어드바이스로 넘기는 방식이다.
이 방식의 장점은 분명한다. 프론트엔드와 맞춰둔 에러 응답 포맷을 한 곳에서 관리할 수 있고, 컨트롤러 예외와 인증 필터 예외의 응답 형식을 맞추기 쉽다.
처음에는 이 방식도 검토했다. 하지만 적용 과정에서 다른 문제가 보였다. 애플리케이션 실행 단계에서 Application Failed to Start가 발생했고, 기존에 쓰던 ObjectMapper 빈이 필터 계층에 주입되지 않는 문제가 있었다.
당시 프로젝트는 Spring Boot 4.0을 사용 중이었고, Jackson 3가 기본으로 채택되면서 패키지 구조가 바뀌었다. 기존 com.fasterxml.jackson 계열 ObjectMapper가 아니라 tools.jackson 계열 JsonMapper를 봐야 하는 상황이었다.
이 문제를 겪으면서 예외를 컨트롤러 계층으로 넘기는 방식이 현재 프로젝트 구조에 맞는지 다시 검토했다.
원인을 확인한 과정
HandlerExceptionResolver를 사용하는 방식 자체가 틀렸다고 보지는 않는다. Spring Security 내부에도 예외를 다음 처리 지점으로 넘기는 구조가 있고, 에러 응답을 한 곳에서 관리하는 것도 장점이 있다.
다만 제가 처리하려던 예외는 인증 객체가 만들어지기 전, JWT 검증 필터 단계에서 발생한 예외였다. 이 경우에는 MVC 계층까지 억지로 전달하기보다, 필터 계층에서 인증 실패 응답을 책임지는 편이 더 적절하다고 판단했다.
응답 포맷을 매핑하는 코드가 일부 중복되더라도, 각 계층에서 발생한 문제는 해당 계층에서 해결하는 방향으로 정리했다.

해결 방안
최종적으로는 상황을 두 가지로 나눠 처리했다.
JWT 토큰의 만료나 위조처럼 인증 흐름 내부에서 발생하는 예외는 JWT 검증 필터 앞단에 별도의 JwtExceptionFilter를 두어 처리했다. 메인 인증을 담당하는 JwtAuthenticationFilter 내부에서는 토큰 검증 중 예외가 발생하더라도 직접 응답을 만들지 않고, 바깥으로 던져 앞단의 필터가 처리하도록 했다.
import tools.jackson.databind.JsonMapper;
@Component
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
private final JsonMapper jsonMapper;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json; charset=UTF-8");
ErrorResponse errorResponse = new ErrorResponse(ErrorCode.INVALID_TOKEN);
response.getWriter().write(jsonMapper.writeValueAsString(errorResponse));
}
}
}
토큰이 없거나, 유효하지 않거나, 인증 자체가 성립하지 않은 경우의 401 응답은 Spring Security가 제공하는 AuthenticationEntryPoint를 구현해 처리했다.
import tools.jackson.databind.JsonMapper;
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final JsonMapper jsonMapper;
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json; charset=UTF-8");
ErrorResponse errorResponse = new ErrorResponse(ErrorCode.UNAUTHORIZED_ACCESS);
response.getWriter().write(jsonMapper.writeValueAsString(errorResponse));
}
}
그리고 이 구현체들을 SecurityConfig에 등록했다. 여기서는 JWT 인증 필터가 UsernamePasswordAuthenticationFilter보다 먼저 실행되고, 예외 처리 필터는 그 앞에서 JWT 인증 필터를 감싸도록 순서를 지정하는 점이 중요했다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final JwtExceptionFilter jwtExceptionFilter;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(exception ->
exception.authenticationEntryPoint(customAuthenticationEntryPoint)
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class)
.build();
}
}
정리한 기준
빠르게 동작하는 코드를 만드는 것과, 프레임워크의 흐름에 맞게 설계하는 것은 별개의 문제라는 점을 확인했다.
물론 서블릿 필터와 스프링 MVC 양쪽에서 응답 DTO를 직렬화하는 코드가 일부 중복되는 점은 남아 있다. 다만 이번 프로젝트에서는 각 생명주기와 계층 간 독립성을 유지했다는 점에서 수용 가능한 선택이라고 판단했다.
이번 이슈를 정리하면서 서블릿 컨테이너와 스프링 컨테이너의 차이도 다시 확인할 수 있었다. 앞으로도 기술 블로그에서 본 해결책을 그대로 적용하기보다, 현재 프로젝트 구조와 예외 발생 위치에 맞는지 먼저 확인하려고 한다.
마무리
이번 문제의 핵심은 "예외를 어떻게 잡을 것인가"보다 "어디에서 발생한 예외를 어디에서 책임질 것인가"였다.
JWT 인증 필터는 컨트롤러 앞에서 동작한다. 그래서 컨트롤러 예외 처리 방식만 믿으면 응답 흐름이 어긋날 수 있다. 필터 계층에서 발생한 인증 예외는 Spring Security의 흐름과 서블릿 필터 생명주기를 기준으로 처리해야 했다.