본문 바로가기

CS

스프링 시큐리티 - 필터의 종류 (1)

 

개발자 유미의 시큐리티 강의를 보고 작성했습니다.

 


DisableEncodeUrlFilter

 

DisableEncodeUrlFilter는 시큐리티의 DefaultSecurityFilterChain에 기본적으로 등록되는 필터로, 필터 체인의 가장 앞단에 위치합니다. 이 필터의 목적은 URL에 세션 ID가 인코딩되어 포함되는 것을 방지하는 데 있습니다. 만약 세션 ID가 URL에 포함되면, 브라우저 주소창이나 서버 로그 등을 통해 민감한 정보가 외부로 유출될 위험이 있습니다. 이를 막기 위해 시큐리티에서는 이 필터를 통해 기본적으로 URL 인코딩 기능을 비활성화합니다.

 

 

이 필터는 커스텀 SecurityFilterChain을 사용하더라도 자동으로 등록되며, 필요에 따라 비활성화할 수 있습니다. 비활성화는 .sessionManagement().disable() 설정을 통해 가능하며, 이는 세션 관련 보안 처리를 비활성화할 때 사용됩니다.

 

 

http
        .sessionManagement((manage) -> manage.disable());

 

 

DisableEncodeUrlFilter는 내부적으로 OncePerRequestFilter를 상속하며, 요청당 한 번만 실행됩니다. 필터의 핵심 로직은 응답 객체를 DisableEncodeUrlResponseWrapper로 감싸서 필터 체인에 넘기는 것입니다.

 

이 래퍼 클래스는 encodeRedirectURL()과 encodeURL() 메서드를 오버라이딩하여, 세션 ID를 인코딩하지 않고 URL을 그대로 반환합니다. 결과적으로 세션 ID가 URL에 붙지 않도록 처리됩니다.

 

 

public class DisableEncodeUrlFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
			
		filterChain.doFilter(request, new DisableEncodeUrlResponseWrapper(response));
	}

	private static final class DisableEncodeUrlResponseWrapper extends HttpServletResponseWrapper {

		private DisableEncodeUrlResponseWrapper(HttpServletResponse response) {
			super(response);
		}

		@Override
		public String encodeRedirectURL(String url) {
			return url;
		}

		@Override
		public String encodeURL(String url) {
			return url;
		}
	}
}

 

 

기본적으로 서블릿 컨테이너는 encodeRedirectURL()과 encodeURL() 메서드에서 세션 쿠키가 전달되지 않거나 쿠키 사용이 불가능한 경우, request.getSessionInternal().getIdInternal()을 통해 세션 ID를 가져와 URL 뒤에 붙입니다. 예를 들어 다음과 같은 구조로 동작합니다

 

@Override
public String encodeRedirectURL(String url) {
    if (isEncodeable(toAbsolute(url))) {
        return toEncoded(url, request.getSessionInternal().getIdInternal());
    } else {
        return url;
    }
}

@Override
public String encodeURL(String url) {

    String absolute;
    try {
        absolute = toAbsolute(url);
    } catch (IllegalArgumentException iae) {
        // Relative URL
        return url;
    }

    if (isEncodeable(absolute)) {
        // W3c spec clearly said
        if (url.equalsIgnoreCase("")) {
            url = absolute;
        } else if (url.equals(absolute) && !hasPath(url)) {
            url += '/';
        }
        return toEncoded(url, request.getSessionInternal().getIdInternal());
    } else {
        return url;
    }

}

 

이런 방식은 클라이언트가 세션 쿠키를 사용하지 못할 때 유용하지만, 동시에 보안적으로는 취약한 면이 있습니다. 시큐리티는 이러한 취약점을 보완하기 위해 DisableEncodeUrlFilter를 도입하여 세션 ID가 URL에 포함되지 않도록 기본 차단하며, URL을 통한 세션 추적 방식을 비활성화하는 역할을 수행합니다.


WebAsyncManagerIntegrationFilter

 

 

WebAsyncManagerIntegrationFilter는 시큐리티의 DefaultSecurityFilterChain에 기본적으로 포함되어 있으며, 두 번째로 실행되는 필터입니다.

 

이 필터의 주요 목적은 서블릿 환경에서 비동기 처리를 수행할 때, 작업 쓰레드에서도 인증 정보를 일관되게 유지할 수 있도록 돕는 것입니다. 시큐리티는 SecurityContextHolder를 통해 인증 정보를 관리하며, 기본적으로 ThreadLocal 전략을 사용하기 때문에 하나의 요청이 여러 쓰레드를 거치게 되면 인증 정보가 공유되지 않는 문제가 발생합니다.

 

@GetMapping("/async")
@ResponseBody
public Callable<String> asyncPage() {

    System.out.println("start" + SecurityContextHolder.getContext().getAuthentication().getName());

    return () -> {
        Thread.sleep(4000);
        System.out.println("end" + SecurityContextHolder.getContext().getAuthentication().getName());

        return "async";
    };
}

 

위와 같은 코드는 Callable<> 인터페이스로 감싼 아래 부분을 다른 쓰레드에서 수행하게 됩니다. 각기 다른 쓰레드에서 수행하지만 ThreadLocal로 관리되는 SecurityContextHolder의 값은 WebAsyncManagerIntegrationFilter와 여타 클래스들을 통해 동일하게 가져올 수 있게 되는 것이죠.

 


출처: 개발자 유미

 

 

그렇다면 필터인 WebAsyncManagerIntegrationFilter가 어떻게 컨트롤러 단에서 발생하는 비동기 작업의 쓰레드 전환 문제를 처리할 수 있을까요? 필터는 요청 초기에 한 번만 실행되고, Callable 내부 로직은 훨씬 나중에 전혀 다른 쓰레드에서 실행되는데도, 어떻게 인증 정보가 정상적으로 이어질 수 있을까요?

 

이 의문은 WebAsyncManagerIntegrationFilter가 실제로 수행하는 작업과 Callable의 동작 방식에 대한 이해를 통해 해소할 수 있습니다.

 

 

출처: 개발자 유미

 

WebAsyncManagerIntegrationFilter는 인증 정보를 직접 복사하거나 전달하는 역할을 하지 않습니다. 대신, 현재 요청을 처리하는 메인 쓰레드의 SecurityContext를 참조할 수 있는 인터셉터를 WebAsyncManager에 등록해두는 역할을 수행합니다.

 

이때 등록되는 객체는 SecurityContextCallableProcessingInterceptor이며, 이는 후속 비동기 작업이 시작될 때 자동으로 작동하여 원래 쓰레드의 SecurityContext를 새로운 쓰레드에 복제해주는 역할을 합니다.

 

즉, 필터는 미리 인증 정보를 등록해두고, 이후 DispatcherServlet과 WebAsyncManager가 그 정보를 기반으로 비동기 실행 쓰레드에서도 동일한 인증 상태를 사용할 수 있게 도와주는 구조입니다.

 

출처: 개발자 유미

 

 

요청이 들어오면, 필터 체인을 거쳐 DispatcherServlet이 알맞은 컨트롤러를 찾고 요청을 전달합니다. 이때 컨트롤러는 Callable 객체를 반환하고, DispatcherServlet은 이를 WebAsyncManager에게 넘깁니다.

 

그리고 WebAsyncManager는 내부적으로 등록된 SecurityContextCallableProcessingInterceptor를 이용해 새로 시작되는 비동기 쓰레드에 기존 인증 정보를 복사합니다.

 

이렇게 하여 비동기 작업 내부에서도 SecurityContextHolder.getContext()를 호출하면 초기 요청과 동일한 인증 정보를 조회할 수 있게 되는 것입니다.

 

@GetMapping("/async")
@ResponseBody
public Callable<String> asyncPage() {

    System.out.println("start" + SecurityContextHolder.getContext().getAuthentication().getName());

    return () -> {
        Thread.sleep(4000);
        System.out.println("end" + SecurityContextHolder.getContext().getAuthentication().getName());

        return "async";
    };
}

 

 

결과적으로, 시큐리티는 필터를 통해 비동기 처리 환경에서도 인증 상태가 일관되게 유지되도록 구성되어 있으며, 개발자가 따로 설정하지 않아도 기본적으로 이 흐름이 적용되기 때문에 Callable을 사용할 때도 인증 정보 접근에 문제가 발생하지 않습니다.

 

다만 주의할 점은, 이 필터는 Callable, DeferredResult와 같은 서블릿 기반의 비동기 처리에는 적용되지만, Spring의 @Async 어노테이션을 사용하는 비동기 처리에는 적용되지 않는다는 것입니다. @Async는 서블릿 컨텍스트 외부에서 별도의 쓰레드를 사용하기 때문에, SecurityContext가 자동으로 전달되지 않으며, 이를 위해서는 별도의 인증 정보 전달 설정이 필요합니다.

 

스프링에서는 비동기 처리를 두 가지 방식으로 지원합니다

 

  1. 서블릿 기반 비동기 처리 (Callable, DeferredResult)
    → 이는 앞서 설명한 대로 WebAsyncManagerIntegrationFilter를 통해 SecurityContext가 전달됩니다.
  2. @Async 어노테이션 기반 비동기 처리 (스프링의 비동기 TaskExecutor 기반)  → 이 경우는 서블릿 컨텍스트 외부의 비동기 작업이기 때문에, WebAsyncManager와 무관하게 동작합니다. 따라서 SecurityContext가 기본적으로 전달되지 않으며, ThreadLocal 기반의 인증 정보가 새로운 쓰레드에서 유지되지 않습니다

SecurityContextHolderFilter

 

 

SecurityContextHolderFilter는 시큐리티의 DefaultSecurityFilterChain에서 기본적으로 등록되는 필터이며, 일반적으로 세 번째 위치에 배치됩니다. 이 필터의 목적은 현재 요청에 대해 SecurityContextHolder에 인증 정보를 등록하고, 요청 처리가 끝난 후에는 이를 초기화하는 데 있습니다.

 

즉, 이전 요청에서 로그인 등으로 이미 인증된 사용자가 있을 경우, 해당 정보를 현재 요청에 연결해주는 역할을 수행하며, 요청이 끝나면 SecurityContextHolder의 ThreadLocal 영역을 비워서 인증 정보가 다른 요청에 잘못 공유되지 않도록 보장합니다. (역할이 끝나면 빈 공간으로 초기화한다는 것이 중요!)

 

출처: 개발자 유미

 

 

이 필터는 인증 정보를 직접 관리하지 않고, SecurityContextRepository라는 전략 인터페이스를 통해 인증 정보를 위임 받아 처리합니다. 구체적으로는 요청이 시작되면 loadDeferredContext() 메서드를 통해 외부 저장소에서 인증 정보를 가져오고, 이 값을 SecurityContextHolder에 설정한 뒤 다음 필터로 흐름을 넘깁니다. 응답이 완료되면 finally 블록에서 SecurityContextHolder를 초기화하여 ThreadLocal에 남아있는 데이터를 제거합니다.

 

SecurityContextRepository는 인증 정보를 저장하고 불러오는 전략을 정의한 인터페이스로, 시큐리티 내부에서 SecurityContextHolder와 인증 상태를 연결해주는 역할을 담당합니다.

 

이 인터페이스는 사용 환경에 따라 다양한 구현체로 확장되어 있으며, 대표적으로 서버 세션을 사용하는 HttpSessionSecurityContextRepository, 아무런 작업을 하지 않는 NullSecurityContextRepository, 그리고 요청 객체에 정보를 임시로 저장하는 RequestAttributeSecurityContextRepository 등이 있습니다.

 

기본적으로 시큐리티는 세션 기반의 인증 전략을 사용합니다. 사용자가 로그인에 성공하면 해당 인증 정보는 세션에 저장되고, 이후 새로운 요청이 들어올 때마다 해당 필터가 세션에서 인증 정보를 꺼내 SecurityContextHolder에 세팅해줍니다. 이 과정 덕분에 사용자는 매번 로그인하지 않아도 인증된 상태를 유지할 수 있습니다.

 

여기서 주의할 점은 인증 상태가 유지된다는 것과 인가 처리가 생략된다는 것은 전혀 다른 개념이라는 점입니다. 인증은 “누구인지 확인하는 과정”이고, 인가는 “해당 사용자가 특정 리소스에 접근할 권한이 있는지 판단하는 과정”입니다. 세션을 통해 인증 정보는 유지되지만, 인가는 요청이 들어올 때마다 반드시 수행됩니다. 다시 말해, 매 요청마다 SecurityContextHolder에 담긴 인증 정보를 바탕으로 현재 요청에 필요한 권한이 있는지를 시큐리티가 확인합니다.

 

즉, SecurityContextHolderFilter는 인증 정보를 요청 범위 안으로 가져오는 역할을 담당하고, 인가는 그 이후 필터나 컨트롤러 진입 전에 별도로 이루어집니다. 이 필터 덕분에 이전 요청에서 로그인했던 인증 정보를 기반으로 매 요청마다 안정적으로 인가 검사가 가능해지는 것입니다. 세션은 인증 정보를 “유지”하는 수단일 뿐, 인가 자체를 생략하거나 단순화하지는 않습니다.

 


 

SecurityContextPersistenceFilter

finally {
	SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
	// Crucial removal of SecurityContextHolder contents before anything else.
	this.securityContextHolderStrategy.clearContext();
	this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
	request.removeAttribute(FILTER_APPLIED);
	this.logger.debug("Cleared SecurityContextHolder to complete request");
}

 

 

 

SecurityContextHolderFilter

finally {
	this.securityContextHolderStrategy.clearContext();
	request.removeAttribute(FILTER_APPLIED);
}

 

한편 SecurityContextHolderFilter는 과거에 사용되던 SecurityContextPersistenceFilter의 역할을 이어받은 필터입니다. 시큐리티 5.8부터 구조가 변경되며, 기존 클래스는 deprecated 처리되었습니다.

 

두 클래스는 기능 면에서는 거의 유사하지만 구현 방식에는 차이가 있습니다. 기존 필터는 인증 상태가 변경되었을 경우 그 변경 내용을 다시 저장소에 반영해주는 동작을 수행했습니다.

 

반면 현재의 SecurityContextHolderFilter는 인증 상태가 바뀌더라도 저장소에 반영하지 않으며, 요청이 끝나면 단순히 SecurityContextHolder를 초기화하는 동작만 수행합니다. 이 구조는 JWT 기반의 stateless 환경에서도 안정적으로 동작하도록 고려된 설계로 볼 수 있습니다.

 

결과적으로 SecurityContextHolderFilter는 요청 시작 시 인증 정보를 설정하고, 요청 종료 시 이를 제거함으로써 인증 상태를 안전하게 관리합니다. 세션을 사용할 경우 사용자는 로그인 후에도 인증 상태가 유지되며, 시큐리티는 매 요청마다 인증 정보 기반으로 인가를 수행하게 됩니다.