Filter 예외를 Controller로?

Drumj·2025년 5월 19일

JWT를 생성하고 예외도 만들었다

이전 게시글에서 JWT를 생성하고 JWT를 검증하는 과정에서 예외가 발생할 경우 공통적으로 사용할 커스텀 예외를 만들었다.

그러나!!

저번 프로젝트를 만들때도 만났던 문제인데... Filter를 통해 JWT 토큰을 가져와서 이 토큰이 유효한지 검사를 하는 과정에 예외가 발생하면 내가 원하는 대로 예외를 처리하지 못한다 ㅠㅠ

이게 무슨 말이냐??


필터에서 발생한 예외는 컨트롤러까지 가지 않아요~~

간단하게 흐름을 살펴보면

요청 (JWT 토큰을 가진 상태) → JwtFilter → 예외발생 → 반환

이렇게 되는데.. Filter에서 예외가 발생하면 로직이 종료되어 버린다. 당연하다! 예외가 발생하는 시점 이후의 로직들이 수행되는게 더 이상하다.

문제는 위치에 있는데, 우리가 사용할 Spring Security의 필터들은 DispatcherServlet보다 먼저 작동하기 때문이다.

이런 그림을 공부하다 많이 봤을 것이다. 여기서도 아주 명확하게 알 수 있다. Filter를 제대로 통과해야 그 이후로 쭉쭉 들어가서 Controller까지 도착할 수 있다. 내가 이전에 작성한 예외는 RestControllerAdvice를 통해 전역으로 처리를 하고 있다.

근데 이렇게 하면 Controller 계층에서 발생한 예외만 처리 할 수 있다는 문제가 존재한다.
이게 뭐냐?? Filter에서 발생한 예외는 이녀석이 처리하지 못한다는 말...

@ExceptionHandler(InvalidTokenException.class)
public ResponseEntity<ApiResponse<String>> invalidToken(InvalidTokenException e) {
	return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
    .body(ApiResponse.unAuthorized(e.getMessage()));
}

이렇게 작성한 코드는 무용지물이 된다는 말이다.. ㅠㅠ


이전에는 어떻게 했는데?

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String token = jwtProvider.getTokenFromHeader(request);

            if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) {
                log.info("JwtFilter 조건 통과");
                jwtProvider.setAuthentication(jwtProvider.getTokenInfo(token));
                response.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token);
            }
            filterChain.doFilter(request, response);
        } catch (JwtTokenException e) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.setCharacterEncoding("UTF-8");
            ApiResponse apiResponse = ApiResponse.unauthorizedResponse(e.getMessage());
            response.getWriter().write(
                    new ObjectMapper()
                            .registerModule(new JavaTimeModule())
                            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
                            .writeValueAsString(apiResponse)
            );
        }
    }
}

먼저 이전에 작성한 코드를 보자.

이때는 단순하게 try-catch를 통해 예외가 발생한 경우 내가 원하는 내용으로 response에 직접 작성해서 반환해주고 있다... (아오 코드 지저분해...)

Spring Security의 다양한 기능을 제대로 알고 있지 않고 그냥 무턱대고 사용해서 이런 대참사가 발생했다 ㅠ~ㅠ

이번에는 다르게 해결하기 위해 ChatCPT와 구글링의 도움을 받았다.


이제 진짜 제대로 처리해보자!

우리는 Spring SecurityFilterChainFilter를 등록해서 사용한다. 이때 예외가 발생하면 Security는 예외 처리 필터가 작동한다고 한다.

지금은 JWT를 이용한 검증시에 발생하는 예외를 처리해야하니 인증과 인가에 관한 녀석들을 알아보자!

ExceptionHandler 등록

@Configuration
@RequiredArgsConstructor
public class WebSecurityConfig {

    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
                .exceptionHandling(e -> e
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                )
                .build();
    }
}

AuthenticationEntryPoint

인증되지 않은 요청에 대한 처리를 담당한다. 401 Unauthorized 응답을 반환한다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final HandlerExceptionResolver handlerExceptionResolver;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) {
        handlerExceptionResolver.resolveException(request, response, null, authException);
    }
}

commence 메서드는 인증되지 않은 요청이 발생했을 때 호출된다. 여기에서 예외를 처리하는 것이 아닌 HandlerExceptionResolver로 넘긴다.


AccessDeniedHandler

인가되지 않은 요청(권한 부족)에 대한 처리를 담당한다. 403 Forbidden 응답을 반환한다.

@Component
@RequiredArgsConstructor
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final HandlerExceptionResolver handlerExceptionResolver;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        handlerExceptionResolver.resolveException(request, response, null, accessDeniedException);
    }
}

handle 메서드는 권한이 없는 요청이 발생했을 때 호출된다. AccessDeniedHandler와 마찬가지로 여기에서 예외를 처리하는 것이 아닌 HandlerExceptionResolver로 넘긴다.

위 두개의 핸들러에서 처리하는 코드는 동일하다!

일단 각 인터페이스에서 Override 해야하는 메서드를 작성하고 여기서 HandlerExceptionResolver로 예외를 넘기기만 하면 된다.

여기서 Object handler 부분을 null로 처리하고 있다는 것을 주의하자!!

그렇다면 이 HandlerExceptionResolver를 알아보자.


HandlerExceptionResolver

개발하는 자몽님 블로그에서 발췌

HandlerExceptionResolverSpring Security의 영역이 아닌 Spring MVC 영역에 속해있는 컴포넌트라고 한다.

Spring MVC에서 HandlerExceptionResolverDispatcherServletHandlerExceptionResolver 체인(예외 처리 체인)에 등록되어 있고, 이 체인은 컨트롤러에서 발생한 예외를 처리하는 역할을 한다.

따라서 AuthenticationEntryPointAccessDeniedHandler에서 HandlerExceptionResolver를 호출하여 컨트롤러에서 예외를 처리하도록 위임할 수 있다.

handlerExceptionResolver.resolveException(request, response, null, accessDeniedException);

위 코드처럼 handlernull로 반환하면 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

우리는 null을 반환하여 @RestControllerAdvice@ExceptionHandler를 사용하는 클래스에서 예외를 처리하도록 한다.


전체 흐름

요청 → Filter → 예외 발생(JWT 토큰 만료) → AuthenticationEntryPoint 호출 → HandlerExceptionResolver 위임 → @RestContollerAdvice에서 처리

요청 → Filter → 예외 발생(권한 없음) → AccessDeniedHandler 호출 → HandlerExceptionResolver 위임 → @RestContollerAdvice에서 처리

이렇게 각각의 원인별로 적절한 handler를 호출하고 각 handler에서 HandlerExceptionResolver를 통해 예외를 처리하기로 했기 때문에 @RestContollerAdvice가 달린 클래스에서 공통적으로 예외를 처리할 수 있게 되었다.

Filter에서 발생한 예외가 Controller 계층까지 내려와서 내가 원했던대로 @RestContollerAdvice를 통해 예외를 처리할 수 있다.


참고로

Filter에서도 바로 HandlerExceptionResolver를 호출해서 처리할 수도 있다고 한다. try-catch에서 바로 사용하면 됨!!

하지만 Spring Security를 사용하고 있기 때문에 인증,인가가 실패하거나 관련 예외가 발생할 경우 AuthenticationEntryPoint, AccessDeniedHandler를 사용하는게 이후 코드의 유지보수나 로직의 변경에 더 쉽게 대응할 수 있다고 판단,

해당 핸들러를 사용하면서 각 핸들러에서는 HandlerExceptionResolver를 호출해서 내가 원하는 방식으로 예외를 Contoller로 가져와서 처리하기로 했다.


추가!! (25.05.20)

어.... 내 애플리케이션에서는 왜 AuthenticationEntryPoint 가 작동을 안하지..???? 어제 이 글을 작성하고 하루 종일 찾아봤다..

도대체 왜 AccessDenied는 제대로 잘 작동을 하는데 EntryPoint는 작동을 안하는거야...??

멍청한 GPT는 뭔 filter@Component로 등록되어 있어서 filter 위치가 이상하다~~ try-catch로 감싸서 예외를 다시 던져라~~

구글링도 실컷하고 클로드, 제미니, 챗지피티 세마리(?)한테 다 물어봤는데 알려준 방법이 하나도 통하지 않았다... (진쫘 나 눠무 열봐돠...)

예외가 자꾸 이상한데서 터진다 ㅠㅠㅠㅠ

그렇게 여기저기 막 알아본 결과...

@Bean
SecurityFilterChain webSecurityFilterChain(HttpSecurity http) throws Exception {
	return http
    	.csrf(AbstractHttpConfigurer::disable)
        .formLogin(AbstractHttpConfigurer::disable)
        .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
        .authorizeHttpRequests(request -> request
        	.requestMatchers(
            	"/api/admin/**"
            ).hasAuthority("ADMIN")
			.anyRequest().permitAll()
        )
		.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
        .exceptionHandling(e -> e
 			.authenticationEntryPoint(jwtAuthenticationEntryPoint)
			.accessDeniedHandler(jwtAccessDeniedHandler)
		)
       .build();
}

여기서 filter의 위치가 문제였던것이다.

이 상태로 애플리케이션을 가동하면

어... 사진이 작아서 잘 안보이넹.. ㅎㅎ

내가 등록한 JwtFilter의 위치가 ExceptionTranslationFilter보다 한참 앞에 있는 걸 볼 수 있다

JwtFilter에서 예외가 발생하는데 ExceptionTranslationFilter 보다 한참 앞에 있으니.. 발생한 예외를 이 필터가 받아서 AuthenticationEntryPoint로 넘겨줄 수 없었던 것 ㅠㅠㅠ

필터의 위치를 변경 시켜주자!!

.addFilterAfter(jwtFilter, ExceptionTranslationFilter.class)

이렇게 내 JwtFilterExceptionTranslationFilter 뒤에 바로 붙어있는 걸 확인 할 수있다. 이렇게 해서 Jwt 검증 시 예외가 발생하면??

흑흑 ㅠㅠㅠㅠ 응답도 내가 원했던 대로 내려오고 AuthenticationEntryPoint도 잘 호출되는 것을 볼 수 있다!!!

요거는 클로드가 알려준 내용인디.. formLogin 을 사용하는 경우에 JWT 검증을 먼저 하고 싶어서 UsernamePasswordAuthenticationFilter보다 앞에 필터를 위치하는 거라고 한다... 흐흐.. 나는 그냥 저게 로그인 했는지 확인하는 건 줄 알았는데 formLogin과 관련된 필터인가??

Spring Seuciry에서 사용하는 Filter 들에 대해서도 조금 공부할 필요가 있겠다.


그럼 AccessDeniedHandler는 왜 잘 작동 되었나?

아마 JwtToken에는 아무 문제가 없어서 JwtFilter를 잘 수행하고 쭉쭉 지나가다가 권한에 문제가 생겨서 ExceptionTranslationFilter를 만나 예외 처리가 잘 된 것으로 보인다.

그러니까 접근 권한은 JwtFilter에서 확인하지 않으니까.. 예외가 안 터지고 다음 filter로 쭉쭉 넘어간 듯 ㅎㅎ;;


참고자료

개발하는 자몽님 블로그

0개의 댓글