카카오테크 부트캠프

Spring Security에서 AccessTokenExpiredException(커스텀 예외)이 무시되는 이유와 해결기

kanado 2025. 5. 11. 15:08

🔍 문제 상황: 분명 커스텀 예외를 던졌는데, 왜 무시될까?

JWT 기반 인증 시스템을 구현하면서, 다음과 같이 Access Token 만료 시 사용자에게 명확한 메시지를 전달하고자 했습니다.

public boolean validateToken(String token) {
    try {
        parseClaims(token);
        return true;
    } catch (ExpiredJwtException e) { // 토큰 만료
        throw new AccessTokenExpiredException();
    } catch (JwtException | IllegalArgumentException e) {
        return false;
    }
}

 

그리고 CustomAuthenticationEntryPoint를 통해 다음과 같은 응답을 내려주길 기대했습니다.

{
  "code": "ACCESS_TOKEN_EXPIRED",
  "message": "Access Token이 만료되었습니다.",
  "data": null
  }

 

하지만 실제로는 다음과 같은 응답이 내려왔습니다.

{
  "code": "UNAUTHORIZED",
  "message": "지정한 리소스에 대한 액세스 권한이 없습니다.",
  "data": null
  }

 

👉 AccessTokenExpiredException을 분명 던졌는데, AuthenticationEntryPoint까지 도달하지 않는 상황입니다.


🤔 원인 분석: Spring Security 예외 흐름 바깥에서 발생한 문제

문제의 핵심은 예외가 Spring Security 예외 처리 흐름에 포함되지 않았기 때문입니다.

JwtAuthenticationFilter 내부를 보면 다음과 같습니다.

if (token != null && jwtTokenProvider.validateToken(token)) {
    // 인증 로직...
}
filterChain.doFilter(request, response);

validateToken() 내부에서 예외가 발생해도, 이는 AuthenticationException이 아니며, ExceptionTranslationFilter가 인식할 수 있는 예외가 아닙니다.

결국 AccessTokenExpiredException은 CustomAuthenticationEntryPoint로 전달되지 않고, Spring Security가 자체적으로 UNAUTHORIZED를 응답해버리는 구조가 됩니다.

 

👉 즉, validateToken()에서 발생한 예외는 Spring Security의 인증 흐름 안에서 발생한 게 아니기 때문에, 등록한 예외 처리 로직까지 도달하지 못합니다.


✅ 해결 방법: JwtAuthenticationFilter에서 직접 만료 예외를 처리하자

핵심은 다음과 같습니다.

  • validateToken()에서는 더 이상 커스텀 예외를 던지지 않고 ExpiredJwtException을 그대로 던진다.
  • JwtAuthenticationFilter에서 ExpiredJwtException 예외를 직접 catch하고 응답을 구성한다.

🔧 수정된 JwtTokenProvider

public boolean validateToken(String token) {
    try {
        parseClaims(token);
        return true;
    } catch (ExpiredJwtException e) {
        throw e; // 그대로 던짐
    } catch (JwtException | IllegalArgumentException e) {
        return false;
    }
}

🔧 수정된 JwtAuthenticationFilter

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

        if (token != null && jwtTokenProvider.validateToken(token)) {
            Long userId = jwtTokenProvider.getUserIdFromToken(token);
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);

    } catch (ExpiredJwtException ex) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");

        Map<String, Object> errorResponse = new HashMap<>();
        errorResponse.put("code", "ACCESS_TOKEN_EXPIRED");
        errorResponse.put("message", "Access Token이 만료되었습니다. Refresh Token으로 재발급 요청이 필요합니다.");
        errorResponse.put("data", null);

        ObjectMapper objectMapper = new ObjectMapper();
        response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
    }
}

 

Spring Security는 내부 인증 처리 과정에서 발생한 AuthenticationException만을 AuthenticationEntryPoint로 전달합니다.

하지만 validateToken()은 그 외부에서 실행되기 때문에, 예외가 처리되지 않고 다음 filter로 남어가 사라지는 일이 발생한 것입니다.

따라서 토큰 검증은 Security 흐름 안쪽에서 하거나, 예외를 직접 처리해줘야 예측 가능한 API 응답을 보장할 수 있습니다.

 

끝.