어제 오늘 내일

[Spring Security] 14편 - 필터 예외 처리의 정석 (ExceptionHandlerFilter) 본문

IT/SpringBoot

[Spring Security] 14편 - 필터 예외 처리의 정석 (ExceptionHandlerFilter)

hi.anna 2026. 2. 24. 07:48

 
사실 실무에서는 "토큰이 만료되었는지", "서명이 위조되었는지", "형식이 잘못되었는지"를 구분해서 프론트엔드에게 알려줘야 할 때가 많습니다. (예: 만료면 재발급 요청, 위조면 강제 로그아웃 등)

하지만 기존 코드처럼 JwtTokenProvider에서 예외를 try-catch에서 false만 반환하면, 구체적인 이유를 알 수 없게 되죠.

이를 해결하기 위해 1. Provider가 예외를 던지게 수정하고, 2. 앞단 필터(ExceptionHandlerFilter)가 이를 잡아서 처리하는 완벽한 구조를 만들어 봅시다.


스프링 시큐리티를 쓰다 보면 당황스러운 점이 하나 있습니다.
"왜 @ControllerAdvice가 JWT 예외를 못 잡지?"

이유는 간단합니다. 필터(Filter)는 컨트롤러보다 먼저 실행되기 때문입니다. 토큰 검증 중에 에러가 터지면 컨트롤러까지 도달하지도 못하고 필터에서 끝나버립니다.

이 문제를 해결하는 가장 깔끔한 패턴인 "필터 앞의 필터", ExceptionHandlerFilter를 구현해 보겠습니다.


Step 1. JwtTokenProvider 수정 (예외 던지기)

가장 먼저 할 일은 JwtTokenProvider가 예외를 삼키지 않고 밖으로 던지도록 수정하는 것입니다.
기존의 try-catch 블록을 제거하거나 수정해야 합니다.

  • 위치: src/main/java/com/example/board/jwt/JwtTokenProvider.java
    // ... 기존 코드 ...

    // 3. 토큰 유효성 검증 (수정됨)
    // 기존에는 boolean을 반환했지만, 이제는 문제가 있으면 예외를 던집니다.
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
            throw e; // ★ 다시 던짐
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
            throw e; // ★ 다시 던짐
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
            throw e; // ★ 다시 던짐
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
            throw e; // ★ 다시 던짐
        }
    }

Step 2. ExceptionHandlerFilter 만들기 (핵심)

JwtAuthenticationFilter보다 먼저 실행되면서, 뒤쪽 필터에서 발생하는 예외를 try-catch로 감싸 안아줄 필터입니다.

  • 위치: src/main/java/com/example/board/jwt/JwtExceptionFilter.java
package com.example.board.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Component
public class JwtExceptionFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            // 다음 필터(JwtAuthenticationFilter)로 이동
            filterChain.doFilter(request, response); 
        } catch (JwtException e) {
            // JWT 관련 예외 발생 시 잡아서 처리
            setErrorResponse(response, e);
        }
    }

    private void setErrorResponse(HttpServletResponse response, JwtException e) throws IOException {
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        Map<String, Object> body = new HashMap<>();
        body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
        body.put("error", "Unauthorized");
        body.put("message", e.getMessage()); // 예외 메시지 그대로 전달 (또는 커스텀 가능)

        ObjectMapper mapper = new ObjectMapper();
        mapper.writeValue(response.getOutputStream(), body);
    }
}

Step 3. JwtAuthenticationFilter 확인하기

이 필터 코드를 수정할 필요는 없지만,Provider가 예외를 던지게 바뀌었으니, 이 필터도 코드를 잠깐 같이 봅시다.
이제 validateToken이 실패하면 false가 아니라 예외가 발생(throw)하므로, if문 구조를 바꿀 필요는 없지만 흐름을 이해해야 합니다.

  • 위치: src/main/java/com/example/board/jwt/JwtAuthenticationFilter.java
    // ... 기존 코드 ...

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        // 1. Request Header 에서 JWT 토큰 추출
        String token = resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        // ★ 여기서 예외가 발생하면 -> 앞단의 JwtExceptionFilter가 잡습니다!
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }

 


Step 4. SecurityConfig에 필터 등록

이제 새로 만든 JwtExceptionFilterJwtAuthenticationFilter "앞에" 배치해야 합니다. 그래야 뒤에서 발생한 Exception을 앞에서 잡을 수 있으니까요.

  • 위치: src/main/java/com/example/board/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtExceptionFilter jwtExceptionFilter; // ★ 주입

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // ... (기존 설정들) ...

            // [필터 순서 중요]
            // ExceptionFilter -> JwtAuthFilter -> UsernamePasswordFilter 순서
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class); // ★ 이것 추가!

        return http.build();
    }
}

💡 전체 동작 흐름 (Sequence)

이 구조가 어떻게 동작하는지 그림으로 그려보면 아주 명확합니다.

🔍 다이어그램 해설

  1. 진입 (1~2번): 요청이 오면 가장 먼저 JwtExceptionFilter가 받습니다. 여기서 try 블록을 열고 다음 필터인 JwtAuthenticationFilter를 호출합니다.
  2. 검증 (3번): JwtAuthenticationFilter는 Provider에게 토큰 검사를 시킵니다.
  3. 예외 발생 (4~5번): Provider에서 throw를 던지면, JwtAuthenticationFilter는 별도의 예외 처리를 하지 않으므로 그대로 자신을 호출했던 JwtExceptionFilter로 예외가 튕겨 나갑니다.
  4. 예외 포착 (6번): 기다리고 있던 JwtExceptionFilter의 catch 블록이 이 예외를 딱 잡아서, 미리 준비해둔 JSON 에러 메시지를 만들어 클라이언트에게 보내줍니다.

이 구조 덕분에 컨트롤러나 다른 곳에서는 예외 처리를 신경 쓸 필요 없이, 깔끔하게 보안 관련 에러를 처리할 수 있게 됩니다.


Step 5. 테스트 결과

이제 만료된 토큰으로 API를 요청하면 다음과 같이 구체적인 에러 메시지가 나옵니다.
응답 (JSON):

{
    "status": 401,
    "error": "Unauthorized",
    "message": "만료된 JWT 토큰입니다." 
}

또는 서명을 위조해서 보내면:

{
    "status": 401,
    "error": "Unauthorized",
    "message": "잘못된 JWT 서명입니다."
}

🎉 마무리

이제 여러분의 서버는:

  1. 일반적인 접근 권한 에러AuthenticationEntryPoint가 처리하고,
  2. 토큰 자체의 문제(만료, 위조)JwtExceptionFilter가 구체적으로 처리하는

완벽한 2중 방어선을 갖추게 되었습니다.
프론트엔드 개발자가 "왜 에러가 났는지 모르겠어요"라고 물어볼 일이 확 줄어들겠죠? 😉
 
 

반응형
Comments