🔍 문제 상황: 분명 커스텀 예외를 던졌는데, 왜 무시될까?
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 응답을 보장할 수 있습니다.
끝.
'카카오테크 부트캠프' 카테고리의 다른 글
| boolean 필드가 JSON에서 read로 직렬화되는 이유 (0) | 2025.05.25 |
|---|---|
| 채팅 도메인 - SSE 도입 테크스펙 (0) | 2025.05.16 |
| OAuth로 카카오 소셜 로그인 구현하기: 프론트 vs 백엔드 주도 방식 비교 (0) | 2025.05.04 |
| [기술 검토 및 선정] 로컬 캐시(Local Cache) 기술 선정 (0) | 2025.04.27 |
| [기술 검토 및 선정] NoSQL 선택 (Redis VS Memcashed) (0) | 2025.04.22 |