[TIL] OncePerRequestFilter — 서블릿 필터 구현 패턴
Spring의 OncePerRequestFilter로 요청당 1회만 실행되는 서블릿 필터를 만드는 방법, shouldNotFilter()로 경로를 선택 적용하고 doFilterInternal()에서 체인을 이어가는 패턴을 정리한 글이다.
Spring에서 커스텀 필터를 만들 때
Filter를 직접 구현하면 forward/include 시 같은 요청에 필터가 여러 번 돌 수 있다.OncePerRequestFilter를 쓰면 요청당 한 번만 실행되고,shouldNotFilter()로 특정 경로를 건너뛰며,doFilterInternal()에서 검증 후filterChain.doFilter()로 체인을 이어갈 수 있다.
- 들어가며
- 1. 서블릿 필터가 하는 일
- 2. 왜 OncePerRequestFilter인가
- 3. 기본 구조: doFilterInternal과 filterChain
- 4. shouldNotFilter()로 경로 선택 적용
- 5. 실전 예시: 내부 API 키 검증 필터
- 6. 필터에서 응답을 끊을 때와 이어갈 때
- 7. @Component로 등록할 때 주의할 점
- 8. 흔한 실수와 점검 포인트
- 9. 적용 체크리스트
- 마무리
들어가며
컨트롤러에 도달하기 전에 공통 검증을 넣고 싶을 때가 있다.
- 내부 API 키 확인
- 허용 IP 검사
- 특정 경로만 추가 인증
이런 로직을 컨트롤러마다 복붙하기보다, 서블릿 필터로 앞단에 두는 편이 낫다. 요청이 들어오면 필터 → 컨트롤러 순으로 지나가고, 필터에서 막으면 컨트롤러까지 가지 않는다.
Spring에서는 이 패턴을 OncePerRequestFilter로 구현하는 경우가 많다.
1. 서블릿 필터가 하는 일
서블릿 필터는 HTTP 요청·응답 파이프라인의 앞단 훅이다.
[클라이언트] → [Filter 1] → [Filter 2] → ... → [DispatcherServlet] → [Controller]
필터의 역할:
- 요청을 검증하거나 헤더를 읽는다.
- 조건을 만족하지 않으면 401/403 등으로 응답을 끝낸다.
- 통과시키면
filterChain.doFilter()로 다음 필터·서블릿으로 넘긴다.
컨트롤러보다 앞에서 공통 정책을 적용할 수 있어, 보안·로깅·트레이싱에 자주 쓰인다.
2. 왜 OncePerRequestFilter인가
jakarta.servlet.Filter를 직접 구현해도 필터는 만들 수 있다. 그런데 서블릿 스펙상 forward나 include가 발생하면, 같은 HTTP 요청 안에서 필터가 여러 번 호출될 수 있다.
예:
- 에러 페이지로 forward
- 내부 JSP include
- 일부 프레임워크 내부 디스패치
이때 일반 Filter 구현은 같은 요청에 대해 검증 로직이 두 번 돌 수 있다. API 키 검증, IP 체크처럼 “요청당 한 번”이 전제인 로직에서는 부작용이 생긴다.
OncePerRequestFilter는 Spring이 제공하는 추상 클래스로, 요청당 정확히 한 번 doFilterInternal()이 실행되도록 보장한다.
정리:
| 방식 | 요청당 실행 횟수 |
|---|---|
Filter 직접 구현 | forward/include 시 중복 가능 |
OncePerRequestFilter | 요청당 1회 보장 |
커스텀 보안·인증 필터를 만들 때는 OncePerRequestFilter를 상속하는 것이 일반적인 선택이다.
3. 기본 구조: doFilterInternal과 filterChain
OncePerRequestFilter를 쓰면 직접 구현할 메서드는 사실상 doFilterInternal() 하나다.
public class ExampleFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 1. 요청 전처리 (검증, 로깅 등)
boolean allowed = checkSomething(request);
if (!allowed) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return; // 체인을 이어가지 않음 → 컨트롤러까지 가지 않음
}
// 2. 통과 시 다음 필터·서블릿으로 전달
filterChain.doFilter(request, response);
}
}
핵심 규칙:
- 검증 실패: 응답 상태를 쓰고
return(doFilter호출 안 함 ) - 검증 통과: 반드시
filterChain.doFilter(request, response)호출
doFilter를 빼먹으면 요청이 다음 단계로 가지 않아 응답이 비거나 타임아웃처럼 보일 수 있다. 필터를 처음 만들 때 가장 흔한 실수 중 하나다.
4. shouldNotFilter()로 경로 선택 적용
모든 요청에 필터를 걸 필요는 없다. 헬스체크, 정적 리소스, 공개 API는 제외하고 싶을 때가 많다.
shouldNotFilter()를 오버라이드하면 된다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/actuator/health")
|| path.startsWith("/public/");
}
shouldNotFilter()가true→ 이 필터는 건너뜀false→doFilterInternal()실행
경로 매칭 시 주의:
getRequestURI()는 컨텍스트 경로를 포함할 수 있다. (/my-app/api/...)- 서블릿 등록 시
/*패턴과 조합해 동작을 확인한다. - Spring MVC의
@RequestMapping경로와 1:1이 아닐 수 있으니, 실제 요청 URI로 테스트한다.
2편에서 다룬 Actuator health만 외부에 열어두는 설정과 맞물리면, 헬스체크 경로는 필터 대상에서 빼 두는 것이 자연스럽다.
5. 실전 예시: 내부 API 키 검증 필터
2편의 SecurityProperties를 주입받아, 내부 API 경로만 API 키를 검증하는 필터 예시다.
public class InternalApiKeyFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-Internal-Api-Key";
private final SecurityProperties securityProperties;
public InternalApiKeyFilter(SecurityProperties securityProperties) {
this.securityProperties = securityProperties;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/internal/");
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String apiKey = request.getHeader(API_KEY_HEADER);
String expected = securityProperties.getInternalApiKey();
if (expected.equals(apiKey)) {
filterChain.doFilter(request, response);
return;
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
이 예시에서 보이는 패턴:
- 경로 분기:
/internal/**만 검증 - 설정 주입: 2편에서 검증한
SecurityProperties재사용 - 실패 시 조기 종료: 401 후
doFilter미호출
4편에서는 이 필터를 Spring Security FilterChain의 어느 위치에 넣을지(addFilterBefore 등) 다룰 예정이다. 3편에서는 필터 자체를 어떻게 작성하는지에 집중한다.
6. 필터에서 응답을 끊을 때와 이어갈 때
필터는 “문지기” 역할이다. 통과/차단만 명확히 하면 된다.
차단 (응답 종료)
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// 필요하면 response.getWriter().write("...");
return;
통과 (체인 계속)
filterChain.doFilter(request, response);
주의할 점:
- 차단 후에도
doFilter를 호출하면 안 된다. - 통과 후
return전에doFilter를 한 번만 호출한다. - 응답 본문을 쓴 뒤
doFilter를 호출하면 응답이 이미 커밋된 상태일 수 있다.
필터는 가능한 한 가볍게 유지하는 것이 좋다. DB 조회나 외부 API 호출 같은 무거운 작업은 서비스 레이어로 내리고, 필터에는 헤더·IP·경로 수준 검증만 두는 편이 낫다.
7. @Component로 등록할 때 주의할 점
@Component만 붙이면 Spring Boot가 필터를 자동 등록한다.
@Component
public class InternalApiKeyFilter extends OncePerRequestFilter { ... }
편하지만 부작용이 있다.
- 모든 테스트 컨텍스트에서 필터 빈이 로드될 수 있다.
- 필터가
SecurityProperties등 다른 빈에 의존하면,@WebMvcTest처럼 웹 레이어만 띄운 테스트에서NoSuchBeanDefinitionException이 날 수 있다.
그래서 운영 전용 필터는 다음 패턴을 자주 쓴다.
@Component대신 4편에서 볼@Bean+@Profile("prod")로 명시 등록- 또는 필터 클래스는 순수하게 두고, 등록은 Security 설정 클래스에서 담당
3편에서 필터 구현을 익혔다면, 4편에서 등록 위치와 프로파일 분리까지 맞추면 운영·테스트 모두 다루기 쉬워진다.
8. 흔한 실수와 점검 포인트
실수 1: filterChain.doFilter() 누락
통과 경로에서 doFilter를 호출하지 않으면 요청이 멈춘다.
실수 2: 차단 후에도 doFilter 호출
401/403을 쓴 뒤 체인을 이어가면, 컨트롤러까지 요청이 흘러갈 수 있다.
실수 3: Filter를 직접 구현해 forward 시 중복 실행
같은 요청에 검증이 두 번 돌거나, 로그가 중복된다.
실수 4: shouldNotFilter() 경로가 실제 URI와 안 맞음
로컬에서는 되는데 운영에서만 막히는 경우, 컨텍스트 경로·프록시 경로 rewrite를 의심한다.
실수 5: 필터에 비즈니스 로직 과다
필터는 공통 게이트웨이 역할에 가깝다. 도메인 규칙은 서비스·컨트롤러에 두는 편이 테스트와 유지보수에 유리하다.
9. 적용 체크리스트
OncePerRequestFilter를 상속해 요청당 1회 실행을 보장했는가- 통과 시
filterChain.doFilter()를 호출하는가 - 차단 시
doFilter없이 상태 코드만 반환하는가 shouldNotFilter()로 health·공개 경로를 제외했는가- 필터가 의존하는 설정 빈(2편)과 경로 정책(1편)이 일치하는가
@Component자동 등록이 테스트에 미치는 영향을 확인했는가
마무리
OncePerRequestFilter는 “요청이 컨트롤러에 닿기 전, 한 번만 검사한다”는 패턴을 안전하게 구현하는 도구다.
정리하면 아래 세 가지면 충분하다.
doFilterInternal()에서 검증한다.- 통과하면
filterChain.doFilter()로 체인을 이어간다. - 필요 없는 경로는
shouldNotFilter()로 건너뛴다.
1편은 노출 범위, 2편은 설정 검증, 3편은 요청 단위 검증 로직을 필터로 작성하는 단계다. 4편에서는 이 필터를 Spring Security FilterChain에 어떻게 끼워 넣을지 이어서 보면 된다.