포스트

[Spring Security] 필터(Filter) 예외 처리에 대한 고민

[Spring Security] 필터(Filter) 예외 처리에 대한 고민

1. 톰캣 기본 에러 페이지 발생 (500 Internal Server Error)

프로젝트에 JWT 인증을 구현하면서 인증 과정에서 발생하는 에러도 당연히 @RestControllerAdvice에서 처리될 줄 알았다. 약속된 공통 에러 포맷으로 응답이 내려갈 거라고 생각하고 토큰 만료 에러(Expired access token)를 테스트했다.

하지만 예상과 달랐다. 커스텀 에러 응답 대신, 톰캣의 500 화이트라벨 에러(WhiteLabel Error Page)가 반환되었다.

원인은 토큰을 검증해서 예외가 터진 곳이 스프링 MVC 계층(DispatcherServlet)이 아니라, 그 앞단인 서블릿 필터(Filter) 영역이었기 때문이다.

Screenshot 2026-02-24 at 1.42.13 am.png


2. HandlerExceptionResolver를 활용한 예외 위임 방식 검토

인터넷에 검색해 보니 가장 많이 나오는 해결책은 서블릿 필터에서 터진 예외를 HandlerExceptionResolver를 통해 스프링 내부의 컨트롤러 어드바이스로 던지는 방식이었다.

프론트엔드와 맞춰둔 에러 응답 포맷(Single Source of Truth)을 일관되게 유지하려면, 예외 처리를 한 곳으로 모으는 이 방법이 가장 실용적으로 보였다.

하지만 막상 적용하려고 보니 엉뚱한 곳에서 에러가 터졌다. 애플리케이션 실행 단계에서 Application Failed to Start 메시지가 뜨면서, 기존에 쓰던 ObjectMapper 빈이 필터 계층에 주입되지 않는 문제(DI 이슈)가 발생한 것이다.

이 문제의 원인을 찾아보니, 단순히 서블릿 필터와 스프링 빈의 생명주기 차이 때문만은 아니었다. 현재 진행 중인 프로젝트는 Spring Boot 4.0을 사용 중인데, 4부터는 내부적으로 Jackson 3가 기본으로 채택되면서 패키지 구조가 완전히 변경되었다(com.fasterxml.jacksontools.jackson).

따라서 기존 버전의 ObjectMapper 타입 자체가 달라졌고, 차세대 표준인 JsonMapper(tools.jackson)가 그 자리를 대체했다. 즉, 나는 컨테이너에 존재하지도 않는 구버전 타입의 빈을 주입받으려고 했기 때문에 당연히 DI가 실패했던 것이다.

이 트러블슈팅 과정을 거치면서, “그럼 예외를 낚아채서 컨트롤러 영역으로 보내는 구조가 과연 내 스택에 맞는 최선의 설계일까?” 하는 고민이 들기 시작했다.


3. 계층 분리와 의존성 문제

img.png

Resolver를 사용하면 에러 응답 코드를 컨트롤러 단 한 곳에서 관리할 수 있어 깔끔해진다. 실제로 스프링 시큐리티 내부에서도 예외를 위임하는 로직(ExceptionTranslationFilter)이 존재하므로, Resolver를 사용하는 것 자체가 무조건적인 안티 패턴이라고 볼 수는 없다.

하지만 우리 프로젝트에서는 계층 간의 독립성을 더 우선시하기로 했다. 프레임워크가 서블릿 영역(Filter)과 MVC 영역(Interceptor, Controller)을 굳이 분리해둔 데에는 독립된 역할이라는 분명한 이유가 있다. 필터 과정에서 터진 예외를 굳이 스프링 깊숙한 곳의 컨트롤러 로직까지 끌고 들어가는 것은, 현재 구성된 프로젝트의 관심사 분리(SoC) 측면에서 볼 때 결합도를 높인다고 판단했다.

결국 응답 포맷을 매핑하는 코드가 조금 중복되더라도, 각 계층에서 발생한 문제는 그 계층에서 해결하는 방향으로 선회하기로 했다.


4. 최종 설계: Filter 계층에서 직접 예외 처리

상황에 따라 두 가지 방어벽을 세워서 에러를 직관적으로 처리했다.

필터 내부 에러 (JwtExceptionFilter)

JWT 토큰의 만료나 위조 같은 ‘인증 흐름 내부의 예외’는 JWT 검증 필터 앞단에 별도의 JwtExceptionFilter를 두어 방어했다. 참고로 메인 인증을 담당하는 JwtAuthenticationFilter 내부에서는 토큰 검증 중 예외가 발생하더라도 억지로 try-catch로 잡지 않고, 자연스럽게 밖으로 던져지도록(throws) 두어 앞단의 JwtExceptionFilter가 제 역할을 하도록 위임했다.

앞서 겪었던 DI 에러의 원인이 ‘낡은 빈(ObjectMapper)을 찾으려 했던 것’이었으므로, 이를 스프링 부트 4가 기본으로 제공하는 JsonMapper 빈을 정상적으로 주입(@RequiredArgsConstructor)받아 사용하는 방식으로 수정했다. 이렇게 하면 억지로 객체를 직접 생성할 필요 없이, 스프링 컨테이너가 제공하는 싱글톤 빈을 안전하게 활용하면서 아키텍처적 일관성을 유지할 수 있다. 또한, 프론트엔드에서 한글 에러 메시지가 깨지지 않도록 response.setContentType("application/json; charset=UTF-8"); 설정을 명시적으로 추가했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import tools.jackson.databind.JsonMapper;
// ... 기타 임포트

@Component
@RequiredArgsConstructor
public class JwtExceptionFilter extends OncePerRequestFilter {
    
    // Spring Boot 4가 기본으로 제공하는 JsonMapper 빈을 정상적으로 주입받음
    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));
        }
    }
}

인가 예외 (AuthenticationEntryPoint)

토큰을 아예 보내지 않았거나 권한이 없는 ‘인가 실패’ 에러는 시큐리티 모듈에서 지원하는 공식 인터페이스인 AuthenticationEntryPoint를 구현해 처리했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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, ServletException {
        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에 명시해서 등록해 주었다. 특히 설정 클래스에서 필터의 실행 순서가 중요한데, 예외 처리 필터가 메인 인증 필터보다 먼저 실행되도록 감싸주어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@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(jwtExceptionFilter, JwtAuthenticationFilter.class)
            .addFilterAfter(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

5. 결론

빠르게 동작하는 코드를 짜는 것과, 프레임워크의 의도에 맞게 코드를 설계하는 것은 다르다는 걸 체감했다.

물론 서블릿 필터(JwtExceptionFilter, AuthenticationEntryPoint)와 스프링(@RestControllerAdvice) 양쪽에서 응답 DTO를 직렬화하는 코드가 중복되는 점은 아쉽다. 하지만 각 생명주기와 계층 간의 독립성을 지켰다는 점에서는 가치 있는 트레이드오프였다고 생각한다.

이번 이슈를 해결하면서 서블릿 컨테이너(Servlet Container)와 스프링 컨테이너(Spring Container)의 차이를 명확하게 배울 수 있었다. 앞으로도 기술 블로그에 나오는 해결책을 그대로 가져다 쓰기 전에, ‘왜 이렇게 짜야 하는가’를 항상 고민해야겠다고 다짐했다.


프로젝트: GRIT 관련 링크

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.