Custom Filter — SecurityFilterChain에 나만의 필터를 추가하는 방법
Spring Security의 기본 필터만으로는 부족한 인증 로직이 있다면, 어떻게 나만의 필터를 추가할 수 있을까요?
JWT 인증, API 키 검증, 요청 로깅 등 커스텀 보안 로직이 필요할 때 SecurityFilterChain에 직접 만든 필터를 추가 할 수 있습니다. 필터의 위치와 순서를 이해하는 것이 핵심입니다.
개념 정의
커스텀 필터 는 Spring Security의 필터 체인에 개발자가 직접 만든 보안 로직을 삽입하는 것입니다. 각 필터는 특정 보안 작업을 담당하며, 삽입 위치가 동작을 결정 합니다.
기본 필터 순서
SecurityContextHolderFilter → SecurityContext 설정
CsrfFilter → CSRF 토큰 검증
LogoutFilter → 로그아웃 처리
UsernamePasswordAuthenticationFilter → 폼 로그인 처리
BasicAuthenticationFilter → HTTP Basic 인증
RequestCacheAwareFilter → 캐시된 요청 복원
SecurityContextHolderAwareRequestFilter → 서블릿 API 보안 통합
AnonymousAuthenticationFilter → 익명 사용자 처리
ExceptionTranslationFilter → 인증/인가 예외 처리
AuthorizationFilter → 최종 인가 결정
OncePerRequestFilter 사용
커스텀 필터를 만들 때 가장 많이 사용하는 기반 클래스입니다.
JWT 인증 필터 예시
JWT 토큰을 검증하여 SecurityContext에 인증 정보를 설정하는 필터를 만들어보겠습니다. 먼저 핵심 인증 로직입니다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
토큰이 유효하면 UserDetails를 로드하여 SecurityContext에 인증 정보를 설정합니다.
if (token != null && jwtTokenProvider.validateToken(token)) {
String username = jwtTokenProvider.getUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response); // 반드시 호출해야 함!
}
핵심은 filterChain.doFilter()입니다. 이것을 호출하지 않으면 이후 필터와 컨트롤러가 실행되지 않기 때문에, 인증 실패 시에도 체인을 중단할 의도가 아니라면 반드시 호출해야 합니다.
토큰 추출과 필터 제외 로직은 다음과 같습니다.
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/api/auth/") || path.startsWith("/public/");
}
}
shouldNotFilter()를 오버라이드하면 특정 경로에서 필터를 건너뛸 수 있습니다. 로그인, 회원가입 등 인증이 불필요한 경로에 활용합니다.
필터 등록
만든 필터를 SecurityFilterChain에 등록합니다. addFilterBefore로 위치를 지정합니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
이렇게 하면 JWT 필터가 폼 로그인 필터보다 먼저 실행되어, JWT로 인증이 완료된 요청은 폼 로그인 과정을 거치지 않습니다.
필터 위치 지정
| 메서드 | 동작 |
|---|---|
| addFilterBefore(filter, Class) | 지정한 필터 앞에 추가 |
| addFilterAfter(filter, Class) | 지정한 필터 뒤에 추가 |
| addFilterAt(filter, Class) | 지정한 필터 위치에 추가 (교체가 아님) |
// UsernamePasswordAuthenticationFilter 앞에 JWT 필터 추가
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// BasicAuthenticationFilter 뒤에 로깅 필터 추가
.addFilterAfter(loggingFilter, BasicAuthenticationFilter.class)
다양한 커스텀 필터 예시
요청 로깅 필터
모든 요청의 메서드, URI, 응답 상태, 처리 시간을 로그로 남기는 필터입니다.
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
try {
filterChain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - startTime;
log.info("{} {} → {} ({}ms)",
request.getMethod(), request.getRequestURI(), response.getStatus(), duration);
}
}
}
try-finally로 감싸는 이유는, 예외가 발생하더라도 요청 로그가 반드시 남도록 보장하기 위해서입니다.
API 키 인증 필터
외부 연동 API에서 API 키를 헤더로 검증하는 필터입니다. 인증 실패 시 filterChain.doFilter()를 호출하지 않고 즉시 응답을 반환합니다.
@Component
public class ApiKeyFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String apiKey = request.getHeader(API_KEY_HEADER);
if (apiKey == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"API 키가 필요합니다\"}");
return; // 체인 중단 — 이후 필터와 컨트롤러 실행되지 않음
}
filterChain.doFilter(request, response);
}
return으로 메서드를 종료하면 filterChain.doFilter()가 호출되지 않으므로 필터 체인이 중단됩니다. 이것이 커스텀 필터에서 요청을 거부하는 패턴입니다.
필터 순서의 중요성
필터는 인증 → 인가 순서로 실행되어야 합니다. 인가 필터가 실행되는 시점에 SecurityContext에 인증 정보가 없으면, 인증된 사용자도 403이 발생하기 때문입니다.
// 잘못된 순서: 인가 필터 뒤에 인증 필터
.addFilterAfter(jwtFilter, AuthorizationFilter.class)
// → 인가 검사 시점에 아직 인증이 안 되어 있음!
// 올바른 순서: 인증 필터를 먼저 실행
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
// → 인증 후 인가 검사
이 실수는 디버깅이 어렵습니다. 인증 자체는 성공하기 때문에 로그에 인증 실패가 나타나지 않고, 인가 단계에서 "권한 없음"이 발생하여 원인을 찾기 어렵습니다.
GenericFilterBean vs OncePerRequestFilter
| 항목 | GenericFilterBean | OncePerRequestFilter |
|---|---|---|
| 요청당 실행 횟수 | 여러 번 가능 | 정확히 한 번 |
| shouldNotFilter | 없음 | 지원 (경로별 필터 제외) |
| 사용 상황 | 범용 필터 | 인증/인가 필터 (권장) |
주의할 점
@Component 필터가 두 번 실행되는 문제
커스텀 필터에 @Component를 붙이면 Spring이 ** 서블릿 필터로도 자동 등록 **합니다. SecurityFilterChain에도 등록했다면 같은 필터가 두 번 실행됩니다.
// 문제: @Component + addFilterBefore 조합 → 필터 2회 실행
@Component // ← Spring Boot가 서블릿 필터로 자동 등록
public class JwtAuthenticationFilter extends OncePerRequestFilter { ... }
@Component로 인해 Spring Boot가 서블릿 필터 체인에 자동 등록합니다.addFilterBefore()로 Security 필터 체인에도 등록됩니다.- 결과적으로 같은 필터가 서블릿 체인과 Security 체인에서 각각 한 번씩, 총 두 번 실행됩니다.
FilterRegistrationBean으로 서블릿 자동 등록을 방지해야 합니다.
@Bean
public FilterRegistrationBean<JwtAuthenticationFilter> jwtFilterRegistration(
JwtAuthenticationFilter filter) {
FilterRegistrationBean<JwtAuthenticationFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false); // 서블릿 필터 자동 등록 방지
return registration;
}
addFilterAt은 교체가 아니다
addFilterAt(myFilter, UsernamePasswordAuthenticationFilter.class)은 기존 필터를 ** 교체하는 것이 아니라 같은 위치에 추가 **합니다. 기존 필터도 여전히 실행되므로, 교체하려면 HttpSecurity에서 기존 필터를 비활성화해야 합니다.
예외 처리 위치를 혼동하기 쉽다
커스텀 필터에서 발생한 예외는 ExceptionTranslationFilter보다 ** 앞에서 실행 **되기 때문에, Security의 기본 예외 처리(AuthenticationEntryPoint, AccessDeniedHandler)가 동작하지 않습니다. JWT 토큰 만료, 파싱 실패 같은 예외는 ** 필터 내부에서 직접 try-catch로 처리 **해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 기반 클래스 | OncePerRequestFilter — 요청당 한 번 실행 보장 |
| 위치 지정 | addFilterBefore/After로 필터 체인 내 위치 결정 |
| JWT 필터 위치 | UsernamePasswordAuthenticationFilter 앞 에 배치 |
| 체인 중단 | filterChain.doFilter() 미호출 시 이후 필터/컨트롤러 미실행 |
| 경로 제외 | shouldNotFilter() 오버라이드로 특정 경로 건너뛰기 |
| 이중 등록 주의 | @Component 필터는 FilterRegistrationBean으로 서블릿 자동 등록 방지 |