서블릿 필터
요청 추적·인증 필터를 구현하고 순서·정리·비동기·오류 디스패치의 경계를 검증합니다.
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는 안정적으로 남습니다. |
도식은 요청 ID를 응답 헤더·요청 속성·MDC에 같은 값으로 게시한 뒤 하위 MVC를 호출하는 순서를 보여 줍니다. 하위 코드가 예외를 던져도 finally가 기존 공개 속성과 MDC를 원상복구하고, 요청 수명 전용 private ID는 같은 요청의 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의 직접 mock 호출을 검사합니다.
컨텍스트 경로가 /academy라면 원 요청 URI는 /academy/api/private/posts가 되어 현재 startsWith("/api/private/")가 맞지 않습니다. 원 코드를 그대로 따라갈 때는 루트 컨텍스트 조건을 유지하세요. 다른 배포에서는 컨텍스트 기준 경로·dispatcher·매핑 규칙을 일관되게 적용한 별도 보안 구성을 만들고 /api/private, /api/private/, /api/private/posts, 공개 경로와 내부 ERROR 경로를 각각 확인해야 합니다. 문자열 접두사 하나가 모든 보호 엔드포인트를 보장한다고 일반화하지 않습니다.
실제 API에서는 JSON 문자열을 손으로 조립하기보다 ObjectMapper와 공통 ProblemDetail 직렬화기를 사용합니다.
예제는 필터가 MVC 예외 핸들러 밖에서 응답을 책임진다는 점을 드러냅니다.
HTML과 API의 실패 표현이 다르면 경로 문자열보다 콘텐츠 협상 또는 별도 필터 체인을 고려합니다.
필터 순서·디스패처 유형
여러 필터가 있으면 트레이싱이 가장 바깥에서 인증 필터의 실패까지 관찰하고, 보안 헤더가 오류 응답에도 붙는 순서를 정할 수 있습니다.
컴포넌트 스캔의 우연한 순서에 기대지 않고 FilterRegistrationBean 또는 Spring 보안 필터 체인에서 순서를 선언합니다.
OncePerRequestFilter의 “한 번”은 논리 요청의 모든 디스패치를 통틀어 무조건 한 번이라는 뜻이 아닙니다.
REQUEST 뒤 비동기, 오류 디스패치가 생길 수 있고 기본 제외 정책을 재정의할 수 있습니다.
shouldNotFilterErrorDispatch() = false는 이 필터가 ERROR 처리를 건너뛰지 않겠다는 선언입니다. 컨테이너가 ERROR dispatch에 이 필터를 호출하도록 등록하는 설정까지 대신하지는 않습니다. OncePerRequestFilter의 API는 dispatcher 등록과 제외 훅을 별도 조건으로 설명합니다. 현재 ASYNC 제외 훅은 기본값을 유지하므로 임의의 비동기 작업 스레드에 MDC가 자동 전달된다고 판단하지 않습니다.
오류 페이지에도 추적 컨텍스트가 필요하면 디스패처별 생명 주기와 MDC 전달을 검토합니다.
| 디스패처 | 발생 시점 | 필터 질문 |
|---|---|---|
| REQUEST | 최초 클라이언트 요청 | 기본 전처리 실행 |
| 비동기 | 비동기 작업 재개 | 스레드 컨텍스트 전달 여부 |
| 오류 | 컨테이너 오류 재전송 | 중복 로그·인증 처리 여부 |
| 포워드 | 내부 포워드 | URI 기준 정책 변화 여부 |
오류 디스패치의 URI와 속성은 최초 요청과 다를 수 있습니다.
필터가 상태를 다시 덮거나 인증 리다이렉트 반복을 만들지 않게 오류 디스패치 제외 여부를 테스트합니다.
다음 절의 서블릿 오류 처리에서 이 흐름을 더 자세히 다룹니다.
필터 경로 검증
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, 크기, 허용한 필드 이름처럼 필요한 메타데이터만 수집하고 표본 추출과 상한을 둡니다.
본문 관찰 가능성 때문에 메모리에 무제한 복사하면 큰 업로드 한 번이 힙 압박으로 이어집니다.
연습 문제
게시판 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에서 25 ms로 이동해 실제 대기 없이 지속 시간을 고정합니다.
로그 수집기에는 메서드, 정규화된 경로, 상태, 경과 시간, 요청 ID만 전달하고 원시 쿼리의 민감 정보는 제외합니다.
독립 확인
새 mock 요청의 허용 헤더는 client-2468, 기존 공개 속성은 outer-attribute, 기존 MDC는 outer-mdc입니다. 하위 chain이 ServletException을 던진 뒤 첫 dispatch가 끝납니다. 같은 요청 객체의 헤더만 client-1357로 바꾸고, 등록된 후속 ERROR dispatch를 모의 호출하면 두 번째 chain 안의 ID와 각 dispatch 뒤의 공개 속성·MDC는 무엇인가요? 응답 헤더가 복원 대상인지도 구분하세요.
요청 수명과 dispatch 문맥을 나누는 풀이
첫 chain 안에서는 client-2468이며 예외는 필터 밖으로 전파됩니다. finally가 공개 속성과 MDC를 각각 outer-attribute, outer-mdc로 복원하지만 private 안정 ID는 남겨 둡니다. 두 번째 ERROR 호출도 이 검증된 private 값을 먼저 찾아 client-2468을 게시하므로 변경한 헤더가 새 ID를 만들지 않습니다. 두 번째 종료 뒤에도 두 이전 값으로 복원됩니다.
응답 헤더는 공개 속성과 MDC의 복원 대상이 아니어서 필터가 게시한 client-2468입니다. 별도 새 요청 객체라면 이전 private ID를 공유하지 않으므로 그 요청의 허용 헤더를 새로 사용합니다. 예외 발생 직후의 응답 상태와 최종 ERROR handler가 만든 상태도 같다고 보장할 수 없습니다. 로그의 response.getStatus()는 관찰 시점의 값이며, 예외를 읽었다는 이유만으로 아직 쓰이지 않은 500 응답을 관찰한 것으로 기록하지 않습니다.
다음 문서에서는 MVC 핸들러를 아는 Interceptor와 컨트롤러 인자를 만드는 HandlerMethodArgumentResolver로 인증 문맥을 더 좁게 전달합니다.