Spring Security 필터 체인(Filter Chain) 완벽 해부 및 커스텀 필터 적용 실무 가이드

Spring Security의 진입 장벽이 높은 가장 큰 이유는 바로 ‘필터 체인(Filter Chain)‘이라는 독특한 아키텍처 때문입니다. 이 구조를 이해하지 못하면 단순히 코드를 복사해서 붙여넣는 수준을 벗어날 수 없습니다. 오늘은 이 블랙박스를 열어 내부 구조를 파악하고, 원하는 위치에 나만의 로직을 심는 방법을 마스터해 보겠습니다.

Spring Security 필터 체인(Filter Chain) 완벽 해부 및 커스텀 필터 적용 실무 가이드

1. 서론

Spring Boot로 개발을 진행하다 보면, spring-boot-starter-security 의존성을 추가하는 순간 모든 API 요청이 차단되고 로그인 페이지로 리다이렉트 되는 경험을 하게 됩니다. 이는 우리가 작성한 컨트롤러에 요청이 도달하기도 전에, 스프링 시큐리티가 앞단에서 강력한 검문소를 세웠기 때문입니다. 많은 개발자가 이 검문소의 설정을 변경하기 위해 SecurityConfig 파일을 만지작거리지만, 정작 내부에서 어떤 일이 일어나는지 명확하게 설명할 수 있는 사람은 많지 않습니다.

스프링 시큐리티는 기본적으로 서블릿 필터(Servlet Filter)의 집합으로 동작합니다. 하지만 일반적인 웹 필터와는 다르게 스프링의 빈(Bean) 관리 컨테이너와 서블릿 컨테이너 사이를 연결하는 독특한 메커니즘을 가지고 있습니다. 이 구조를 이해하지 못하면 “왜 내 필터가 실행되지 않지?”, “왜 필터가 두 번 실행되지?”와 같은 난해한 버그에 시달리게 됩니다. 오늘은 스프링 시큐리티의 심장부인 ‘필터 체인(Filter Chain)‘의 아키텍처를 해부하고, 이를 바탕으로 JWT 인증이나 로깅을 위한 커스텀 필터를 적재적소에 배치하는 방법에 대해 심도 있게 알아보겠습니다.


2. 본론

1. 서블릿 컨테이너와 스프링 컨테이너의 가교, DelegatingFilterProxy

스프링 시큐리티를 이해하기 위해서는 먼저 웹 요청이 서버에 도달하는 과정을 알아야 합니다. 클라이언트의 요청은 가장 먼저 톰캣(Tomcat)과 같은 서블릿 컨테이너에 도착합니다. 이곳에는 기본적인 필터(Filter)들이 존재합니다. 그다음, 요청은 스프링이 관리하는 스프링 컨테이너(IoC Container)로 넘어가서 DispatcherServlet을 통해 컨트롤러로 전달됩니다.

문제는 여기서 발생합니다. 스프링 시큐리티의 로직은 스프링 빈(Bean)으로 등록된 객체들(UserDetailsUserPasswordEncoder 등)을 사용해야 하는데, 서블릿 컨테이너의 표준 필터는 스프링 빈을 알지 못합니다. 이 둘 사이의 다리 역할을 하기 위해 존재하는 것이 바로 DelegatingFilterProxy입니다.

  1. DelegatingFilterProxy: 서블릿 컨테이너에 등록된 표준 필터입니다. 요청이 들어오면 직접 처리하지 않고, 스프링 컨텍스트 내부에서 springSecurityFilterChain이라는 이름을 가진 빈을 찾아 요청을 위임(Delegate)합니다.
  2. FilterChainProxy: 위임을 받은 실질적인 보안 처리 관리자입니다. 이 빈은 내부에 여러 개의 SecurityFilterChain리스트를 가지고 있으며, 요청 URL에 매칭되는 적절한 체인을 선택하여 실행시킵니다.
  3. SecurityFilterChain: 실제 보안 로직을 수행하는 필터들의 묶음입니다. 우리가 SecurityConfig에서 설정하는 내용들이 바로 이 체인 안에 어떤 필터를 어떤 순서로 넣을지 결정하는 것입니다.

이러한 계층 구조 덕분에 우리는 서블릿 기술을 그대로 사용하면서도, 스프링의 강력한 의존성 주입(DI)과 빈 관리 기능을 보안 로직에 활용할 수 있게 되는 것입니다.

2. 주요 필터의 종류와 실행 순서의 중요성

SecurityFilterChain 내부에는 약 15~30개 정도의 필터가 순서대로 배치되어 있습니다. 이 순서는 매우 엄격하게 정해져 있으며, 각 필터는 자신의 역할이 끝나면 chain.doFilter()를 호출하여 다음 주자에게 바통을 넘깁니다. 커스텀 필터를 만들 때 이 순서를 알아야 정확한 위치에 끼워 넣을 수 있습니다.

주요 필터들의 실행 순서는 다음과 같습니다.

  1. SecurityContextPersistenceFilter (최상단): 이전에 로그인한 사용자의 세션 정보가 있다면, 이를 불러와서 SecurityContextHolder에 채워 넣는 역할을 합니다. (Spring Security 6에서는 SecurityContextHolderFilter로 변경됨)
  2. LogoutFilter: 로그아웃 요청(POST /logout)인지 확인하고, 맞다면 세션을 무효화하고 쿠키를 삭제합니다.
  3. UsernamePasswordAuthenticationFilter: 폼 로그인 방식에서 사용자가 제출한 ID/PW를 탈취하여 인증을 시도하는 핵심 필터입니다. 기본적으로 /login URL을 감시합니다.
  4. BearerTokenAuthenticationFilter (또는 JwtFilter): JWT 기반 인증을 사용할 때 우리가 주로 커스텀하여 배치하는 위치입니다. ID/PW 검사 전에 토큰 유효성을 먼저 검증해야 하기 때문입니다.
  5. ExceptionTranslationFilter: 인증이나 인가 과정에서 발생한 예외(AuthenticationExceptionAccessDeniedException)를 잡아서 로그인 페이지로 보내거나 403 에러를 뱉는 등 후처리를 담당합니다.
  6. FilterSecurityInterceptor (최하단): 인증된 사용자가 해당 URL에 접근할 권한이 있는지(인가, Authorization)를 최종적으로 검사합니다. 여기서 통과하지 못하면 요청은 컨트롤러에 도달하지 못합니다.

커스텀 필터를 만들 때는 이 순서 사이사이 중 어디에 들어갈지를 addFilterBeforeaddFilterAfteraddFilterAt 메서드를 통해 명시적으로 지정해야 합니다. 예를 들어 JWT 인증 필터라면 UsernamePasswordAuthenticationFilter 앞(Before)에 배치하여, 굳이 DB를 조회하지 않고도 토큰만으로 인증을 끝내도록 설계하는 것이 일반적입니다.

3. 실무형 커스텀 필터 구현 (OncePerRequestFilter 활용)

이제 실제로 커스텀 필터를 구현해 보겠습니다. 단순히 javax.servlet.Filter 인터페이스를 구현할 수도 있지만, 스프링 시큐리티에서는 OncePerRequestFilter 상속을 강력하게 권장합니다.

[왜 OncePerRequestFilter 인가?]

일반적인 서블릿 필터는 포워딩(Forwarding)이나 인클루드(Include) 같은 서블릿 내부의 요청 재전송 시 다시 한번 실행될 수 있습니다. 인증 로직이 불필요하게 두 번 실행되는 것은 리소스 낭비일 뿐만 아니라 보안상 의도치 않은 부작용을 낳을 수 있습니다. OncePerRequestFilter는 이름 그대로 ‘하나의 HTTP 요청에 대해 딱 한 번만 실행됨‘을 보장해 줍니다.

1. 커스텀 필터 작성 (CustomAuthenticationFilter)

Java

@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        
        // 1. 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 토큰 유효성 검증
        if (token != null && jwtProvider.validateToken(token)) {
            // 3. 인증 객체 생성 및 SecurityContext에 저장
            Authentication auth = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
            log.info("인증 성공: {}", auth.getName());
        }

        // 4. 다음 필터로 진행 (필수)
        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

2. SecurityConfig에 필터 등록

Java

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtProvider jwtProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            // [핵심] UsernamePasswordAuthenticationFilter 앞에 커스텀 필터 배치
            .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

4. 커스텀 필터 등록 시 주의할 점 (이중 호출 문제)

이 부분은 실무에서 시니어 개발자들도 자주 실수하는 포인트입니다.

만약 위에서 만든 JwtAuthenticationFilter 클래스 위에 @Component 어노테이션을 붙이면 어떻게 될까요?

  1. 스프링 부트 자동 설정: @Component가 붙었으므로 스프링 부트가 이 필터를 스캔하여 ‘글로벌 서블릿 필터 체인‘에 자동으로 등록해 버립니다.
  2. 시큐리티 설정: 우리는 SecurityConfig에서 .addFilterBefore를 통해 ‘스프링 시큐리티 필터 체인‘에도 이 필터를 등록했습니다.

결과적으로, 하나의 요청에 대해 필터가 두 번 실행되는 참사가 발생합니다.

  • 첫 번째 실행: 일반 서블릿 필터로서 실행 (이때는 시큐리티 컨텍스트가 동작하지 않을 수 있음)
  • 두 번째 실행: 시큐리티 체인 내부에서 실행

따라서, 시큐리티 체인 안에만 필터를 넣고 싶다면 절대로 커스텀 필터에 @Component를 붙여서 빈으로 등록하지 말고new JwtAuthenticationFilter() 처럼 객체를 직접 생성해서 주입하거나, 빈으로 등록하더라도 FilterRegistrationBean을 사용하여 자동 등록을 비활성화해야 합니다. 가장 깔끔한 방법은 빈 등록을 피하고 생성자 주입을 활용하여 Config 클래스 내부에서 new 키워드로 주입하는 것입니다.


3. 결론

지금까지 Spring Security의 핵심 엔진인 필터 체인의 구조와 DelegatingFilterProxy의 역할, 그리고 커스텀 필터를 안전하게 구현하고 등록하는 방법에 대해 상세히 알아보았습니다.

스프링 시큐리티는 ‘마법’이 아닙니다. 잘 짜인 객체지향적인 필터들의 합주입니다. 이 구조를 이해하고 나면, 단순히 인증/인가뿐만 아니라 요청/응답 로깅, XSS 방어, CORS 처리 등 웹 애플리케이션 전반에 걸친 공통 관심사(Cross-cutting Concerns)를 처리하는 가장 강력한 도구를 손에 쥐게 되는 셈입니다.

특히 오늘 강조한 실행 순서와 이중 호출 방지 팁은 실제 운영 환경에서 발생할 수 있는 잠재적인 버그를 막아주는 중요한 지식이 될 것입니다. 이제 여러분의 SecurityConfig 파일이 더 이상 복사 붙여넣기 한 코드가 아니라, 여러분이 완전히 통제하고 설계한 보안 아키텍처가 되기를 바랍니다.


[당신을 위해 할 수 있는 다음 단계]

인증 필터를 통과한 후, 실제로 권한을 검사하는 과정도 궁금하지 않으신가요? 다음 포스팅으로 “Spring Security의 권한 부여(Authorization) 아키텍처: RoleHierarchy를 이용한 계층형 권한 관리와 @PreAuthorize 활용법“에 대해 다뤄보는 것을 추천합니다. 관리자(Admin)는 유저(User)의 권한을 자동으로 포함하게 만드는 실무 팁입니다.

댓글 남기기