서블릿 필터
요청 추적·인증 필터를 구현하고 순서·정리·비동기·오류 디스패치의 경계를 검증합니다.
Filter는 Spring MVC 핸들러가 선택되기 전 서블릿 컨테이너 경계에서 실행됩니다.
정적 리소스, 존재하지 않는 경로, 오류 디스패치까지 볼 수 있어 요청 ID·보안 헤더·인증 같은 횡단 관심사에 적합합니다.
그만큼 chain.doFilter 호출과 상태 정리를 잘못하면 애플리케이션 전체 요청이 멈추거나 스레드 간 정보가 섞입니다.
필터 처리 구조
요청 ID를 MDC에 넣었다면 스레드 풀이 다음 요청을 처리하기 전에 반드시 제거해야 합니다.
정상 응답 뒤 코드만으로 정리하면 컨트롤러 예외 경로에서 값이 남습니다.
package board.web;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public final class RequestTraceFilter extends OncePerRequestFilter {
private static final String HEADER = "X-Request-Id";
private static final Pattern SAFE =
Pattern.compile("[A-Za-z0-9-]{8,64}");
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String requestId = accepted(request.getHeader(HEADER));
MDC.put("requestId", requestId);
response.setHeader(HEADER, requestId);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId");
}
}
private String accepted(String candidate) {
if (candidate != null && SAFE.matcher(candidate).matches()) {
return candidate;
}
return UUID.randomUUID().toString();
}
}클라이언트 ID를 그대로 로그 패턴에 넣지 않고 길이와 문자 집합을 제한해 로그 주입과 지나친 카디널리티를 줄입니다.
외부 앞단 프록시가 신뢰 가능한 ID를 발급한다면 헤더 덮어쓰기 정책을 명시합니다.
요청 ID는 인증 토큰이 아니며 두 요청이 같은 사용자인지 증명하지 않습니다.
필터의 응답 책임
인증 필터가 보호 경로의 익명 요청을 차단하면 상태, 콘텐츠 타입, 본문 또는 리다이렉트를 직접 완성하고 반환해야 합니다.
응답에 401을 쓴 뒤 체인을 계속 호출하면 컨트롤러가 본문을 추가하거나 이미 커밋된 응답에 예외를 낼 수 있습니다.
package board.web.auth;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public final class LoginRequiredFilter extends OncePerRequestFilter {
private final SessionMemberReader sessions;
public LoginRequiredFilter(SessionMemberReader sessions) {
this.sessions = sessions;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return !path.startsWith("/api/private/");
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws IOException, ServletException {
var member = sessions.read(request);
if (member.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
response.getWriter().write("""
{"title":"Authentication required","status":401,
"code":"authentication-required"}
""");
return;
}
request.setAttribute("authenticatedMember", member.get());
chain.doFilter(request, response);
}
}실제 API에서는 JSON 문자열을 손으로 조립하기보다 ObjectMapper와 공통 ProblemDetail 직렬화기를 사용합니다.
예제는 필터가 MVC 예외 핸들러 밖에서 응답을 책임진다는 점을 드러냅니다.
HTML과 API의 실패 표현이 다르면 경로 문자열보다 콘텐츠 협상 또는 별도 필터 체인을 고려합니다.
필터 순서·디스패처 유형
여러 필터가 있으면 트레이싱이 가장 바깥에서 인증 필터의 실패까지 관찰하고, 보안 헤더가 오류 응답에도 붙는 순서를 정할 수 있습니다.
컴포넌트 스캔의 우연한 순서에 기대지 않고 FilterRegistrationBean 또는 Spring 보안 필터 체인에서 순서를 선언합니다.
OncePerRequestFilter의 “한 번”은 논리 요청의 모든 디스패치를 통틀어 무조건 한 번이라는 뜻이 아닙니다.
REQUEST 뒤 비동기, 오류 디스패치가 생길 수 있고 기본 제외 정책을 재정의할 수 있습니다.
오류 페이지에도 추적 컨텍스트가 필요하면 디스패처별 생명주기와 MDC 전달을 검토합니다.
| 디스패처 | 발생 시점 | 필터 질문 |
|---|---|---|
| REQUEST | 최초 클라이언트 요청 | 기본 전처리 실행 |
| 비동기 | 비동기 작업 재개 | 스레드 컨텍스트 전달 여부 |
| 오류 | 컨테이너 오류 재전송 | 중복 로그·인증 처리 여부 |
| 포워드 | 내부 포워드 | URI 기준 정책 변화 여부 |
오류 디스패치의 URI와 속성은 최초 요청과 다를 수 있습니다.
필터가 상태를 다시 덮거나 인증 리다이렉트 반복을 만들지 않게 오류 디스패치 제외 여부를 테스트합니다.
다음 절의 서블릿 오류 처리에서 이 흐름을 더 자세히 다룹니다.
필터 경로 검증
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.mock.web.MockFilterChain;
class RequestTraceFilterTest {
@Test
void downstream_예외가_나도_MDC를_정리한다() {
var filter = new RequestTraceFilter();
var request = new MockHttpServletRequest("GET", "/posts");
request.addHeader("X-Request-Id", "client-1234");
var response = new MockHttpServletResponse();
var chain = new MockFilterChain((req, res) -> {
assertThat(MDC.get("requestId")).isEqualTo("client-1234");
throw new ServletException("downstream failed");
});
assertThatThrownBy(() -> filter.doFilter(request, response, chain))
.isInstanceOf(ServletException.class)
.hasMessage("downstream failed");
assertThat(MDC.get("requestId")).isNull();
assertThat(response.getHeader("X-Request-Id"))
.isEqualTo("client-1234");
}
}request id inside chain = client-1234
downstream exception = propagated
response header = client-1234
MDC after filter = null
thread-local leak = false차단 필터 테스트는 MockFilterChain.getRequest()가 null인지 확인해 체인이 호출되지 않았음을 증명합니다.
정상 인증 요청은 정확히 한 번 통과해야 합니다.
“응답 상태가 401이다”만 확인하면 컨트롤러가 실행된 뒤 우연히 401을 썼는지 구별할 수 없습니다.
반복 가능 요청 본문
감사 로그를 위해 request.getInputStream()을 필터에서 먼저 읽으면 컨트롤러의 @RequestBody가 빈 스트림을 받을 수 있습니다.
ContentCachingRequestWrapper도 본문을 자동으로 미리 읽는 도구가 아니며 하위 시스템이 소비한 뒤 캐시가 채워지는 특성을 이해해야 합니다.
응답 캐싱 래퍼를 쓰면 copyBodyToResponse()를 빠뜨리지 않아야 클라이언트가 빈 본문을 받지 않습니다.
비밀번호, 토큰, 멀티파트 바이너리를 통째로 로그하지 않습니다.
Content-Type, 크기, 허용한 필드 이름처럼 필요한 메타데이터만 수집하고 표본 추출과 상한을 둡니다.
본문 관찰 가능성 때문에 메모리에 무제한 복사하면 큰 업로드 한 번이 힙 압박으로 이어집니다.
연습 문제
게시판 API의 지연 시간과 상태를 기록하는 필터를 구현하세요.
하위 시스템 예외를 삼키지 않고, 응답이 이미 커밋돼도 상태를 읽으며, 요청 ID와 지속 시간을 한 로그 이벤트로 남기세요.
정상·401 차단·컨트롤러 예외 세 경로에서 정확히 한 번 기록되는지 테스트합니다.
해설 보기
System.currentTimeMillis보다 단조 증가하는 지속 시간에 적합한 System.nanoTime 또는 주입 가능한 시간 제공자를 사용합니다.
상태와 예외 클래스를 로컬 변수에 기록하고 로그는 finally에서 한 번 수행합니다.
package board.web;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
public final class ElapsedTime {
private final LongSupplier nanos;
public ElapsedTime(LongSupplier nanos) {
this.nanos = nanos;
}
public long start() {
return nanos.getAsLong();
}
public long millisSince(long started) {
return TimeUnit.NANOSECONDS.toMillis(
nanos.getAsLong() - started);
}
}테스트용 시간 제공자를 0에서 25ms로 이동해 실제 대기 없이 지속 시간을 고정합니다.
로그 수집기에는 메서드, 정규화된 경로, 상태, 경과 시간, 요청 ID만 전달하고 원시 쿼리의 민감 정보는 제외합니다.
다음 문서에서는 MVC 핸들러를 아는 Interceptor와 컨트롤러 인자를 만드는 HandlerMethodArgumentResolver로 인증 문맥을 더 좁게 전달합니다.