Spring Security Filter 예외 처리 및 전략
Controller 레벨의 예외 처리는 아래 포스팅을 참고해주시면 됩니다.
RFC 9457과 헥사고날 아키텍처로 구현하는 예외 처리 설계서
0️⃣들어가며
Spring Boot로 REST API를 개발할 때 JWT 기반 인증을 도입하면, 반드시 마주치는 문제가 있습니다. 공들여 설계한 @RestControllerAdvice 기반 예외 처리가 Security Filter에서는 전혀 동작하지 않는다는 점입니다.
단순히 "Filter는 DispatcherServlet 앞단이라서"라고 넘기기 쉽지만, 정확히 왜 그런지 구조를 이해하지 못하면 JWT 커스텀 필터를 만들 때 의도치 않은 500 응답을 마주하게 됩니다.
이 포스팅은 JWT 커스텀 필터를 사용하는 환경을 전제로, Spring Container와 Servlet Container의 예외 처리 구조 차이를 이해하고 RFC 9457 응답 포맷을 Security Filter 레이어까지 일관되게 유지하는 전략을 다룹니다.
1️⃣ Spring Container vs Servlet Container 예외 처리 구조
Security Filter는 누가 관리하나?
└> Spring Security Filter는 @Bean으로 등록된 Spring Bean입니다. 생성과 관리는 Spring Container가 담당합니다.
그런데 문제가 있습니다. Servlet Container는 Spring Bean을 모릅니다.
Filter를 등록하려면 Servlet Container가 인식할 수 있어야 합니다.
이 사이를 연결하는 것이 DelegatingFilterProxy입니다.
DelegatingFilterProxy는 Servlet Container에 등록되는 진짜 Servlet Filter지만, 실제 동작은 Spring Bean인 FilterChainProxy에게 위임합니다.
Servlet Container 입장에서는 DelegatingFilterProxy만 알고 있고, Security Filter들은 Spring Container가 만들어서 관리합니다.
| 구분 | 생성 주체 |
| DelegatingFilterProxy | Servlet Container |
| FilterChainProxy 및 Security Filters | Spring Container |
왜 @RestControllerAdvice가 Security Filter에서 안 되나
전체 요청 흐름을 보면 이유가 명확합니다.
Servlet Container
└── DelegatingFilterProxy
└── FilterChainProxy
└── JwtAuthenticationFilter (커스텀)
└── ExceptionTranslationFilter
└── AuthorizationFilter
↓
DispatcherServlet
└── Controller
└── GlobalExceptionHandler (@RestControllerAdvice)
@RestControllerAdvice는 DispatcherServlet 내부에서 동작합니다.
Security Filter는 DispatcherServlet 앞단에서 실행되기 때문에, Filter에서 발생한 예외는 DispatcherServlet에 도달하지 않아 @RestControllerAdvice가 잡을 수 없습니다.
JWT 인증 필터를 만들고 예외를 그냥 throw하면 @RestControllerAdvice에서 잡힐 거라고 착각하는 경우가 많은데, 구조적으로 불가능합니다.
예외 발생 위치별 처리 주체
| 예외 발생 위치 | 처리 주체 |
| Controller / Service | @RestControllerAdvice (Spring) |
| ExceptionTranslationFilter 하위 필터 | ExceptionTranslationFilter |
| ExceptionTranslationFilter 상위 필터 | Servlet Container (500) |
커스텀 Filter에서 예외를 직접 throw하면 @ControllerAdvice에서 잡힐 거라고 착각하는 경우가 많습니다.
구조적으로 불가능하고, 잘못하면 의도치 않은 500 응답이 나오는 이유가 바로 이 표에 있습니다.
2️⃣ Spring Security 예외 처리 핵심 컴포넌트
Security Filter 레이어의 예외 설계를 논하기 전에,
Spring Security가 예외 처리를 위해 제공하는 핵심 컴포넌트를 먼저 이해해야 합니다.
AuthenticationException
Spring Security가 제공하는 인증 실패 예외의 최상위 추상 클래스입니다. (RuntimeException을 상속)
public abstract class AuthenticationException extends RuntimeException { ... }
Spring Security 내부의 인증 실패 상황에서 이 타입의 예외가 사용되며,
ExceptionTranslationFilter(※아래 설명)와 AuthenticationEntryPoint(※아래 설명)가 이 타입을 기준으로 동작합니다.
AuthenticationException
├── BadCredentialsException // 아이디/비밀번호 불일치
├── InsufficientAuthenticationException // 토큰 없음, 미인증
└── UsernameNotFoundException // 사용자 없음
ExceptionTranslationFilter
Spring Security 필터 체인 중 하나로,
filterChain.doFilter()를 try-catch로 감싸 자신보다 뒤에 위치한 필터에서 발생한 보안 예외를 감지합니다.
// ExceptionTranslationFilter 내부 구조
try {
filterChain.doFilter(request, response); // 이후 필터 전체 실행
} catch (AuthenticationException e) {
// AuthenticationEntryPoint로 위임
} catch (AccessDeniedException e) {
// AccessDeniedHandler로 위임
}
감지한 예외의 종류에 따라 두 핸들러로 위임합니다.
- AuthenticationException(※추상 클래스) → AuthenticationEntryPoint
- AccessDeniedException(※인터페이스) → AccessDeniedHandler
AuthenticationEntryPoint
인증 실패(401) 처리를 담당하는 인터페이스입니다.
public interface AuthenticationEntryPoint {
void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException;
}
Spring Security 기본 구현체는 브라우저 기반 로그인 폼으로 리다이렉트하는 방식이라,
REST API에서는 직접 구현체를 만들어 JSON 응답을 반환하도록 교체합니다.
AccessDeniedHandler
인가 실패(403) 처리를 담당하는 인터페이스입니다.
public interface AccessDeniedHandler {
void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException;
}
| AuthenticationEntryPoint | AccessDeniedHandler | |
| 담당 | 인증 실패 (401) | 인가 실패 (403) |
| 메서드 | commence() | handle() |
| 트리거 | 미인증 요청 | 권한 없는 요청 |
3️⃣ ExceptionTranslationFilter 동작 원리와 커스텀 필터의 한계
실제 Spring Security 필터 순서는 아래와 같습니다.
// Filter 순서
CsrfFilter
LogoutFilter
JwtAuthenticationFilter ← addFilterBefore로 등록한 커스텀 필터
UsernamePasswordAuthenticationFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter ← JwtAuthenticationFilter보다 뒤에 위치
AuthorizationFilter
ExceptionTranslationFilter는 filterChain.doFilter()로 이후 필터 전체를 실행하고, 거기서 발생한 예외를 catch합니다.
JwtAuthenticationFilter는 이미 실행이 끝난 상태이므로 catch 대상이 아닙니다.
JwtAuthenticationFilter에서 예외 throw
↓
catch 하는 필터 없음
↓
FilterChainProxy → DelegatingFilterProxy 뚫고 올라감
↓
Servlet Container가 처리 → 500 응답
이것이 JWT 커스텀 필터에서 예외를 그냥 throw했을 때 의도치 않은 500 응답이 나오는 이유입니다.
ExceptionTranslationFilter를 기대할 수 없는 위치이기 때문에, 필터 내부에서 직접 처리해야 합니다.
4️⃣ 실제 예외 설계 적용
구조적 제약을 이해했다면, RFC 9457 응답 포맷을 Security Filter 레이어까지 일관되게 유지하기 위한 전략은 아래와 같습니다.
JWT 커스텀 필터에서 발생한 예외는 ExceptionTranslationFilter를 기대할 수 없으므로, 필터 내부에서 직접 EntryPoint를 호출한다. 이때 EntryPoint가 ErrorCode를 꺼낼 수 있도록 AuthenticationException 타입 계약을 맞춘 커스텀 예외를 정의한다.
SecurityAuthException 정의 - Like a RuntimeError 상속받은 예외 클래스
// global/exception/SecurityAuthException.java
public class SecurityAuthException extends AuthenticationException {
private final ErrorCode errorCode;
public SecurityAuthException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() { return errorCode; }
}
AuthenticationException을 상속하는 이유는 자동 위임이 아니라 타입 계약입니다.
AuthenticationEntryPoint.commence()의 파라미터 타입이 AuthenticationException이기 때문에, 상속하지 않으면 EntryPoint로 넘길 수 없습니다.
※Spring Container에서 RuntimeException을 상속받아 BusinessLogicException을 만드는것과 같은 이유입니다.
예외 별 커스텀 클래스가 필요할 경우 SecurityAuthException를 추가 상속받아 작성해도 되지만, 그에따른 이유가 필요할 경우에만 사용하는 것을 권장합니다.
// 예시: JwtTokenProvider
throw new SecurityAuthException(ErrorCode.INVALID_TOKEN); // JWT 위변조·형식 오류
throw new SecurityAuthException(ErrorCode.EXPIRED_TOKEN); // JWT 만료
JwtAuthenticationEntryPoint 구현 (401) - Like a @ExceptionHandler
ExceptionTranslationFilter 앞단에 있는 커스텀 필터는 ExceptionTranslationFilter가 예외를 잡아줄 수 없습니다.
따라서 앞단의 필터에서는 ExceptionTranslationFilter를 통해 EntryPoint나 AccessDeniedHandler를 호출하는 것이 아닌 직접적으로 호출을 해야합니다.
이를 위해 Spring Security가 제공하는 AuthenticationEntryPoint 또는 AccessDeniedHandler 인터페이스를 구현한 구현체를 만들고, 예외가 발생한 위치에서 직접 commence() 또는 handle()을 호출해야 합니다.
또한 EntryPoint에서는 instanceof 판단으로 ErrorCode를 추출합니다.
// 예시: JwtAuthenticationEntryPoint.java
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
ErrorCode errorCode = ErrorCode.UNAUTHORIZED; // 토큰 없음 기본값
if (authException instanceof SecurityAuthException e) {
errorCode = e.getErrorCode(); // INVALID_TOKEN / EXPIRED_TOKEN
}
response.setStatus(errorCode.getHttpStatus().value());
response.setContentType("application/problem+json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), ErrorResponse.of(
errorCode, errorCode.getMessage(), request.getRequestURI(), null));
}
}
// 예시: JwtAuthenticationFilter.java
try {
jwtTokenProvider.validateAccessToken(jwt);
} catch (SecurityAuthException e) {
SecurityContextHolder.clearContext();
jwtAuthenticationEntryPoint.commence(request, response, e); // 직접 호출
return;
}
흐름을 정리하면 아래와 같습니다.
JwtAuthenticationFilter
└── SecurityAuthException catch
└── EntryPoint.commence() 직접 호출
└── instanceof 판단 → ErrorCode 추출 → RFC 9457 응답
JwtAccessDeniedHandler 구현 (403) - Like a @ExceptionHandler
인가 실패는 AuthorizationFilter에서 발생하므로 ExceptionTranslationFilter가 정상적으로 개입합니다.
필터 내부에서 직접 처리할 필요 없이 AccessDeniedHandler 구현체만 등록하면 됩니다.
SecurityConfig 등록 - Like a @RestControllerAdvice
따로 설정을 등록하지 않으면 ExceptionTranslationFilter는 Spring Security 기본 구현체를 사용하게 됩니다.
기본 구현체는 브라우저 기반 로그인 폼으로 리다이렉트하는 방식이라 REST API에서는 의미가 없기 때문에,
ExceptionTranslationFilter가 예외를 감지했을 때 어떤 구현체를 호출할지 알려주기 위해서 설정을 진행해야 합니다.
※ @RestControllerAdvice 안에 @ExceptionHandler 등록하는 것과 비슷한 양상
// SecurityConfig.java
.exceptionHandling(e -> e
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
.accessDeniedHandler(jwtAccessDeniedHandler)) // 403
5️⃣정리

Spring Security 필터 환경에서 RFC 9457 응답 포맷을 일관되게 유지하기 위한 핵심 전략을 정리하면 아래와 같습니다.
- Spring Security Filter는 Spring Container가 관리하지만, DelegatingFilterProxy를 통해 Servlet Container에 등록된다.
└> @RestControllerAdvice는 이 레이어에 도달하지 못한다. - ExceptionTranslationFilter는 자신보다 뒤에 위치한 필터의 예외만 잡는다.
└> JWT 커스텀 필터는 앞단에 등록되므로 직접 EntryPoint를 호출해야 한다. - AuthenticationException 상속은 EntryPoint 메서드 파라미터 타입 계약을 맞추기 위한 것이다.
└> EntryPoint내부의 commence 메서드의 파라메터는 AuthenticationException객체만 받기 때문. - ErrorCode를 SecurityAuthException에 주입하면 EntryPoint에서 RFC 9457 응답을 일관되게 구성할 수 있다.
'Develop > Spring' 카테고리의 다른 글
| RFC 9457과 헥사고날 아키텍처로 구현하는 예외 처리 설계서 (0) | 2026.05.21 |
|---|---|
| [Springboot] 액추에이터(Actuator) (1) | 2024.04.16 |
| [JPA] 인스타그램 유저 검색 N+1 문제 (0) | 2024.04.02 |
| [JWT] 동작 원리 (Feat. Refresh Token) (0) | 2024.03.11 |
| [JWT] JSON Web Token - With JWT.io (0) | 2024.03.11 |
