[ApartTime] Spring Security 필터 중복 등록 이슈와 OncePerRequestFilter의 동작 원리

고뭉남·2025년 6월 2일

ApartTime

목록 보기
2/6
post-thumbnail

아파트타임은 Spring Security와 JWT 기반의 인증 방식을 채택했습니다.

(관련 글: [ApartTime] 관리자 시스템 인증 흐름)


구현

JWT 토큰 검증 및 예외 처리 위임을 위해 아래와 같은 2개의 필터를 구현했습니다.

  • JwtAuthenticationFilter
    Access Token을 검증하고 검증에 성공하면 SecurityContext에 인증 정보를 설정

  • JwtExceptionFilter
    JWT 관련 예외를 전역적으로 처리하기 위한 필터로, JwtAuthenticationFilter에서 발생한 예외를 캐치해서 적절한 응답 반환

기본적으로 Spring Security의 Filter Chain 내에서 발생한 예외는 DispatcherServlet까지 전달되지 않기 때문에 따로 만들어 놓은 GlobalExceptionHandler에서 직접 처리할 수 없습니다.

따라서 JWT 인증 과정에서 발생하는 예외는 JwtExceptionFilter에서 먼저 캐치한 후, HandlerExceptionResolver를 통해 Spring MVC 예외 처리 흐름(GlobalExceptionHandler)으로 위임하는 구조로 구성했습니다.


코드

// JwtAuthenticationFilter

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

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

        String token = resolveToken(request);

        if (token != null) {
            jwtTokenProvider.validateAccessToken(token);

            UsernamePasswordAuthenticationToken authentication =
                (UsernamePasswordAuthenticationToken) jwtTokenProvider.getAuthentication(token);

            authentication.setDetails(
                new WebAuthenticationDetailsSource().buildDetails(request)
            );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);

    }

    @Override
    protected boolean shouldNotFilter(
        HttpServletRequest request
    ) throws ServletException {

        String path = request.getRequestURI();

        log.info(">>> JwtAuthenticationFilter shouldNotFilter() path: {}", path);

        return "/api/auth/reissue".equals(path);

    }

    private String resolveToken(
        HttpServletRequest request
    ) {

        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (bearerToken != null && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(BEARER_PREFIX_LENGTH);
        }

        return null;

    }
}
// JwtExceptionFilter

public class JwtExceptionFilter extends OncePerRequestFilter {

    private final HandlerExceptionResolver resolver;

    public JwtExceptionFilter(
        @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver
    ) {
        this.resolver = resolver;
    }

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

        try {
            filterChain.doFilter(request, response);
        } catch (RestApiException exception) {
            resolver.resolveException(request, response, null, exception);
        }

    }
}

shouldNotFilter() 도입 배경

Access Token이 만료되었을 때, 아파트타임의 인증 흐름은 다음과 같습니다.

  1. 클라이언트가 만료된 access token으로 API 요청

  2. JwtAuthenticationFilter에서 토큰 검증 중 ExpiredAccessTokenException throw

  3. JwtExceptionFilter가 해당 예외를 catch하여 클라이언트에 응답

  4. 클라이언트는 /api/auth/reissue EP로 토큰 재발급 요청 (Silent Refresh)

문제는 이 재발급 요청 또한 JwtAuthenticationFilter를 통과하기 때문에, 만료된 Access Token을 헤더에 포함하고 있다면 또 다시 ExpiredAccessTokenException이 발생하여 무한 루프에 빠지게 된다는 점이었습니다.

이를 방지하기 위해 shouldNotFilter() 메서드를 오버라이드하여 /api/auth/reissue 요청은 필터링에서 제외하도록 설정했습니다.


문제 상황

저는 JwtAuthenticationFilterOncePerRequestFilter를 상속 받기 때문에, 하나의 요청에 대해 한 번만 필터가 실행되는 것이 보장되어야 한다고 생각했습니다.

그런데 실제 동작을 확인해본 결과, shouldNotFilter()가 동일한 요청에서 2번 호출되는 것을 확인할 수 있었습니다.

shouldNotFilter_중복_호출


문제 분석

처음에는 JwtAuthenticationFilter 내에 작성된 로직에 문제가 있는 줄 알았지만, 실제로는 JwtAuthenticationFilter가 중복으로 등록됐기 때문이었습니다.

JwtAuthenticationFilter에 붙어있던 @Component 어노테이션으로 인해 Spring Boot는 자동으로 Servlet Filter Chain에 JwtAuthenticationFilter를 등록합니다.

Servlet Filter Chain은 DispatcherServlet이 처리하는 요청 흐름 전체에 적용되는 필터 목록이며, Spring Boot 애플리케이션이 구동될 때 자동으로 구성됩니다.

한편, SecurityConfig 클래스에서는 아래와 같이 JwtAuthenticationFilter를 Security Filter Chain에도 명시적으로 등록하고 있었습니다.

// SecurityConfig

...

.addFilterBefore(
    jwtAuthenticationFilter,
    UsernamePasswordAuthenticationFilter.class
)

...

여기서 중요한 점은 Security Filter Chain도 결국 Servlet Filter Chain 내부에서 실행되는 하나의 필터(DelegatingFilterProxy)라는 점입니다.

즉, Security Filter Chain 자체가 Servlet Filter Chain의 일부로서 동작을 하고, 내부적으로 여러 보안 필터들을 실행한다는 것입니다.

결과적으로 저는 JwtAuthenticationFilter를 Security Filter Chain에 1번, Servlet Filter Chain에도 1번, 총 2번을 등록한 상태였던 것이고 이로 인해 shouldNotFilter() 메서드가 단일 요청에 2번 호출되는 문제가 발생했던 것이었습니다.

이는 로그를 통해서 명확하게 확인할 수 있었습니다.


...

2025-06-14T11:00:13.435+09:00 DEBUG 55903 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping filters: springSecurityFilterChain urls=[/*] order=-100, characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105, jwtAuthenticationFilter urls=[/*] order=2147483647

...

2025-06-14T11:00:13.810+09:00 DEBUG 55903 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, JwtExceptionFilter, JwtAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter

...

문제 해결

JwtAuthenticationFilter는 Spring Security의 인증 프로세스에서 동작해야하므로, Security Filter Chain에만 등록하는 것이 적절합니다.

따라서 Spring Boot가 자동으로 해당 필터를 Servlet Filter Chain에 등록하지 않도록 JwtAuthenticationFilter에 할당된 @Component 어노테이션을 지워주고 SecurityConfig에서 아래처럼 직접 new 연산자를 통해 Security Filter Chain에 등록시켜주면 JwtAuthenticationFilter가 2번 호출되던 문제는 해결할 수 있습니다.

// SecurityConfig

...

.addFilterBefore(
    new JwtAuthenticationFilter(jwtTokenProvider),
    UsernamePasswordAuthenticationFilter.class
)

...

수정 후 로그는 아래와 같이 찍히는 것을 확인할 수 있었습니다.


...

2025-06-14T11:04:39.799+09:00 DEBUG 56680 --- [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping filters: springSecurityFilterChain urls=[/*] order=-100, characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105

...

2025-06-14T11:04:41.278+09:00 DEBUG 56680 --- [           main] o.s.s.web.DefaultSecurityFilterChain     : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, JwtExceptionFilter, JwtAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter

...

추가 궁금증

문제는 해결됐지만, 여전히 좀 더 알아보고 싶은 부분이 있었습니다.

'OncePerRequestFilter는 어떻게 단일 요청에 대해 한 번만 필터를 실행하는가?'

저는 OncePerRequestFilter의 내부 동작 방식을 디버깅을 통해 확인해보면 답을 얻을 수 있을 것이라 판단했습니다.

코드를 다시 JwtAuthenticationFilter를 Servlet Filter Chain과 Security Filter Chain에 중복으로 등록해준 뒤 OncePerRequestFilterdoFilter()에 BP를 걸고 디버깅을 시도했습니다.


디버깅을 통한 흐름 확인

실제 디버깅을 통해 확인한 요청 처리 흐름은 아래와 같았습니다.

Servlet_Filter_Chain_등록_필터

  1. characterEncodingFilter

  2. formContentFilter

  3. requestContextFilter

  4. springSecurityFilterChain (DelegatingFilterProxy)

  5. jwtAuthenticationFilter (중복 등록된 필터)

  6. tomcatWebSocketFilter

앞서 확인한 것처럼 제 개발 환경의 Servlet Filter Chain에는 Security Filter Chain과 중복 등록된 JwtAuthenticationFilter를 포함하여 총 6개의 필터가 등록되어 있었습니다.

계속 진행하니 springSecurityFilterChain 내부로 진입할 수 있었습니다.

Security_Filter_Chain_등록_필터

이어서 JwtAuthenticationFilter가 호출되고, OncePerRequestFilter에서 아래의 코드가 실행되는 것을 확인할 수 있었습니다.

// OncePerRequestFilter

...

@Override
	public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		if (!((request instanceof HttpServletRequest httpRequest) && (response instanceof HttpServletResponse httpResponse))) {
			throw new ServletException("OncePerRequestFilter only supports HTTP requests");
		}

		String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
		boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;

		if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
			// Proceed without invoking this filter...
			filterChain.doFilter(request, response);
		}
		else if (hasAlreadyFilteredAttribute) {
			if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
				doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
				return;
			}

			// Proceed without invoking this filter...
			filterChain.doFilter(request, response);
		}
		else {
			// Do invoke this filter...
			request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
			try {
				doFilterInternal(httpRequest, httpResponse, filterChain);
			}
			finally {
				// Remove the "already filtered" request attribute for this request.
				request.removeAttribute(alreadyFilteredAttributeName);
			}
		}
	}

...

doFilter() 메서드를 보면, 필터 실행 여부를 결정하는 몇 가지 조건이 있습니다.

먼저 shouldNotFilter()를 확인하고 그 다음 hasAlreadyFilteredAttribute를 확인하는 순서로 진행됩니다.

여기서 핵심은 alredayFilteredAttributeName입니다.

이 값은 getAlreadyFilteredAttributeName() 메서드를 통해 생성되는데, 실제로 디버깅 해보니 다음과 같은 형태였습니다.

alreadyFilteredAttributeName

OncePerRequestFilter는 이를 통해 필터의 중복 실행을 방지하고 있었습니다.

필터가 처음 실행될 때, request에 고유한 속성(alreadyFilteredAttributeName)을 true로 설정하고 필터의 핵심 로직인 doFilterInternal()을 수행합니다.


...

// Do invoke this filter...
			request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
			try {
				doFilterInternal(httpRequest, httpResponse, filterChain);
			}

...

jwtAuthenticationFilter.FILTERED

이후 Servlet Filter Chain에 직접 등록된 JwtAuthenticationFilter가 다시 호출되더라도 request.getAttribute(alreadyFilteredAttributeName)에서 이미 기존에 설정된 값이 존재하는 것을 감지하고, doFilterInternal()을 실행하지 않고 다음 필터로 흐름을 넘기게 되는 것입니다.

아래는 중복 등록된 JwtAuthenticationFilter가 다시 호출될 때 OncePerRequestFilter 내에서 확인한 request에 담긴 값입니다.

jwtAuthenticationFilter.FILTERED

그리고 아래의 코드를 통해 request에서 true를 추출하여 hasAlreadyFilteredAttribute에 담아주고, 담긴 true를 통해 filterChain.doFilter(request, response)를 실행하여 현재 필터를 건너뛰고 바로 다음 필터로 흐름을 넘기게 됩니다.


...

String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;

if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
	// Proceed without invoking this filter...
    filterChain.doFilter(request, response);
}
else if (hasAlreadyfilteredAttribute) {
	if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
    	doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
        return;
    }
    
    // Proceed without invoking this filter...
    filterChain.doFilter(request, response);
}

...

정리 및 결론

추가적인 디버깅을 통해 확인할 수 있었던 핵심은 다음과 같습니다.

  1. OncePerRequestFilter는 request 객체에 고유한 속성(alreadyFilteredAttributeName)을 기록함으로써 동일 필터가 하나의 요청에서 두 번 이상 핵심 로직(doFilterInternal())을 실행하지 않도록 보장합니다.

  2. 하지만 이와는 별개로 shouldNotFilter() 메서드는 doFilter() 실행 시점에서 가장 먼저 호출되는 검사 로직이기 때문에, 중복 등록된 필터가 존재한다면 해당 메서드는 여러 번 호출될 수 있습니다.

profile
개발자 고뭉남입니다.

0개의 댓글