[SpringBoot] Filter에서의 예외처리

2025. 3. 8. 18:44·Springboot

상황

JWT 토큰 인증을 Filter에서 공통으로 처리하도록 구현하였고, 클라이언트에서 만료된 토큰을 가지고 인증하는 과정에서 예외를 잡지 못하는 문제가 발생하였다.

코드는 아래와 같다.

ApiExceptionHandler.java: 예외 처리 핸들러

@Slf4j
@RestControllerAdvice // 컨트롤러에 적용되는 공통 관심사 분리
@Order(value=Integer.MIN_VALUE) // 최우선 처리
public class ApiExceptionHandler {

    @ExceptionHandler(ApiException.class) // ApiException에 해당하는 예외처리 실행
    public ResponseEntity<Api<Object>> apiException(
            ApiException apiException
    ) {
        log.error("", apiException); //ApiException은 RunTimeException을 상속받았기 때문에 stacktrace 가능

        var errorCode=apiException.getErrorCodeIfs();

        return ResponseEntity
                .status(errorCode.getHttpStatusCode())
                .body(
                        Api.ERROR(errorCode, apiException.getErrorDescription())
                );

    }
}
  • ApiExceptionHandler를 통해 사전 정의한 ApiException 예외가 발생하였을 경우 응답 처리할 수 있도록 정의하였다.

JwtTokenHelper.java : JWT 토큰 발급, 검증을 담당하는 유틸 클래스

@Override
    public Map<String, Object> validationTokenWithThrow(String token) {
        var key=Keys.hmacShaKeyFor(secretKey.getBytes());

        var parser=Jwts.parser()
                .setSigningKey(key)
                .build();
        try{
            var result = parser.parseClaimsJws(token); // 토큰 문자열 파싱, 서명 검증, 클레임 추출 result는 Jws<Claims> 형식
            log.info("토큰 문자열 파싱 결과: {}", result);

            return new HashMap<String, Object>(result.getBody());

        }catch (Exception e){

            if(e instanceof SignatureException){
                // 토큰이 유효하지 않을때
                throw new ApiException(TokenErrorCode.INVALID_TOKEN, e);
            }
            else if(e instanceof ExpiredJwtException){
                //  만료된 토큰
                throw new ApiException(TokenErrorCode.EXPIRED_TOKEN, e);
            }
            else{
                // 그외 에러
                throw new ApiException(TokenErrorCode.TOKEN_EXCEPTION, e);
            }
        }
    }
  • JWT 토큰을 파싱하는 상황에서 발생하는 예외가 생길 경우 ApiException을 통해 예외 핸들러로 전해지도록 구현하였다.

JWTAuthenticationFilter: 인증을 담당하는 필터

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final TokenHelperIfs jwtTokenHelper;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String uri = request.getRequestURI();
        // 허용 경로라면 JWT 인증 로직을 스킵
        if (
                uri.startsWith("/api/swagger") ||
                uri.startsWith("/api/swagger-ui") ||
                uri.startsWith("/v3/api-docs") ||
                uri.startsWith("/open-api")
        )
        {
            filterChain.doFilter(request, response);
            return;
        }

        String header = request.getHeader("Authorization");

        if (header == null || !header.startsWith("Bearer ")) {
            throw new ApiException(TokenErrorCode.AUTHORIZATION_TOKEN_NOT_FOUND);
        }

        String token = header.substring(7);

        // 토큰 검증 및 클레임 추출
        Map<String, Object> claims = jwtTokenHelper.validationTokenWithThrow(token);
        Object userIdObject = claims.get("userId");

        if(userIdObject == null){
            throw new ApiException(UserErrorCode.USER_NOT_FOUNT);
        }

        Long userId = Long.parseLong(userIdObject.toString());
        log.info("클레임 추출 user_id: {}", userId);

        try {
            // 인증 객체 생성
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());

            // 인증 객체 저장
            SecurityContextHolder.getContext().setAuthentication(authentication);

        } catch (JwtException | IllegalArgumentException e) {
            // 토큰 검증 실패 시 SecurityContext 초기화
            SecurityContextHolder.clearContext();
            throw new ApiException(TokenErrorCode.ERROR_CREATE_AUTHORIZATION);
        }
        filterChain.doFilter(request, response);
    }
}
  • JwtTokenHelper가 던진 예외를 상위 모듈에서 잡으면 exceptionhandler에 전해지지 않기 때문에, 그 부분에 대해서 따로 예외처리를 하지 않았다.

예상 결과

위 코드의 예상 흐름은 다음과 같다.

  1. 클라이언트에서 만료된 토큰을 통해 요청
  2. 서버에서는 토큰 파싱 후 만료된 토큰으로 판단.
  3. 서버에서 예외처리하여 클라이언트로 “만료된 토큰” 메시지 전달.
  4. 클라이언트에서는 요청에 대한 명확한 응답을 받는다.

결과

하지만 예상 결과와 다르게 swagger로 테스트한 결과 단순 500에러가 내려오게 되었다.

 

 

이유

다음과 같이 문제가 발생하게 된 이유는 필터와 디스패처 서블릿의 구조 때문이다.

@RestControllerAdvice는 기본적으로 스프링 MVC의 컨트롤러 계층에서 발생하는 예외만 처리한다.

Filter는 스프링 MVC의 DispatcherSevlet의 앞단에 위치하기 때문에 Controller보다 먼저 실행된다.

그런 이유로 예외 핸들러가 예외를 catch하지 못했던 것이다.

문제를 해결하기 위해 예외 핸들러를 사용하지 않고 응답을 반환해주는 코드를 추가하기로 하였다.

 

그렇다고 JWT인증을 처리하는 Filter에 응답 반환 로직을 추가하면, 역할의 분리가 안될 뿐더러 수정해야하는 코드가 많이 발생하는 문제가 생긴다.

따라서 예외 응답을 처리하는 Filter를 새로 만들어 필터 체인에 연결하였다. 예외 처리 필터 코드는 아래와 같다.

예외 처리 필터 코드

@Slf4j
@Component
public class ExceptionHandlerFilter extends OncePerRequestFilter {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        try{
            filterChain.doFilter(request, response);
        }catch (ExpiredJwtException e){
            setErrorResponse(response, TokenErrorCode.EXPIRED_TOKEN);
        }catch (JwtException | IllegalArgumentException e){
            setErrorResponse(response, TokenErrorCode.INVALID_TOKEN);
        }
    }
    private void setErrorResponse(
            HttpServletResponse response,
            ErrorCodeIfs errorCode
    ){
        response.setStatus(errorCode.getHttpStatusCode());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        try{
            response.getWriter().write(objectMapper.writeValueAsString(Api.ERROR(errorCode)));
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}
  • 먼저 ObjectMapper를 통하여 응답을 내려주기 위해 의존성 주입을 받아야한다. 따라서 Component어노테이션을 통해 빈으로 등록하여 ObjectMapper를 주입받았다.
  • 필터에서 예외가 터지면 응답을 만들어서 내려주도록 설정하였다.
    • 이때 예외 메시지는 사전에 만들어놓은 커스텀 ErrorCode를 활용하였다.
    • 이로인해 각 예외 상황에 재사용률을 높여 예외 메시지를 전달할 수 있게되었다.
    • 응답 문자열은 한글로 내려주기 때문에 문자열 인코딩 형식을 UTF-8로 설정하였다.

필터 체인 연결

http
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenHelper), UsernamePasswordAuthenticationFilter.class)
                .addFilterBefore(exceptionHandlerFilter, JwtAuthenticationFilter.class);

        return http.build();

예외처리 필터를 추가하였음으로, SecruityFilterChain에 예외처리 필터를 연결하였다.

필터의 처리 순서는 다음과 같다.

ExceptionHandlerFilter → JwtAuthenticationFilter → UsernamePasswordAuthenticationFilter

  • exceptionHandlerFilter의 경우 앞서 Component 어노테이션을 통해 빈으로 등록하였기 때문에 주입하였다.

부가적인 코드

public interface ErrorCodeIfs {
    Integer getHttpStatusCode();

    Integer getErrorCode();

    String getDescription();
}

@AllArgsConstructor
@Getter
public enum TokenErrorCode implements ErrorCodeIfs{

    INVALID_TOKEN(400, 2000, "유요하지 않은 토큰"),
    EXPIRED_TOKEN(400, 2001, "만료된 토큰, 리프래쉬 토큰을 통해 토큰 재발급"),

    TOKEN_EXCEPTION(400, 2002, "토큰 알 수 없는 에러"),
    AUTHORIZATION_TOKEN_NOT_FOUND(400, 2003, "인증 헤더 토큰 없음"),
    ERROR_CREATE_AUTHORIZATION(500,2004, "인증 객체 생성 중 오류")
    ;

    private final Integer httpStatusCode;

    private final Integer errorCode; //내부 코드

    private final String description;
}

예외처리 코드에서 사용한 커스텀 예외 코드이다.

기본 베이스를 인터페이스로 선언하고 상속하여 예외 코드의 종류에 따라 분리할 수 있도록 설계하였다.

추가

ObjectMapper를 통하여 요청과 응답에 snakecase를 적용하도록 수정하였지만,

swagger 문서에는 리플렉션을 통해 자바 필드를 그대로 반영하기 때문에, 카멜케이스로 예시, 요청 필드가 설정되어 있어서, 오류, 혼동의 문제가 발생한다.

따라서 ModelResolver를 설정하여 문제를 해결한다.

@Configuration
class ModelResolverConfig {
    @Bean
    public ModelResolver modelResolver(ObjectMapper objectMapper) {
        return new ModelResolver(objectMapper);
    }
}

그 결과 리플렉션시에도 objectmapper를 반영하여 snakecase가 적용된다.

'Springboot' 카테고리의 다른 글

[스프링부트] ObjectMapper JsonNode Object로 변환  (0) 2025.03.12
[SpringSecurity] BCrypt 비밀번호 인증 구현  (0) 2025.03.12
[스프링 부트] Stream.map() 함수  (0) 2024.07.20
[스프링 부트] orElseThrow 메소드 구현부  (0) 2024.07.15
[스프링 부트] JWT 검증 및 사용자 정보 가져오기  (0) 2024.07.15
'Springboot' 카테고리의 다른 글
  • [스프링부트] ObjectMapper JsonNode Object로 변환
  • [SpringSecurity] BCrypt 비밀번호 인증 구현
  • [스프링 부트] Stream.map() 함수
  • [스프링 부트] orElseThrow 메소드 구현부
코딩 못하는 감자
코딩 못하는 감자
  • 코딩 못하는 감자
    코딩 못하는 감자의 기록
    코딩 못하는 감자
  • 전체
    오늘
    어제
    • 분류 전체보기 (91)
      • Kubernetes (10)
      • Github Action (1)
      • Docker, Container (3)
      • Springboot (26)
      • Baekjoon (4)
      • 명품 운영체제 (9)
      • 데이터베이스 (2)
      • JSP (3)
      • 안드로이드프로그래밍 (1)
      • 미니프로젝트 (1)
      • 용어정리 (0)
      • 소프트웨어공학 (3)
      • 운영체제 (2)
      • Flutter (0)
      • Git (1)
      • HTTP (0)
      • RAG (1)
      • Database (2)
      • FastAPI (1)
      • Elasticsearch (7)
      • Redis (0)
      • JPA (5)
      • Linux (1)
      • MCP (1)
  • 블로그 메뉴

    • 홈
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    mcp #model context protocol #claude desktop #mcp claude연동 #claude 파일 시스템 연동
    elasticsearch ngram
    엘라스틱서치 인덱스
    Dockerfile
    elasticsearch analyzer
    응답 로그
    elasticsearch 커스텀분석기
    엘라스틱서치 인덱스 복사
    SpringBoot
    fuzziness
  • hELLO· Designed By정상우.v4.10.3
코딩 못하는 감자
[SpringBoot] Filter에서의 예외처리
상단으로

티스토리툴바