Kkrap/개발하면서 공부하게 된 것들

[Spring boot] JWT Accesstoken 및 Refresh 검사 흐름?

재윤 2025. 8. 8. 13:53
반응형
  • 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 에러 처리 흐름 부터 보자

spring boot jwt 에러 처리 방식

 

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 검증 실패 시
  1. JwtAuthenticationFilter는 Spring Security 필터 체인 안에 있다
  2. 요청이 컨트롤러로 가기 전, doFilterInternal()에서 토큰 검증 실행
  3. 검증 실패 시 → UnauthorizedException 발생
  4. 그런데 이 예외는 DispatcherServlet까지 가지 않음
  5. 따라서 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 계열을 던지면 생기는 일

  1. JwtAuthenticationFilter에서 throw new BadCredentialsException("토큰 만료")
  2. ExceptionTranslationFilter가 잡음
  3. 등록해 둔 AuthenticationEntryPoint를 호출
  4. 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