서블릿 필터
요청 추적·인증 필터를 구현하고 순서·정리·비동기·오류 디스패치의 경계를 검증합니다.
필터(Filter)는 Spring MVC의 핸들러가 선택되기 전, 서블릿 컨테이너 단계에서 실행됩니다. 정적 리소스와 존재하지 않는 경로, 오류 디스패치까지 볼 수 있어 요청 ID와 보안 헤더, 인증 같은 횡단 관심사에 알맞습니다. 그만큼 chain.doFilter 호출이나 상태 정리를 잘못하면 애플리케이션의 모든 요청이 멈추거나 스레드 사이에 정보가 섞입니다.
요청 ID는 하위 처리 전에 게시하고 예외 뒤에는 반드시 이전 문맥으로 돌아간다
안정 ID 결정·이전 문맥 보관·게시·하위 처리/예외·finally 복원과 ERROR 재사용을 따라간다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
요청 ID 게시와 finally 복원을 디스패치마다 검증한다
허용된 외부 후보 또는 UUID를 private 요청 속성에 보존합니다. 하위 호출 중 공개 속성·MDC·응답 헤더를 맞추고, finally는 공개 속성과 MDC의 이전 값을 복원합니다.
ACCEPTED CORRELATION INPUT
8~64자의 안전한 외부 후보도 수용한다
[A-Za-z0-9-]{8,64} 후보는 그대로 쓰고 그 외에는 UUID입니다. 예제 client-1234는 header/속성/MDC에서 같게 관찰됩니다. 요청 ID는 관찰용이며 인증·권한 또는 신뢰된 내부 신원을 증명하지 않습니다.
TRY / FINALLY
이전 값은 복원 · 이전 부재는 제거
chain에 들어갈 때의 공개 requestId와 MDC를 저장합니다. 정상 반환과 ServletException 모두 finally를 실행합니다. outer-attribute와 outer-mdc는 정확히 복원되고 원래 없으면 둘 다 null입니다. 응답 헤더와 private 안정 ID는 이 공개 문맥 정리 대상이 아닙니다.
ACTUAL EXCEPTION UNIT TEST
하위 예외를 삼키지 않고 헤더를 유지
실제 test의 chain은 client-1234를 읽고 downstream failed 예외를 던집니다. 필터 밖에서도 같은 예외, response header=client-1234와 두 이전 문맥을 확인합니다. 이 검사는 MVC advice가 실제 오류 응답을 생성했다는 증명과 다릅니다.
REQUEST + ERROR MOCK TEST
같은 request의 두 구간에 같은 ID를 게시
shouldNotFilterErrorDispatch=false입니다. test가 REQUEST 실행 뒤 같은 MockHttpServletRequest를 ERROR로 바꾸어 두 번 client-9012를 읽고 각 구간의 문맥 복원을 확인합니다. 실제 ERROR filter mapping/내장 컨테이너는 추가 통합 검사입니다. 다음 문서의 MVC fixture도 두 번째 ERROR handler를 별도로 검사합니다.
FILTER OWNS EARLY RESPONSE
익명 보호 경로는 401을 쓰고 즉시 return
LoginRequiredFilter는 /api/private/에서 typed 세션 주체가 없으면 application/problem+json 401과 authentication-required를 작성하고 chain을 호출하지 않습니다. 이 장의 세 실행 unit tests는 추적 필터에 대한 것이며 인증 필터의 호출 횟수 검사는 추가 계약입니다.
ASYNC / ORDER / LOGGING EXERCISE
비동기 문맥과 지연 시간 로그는 별도 구현
기본 OncePerRequestFilter의 ASYNC 제외와 컨테이너 dispatcher 등록을 함께 정해야 합니다. 필터 순서와 비동기 스레드 문맥 전달, ElapsedTime을 로그에 연결해 정상/401/예외에서 1회 기록하는 정책은 이 trace fixture에서 구현·검사되지 않았습니다.
RequestTraceFilter는 허용된 외부 X-Request-Id 또는 새 UUID를 안정 요청 속성에 저장합니다. 이전 공개 속성과 MDC를 보관하고 같은 ID를 게시한 뒤 chain을 호출합니다. 하위 예외도 전파하며 finally는 이전 값을 복원하거나 부재면 제거합니다. 같은 request의 후속 ERROR 디스패치는 private ID를 재사용합니다. 이 장의 테스트는 수동 mock 디스패치이며 실제 컨테이너 ERROR 등록은 다음 오류 처리 계약입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- RequestTraceFilter는 허용된 외부 X-Request-Id 또는 새 UUID를 안정 요청 속성에 저장합니다. 이전 공개 속성과 MDC를 보관하고 같은 ID를 게시한 뒤 chain을 호출합니다. 하위 예외도 전파하며 finally는 이전 값을 복원하거나 부재면 제거합니다. 같은 request의 후속 ERROR 디스패치는 private ID를 재사용합니다. 이 장의 테스트는 수동 mock 디스패치이며 실제 컨테이너 ERROR 등록은 다음 오류 처리 계약입니다.
- 기존 데스크톱 여섯 노드와 다섯 관계이다. 앞 다섯 단계는 각 추적 dispatch의 호출과 finally 정리이며, 마지막 관계는 등록된 후속 ERROR가 생긴 경우만 같은 요청의 private ID를 재사용하는 조건부 경로이다. 정상 요청마다 ERROR가 생기는 것은 아니며 실제 컨테이너 매핑과 비동기 문맥 전달은 추가 검증이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 요청 ID는 하위 처리 전에 게시하고 예외 뒤에는 반드시 이전 문맥으로 돌아간다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 안정 ID 결정·보관 허용 외부 ID 또는 UUID를 private 속성에 두고 이전 공개 값·MDC를 보관합니다. | ID: 안정 private 속성 · 이전 문맥: 보관 | 후속 dispatch에도 사용할 ID가 정해집니다. |
| 같은 ID로 하위 처리 공개 attribute·MDC에 같은 ID를 게시하고 chain을 호출합니다. | 하위 입력: 같은 ID · 예외: 그대로 전파 | 요청 문맥이 하위 실행에 전달됩니다. |
| finally와 ERROR 재사용 이전 값 복원·제거 뒤 같은 요청 ERROR dispatch는 private ID를 재사용합니다. | worker 문맥: 복원·제거 · ERROR: private ID 재사용 · 테스트: 수동 mock | 정리 후에도 요청 identity는 안정적으로 남습니다. |
요청 ID는 하위 처리 전에 게시하고 예외 뒤에는 반드시 이전 문맥으로 돌아간다
안정 ID 결정·이전 문맥 보관·게시·하위 처리/예외·finally 복원과 ERROR 재사용을 따라간다.
CONTRACT / IMPLEMENTATION / REVIEW SCOPE
요청 ID 게시와 finally 복원을 디스패치마다 검증한다
허용된 외부 후보 또는 UUID를 private 요청 속성에 보존합니다. 하위 호출 중 공개 속성·MDC·응답 헤더를 맞추고, finally는 공개 속성과 MDC의 이전 값을 복원합니다.
ACCEPTED CORRELATION INPUT
8~64자의 안전한 외부 후보도 수용한다
[A-Za-z0-9-]{8,64} 후보는 그대로 쓰고 그 외에는 UUID입니다. 예제 client-1234는 header/속성/MDC에서 같게 관찰됩니다. 요청 ID는 관찰용이며 인증·권한 또는 신뢰된 내부 신원을 증명하지 않습니다.
TRY / FINALLY
이전 값은 복원 · 이전 부재는 제거
chain에 들어갈 때의 공개 requestId와 MDC를 저장합니다. 정상 반환과 ServletException 모두 finally를 실행합니다. outer-attribute와 outer-mdc는 정확히 복원되고 원래 없으면 둘 다 null입니다. 응답 헤더와 private 안정 ID는 이 공개 문맥 정리 대상이 아닙니다.
ACTUAL EXCEPTION UNIT TEST
하위 예외를 삼키지 않고 헤더를 유지
실제 test의 chain은 client-1234를 읽고 downstream failed 예외를 던집니다. 필터 밖에서도 같은 예외, response header=client-1234와 두 이전 문맥을 확인합니다. 이 검사는 MVC advice가 실제 오류 응답을 생성했다는 증명과 다릅니다.
REQUEST + ERROR MOCK TEST
같은 request의 두 구간에 같은 ID를 게시
shouldNotFilterErrorDispatch=false입니다. test가 REQUEST 실행 뒤 같은 MockHttpServletRequest를 ERROR로 바꾸어 두 번 client-9012를 읽고 각 구간의 문맥 복원을 확인합니다. 실제 ERROR filter mapping/내장 컨테이너는 추가 통합 검사입니다. 다음 문서의 MVC fixture도 두 번째 ERROR handler를 별도로 검사합니다.
FILTER OWNS EARLY RESPONSE
익명 보호 경로는 401을 쓰고 즉시 return
LoginRequiredFilter는 /api/private/에서 typed 세션 주체가 없으면 application/problem+json 401과 authentication-required를 작성하고 chain을 호출하지 않습니다. 이 장의 세 실행 unit tests는 추적 필터에 대한 것이며 인증 필터의 호출 횟수 검사는 추가 계약입니다.
ASYNC / ORDER / LOGGING EXERCISE
비동기 문맥과 지연 시간 로그는 별도 구현
기본 OncePerRequestFilter의 ASYNC 제외와 컨테이너 dispatcher 등록을 함께 정해야 합니다. 필터 순서와 비동기 스레드 문맥 전달, ElapsedTime을 로그에 연결해 정상/401/예외에서 1회 기록하는 정책은 이 trace fixture에서 구현·검사되지 않았습니다.
RequestTraceFilter는 허용된 외부 X-Request-Id 또는 새 UUID를 안정 요청 속성에 저장합니다. 이전 공개 속성과 MDC를 보관하고 같은 ID를 게시한 뒤 chain을 호출합니다. 하위 예외도 전파하며 finally는 이전 값을 복원하거나 부재면 제거합니다. 같은 request의 후속 ERROR 디스패치는 private ID를 재사용합니다. 이 장의 테스트는 수동 mock 디스패치이며 실제 컨테이너 ERROR 등록은 다음 오류 처리 계약입니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- RequestTraceFilter는 허용된 외부 X-Request-Id 또는 새 UUID를 안정 요청 속성에 저장합니다. 이전 공개 속성과 MDC를 보관하고 같은 ID를 게시한 뒤 chain을 호출합니다. 하위 예외도 전파하며 finally는 이전 값을 복원하거나 부재면 제거합니다. 같은 request의 후속 ERROR 디스패치는 private ID를 재사용합니다. 이 장의 테스트는 수동 mock 디스패치이며 실제 컨테이너 ERROR 등록은 다음 오류 처리 계약입니다.
- 기존 데스크톱 여섯 노드와 다섯 관계이다. 앞 다섯 단계는 각 추적 dispatch의 호출과 finally 정리이며, 마지막 관계는 등록된 후속 ERROR가 생긴 경우만 같은 요청의 private ID를 재사용하는 조건부 경로이다. 정상 요청마다 ERROR가 생기는 것은 아니며 실제 컨테이너 매핑과 비동기 문맥 전달은 추가 검증이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 요청 ID는 하위 처리 전에 게시하고 예외 뒤에는 반드시 이전 문맥으로 돌아간다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 안정 ID 결정·보관 허용 외부 ID 또는 UUID를 private 속성에 두고 이전 공개 값·MDC를 보관합니다. | ID: 안정 private 속성 · 이전 문맥: 보관 | 후속 dispatch에도 사용할 ID가 정해집니다. |
| 같은 ID로 하위 처리 공개 attribute·MDC에 같은 ID를 게시하고 chain을 호출합니다. | 하위 입력: 같은 ID · 예외: 그대로 전파 | 요청 문맥이 하위 실행에 전달됩니다. |
| finally와 ERROR 재사용 이전 값 복원·제거 뒤 같은 요청 ERROR dispatch는 private ID를 재사용합니다. | worker 문맥: 복원·제거 · ERROR: private ID 재사용 · 테스트: 수동 mock | 정리 후에도 요청 identity는 안정적으로 남습니다. |
그림은 허용된 외부 후보나 UUID로 정한 요청 ID를 private 요청 속성에 보관하고, 이전의 공개 속성과 MDC를 저장한 뒤, 같은 ID를 공개 속성과 MDC, 응답 헤더에 게시하고 하위 체인을 호출하는 순서를 보여 줍니다. 하위 코드가 예외를 던져도 finally가 이전의 공개 속성과 MDC를 되돌리거나, 원래 없었다면 지웁니다. 마지막 화살표는 등록된 ERROR 디스패치가 뒤따를 때만 같은 요청의 private ID를 다시 쓰는 조건부 경로이며, 모든 요청에 ERROR가 생긴다는 뜻은 아닙니다. 이 글의 테스트는 직접 모의 호출로 디스패치를 흉내 내며, 실제 컨테이너의 ERROR 등록은 다음 오류 처리 글에서 다룹니다.
필터의 처리 구조
요청 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 {
public static final String REQUEST_ID_HEADER = "X-Request-Id";
public static final String REQUEST_ID_ATTRIBUTE = "requestId";
private static final String MDC_KEY = "requestId";
private static final String STABLE_REQUEST_ID_ATTRIBUTE =
RequestTraceFilter.class.getName() + ".stableRequestId";
private static final Pattern SAFE =
Pattern.compile("[A-Za-z0-9-]{8,64}");
@Override
protected boolean shouldNotFilterErrorDispatch() {
return false;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
String requestId = stableRequestId(request);
Object previousAttribute = request.getAttribute(
REQUEST_ID_ATTRIBUTE);
String previousMdc = MDC.get(MDC_KEY);
request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);
MDC.put(MDC_KEY, requestId);
try {
response.setHeader(REQUEST_ID_HEADER, requestId);
chain.doFilter(request, response);
} finally {
if (previousAttribute == null) {
request.removeAttribute(REQUEST_ID_ATTRIBUTE);
} else {
request.setAttribute(
REQUEST_ID_ATTRIBUTE, previousAttribute);
}
if (previousMdc == null) {
MDC.remove(MDC_KEY);
} else {
MDC.put(MDC_KEY, previousMdc);
}
}
}
private String stableRequestId(HttpServletRequest request) {
Object stable = request.getAttribute(
STABLE_REQUEST_ID_ATTRIBUTE);
if (stable instanceof String requestId
&& SAFE.matcher(requestId).matches()) {
return requestId;
}
String requestId = accepted(
request.getHeader(REQUEST_ID_HEADER));
request.setAttribute(
STABLE_REQUEST_ID_ATTRIBUTE, requestId);
return 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.web.filter.OncePerRequestFilter;
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(
SessionMemberReader.AUTHENTICATED_MEMBER_ATTRIBUTE,
member.get());
chain.doFilter(request, response);
}
}이 LoginRequiredFilter의 문자열 검사는 애플리케이션이 루트 컨텍스트에 있고 보호할 URI가 /api/private/로 시작한다는 예입니다. 클래스를 선언하기만 해서는 컨테이너의 필터로 등록되지 않으므로, 실제로 연결하려면 필터 등록도 필요합니다. 아래 세 테스트는 등록된 HTTP 인증 체인이 아니라 RequestTraceFilter를 직접 모의로 호출해 검사합니다.
컨텍스트 경로가 /academy라면 요청 URI는 /academy/api/private/posts가 되어 지금의 startsWith("/api/private/")와 맞지 않습니다. 코드를 그대로 따라 할 때는 루트 컨텍스트를 유지합니다. 다른 배포에서는 컨텍스트 기준의 경로와 디스패처, 매핑 규칙을 일관되게 적용한 별도의 보안 구성을 만들고, /api/private, /api/private/, /api/private/posts, 공개 경로, 내부 ERROR 경로를 각각 확인해야 합니다. 문자열 접두사 하나가 모든 보호 엔드포인트를 지켜 준다고 일반화하지 않습니다.
실제 API에서는 JSON 문자열을 손으로 조립하기보다 ObjectMapper와 공통 ProblemDetail 직렬화기를 씁니다. 이 예제는 필터가 MVC 예외 핸들러 바깥에서 응답을 직접 완성한다는 점을 보여 줍니다. HTML과 API의 실패 표현이 다르다면 경로 문자열보다 콘텐츠 협상이나 별도의 필터 체인을 고려합니다.
필터의 순서와 디스패처 유형
필터가 여럿이면 순서를 정해, 추적 필터가 가장 바깥에서 인증 필터의 실패까지 기록하고 보안 헤더가 오류 응답에도 붙게 할 수 있습니다. 컴포넌트 스캔의 우연한 순서에 기대지 않고, FilterRegistrationBean이나 Spring Security의 필터 체인에서 순서를 선언합니다.
OncePerRequestFilter의 한 번은 논리적인 요청 하나의 모든 디스패치를 통틀어 무조건 한 번이라는 뜻이 아닙니다. REQUEST 뒤에 비동기나 오류 디스패치가 생길 수 있고, 기본 제외 정책을 바꿀 수도 있습니다. shouldNotFilterErrorDispatch() = false는 이 필터가 ERROR 처리를 건너뛰지 않겠다는 선언일 뿐, 컨테이너가 ERROR 디스패치에서 이 필터를 호출하도록 등록하는 설정까지 대신하지는 않습니다. OncePerRequestFilter의 API는 디스패처 등록과 제외 훅을 별도의 조건으로 설명합니다. 지금은 ASYNC 제외 훅을 기본값으로 두었으므로, 임의의 비동기 작업 스레드에 MDC가 저절로 전달된다고 판단하지 않습니다. 오류 페이지에도 추적 문맥이 필요하다면 디스패처별 수명 주기와 MDC 전달을 검토합니다.
| 디스패처 | 생기는 시점 | 필터가 확인할 것 |
|---|---|---|
| REQUEST | 클라이언트의 첫 요청 | 기본 전처리 실행 |
| 비동기 | 비동기 작업의 재개 | 스레드 문맥을 전달할지 |
| 오류 | 컨테이너의 오류 재전송 | 로그와 인증 처리가 중복되는지 |
| 포워드 | 내부 포워드 | URI 기준의 정책이 바뀌는지 |
오류 디스패치의 URI와 속성은 첫 요청과 다를 수 있습니다. 필터가 상태를 다시 덮어쓰거나 인증 리다이렉트를 반복하지 않도록, 오류 디스패치를 제외할지 테스트합니다. 이 흐름은 서블릿 오류 디스패치에서 더 자세히 다룹니다.
더 깊이: Boot의 필터 등록과 OncePerRequestFilter의 실제 판정
내장 컨테이너를 쓰는 Spring Boot는 Filter 타입의 빈을 찾으면 컨테이너에 등록하고, 경로를 따로 정하지 않았다면 /*에 연결합니다. 어떤 디스패처 유형에 등록할지는 AbstractFilterRegistrationBean.determineDispatcherTypes()가 정합니다. Spring Boot 4.1.2의 소스를 보면, 유형을 지정하지 않은 필터는 REQUEST에만 등록되지만 OncePerRequestFilter의 하위 클래스는 모든 디스패처 유형에 등록됩니다. 그래서 Boot에서 OncePerRequestFilter를 상속한 필터는 컨테이너가 ERROR와 ASYNC 디스패치에서도 호출하고, 실제로 일을 할지는 기본값이 둘 다 true인 shouldNotFilterErrorDispatch()와 shouldNotFilterAsyncDispatch()가 정합니다. 반대로 Filter를 직접 구현한 필터는 등록을 바꾸지 않는 한 ERROR 디스패치에서 아예 호출되지 않습니다.
OncePerRequestFilter가 한 번을 지키는 방법은 요청 속성 하나입니다. Spring Framework 7.0.10의 doFilter는 doFilterInternal을 부르기 전에 필터 이름 뒤에 .FILTERED를 붙인 속성을 요청에 넣고, finally에서 지웁니다. 그래서 체인 안에서 포워드처럼 같은 요청이 다시 디스패치되면 속성이 남아 있어 이 필터를 건너뛰고, 처음 디스패치가 끝난 뒤에 오는 ERROR 디스패치에서는 속성이 이미 지워져 있어 다시 실행될 수 있습니다. 또 shouldNotFilterErrorDispatch()로 건너뛸 ERROR 디스패치인지는 디스패처 유형이 아니라 jakarta.servlet.error.request_uri 속성이 있는지로 판단합니다. 테스트에서 디스패처 유형만 ERROR로 바꾸고 이 속성을 채우지 않으면, 실제 컨테이너와 다른 경로를 타게 됩니다.
필터 빈이 여럿이라면 logging.level.web=debug로 시작하면 등록된 필터마다의 순서와 URL 패턴이 시작 로그에 나옵니다. Spring Boot 문서는 순서가 필요하면 필터 클래스에 @Order를 붙이거나 Ordered를 구현하라고 하며, @Bean 메서드에 붙인 @Order로는 필터 순서를 정할 수 없다고 적고 있습니다.
필터 경로 검증
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.FilterChain;
import jakarta.servlet.RequestDispatcher;
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;
class RequestTraceFilterTest {
@Test
void downstream_예외가_나도_request와_MDC를_원상복구한다() {
var filter = new RequestTraceFilter();
var request = new MockHttpServletRequest("GET", "/posts");
request.addHeader(
RequestTraceFilter.REQUEST_ID_HEADER,
"client-1234");
request.setAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE,
"outer-attribute");
var response = new MockHttpServletResponse();
FilterChain chain = (servletRequest, servletResponse) -> {
assertThat(servletRequest.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE))
.isEqualTo("client-1234");
assertThat(MDC.get("requestId"))
.isEqualTo("client-1234");
throw new ServletException("downstream failed");
};
String beforeTest = MDC.get("requestId");
MDC.put("requestId", "outer-mdc");
try {
assertThatThrownBy(
() -> filter.doFilter(request, response, chain))
.isInstanceOf(ServletException.class)
.hasMessage("downstream failed");
assertThat(request.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE))
.isEqualTo("outer-attribute");
assertThat(MDC.get("requestId")).isEqualTo("outer-mdc");
assertThat(response.getHeader(
RequestTraceFilter.REQUEST_ID_HEADER))
.isEqualTo("client-1234");
} finally {
if (beforeTest == null) {
MDC.remove("requestId");
} else {
MDC.put("requestId", beforeTest);
}
}
}
@Test
void 기존_context가_없으면_filter_종료_뒤_모두_제거한다()
throws Exception {
var filter = new RequestTraceFilter();
var request = new MockHttpServletRequest("GET", "/posts");
request.addHeader(
RequestTraceFilter.REQUEST_ID_HEADER,
"client-5678");
var response = new MockHttpServletResponse();
FilterChain chain = (servletRequest, servletResponse) -> {
assertThat(servletRequest.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE))
.isEqualTo("client-5678");
assertThat(MDC.get("requestId"))
.isEqualTo("client-5678");
};
String beforeTest = MDC.get("requestId");
MDC.remove("requestId");
try {
filter.doFilter(request, response, chain);
assertThat(request.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE)).isNull();
assertThat(MDC.get("requestId")).isNull();
} finally {
if (beforeTest != null) {
MDC.put("requestId", beforeTest);
}
}
}
@Test
void 같은_request의_ERROR_dispatch는_ID를_재사용하고_각각_복원한다()
throws Exception {
var filter = new RequestTraceFilter();
var request = new MockHttpServletRequest("GET", "/posts/41");
request.addHeader(
RequestTraceFilter.REQUEST_ID_HEADER,
"client-9012");
request.setAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE,
"outer-attribute");
var response = new MockHttpServletResponse();
var observed = new ArrayList<String>();
FilterChain chain = (servletRequest, servletResponse) -> {
observed.add((String) servletRequest.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE));
assertThat(MDC.get("requestId"))
.isEqualTo("client-9012");
};
String beforeTest = MDC.get("requestId");
MDC.put("requestId", "outer-mdc");
try {
filter.doFilter(request, response, chain);
assertThat(request.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE))
.isEqualTo("outer-attribute");
assertThat(MDC.get("requestId")).isEqualTo("outer-mdc");
request.setDispatcherType(DispatcherType.ERROR);
request.setAttribute(
RequestDispatcher.ERROR_REQUEST_URI,
"/posts/41");
filter.doFilter(request, response, chain);
assertThat(observed)
.containsExactly("client-9012", "client-9012");
assertThat(request.getAttribute(
RequestTraceFilter.REQUEST_ID_ATTRIBUTE))
.isEqualTo("outer-attribute");
assertThat(MDC.get("requestId")).isEqualTo("outer-mdc");
} finally {
if (beforeTest == null) {
MDC.remove("requestId");
} else {
MDC.put("requestId", beforeTest);
}
}
}
}request attribute inside chain = client-1234
MDC inside chain = client-1234
downstream exception = propagated
response header = client-1234
request attribute after filter = outer-attribute
MDC after filter = outer-mdc
empty context after second run = null
REQUEST dispatch id = client-9012
ERROR dispatch id = client-9012
public context after each dispatch = restored차단 필터를 테스트할 때는 MockFilterChain.getRequest()가 null인지 확인해, 체인이 호출되지 않았다는 것을 보여 줍니다. 정상적으로 인증된 요청은 정확히 한 번 통과해야 합니다. 응답 상태가 401이라는 것만 확인하면, 컨트롤러가 실행된 뒤 우연히 401을 썼는지 구별할 수 없습니다.
반복해서 읽을 수 있는 요청 본문
감사 로그를 남기려고 필터에서 request.getInputStream()을 먼저 읽으면, 컨트롤러의 @RequestBody가 빈 스트림을 받을 수 있습니다. ContentCachingRequestWrapper도 본문을 미리 읽어 두는 도구가 아니라, 하위 단계가 본문을 소비한 뒤에야 캐시가 채워진다는 점을 알아야 합니다. 응답 캐싱 래퍼를 쓴다면 copyBodyToResponse()를 빠뜨리지 않아야 클라이언트가 빈 본문을 받지 않습니다.
비밀번호와 토큰, 멀티파트의 바이너리를 통째로 로그에 남기지 않습니다. Content-Type과 크기, 허용한 필드 이름처럼 필요한 메타데이터만 모으고, 표본 추출과 상한을 둡니다. 본문을 보겠다고 메모리에 무제한 복사하면 큰 업로드 한 번이 힙 압박으로 이어집니다.
정리
- 필터는 MVC 핸들러가 선택되기 전에 실행되며, MDC 같은 스레드 문맥은
finally에서 이전 값으로 되돌리거나 지웁니다. - 클라이언트가 보낸 요청 ID는 길이와 문자 집합을 제한해 받고, 요청 ID를 인증 수단으로 쓰지 않습니다.
- 요청을 막는 필터는 상태와 본문을 직접 완성한 뒤 체인을 호출하지 않고 돌아오며, 테스트는 체인이 호출되지 않았는지까지 확인합니다.
OncePerRequestFilter의 ERROR와 ASYNC 처리 여부는 제외 훅과 컨테이너 등록을 함께 보고 정하며, 필터의 순서는 명시적으로 선언합니다.- 필터에서 요청 본문을 미리 읽으면 컨트롤러가 빈 본문을 받을 수 있으므로, 본문 로그는 메타데이터와 상한으로 제한합니다.
연습 문제
1. 지연 시간과 상태를 기록하는 필터
게시판 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만 넘기고, 원시 쿼리의 민감한 정보는 뺍니다. 세 경로마다 기록된 이벤트가 정확히 하나인지, 예외 경로에서는 예외가 필터 밖으로 전파되는지도 함께 확인합니다.
2. 요청의 수명과 디스패치의 문맥
새 모의 요청의 허용된 헤더는 client-2468, 기존 공개 속성은 outer-attribute, 기존 MDC는 outer-mdc입니다. 하위 체인이 ServletException을 던지며 첫 디스패치가 끝납니다. 같은 요청 객체의 헤더만 client-1357로 바꾸고 이어지는 ERROR 디스패치를 모의로 호출하면, 두 번째 체인 안의 ID와 각 디스패치가 끝난 뒤의 공개 속성과 MDC는 무엇인가요? 응답 헤더도 되돌리는 대상인지 구분하세요.
해설 보기
첫 체인 안에서는 client-2468이고, 예외는 필터 밖으로 전파됩니다. finally가 공개 속성과 MDC를 각각 outer-attribute와 outer-mdc로 되돌리지만, private에 보관한 안정된 ID는 남겨 둡니다. 두 번째 ERROR 호출도 이 검증된 private 값을 먼저 찾아 client-2468을 게시하므로, 바뀐 헤더가 새 ID를 만들지 않습니다. 두 번째가 끝난 뒤에도 두 이전 값으로 되돌아갑니다.
응답 헤더는 공개 속성과 MDC처럼 되돌리는 대상이 아니므로 필터가 게시한 client-2468이 남습니다. 별도의 새 요청 객체라면 이전의 private ID를 함께 쓰지 않으므로, 그 요청의 허용된 헤더를 새로 씁니다. 예외가 난 직후의 응답 상태와 최종 ERROR 핸들러가 만든 상태가 같다고 보장할 수도 없습니다. 로그의 response.getStatus()는 읽은 시점의 값이며, 예외를 보았다는 이유로 아직 쓰이지 않은 500을 기록하지 않습니다.
다음 글 인터셉터와 인자 해석에서는 MVC 핸들러를 아는 인터셉터와 컨트롤러 인자를 만드는 HandlerMethodArgumentResolver로, 인증 문맥을 더 좁은 범위로 전달합니다.