- Accesstoken 만료검사를 위해 만료된 accesstoken을 서버에 날려서 내가 만든 Error Handler가 잘 동작하는지 보기 위해서 Postman으로 던져보았다.
Exception은 다음과 같이 정의했다
@Getter
@AllArgsConstructor
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message){
super(message);
}
public static UnauthorizedException of(String message){
return new UnauthorizedException(message);
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UnauthorizedException.class)
public ResponseEntity<ErrorResponse> handleUnauthorizedException(UnauthorizedException ex){
log.error("handleUnauthorizedException", ex);
ErrorResponse response = ErrorResponse.from(HttpStatus.UNAUTHORIZED.value(), ex.getMessage());
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
}
doFilterInternal
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UsersService usersService;
public JwtAuthenticationFilter(JwtUtil jwtUtil,
UsersService usersService) {
this.jwtUtil = jwtUtil;
this.usersService = usersService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // "Bearer " 제외
try {
jwtUtil.validateToken(token);
Long userId = jwtUtil.getUserIdFromToken(token);
Users user = usersService.findById(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user.getUserId(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(authentication);
}catch (Exception ex){
throw UnauthorizedException.of("유효하지 않은 refresh token입니다.!");
}
}
filterChain.doFilter(request, response);
}
}
그런데 Postman으로 에러 핸들링이 먹히지 않는다..

그래서 refreshtoken은 잘 되는지 확인해보았다.
AuthService.java
public ResponseEntity<TokenResponse> refreshAccessToken(String refreshToken) {
jwtUtil.validateToken(refreshToken);
Long userId = jwtUtil.getUserIdFromToken(refreshToken);
refreshTokenService.validateStoredRefreshToken(userId, refreshToken); // 유효성 검사 메서드 필요
String newRefreshToken = jwtUtil.generateRefreshToken(userId);
refreshTokenService.updateRefreshToken(userId, newRefreshToken);
String newAccessToken = jwtUtil.generateAccessToken(userId) ;
return ResponseEntity.ok(TokenResponse.of(newAccessToken, newRefreshToken));
}
jwtUtil
public void validateToken(String token) {
try {
log.info("token {}", token);
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
} catch (JwtException | IllegalArgumentException e) {
throw UnauthorizedException.of("유효하지 않은 refresh token입니다.!");
}
}
- refresh는 내가 작업한 error hanlder에 잘 먹힌다

뭐가 다른 거지라는 생각이 들었다.
이 구조를 파악하기 위해 Spring boot 에러 처리 흐름 부터 보자

Tomca과 자세한 요청처리는 다음과 같은 블로그에 있다.
Tomcat → https://wo-dbs.tistory.com/231
자세한 요청 처리 → https://wo-dbs.tistory.com/233
1. 서블릿 컨테이너
- 내장 Tomcat이 HTTP 요청을 받는다.
- Servlet Filter 체인을 통해 요청이 전달된다.
2. Filter Chain
- OncePerReuqestFilter, CharaterEncodingFilter, JwtAuthenticationFilter와 같은 필터들이 순서대로 실행
→ 여기서 요청을 가로채 인증/인가, 로깅, 캐싱 등을 처리
필터에서 예외가 발생하면, 기본적으로 DispatcherServlet까지 요청이 가지 못함
3. DispatcherServlet
- MVC의 중앙 컨트롤러 역할을 하며, 요청을 적절한 컨트롤러 메서드로 연결
- 이 시점에서 컨트롤러나 서비스 계층에서 예외가 발생하면 @ControllerAdvice/@RestControllerAdvice에 등록한 전역 예외 처리기가 동작함
전역 예외 처리기의 동작 범위
- @RestControllerAdvice는 DispatcherServlet 내부에서 발생한 예외를 잡아 처리함
Controller, Service, Respository 안에서 발생한 예외 HandlerInterceptor에서 발생한 예외
는 전역 예외 핸들러가 잡을 수 있음
but
: Filter에서 발생한 예외는 기본적으로 DispatcherServlet에 도달하지 않으므로, 전역 예외 핸들러가 잡을 수 없다.
상황 분석
- Access Token 검증 실패 시
- JwtAuthenticationFilter는 Spring Security 필터 체인 안에 있다
- 요청이 컨트롤러로 가기 전, doFilterInternal()에서 토큰 검증 실행
- 검증 실패 시 → UnauthorizedException 발생
- 그런데 이 예외는 DispatcherServlet까지 가지 않음
- 따라서 GlobalExceptionHandler의 @ExceptionHandler(UnauthorizedException.class)가 호출되지 않음
반면 Refresh Token 검증은:
- AuthService.refreshAccessToken() 메서드 안에서 jwtUtil.validateToken() 실행
- 이 시점은 이미 Controller 메서드 호출 중 → DispatcherServlet 내부
- 예외 발생 시 전역 예외 핸들러가 정상 동작

왜 Filter 예외는 잡히지 않는가?
FilterChain은 DispatcherServlet 이전 단계에서 동작하기 때문에, 여기서 던진 예외는 DispatcherServlet의 예외 처리 메커니즘(@RestControllerAdvice)까지 도달하지 않는다
Filter 단계 예외 → DispatcherServlet 모름 → GlobalExceptionHandler 못 씀
해결 방법
방법 1: JwtAuthenticationFilter에서 직접 응답 처리
catch (Exception ex) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write(
"{\\"status\\":401, \\"message\\":\\"유효하지 않은 access token입니다.\\"}"
);
return;
}
방법 2 : spring Security의 AuthenticationEntryPoint 활용
- Spring Security는 인증 실패 시 호출되는 AuthenticationEntryPoint 제공
- 예외가 발생하면 commence()에서 응답을 커스터마이징 할 수 있음
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\\"error\\":\\"Unauthorized\\"}");
}
}
SecurityConfig에 등록:
http.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint);
시스템상에서 예외처리를 filter에서 하는 게 더 좋을까? 아니면 전역 예외 핸들러에서 하는 게 좋을까?
결론부터 말하면
- 인증/인가(401/403) 관련 예외 → Security 필터 체인에서 처리하는 것이 좋다.
- AuthenticationEntryPoint(401), AccessDeniedHandler(403)로 응답 내려주는 게 정석.
- 비즈니스/검증/컨트롤러 레이어 예외 → 전역 예외 핸들러(@RestControllerAdvice) DTO 검증 실패, 도메인 규칙 위반, 서비스/리포지토리에서 터진 예외 등
Accesstoken 예외는 JwtAuthenticationFilter에서는 커스텀 예외를 던지기 보다 AutheticationException 계열을 던지면 ExceptionTranslationFilter가 EntryPoint/Hanlder로 연결
왜 이렇게 하라는 걸까?

- Spring Security 필터 체인의 예외 처리 흐름 때문이다.
- 핵심은 ExceptionTranslationFilter 라는 중간 필터가 AuthenticationException 이나 AuthenticationException만 특별히 취급해서, 적절한 처리기(AuthenticationEntryPoint) 나 AccessDeniedHandler)로 연결해 주기 때문
왜 커스텀 예외(UnauthorizedException)는 안 되나?
- ExceptionTranslationFilter는 타입 체크를 해서 AuthenticationException이나 AccessDeniedException만 잡는다.
- UnauthorizedException은 RuntimeException이라서 그냥 필터 체인을 타고 올라가 버림.
- 필터 체인 바깥의 @RestControllerAdvice까지 가면 좋겠지만, Security 필터는 DispatcherServlet 전에 있어서 전역 예외 핸들러까지 도달하지 못한다.
- 그래서 결국 응답이 안 내려가거나, Security의 기본 403/401 페이지로 바뀌는 현상이 생긴다
AuthenticationException 계열을 던지면 생기는 일
- JwtAuthenticationFilter에서 throw new BadCredentialsException("토큰 만료")
- ExceptionTranslationFilter가 잡음
- 등록해 둔 AuthenticationEntryPoint를 호출
- JSON 포맷의 401 응답을 내려줌
이게 Spring Security 표준 패턴이고, EntryPoint에서 내려주는 응답 포맷을 프로젝트 전역 스타일에 맞게 통일할 수 있다.
- AuthenticationException = "Security 필터 체인에서 공식적으로 인정하는 경고 신호"
- ExceptionTranslationFilter = "경고 신호가 오면 EntryPoint/Handler에 넘겨주는 관제탑"
- UnauthorizedException = "관제탑이 모르는 신호" → 그냥 무시되거나 외부까지 못 나감
장단점 비교
- 필터에서 처리
- 인증 실패를 가장 앞단에서 차단(불필요한 컨트롤러 진입 방지, 성능/보안 유리)
- Spring Security 표준 흐름(EntryPoint/DeniedHandler) 활용 → 유지보수 쉬움
- 전역 핸들러와 섞어서 쓰면 “왜 어떤 건 잡히고 어떤 건 안 잡히지?” 혼동될 수 있음(의도대로 분리하면 해결)
- 전역 예외 핸들러에서 처리
- 비즈니스 로직 에러를 한곳에서 관리
- Security 필터에서 발생한 예외는 도달하지 않음(구조상 불가)
하지만 역할은 당연히 나뉜 게 맞다
- Accesstoken은 리소스 요청에서 빠르게만 검증이고
- refreshtoken은 DB조회, 회전(rotate), 재발급 정책 등으로 비즈니스 로직이 섞임
그래서 위처럼 에러 핸들링을 잡는 것이 맞다.
코드 바꾸기
Accesstoken을 AuthenticatipnException 계열 예외를 던지도록 바꾸고 실패 시 응답은 AuthenticationEntryPoint에서 처리하는 방식으로 하자.
- 밑처럼 하면 예외가 ExceptionTranslationFilter로 전달되고, 거기서 AuthenticationEntryPoint가 호출된다.
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
jwtUtil.validateToken(token);
Long userId = jwtUtil.getUserIdFromToken(token);
Users user = usersService.findById(userId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user.getUserId(), null, List.of());
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception ex) {
SecurityContextHolder.clearContext();
// UnauthorizedException 대신 Spring Security 예외 사용
throw new BadCredentialsException("유효하지 않은 access token입니다.", ex);
}
}
filterChain.doFilter(request, response);
}
CustomAuthenticationEntryPoint 추가
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
ErrorResponse errorResponse = ErrorResponse.from(401, authException.getMessage());
response.getWriter().write(objectMapper.writeValueAsString(errorResponse));
}
}
SecurityConfig에 EntryPoint 등록
- addFilterBefore
- 스프링 로그인 필터 전에 JWT 검사 필터를 끼워 넣음.
- 토큰이 유효하면 SecurityContext에 인증 정보를 채움.
- 토큰이 없거나/유효하지 않으면 AuthenticationException을 던져서 그림처럼 ExceptionTranslationFilter → AuthenticationEntryPoint로 넘어감.
- exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint)
- 위처럼 인증 예외가 발생했을 때(미인증/토큰불량) 여기서 401 응답을 커스터마이즈해서 내려줌.
- 즉, 그림의 AuthenticationEntryPoint 박스를 네가 만든 customAuthenticationEntryPoint로 바꾸는 것.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login", "/auth/refresh").permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(customAuthenticationEntryPoint) // 등록
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
- 이렇게 바꾸니 → AccessToken 에러가 이렇게 온다.
{
"code": 401,
"message": "Full authentication is required to access this resource"
}
'Kkrap > 개발하면서 공부하게 된 것들' 카테고리의 다른 글
| CI/CD란? (1) | 2025.08.25 |
|---|---|
| [Spring boot] JWT 로그인 요청 검증 방법 (3) | 2025.08.08 |
| [Spring boot] JWT 관리 코드 분석 (2) | 2025.08.08 |
| JWT란? (5) | 2025.08.03 |
| 커서 기반 페이지네이션이란? (1) | 2025.07.30 |