포스트

Security Filter 던진 예외가 왜 안잡힐까

Security Filter 던진 예외가 왜 안잡힐까

배경

  • Spring Boot + Spring Security 환경에서 JWT를 구현
  • 잘못된 JWT 토큰이 들어왔을 때 사용자에게 명확한 에러 메세지를 내려주고 싶었다.

처음에는 단순히 @RestControllerAdvice 기반의 GlobalExceptionController로 해결할 수 있을거라 생각했다. 하지만 결과는 예상과 달랐다.

첫 번째 시도 방법: GlobalExceptionHandler 구현

GlobalExceptionHandler 구현

1
2
3
4
5
6
7
8
@RestControllerAdvice
public class GlobalExceptionHandler {
	@ResponseStatus(HttpStatus.UNAUTHORIZED)
	@ExceptionHandler(JwtAuthenticationException.class)
	    public ErrorResponseDto handleJwtAuthenticationException() {
	        return new ErrorResponseDto(AUTHENTICATION_FAILED);
	    }
}

의도:

  • JwtAuthenticationFilter에서 예외 발생 → 전역 예외 핸들러에서 처리 → 클라이언트에게 에러 응답을 전달

결과:

  • JwtAuthenticationFilter 에서 예외는 발생했지만, GlobalExceptionHandler가 전혀 잡지 못함
  • 클라이언트는 단순히 401 Unauthorized 상태 코드만 받고, 커스텀 응답 메시지는 전달되지 않음.

원인 분석

  • Spring Security의 Filter Chain은 DispatcherServlet 보다 먼저 동작한다.
  • @RestControllerAdvice는 Controller 단에서 발생한 예외만 처리 가능하다.
  • 따라서 Filter에서 발생한 예외는 GlobalExceptionHandler로는 처리할 수 없다.

두 번째 시도 방법: AuthenticationEntryPoint 구현

Spring Security 공식 문서를 참고하니, 인증/인가 과정에서 발생한 예외는 ExceptionTranslationFilter가 가로채고, 최종적으로 AuthenticationEntryPoint가 처리한다는 점을 알게 되었다.

따라서 AuthenticationEntryPoint 를 커스터마이징하면, 401 에러 상황에서 클라이언트에게 JSON 형태의 응답을 내려줄 수 있을 것이라 판단했다.

Spring Security 공식문서 예외 처리 방법

Spring Security 공식문서 예외 처리 방법

CustomAuthenticationEntryPoint 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {

        AuthErrorType errorType = (AuthErrorType) request.getAttribute("authErrorType");

        if(errorType == null){
            errorType = AuthErrorType.AUTHENTICATION_FAILED;
        }

        String errorResponse = generateErrorResponse(errorType);

        response.setCharacterEncoding("UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write(errorResponse);
    }

    private String generateErrorResponse(AuthErrorType error){
        String response = String.format("""
                        {
                            "success": false,
                            "error": "UNAUTHORIZED",
                            "message": "%s",
                            "timestamp": "%s"
                        }
                        """,
                error.getMessage(),
                LocalDateTime.now()
        );
        return response;
    }
}

의도:

  • JWT 필터에서 예외 발생 → ExceptionTranslationFilter 호출 → CustomAuthenticationEntryPoint에서 예외 처리 → 클라이언트에게 JSON 응답 반환

결과:

  • CustomAuthenticationEntryPoint는 정상적으로 작동했으나, JWT 필터에서 발생한 예외 메시지를 그대로 전달하지는 못함
  • 클라이언트는 내가 정의한 에러 포멧을 받았지만, 예외 원인은 내가 지정한 원인으로 받지 못함.

원인 분석

  • ExceptionTranslationFilter는 내부적으로 예외를 AuthenticationException 혹은 AccessDeniedException으로 변환한 뒤 AuthenticationEntryPoint를 호출한다.
  • 따라서 JwtAuthenticationFilter에서 던진 커스텀 예외 메시지는 AuthenticationEntryPoint까지 그대로 전달되지 않았다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ExceptionTranslationFilter.java
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
        throws IOException, ServletException {
    try {
        chain.doFilter(request, response);
    }
    catch (IOException ex) {
        throw ex;
    }
    catch (Exception ex) {
        // Try to extract a SpringSecurityException from the stacktrace
        Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
        // **👉** AuthenticationException으로 변환
        RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
                .getFirstThrowableOfType(AuthenticationException.class, causeChain);
        if (securityException == null) {
            // **👉** AccessDeniedException으로 변환
            securityException = (AccessDeniedException) this.throwableAnalyzer
                    .getFirstThrowableOfType(AccessDeniedException.class, causeChain);
        }
        if (securityException == null) {
            rethrow(ex);
        }
        if (response.isCommitted()) {
            throw new ServletException("Unable to handle the Spring Security Exception "
                    + "because the response is already committed.", ex);
        }
        handleSpringSecurityException(request, response, chain, securityException);
    }
}

최종 해결: JwtExceptionHandlerFilter

그래서 선택한 방법은 예외 처리 전용 Filter를 생성하는 것이다.

흐름:

1
JwtExceptionHandlerFilter → JwtFilter → 나머지 Security Filters …
  • 흐름은 ExceptionTranslationFilter의 매커니즘과 동일하게 JwtExceptionHandlerFIlter에서 chain.doFilter()를 감싸고, JwtAuthenticationFilter에서 발생하는 예외를 캐치해서 JSON 응답으로 변환했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Slf4j
public class JwtExceptionHandlerFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try{
            log.info("JwtExceptionHandlerFilter 실행");
            filterChain.doFilter(request,response);
        } catch (MalformedJwtException e) {
            log.info("올바르지 않은 JWT 토큰 형식입니다.");
            handleJwtException(response, INVALID_TOKEN);
            return;
        }catch (ExpiredJwtException e){
            log.info("만료된 JWT 토큰입니다.");
            handleJwtException(response, EXPIRED_TOKEN);
            return;
        } catch (UnsupportedJwtException e) {
            log.info("지원하지 않는 JWT 형식입니다.");
            handleJwtException(response, UNSUPPORTED_TOKEN);
            return;
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 비어있거나 잘못되었습니다.");
            handleJwtException(response, ILLEGAL_TOKEN);
            return;
        } catch (SignatureException e) {
            log.info("JWT 서명이 유효하지 않습니다.");
            handleJwtException(response, SIGNATURE_MISMATCH);
            return;
        }
    }

    private void handleJwtException(HttpServletResponse response, JwtErrorType errorType) throws IOException {
        String errorCode = errorType.name();
        String message = errorType.getMessage();
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(String.format(
                """
                        {
                            "success": false,
                            "error": "%s",
                            "message": "%s",
                            "timestamp": "%s"
                        }
                        """, errorCode, message, LocalDateTime.now()));
    }

}

배운점

  • Spring Security의 Filter Chain은 단순히 “앞에서 막는다” 수준이 아니라, 순서와 책임 분리를 이해해야 제대로 활용할 수 있다.
  • AuthenticationEntryPoint는 스프링에서 제공하는만큼 편하지만, 모든 상황에서 동작하는 것은 아니다. (특히 커스텀 필터 앞단에서는 적용되지 않음)
  • 결국 내 프로젝트의 구조와 요구사항에 맞는 예외 처리 전략을 세우는 것이 중요하다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.