본문 바로가기
1. 개발

[Spring Security]: 시큐리티 필터 예외 바꿔치기 문제

by su8y 2025. 9. 7.

안녕하세요! 오늘은 Spring Security 환경에서 JWT 인증 필터를 통해서 인증을 실패했을때 발행했던 Exception이 Override되는 이슈를 해결했던 경험을 정리합니다.

 

분명히 토큰 파싱 중 발생한 ExpiredJwtException이나 MalformedJwtException에 대한 에러 처리를 했는데, 정작 클라이언트가 받는 것은 내가 의도한 메시지가 아닌 InsufficientAuthenticationException의 기본 메시지인 "Full authentication is required to access this resource" 이었습니다.

 

 

이 현상의 원인을 AnonymousAuthenticationFilter부터 ExceptionTranslationFilter까지 이어지는 Spring Security의 내부 동작을 추적하며 알게된 정보에 대해서 정리하겠습니다.

 

우선 왜 헷갈렸을까요 ? 먼저 에러는 AccessDiniedHandler가 아닌 AuthenticationEntryPoint가 처리를 합니다.
=> 익명 사용자가 실패를 했다면 권한 관련 에러가 나왔을거라 생각해서, 인증에 대해서 오류가 생긴거를 처리하는 과정에서 문제가 생겼다고 판단하고 익명 사용자에 대해 생각을 하지 못했습니다.

 

요약

  1. 구현한 AuthenticationFilter에서 발생한 Exception은 무시되고, AnonymousAuthenticationFilter(익명 사용자)에 대해 다시 인증 요청을 보내기 때문에 처리해야될 Exception오버라이드 되는 문제.
  2. 즉, 인증 실패 > 익명 사용자 요청 > 익명 사용자 요청 처리 실패
  3. 추가로AnonymousAuthenticationFilter의 경우 인가를 실패하는 것이 아닌, 인증 실패로 처리가 됨(익명 사용자로 요청이 실패할 경우 로그인을 하라는 것을 명시적으로 알려주기 위함.)
  4. 즉, Authentication 로직 중 실패를 하고 인증 문제가 있다는 친절한 안내를 위해서는 다음 필터로 가지 못하게 해당 필터 로직에서 중단을 하는 로직이 필요

문제 시작: 사라진 나의 예외

API 서버에 JWT 인증을 도입하기 위해 OncePerRequestFilter를 상속받아 직접 JwtAuthenticationFilter를 구현했습니다. 토큰이 만료되거나, 형식이 잘못되었거나, 서명이 유효하지 않을 때 각각 다른 AuthenticationException을 발생시켜 클라이언트에게 친절한 안내를 해주고 싶었습니다.

하지만 실제로는 토큰 검증에 실패하자, 우리가 원했던 섬세한 안내 메시지 대신 다음과 같은 뭉툭한 예외가 발생했습니다.

{
  "status": 401,
  "error": "Unauthorized",
  "message": "Full authentication is required to access this resource"
}

디버깅을 통해 메세지에 대해서 보았더니, 이 메시지는 InsufficientAuthenticationException에서 비롯된 것입니다. 분명 우리는 AuthenticationException를 상속한 BadCredentialException을 던졌는데, 왜 그리고 어디서 이 예외로 바뀌게 된 걸까요?

현상 분석: 익명 사용자의 등장과 잘못된 흐름 추적

이 미스터리를 풀기 위해 Spring Security 필터 체인의 동작을 한 단계씩 따라가 보겠습니다.

1. AuthenticationFilter의 인증 실패

클라이언트가 잘못된 토큰으로 요청을 보냅니다. 우리의 JwtAuthenticationFilter가 이 요청을 가로채 토큰 검증을 시도하고, 실패하여 AuthenticationException을 던집니다.

 

하지만 여기서 실수가 발생합니다. 기본적으로 등록이 되는 AnonymousAuthenticationFilter의 동작을 제한하지 않으면, 예외를 던진 후, 필터 체인의 흐름을 멈추지 않고 익명 사용자를 통해 다시 한번 인가를 요청하게 됩니다.

이는 원래 의도했던 동작이 아니며 인증을 받았을때 인증에 문제가있다는 메세지를 보내기에 적절하지 않은 시나리오라고 판단했습니다.

2. AnonymousAuthenticationFilter의 개입

SecurityContextHolder에 아무런 인증 정보가 없는 상태로 요청은 다음 필터로 흘러갑니다. 그러다 AnonymousAuthenticationFilter를 만나게 됩니다. 이 필터의 역할은 SecurityContext가 비어있을 경우, "익명의 사용자"를 나타내는 AnonymousAuthenticationToken을 기본값으로 채워주는 것입니다.

이제 우리의 요청은 인증에 실패했지만, '인증 정보가 없는 익명 사용자(Anonymous User)' 로 둔갑하여 필터를 처리하게 됩니다.

3. AuthorizationFilter에서의 2차 실패 (인가 실패)

이제 '익명 사용자'로 인증된 요청은 마침내 AuthorizationFilter에 도달하여 인가(Authorization) 검사를 받습니다. 당연히 해당 API 엔드포인트는 익명 사용자의 접근을 허용하지 않았음으로, 여기서 AccessDeniedException(접근 거부 예외, 인가)이 발생합니다.

4. ExceptionTranslationFilter의 최종 판결

AccessDeniedExceptionExceptionTranslationFilter에 의해 처리됩니다. 여기서 문제의 핵심이 드러납니다. ExceptionTranslationFilter의 내부 로직(#handleAccessDeniedException 메서드)을 살펴보면 다음과 같은 구문이 있습니다.

// ExceptionTranslationFilter.java의 일부
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
        Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
        boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
        if (isAnonymous || this.authenticationTrustResolver.isRememberMe(authentication)) {
            if (logger.isTraceEnabled()) {
                logger.trace(LogMessage.format("Sending %s to authentication entry point since access is denied",
                        authentication), exception);
            }
            // AuthenticationException으로 바꿔줌
            AuthenticationException ex = new InsufficientAuthenticationException(
                    this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication",
                            "Full authentication is required to access this resource"));
            ex.setAuthenticationRequest(authentication);
            sendStartAuthentication(request, response, chain, ex);
        }
        else {
           // ...
        }
    }

바로 이 지점입니다! ExceptionTranslationFilter는 현재 사용자가 '익명 사용자'인 것을 확인하고, 접근이 거부된 상황을 "권한이 없는 것(403 Forbidden)"이 아니라 "인증이 충분하지 않기 때문(401 Unauthorized)"이라고 판단합니다. 그리고 직접 InsufficientAuthenticationException을 생성하여 우리가 봤던 그 메시지를 만들어내는 것입니다.

처음에는 왜 이렇게 처리할까? 의문이 들어서 공식문서를 더 자세히 읽어보기로 했습니다.

In Spring Security
Rounding out the anonymous authentication discussion is the AuthenticationTrustResolver interface, with its corresponding AuthenticationTrustResolverImpl implementation. This interface provides an isAnonymous(Authentication) method, which allows interested classes to take into account this special type of authentication status. The ExceptionTranslationFilter uses this interface in processing AccessDeniedException instances. If an AccessDeniedException is thrown and the authentication is of an anonymous type, instead of throwing a 403 (forbidden) response, the filter, instead, commences the AuthenticationEntryPoint so that the principal can authenticate properly. This is a necessary distinction. Otherwise, principals would always be deemed “authenticated” and never be given an opportunity to login through form, basic, digest, or some other normal authentication mechanism.

 

Spring Security 공식 문서에서도 익명 사용자의 접근 실패는 인증을 다시 유도하기 위해 403이 아닌 401 오류를 발생시킨다고 설명하며, 이는 의도된 설계입니다.

결국, 우리가 처음 던졌던 AuthenticationException은 아무런 역할도 하지 못하고, '익명 사용자'의 인가 실패라는 완전히 다른 시나리오로 인해 InsufficientAuthenticationException이 그 예외를 대체해버린 것입니다.

해결책: 인증 실패 시 필터 체인 즉시 중단하기

AnonymousAuthentcationFilter 덕분에(?) 헷갈렸지만, 근본 원인은 인증 실패 후에도 필터 체인의 흐름을 멈추지 않은 것입니다. 해결책은 간단합니다. JwtAuthenticationFilter에서 인증 예외가 발생했을 때, 즉시 요청 처리를 중단하고 클라이언트에게 원하는 응답을 보내야 합니다.

이를 위해 Spring Security의 AuthenticationEntryPoint를 사용합니다.

Java

// 수정된 JwtAuthenticationFilter.java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final AuthenticationEntryPoint authenticationEntryPoint;
    // ...

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        try {
            // 1. 토큰 검증 및 인증 처리
            // 2. 인증 성공 시 다음 필터로 진행
            filterChain.doFilter(request, response);
        } catch (AuthenticationException authException) {
            // 3. 인증 예외 발생 시
            // AuthenticationEntryPoint를 호출하여 즉시 401 응답
            authenticationEntryPoint.commence(request, response, authException);
            return;
        }
    }
}

핵심은 catch 블록에서 AuthenticationEntryPoint의 commence 메서드를 호출하여 클라이언트에게 직접 응답을 보낸 후, return을 통해 doFilterInternal 메서드를 완전히 종료시키는 것입니다. 이렇게 하면 요청이 더 이상 진행되지 않으므로, AnonymousAuthenticationFilter가 개입할 여지 자체가 사라집니다.

마무리

Spring Security의 필터 체인은 매우 강력하지만, 각 필터의 역할과 상호작용을 정확히 이해하지 못하면 오늘과 같은 예상치 못한 동작을 마주할 수 있습니다.

핵심을 다시 한번 정리하자면, 인증(Authentication) 필터의 책임은 인증을 성공시키거나, 실패 시 즉시 흐름을 끊고 AuthenticationEntryPoint를 통해 응답하는 것까지입니다. 인증 실패를 어설프게 넘겨 인가(Authorization) 단계에서 처리되도록 방치하는 것은 우리가 겪었던 "예외 바꿔치기" 문제의 원인이 됩니다.