쉽게 풀어 쓴 Spring Security 필터링 매커니즘

2024.09.23
22분
댓글

해당 문서와 관련된 코드는 링크를 참고해주세요.

Spring Security 시리즈는 아래 버전을 기준으로 작성되었습니다.



글을 시작하며

Spring Security에서 인증, 인가 등 보안 관련 기능을 처리하기 위해 내부적인 작동이 어떻게 이뤄질까요?
Servlet 환경을 기준으로 Spring Security가 동작하기 위한 Filtering 매커니즘에 대해 알아보겠습니다.

이해를 돕기 위해, 스프링이 HTTP 요청을 처리하는 시점부터 스프링 시큐리티가 종료되기까지의 과정을 차근차근 살펴보겠습니다.

이 글은 스프링 부트를 기반으로 작성되었으며, 스프링 프레임워크의 일부 세부 사항이 생략되어 있을 수 있습니다.

1. 간단히 알아보는 스프링의 HTTP 통신 과정


Servlet 구조출처: https://mangkyu.tistory.com/18


스프링은 HTTP 통신을 위해 서블릿이라는 기술을 사용합니다. 서블릿은 웹 애플리케이션 개발 시 HTTP 통신을 보다 쉽게 처리할 수 있게 해주는 기능입니다. 이를 통해 개발자는 HTTP 메세지를 직접 파싱하거나, TCP 소켓을 관리하는 등의 수고를 덜 수 있습니다. 😏

위 사진의 Web Context는 서블릿 영역을 나타내며, 클라이언트 요청을 받으면 DispatcherServlet이라는 HTTP 서블릿을 이용해 Spring Context로 HTTP 요청을 전달합니다. 이후 Spring Context에서 비즈니스 로직을 처리하여 클라이언트에게 응답을 내려줍니다.

또한, 요청이 Spring Context로 전달되기 전 Filter 레이어가 존재합니다. Filter는 서블릿 영역에 포함되어 HTTP 요청의 전달 전/후로 공통된 작업을 수행할 수 있습니다. 이 글에서 다루게 될 Spring Security가 동작하는 레이어이기도 합니다.


스프링이 HTTP 요청을 처리하는 순서를 요약하면 다음과 같습니다.

  1. 클라이언트가 HTTP 요청을 서버에 전송합니다.
  2. 서블릿 컨테이너(Servlet Container)에서 요청을 읽어 HttpServletRequest, HttpServletResponse 객체를 생성합니다.
  3. Spring Context에 데이터를 전달하기 전, Servlet FilterChain을 실행합니다.
    • FilterChain은 서블릿 영역의 Bean으로 등록된 Filter들을 순차적으로 실행합니다.
  4. 이후 요청을 DispatcherServlet으로 전달합니다. 이때부터 Spring Context로 전환됩니다.
  5. DispatcherServlet은 URL 기반 라우팅을 통해 Controller로 요청을 전달합니다.
  6. 비즈니스 로직 수행 후, 생성된 HTTP 응답을 서블릿 컨테이너를 통해 클라이언트로 응답합니다.

알아두면 좋은 내용

  • 서블릿과 서블릿 컨테이너는 HTTP 통신을 쉽게 처리하기 위한 스프링의 핵심 요소입니다. 서블릿 컨테이너는 서블릿의 생명주기를 관리하며, HTTP 요청을 받아 서블릿으로 전달합니다.
  • 서블릿 컨테이너의 구현체로는 톰캣(Tomcat), 제티(Jetty) 등이 존재하며, TCP 소켓 통신이나 HTTP 메세지를 파싱하여 HttpServletRequest, HttpServletResponse 객체로 변환하는 과정을 담당합니다.
  • 서블릿의 구현체는 HttpServlet이며, 이를 상속한 DispatcherServlet이라는 자바 클래스가 주로 사용됩니다. DispatcherServlet은 URL 기반 라우팅을 지원하여 요청을 적절한 Controller로 전달합니다.
  • HTTP 요청이 들어오면 서블릿 컨테이너는 스레드를 생성하고,


2. 스프링 시큐리티가 등록되는 위치, FilterChain


웹 애플리케이션의 안정성을 위해 보안 정책은 HTTP 요청이 컨트롤러로 전달되기 전에 처리되어야 합니다. 이는 보안 위협을 사전에 차단하고, 불필요한 비즈니스 로직 수행을 방지하여 애플리케이션의 효율성과 안정성을 높일 수 있기 때문입니다. 이러한 이유로 스프링 시큐리티는 Filter 레이어에서 인증/인가를 수행하게 됩니다.

이 과정에서 중요한 역할을 하는 것이 바로 FilterChain입니다. FilterChain은 단일 HTTP 요청을 처리하기 위해 여러 Filter들이 사슬처럼 연결된 구조로 동작합니다. 각 Filter는 정해진 순서에 따라 동기적으로 실행되며, 요청이 컨트롤러에 도달하기 전/후로 필요한 작업을 수행합니다. 예를 들어, 특정 Filter는 사용자의 인증 상태를 확인하고, 다른 Filter는 인가 정보를 검증하거나 로깅을 처리할 수 있습니다.

Filter는 Spring의 @Order 어노테이션이나 Orderd 인터페이스 등 여러 방법을 이용해 FilterChain의 순서를 지정할 수 있으며, URL 패턴을 이용해 특정 요청에만 적용할 수도 있습니다.



3. FilterChain의 구조


FilterChain 구조출처: https://docs.spring.io/spring-security/reference/servlet/architecture.html


스프링 시큐리티는 서블릿 Filter를 기반으로 동작하기 때문에, FilterChain 과정을 함께 살펴보면 이해가 쉬워집니다. 위 이미지는 스프링 HTTP 통신 과정의 3번째 단계인 FilterChain의 세부 과정을 나타낸 것입니다.

여기서 눈여겨볼 점은 DelegatingFilterProxy라는 Filter입니다. 이는 스프링과 톰캣의 실행 타이밍이 달라 발생하는 NullPointerException을 방지하기 위한 장치로, Lazy Loading을 통해 스프링 Bean에 안전하게 접근하기 위해 프록시 패턴을 사용합니다.

스프링 부트는 톰캣을 내장하여 생명주기 제어가 가능하기 때문에, 서블릿 영역에서 프록시 패턴을 사용하지 않아도 스프링 Bean 접근이 가능합니다.


아래 Pseudo CodeDelegatingFilterProxy의 호출 시점 일부를 나타낸 코드입니다.

/* DelegatingFilterProxy.java */

protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
    // 스프링 시큐리티의 경우, targetBeanName은 `securityFilterChain`
    String targetBeanName = this.getTargetBeanName();

    // targetBeanName과 일치하는 이름을 가진 Filter Bean을 찾음
    Filter delegate = (Filter)wac.getBean(targetBeanName, Filter.class);

    // 반환 이후 `delegate.doFilter()`를 통해 Filter 구현체를 실행
    return delegate
}

이를 통해 의존성 주입(DI)과 같은 스프링의 기술을 사용할 수 있어, 스프링 시큐리티는 유연하고 확장성있는 보안 설정이 가능하며 다양한 서비스 요구 사항을 충족시킬 수 있습니다.



4. 스프링 시큐리티의 시작점, FilterChainProxy


SecurityFilterChain출처: https://docs.spring.io/spring-security/reference/servlet/architecture.html


DelegatingFilterProxy의 구현체를 보면 SecurityFilterChain이 아닌 FilterChainProxy로 되어 있습니다. FilterChainProxy는 스프링 시큐리티의 시작점이자 핵심 요소로, 여러 SecurityFilterChain을 관리하고, 방화벽 처리, SecurityContext 메모리 관리 등 스프링 시큐리티 과정에 필요한 작업을 수행합니다.

스프링 시큐리티의 시작점이 FilterChainProxy라는 점을 통해, 스프링 시큐리티를 적용하다 문제를 겪는다면 FilterChainProxy를 디버깅 포인트로 추가해 보는 것도 좋은 방법입니다.

위의 작업이 완료된 후, FilterChainProxy는 보안 작업을 위해 적절한 SecurityFilterChain을 호출하여 개발자가 지정한 보안 Filter들을 순차적으로 실행합니다.


스프링 시큐리티의 보안 Filter 실행 순서 (스압주의)
  1. ChannelProcessingFilter
    • 프로토콜(HTTP/HTTPS)을 검사하고, 필요 시 리다이렉트 또는 거부합니다.
    • HTTPS가 필수인 요청을 HTTP로 받은 경우 차단할 수 있습니다.
  2. ConcurrentSessionFilter
    • 하나의 계정으로 동시 세션 로그인을 제한합니다.
    • 세션 만료 시 사용자를 로그아웃하거나 알림을 제공합니다.
  3. WebAsyncManagerIntegrationFilter
    • 비동기 요청의 보안 처리를 지원합니다.
    • 비동기 요청이 끝난 후에도 SecurityContext가 올바르게 처리되도록 보장합니다.
  4. SecurityContextPersistenceFilter
    • 세션 또는 저장소에서 SecurityContext를 복원하고 저장합니다.
    • 요청이 끝나면 SecurityContext를 다시 저장합니다.
  5. HeaderWriterFilter
    • HTTP 응답에 보안 헤더(CSP, X-Content-Type-Options 등)를 추가합니다.
    • XSS 및 클릭재킹 공격을 방지할 수 있습니다.
  6. CorsFilter
    • CORS 정책을 검증하고 외부 도메인 요청을 제어합니다.
    • 잘못된 CORS 요청을 차단합니다.
  7. CsrfFilter
    • CSRF 토큰을 검증하여 CSRF 공격을 방지합니다.
    • 주로 POST, PUT, DELETE 요청에 적용됩니다.
  8. LogoutFilter
    • 로그아웃 요청을 처리하고 세션과 쿠키를 정리합니다.
    • 로그아웃 후 리다이렉트할 URL을 지정할 수 있습니다.
  9. OAuth2AuthorizationRequestRedirectFilter
    • OAuth2 인증을 위한 권한 요청을 리다이렉트합니다.
    • 로그인 과정에서 사용자의 동의를 요청할 때 사용됩니다.
  10. Saml2WebSsoAuthenticationRequestFilter
    • SAML2 프로토콜을 사용한 SSO 로그인 요청을 처리합니다.
    • SSO 공급자와의 인증 흐름을 시작합니다.
  11. X509AuthenticationFilter
    • X.509 인증서를 사용해 사용자를 인증합니다.
    • 주로 클라이언트 인증서 기반의 환경에서 사용됩니다.
  12. AbstractPreAuthenticatedProcessingFilter
    • 이미 인증된 사용자 정보가 있는 경우, 이를 사용해 인증을 처리합니다.
    • 외부 인증 시스템과의 통합에 유용합니다.
  13. CasAuthenticationFilter
    • CAS (Central Authentication Service)를 통한 인증을 처리합니다.
    • 주로 SSO 환경에서 사용됩니다.
  14. OAuth2LoginAuthenticationFilter
    • OAuth2 로그인 인증을 처리합니다.
    • 사용자가 OAuth2 공급자(Google, Facebook 등)로부터 인증 후 돌아올 때 사용됩니다.
  15. Saml2WebSsoAuthenticationFilter
    • SAML2 프로토콜을 사용해 SSO 응답을 처리합니다.
    • 인증 결과를 파싱하고 사용자 정보를 인증합니다.
  16. UsernamePasswordAuthenticationFilter
    • 폼 기반 로그인에서 사용자 아이디와 비밀번호를 인증합니다.
    • 로그인 성공 또는 실패 시 이벤트를 발생시킵니다.
  17. ConcurrentSessionFilter
    • 동시 세션을 관리하며, 중복 로그인을 제한합니다.
    • 사용자의 이전 세션을 만료시키거나 알립니다.
  18. OpenIDAuthenticationFilter
    • OpenID 프로토콜을 사용해 인증합니다.
    • 현재는 OAuth2로 대체되는 추세입니다.
  19. DefaultLoginPageGeneratingFilter
    • 커스텀 로그인 페이지가 없는 경우 기본 로그인 페이지를 생성합니다.
  20. DefaultLogoutPageGeneratingFilter
    • 커스텀 로그아웃 페이지가 없는 경우 기본 로그아웃 페이지를 생성합니다.
  21. DigestAuthenticationFilter
    • HTTP Digest 인증을 처리합니다.
    • 비밀번호를 평문 대신 해시로 전송합니다.
  22. BearerTokenAuthenticationFilter
    • Bearer 토큰(JWT, OAuth2 토큰 등)을 사용한 인증을 처리합니다.
  23. BasicAuthenticationFilter
    • HTTP Basic 인증을 처리합니다.
    • 요청 헤더에 포함된 사용자 정보를 기반으로 인증합니다.
  24. RequestCacheAwareFilter
    • 인증 후 사용자가 원래 요청했던 URL로 리다이렉트합니다.
    • 로그인 전 요청을 캐싱했다가 인증 후 재시도합니다.
  25. SecurityContextHolderAwareRequestFilter
    • 요청에 SecurityContext의 인증 정보를 추가합니다.
    • 컨트롤러나 서비스에서 인증된 사용자 정보를 쉽게 사용할 수 있게 합니다.
  26. JaasApiIntegrationFilter
    • JAAS(Java Authentication and Authorization Service)와 통합합니다.
    • JAAS 기반 인증 환경에서 사용됩니다.
  27. RememberMeAuthenticationFilter
    • 사용자가 로그인 상태를 유지하도록 Remember-Me 기능을 처리합니다.
    • 브라우저 재시작 후에도 로그인 상태를 유지할 수 있습니다.
  28. AnonymousAuthenticationFilter
    • 인증되지 않은 사용자를 익명 사용자로 간주하고 기본 권한을 부여합니다.
  29. OAuth2AuthorizationCodeGrantFilter
    • OAuth2 Authorization Code Grant 흐름을 처리합니다.
    • 인증 코드를 받아 액세스 토큰으로 교환합니다.
  30. SessionManagementFilter
    • 세션 고정 공격 방지와 세션 관리 정책을 적용합니다.
    • 동시 세션 제한을 처리할 수도 있습니다.
  31. ExceptionTranslationFilter
    • 인증 또는 인가 실패 시 발생하는 예외를 처리합니다.
    • 로그인 페이지로 리다이렉트하거나 HTTP 오류 응답을 반환합니다.
  32. FilterSecurityInterceptor
    • 요청 URL에 대한 최종 인가(Authorization)를 수행합니다.
    • 사용자가 리소스에 접근할 권한이 있는지 확인합니다.
  33. SwitchUserFilter
    • 관리자가 다른 사용자로 전환해 시스템을 사용할 수 있도록 지원합니다.
    • 주로 관리자 기능에서 사용됩니다.


5. 요구사항에 따라 보안 전략을 달리 구성하고 싶다면? 다중 SecurityFilterChain


Multi-SecurityFilterChain출처: https://docs.spring.io/spring-security/reference/servlet/architecture.html


서비스 요구 사항 별 보안 조치를 달리 해야할 경우도 있는데, 위 사진과 같이 FilterChainProxy를 이용해 URL 별로 로직을 분리하여 서로 다른 보안 Filter를 동작시킬 수 있습니다.


아래 Pseudo CodeFilterChainProxy에서 SecurityFilterChain을 호출하는 과정을 나타낸 코드입니다.

/* FilterChainProxy.java */

private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    // 방화벽 설정하는 단계로 Method, URL, IP 등이 적절한지 검사
    FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest)request);

    // RequestMatcher를 기반으로 조건에 맞는 SecurityFilterChain를 가져옴
    List<Filter> filters = this.getFilters((HttpServletRequest)firewallRequest);
    
    // 아래 VirtualFilterChain이 모두 완료된 후 실행되는 콜백
    // 기존 서블릿 FilterChain으로 돌아가기 위한 장치
    FilterChain reset = (req, res) -> {
        firewallRequest.reset();
        chain.doFilter(req, res);
    };

    // 단순 Filter 리스트를 FilterChain으로 변환해주는 단계
    VirtualFilterChain filterChain = this.filterChainDecorator.decorate(reset, filters);

    // Security Filter를 순차적으로 수행함
    filterChain.doFilter(firewallRequest, firewallResponse);
}

스프링 시큐리티의 FilterChainProxyRequestMatcher를 이용해 어떤 SecurityFilterChain을 사용할지 결정하는 유연성을 갖추고 있습니다. 이를 통해 공통 작업은 FilterChainProxy로 위임하며, 조건별 작업은 로직을 달리 구성할 수 있습니다.


사전 정의한 SecurityConfig 클래스에서 아래와 같이 SecurityFilterChain을 등록하면 다중 SecurityFilterChain을 구성할 수 있습니다.

/* SecurityConfig.kt */

@Configuration
@EnableWebSecurity
class SecurityConfig {
    /* USER, ADMIN 사용자 생성 */
    @Bean
    fun userDetailService(): UserDetailsService {
        val user: User.UserBuilder = User.builder().passwordEncoder { "{noop}$it" }
        val manager = InMemoryUserDetailsManager()
        manager.createUser(user.username("user").password("password").roles("USER").build())
        manager.createUser(user.username("admin").password("password").roles("USER", "ADMIN").build())
        return manager
    }

    /*
        '/admin' 경로로 접근 시 해당 FilterChain을 사용하게 됨
        'ROLE_ADMIN' 권한이 없으면 403 Forbidden 발생
    */
    @Bean
    @Order(1)
    fun adminFilterChain(http: HttpSecurity): SecurityFilterChain {
        http.securityMatcher("/admin")
        http.authorizeHttpRequests { it.anyRequest().hasRole("ADMIN") }
        http.httpBasic { }

        return http.build()
    }

    /*
        이 외의 URL로 접근하면 해당 FilterChain을 사용하게 됨
        @Order 어노테이션이 없으면 후순위로 동작함
    */
    @Bean
    fun defaultFilterChain(http: HttpSecurity): SecurityFilterChain {
        http.authorizeHttpRequests { it.anyRequest().authenticated() }
        http.formLogin { }

        return http.build()
    }
}

FilterChainProxy에서 securityMatcher를 기준으로 SecurityFilterChain을 결정하게 됩니다. 이를 통해 서비스 요구 사항에 따라 보안 Filter 구성을 달리 할 수 있습니다.


위 코드에서 구성된 SecurityFilterChain은 아래 Pseudo Code와 같이 FilterChainProxy에서 선택됩니다.

/* FilterChainProxy.java */

private List<Filter> getFilters(HttpServletRequest request) {
    Iterator iter = this.filterChains.iterator();
    SecurityFilterChain chain;

    do {
        if (!iter.hasNext()) {
            return null;
        }

        chain = (SecurityFilterChain) iter.next();

        /*
            반복문을 돌며 RequestMatcher를 기반으로 조건에 맞는 FilterChain을 선택합니다.
            URL, Request Header 등 다양한 조건을 설정할 수 있습니다.
        */
    } while (!chain.matches(request));

    return chain.getFilters();
}

console



6. 동작 구조 요약


  1. DelegatingFilterProxy가 호출되면 WebApplicationContext를 참조해 Filter 구현체인 FilterChainProxy를 실행합니다.
    • NullPointerException을 방지하기 위해 WebApplicationContext가 준비될 때까지 기다립니다.
    • 내부적으로 DelegatingFilterProxy -> WebMvcSecurityConfiguration -> CompositeFilter -> FilterChainProxy 순의 호출 과정이 존재합니다.
  2. FilterChainProxy는 요청 조건에 따라 적절한 SecurityFilterChain을 선택하고 호출합니다.
    • RequestMatcher를 이용해 미리 지정한 조건을 따릅니다.
  3. 선택된 SecurityFilterChain 내의 보안 Filter들을 순차적으로 실행합니다.
  4. 모든 Filter를 통과하면 DispatcherServlet으로 요청을 전달하며, 스프링 시큐리티 필터링 과정이 종료됩니다.
    • 이후에도 SecurityContextHolder를 통해 인증 및 인가 정보를 확인할 수 있습니다.


📚 참고하면 좋은 자료



Spring Security

1 / 1
Spring의 표준 보안 프레임워크인 Spring Security를 차근차근 알아봅시다!
Kotlin
Spring Boot
Spring Security
Servlet

프로필 사진
Seongsu Kim
Backend Developer