MVC 요청 관찰
Spring Boot 4.1 MVC의 REQUEST 디스패치에서 내부 요청 ID와 MDC 수명 주기를 안전하게 관리하고, 로그·메트릭·트레이스의 증거 범위를 구분합니다.
Spring MVC 요청을 진단할 때 먼저 필요한 것은 로그를 많이 남기는 일이 아니라, 같은 요청에서 나온 기록을 서로 연결하는 일입니다. 이 글에서는 애플리케이션이 직접 만든 requestId를 응답 헤더와 MDC(Mapped Diagnostic Context)에 함께 넣습니다. 클라이언트가 보낸 X-Request-Id는 믿지 않고, 필터가 실행될 때마다 새 내부 ID를 정확히 한 번 만듭니다. 같은 스레드에서 실행되는 MVC와 핸들러의 로그는 이 MDC 값을 함께 출력할 수 있습니다. 먼저 세 식별자의 역할을 나눕니다.
| 식별자 | 만드는 쪽과 범위 | 이 글의 규칙 |
|---|---|---|
| 외부 상관 후보 | 브라우저나 호출자, 프록시가 보낸 값 | 기본 구현에서는 읽지 않습니다. 필요하면 별도 필드로 보존하되, 믿을 수 있는 값인지부터 확인합니다. |
requestId | 애플리케이션의 REQUEST 디스패치 하나 | 애플리케이션이 UUID를 새로 만들어 응답 헤더와 MDC에 씁니다. |
traceId·spanId | 분산 추적 시스템의 트레이스와 작업 구간 | 계측 라이브러리가 관리합니다. requestId로 대신하거나 두 값을 같은 필드에 섞지 않습니다. |
TRUSTED CORRELATION · POSITIVE CHECKPOINTS
양성 증거를 요청 경계별로 연결한다
외부 식별자는 trust boundary에서 분리하고, 마지막으로 실제 확인된 증거부터 다음 조사 범위를 좁힙니다.
점선은 외부 식별자를 내부 requestId와 분리하는 경계입니다. 마지막으로 확인한 증거 다음부터 조사하되, 로그가 없다는 이유만으로 다음 경계의 미실행을 단정하지 않습니다.
01 · TRUST BOUNDARY
외부 요청 ID는 신뢰된 내부 ID가 아니다
문법 검사는 로그 주입을 줄일 뿐 진위나 유일성을 증명하지 않습니다.
이 fixture는 외부 헤더를 읽지 않습니다. 연습에서 외부 상관 값을 유지하려면 신뢰된 ingress의 제거·덮어쓰기와 우회 차단을 확인한 뒤 externalCorrelationId라는 별도 필드로 제한해 보존합니다.
02 · FILTER CHECKPOINT
Filter가 내부 requestId와 스레드 범위를 만든다
내부 requestId는 응답과 요청 처리 로그를 연결합니다.
분산 추적의 traceId와 spanId는 별도
전파 규칙을 가진 다른 식별자입니다.
03 · MVC / APPLICATION
route, selected handler, outcome은 서로 다른 양성 증거다
Spring MVC와 application 내부 알고리즘은 펼치지 않습니다. 이 문서에서는 요청이 해당 경계를 통과했다는 관찰 증거만 연결합니다.
04 · LAST POSITIVE CHECKPOINT
마지막으로 실제 확인된 증거부터 조사한다
다음 로그가 없다는 사실은 범위를 좁히는 단서일 뿐 미실행의 증명이 아닙니다. 설정, sampling, 제어 흐름도 함께 확인해야 합니다.
이 sequence는 REQUEST 경계의 관찰 순서만 보여 줍니다. 신뢰 정책과 식별자 종류를 섞지 않고, 부재 추론보다 실제로 확인된 증거를 우선합니다.
그림은 요청이 외부 요청을 처음 받는 프록시나 로드 밸런서 같은 진입 지점인 인그레스(ingress)와 RequestTraceFilter, Spring MVC, 핸들러와 애플리케이션을 지나며 접근 이벤트, 내부 UUID, 핸들러 선택, 처리 이벤트를 차례로 남기는 모습을 보여 줍니다. 첫 구간의 점선은 외부 ID를 읽지 않고 내부 requestId를 따로 만드는 지점입니다. MVC 안의 리졸버들은 일부러 펼치지 않았습니다. 마지막으로 확인된 기록은 조사할 범위를 줄여 주지만, 다음 로그가 없다는 사실만으로 다음 코드가 실행되지 않았다고 결론 내릴 수는 없습니다.
실행 기준을 정합니다
예제는 Java 25와 Spring Boot 4.1.2의 BOM, Spring Framework 7.0.10, JUnit 6.0.3을 하나의 의존성 그래프로 씁니다. 버전을 적지 않은 모듈은 모두 같은 Boot BOM에서 버전을 받습니다.
rootProject.name = 'mvc-request-observation'plugins {
id 'java'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation platform(
'org.springframework.boot:spring-boot-dependencies:4.1.2')
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation platform(
'org.springframework.boot:spring-boot-dependencies:4.1.2')
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}이 예제는 실행 가능한 Boot 애플리케이션을 패키징하지 않으므로 Boot Gradle 플러그인을 추가하지 않고, Boot 4.1.2 BOM을 플랫폼으로 가져와 모듈 버전을 한곳에서 맞춥니다. spring-boot-starter-web 같은 예전 이름을 우회로로 남기지 않고, Boot 4의 서블릿 MVC 스타터인 spring-boot-starter-webmvc를 씁니다.
내부 요청 ID와 MDC 수명 주기
RequestTraceFilter는 외부 요청 헤더를 읽지 않습니다. Supplier<UUID>가 돌려준 UUID만 쓰므로 요청 ID의 길이와 문자 집합도 정해져 있습니다.
package board.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;
public final class RequestTraceFilter extends OncePerRequestFilter {
public static final String RESPONSE_HEADER = "X-Request-Id";
static final String MDC_KEY = "requestId";
private static final Logger log =
LoggerFactory.getLogger(RequestTraceFilter.class);
private final Supplier<UUID> idGenerator;
public RequestTraceFilter(Supplier<UUID> idGenerator) {
this.idGenerator = Objects.requireNonNull(idGenerator);
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
var requestId = Objects.requireNonNull(
idGenerator.get(),
"idGenerator must return a UUID")
.toString();
var dispatcher = request.getDispatcherType();
var previousRequestId = MDC.get(MDC_KEY);
response.setHeader(RESPONSE_HEADER, requestId);
MDC.put(MDC_KEY, requestId);
try {
try {
chain.doFilter(request, response);
} catch (ServletException
| IOException
| RuntimeException
| Error failure) {
log.debug(
"mvc.request outcome=thrown dispatcher={} requestId={}",
dispatcher,
requestId);
throw failure;
}
log.debug(
"mvc.request outcome=returned dispatcher={} dispatchStatus={} requestId={}",
dispatcher,
response.getStatus(),
requestId);
} finally {
if (previousRequestId == null) {
MDC.remove(MDC_KEY);
} else {
MDC.put(MDC_KEY, previousRequestId);
}
}
}
}응답 헤더는 체인에 들어가기 전에 설정하므로 컨트롤러와 응답이 같은 내부 ID를 봅니다. MDC도 chain.doFilter 전에 넣고 가장 바깥의 finally에서 정리합니다.
여기서 MDC.putCloseable만 쓰지 않은 데는 이유가 있습니다. 그 close는 키를 지우지만, 필터에 들어오기 전에 같은 키에 값이 있었다면 그 값을 되돌려 놓지 않습니다. 이 구현은 이전 값을 먼저 기억해 두었다가 finally에서 복원하고, 이전 값이 없을 때만 키를 지웁니다.
MDC의 기본 단위는 요청 객체가 아니라 지금 실행 중인 스레드입니다. 같은 작업 스레드가 요청 A와 B를 차례로 처리하고 들어오기 전의 값이 모두 없었다면, A를 처리하는 동안에는 A의 ID를 쓰고 반환 뒤 지운 다음, B를 처리하는 동안 B의 ID를 씁니다. 이 필터는 자신이 바꾼 requestId 키만 복원하거나 지우며, 다른 구성 요소가 넣은 MDC 키까지 모두 지우지는 않으므로 다른 키의 수명은 그 키를 넣은 쪽이 관리해야 합니다.
정상 반환 로그의 dispatchStatus는 지금의 REQUEST 디스패치가 반환한 시점의 상태입니다. 반대로 체인이 예외를 던지면 그 뒤에 예외 리졸버나 컨테이너가 상태를 정할 수 있으므로, 최종 상태를 추측하지 않고 outcome=thrown만 기록합니다. 잡은 예외 객체는 바꾸거나 감싸지 않고 그대로 다시 던집니다.
로그 필드는 outcome, dispatcher, 정수 상태, 내부 UUID로 제한했습니다. 원시 경로와 쿼리 문자열, 요청 본문, Authorization 헤더, 쿠키, 예외 메시지는 이 필터의 로그에 넣지 않습니다.
MDC 키는 두 출구에서 모두 복원하거나 제거한다
이전 MDC 보관·UUID 게시·chain 정상/예외·finally 복원/제거로 같은 thread 문맥이 변한다.
REQUEST DISPATCH · NORMAL / THROWN CONVERGENCE
MDC 키는 두 출구에서 모두 복원하거나 제거한다
정상 반환과 예외 재전파는 cleanup에서 합류하지만, 관찰 가능한
status와 outcome=thrown은 같은 주장으로 합치지 않습니다.
예외도 finally를 지난 뒤 원래 객체로 전달합니다. 이전 값이 있으면 복원하고 없으면 키를 제거합니다. 정상 반환 자체는 최종 사용자 요청 성공을 보장하지 않습니다.
-
REQUEST ONLY
이 보장은
DispatcherType.REQUEST의 동기 경로다ASYNC와 ERROR dispatch는 기본 흐름 밖이며 dispatcher 등록, 안전한 ID 재부착과 스레드별 정리 정책이 따로 필요합니다.
-
SAVE THEN PUT
내부 ID를 정하고 기존 MDC 값을 먼저 저장한다
기존 MDC를
previous에 저장한 뒤 새requestId를 넣고chain.doFilter를 호출합니다. -
TWO OUTCOMES
반환 경로의 status와 thrown 경로의 outcome을 구분한다
정상 반환 또는 내부에서 처리된 오류는 현재 dispatch의 status를 관찰합니다. 밖으로 던져진 예외는
outcome=thrown으로 보존하고 status를 최종값이라고 부르지 않습니다. -
COMMON CLEANUP
이전 값이 있으면 복원하고 없으면 키를 제거한다
MDC.putCloseable(...).close()는 key를 제거할 뿐 이전 값을 복원하지 않습니다. 따라서 독점 키 계약이 없다면 명시적으로 save와 restore를 수행해야 합니다. -
PRESERVE OUTCOME
cleanup 뒤 원래 응답 또는 원래 예외를 전달한다
정리 과정이 chain의 정상 반환과 예외 재전파 의미를 바꾸면 안 됩니다.
close는 remove이지 restore가 아닙니다. REQUEST 경로의 두 결과는 공통 cleanup에서 합류하고, ASYNC와 ERROR dispatch는 별도 정책으로 남깁니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 응답 header와 MDC에 내부 UUID를 넣고 chain을 호출합니다. 정상 반환은 현재 dispatchStatus, 예외는 outcome thrown만 기록합니다. finally에서 이전 MDC를 복원하거나 키를 제거한 뒤 원래 chain 결과를 전달합니다.
- 데스크톱과 같은 네 노드·네 관계이다. 내부 UUID를 헤더와 MDC에 넣고 chain을 호출한 뒤 정상 반환과 예외 발생이 분기한다. returned는 현재 dispatchStatus만 관찰하고 thrown은 최종 상태를 추측하지 않는다. 두 결과는 공통 finally에서 이전 값을 복원하거나 키를 제거한 뒤 원래 결과를 전달한다. 오른쪽 선은 예외 선택, 왼쪽 선은 정상 결과의 정리 경로이며 세로로 놓인 두 결과 노드를 연달아 실행하지 않는다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| UUID와 이전 MDC 내부 UUID를 header·MDC에 게시하며 이전 값을 보관합니다. | MDC: 내부 UUID · 이전 값: 보관 | 하위 처리에 현재 요청 문맥이 생깁니다. |
| chain 결과 기록 정상은 현재 dispatchStatus, 예외는 outcome thrown만 기록합니다. | 정상: dispatchStatus · 예외: thrown | 실제 chain 결과로 기록을 구분합니다. |
| finally 복원 이전 MDC가 있으면 복원하고 없으면 키를 제거합니다. | 이전 있음: 복원 · 이전 없음: remove | 두 출구 모두 worker 문맥을 원래대로 돌립니다. |
MDC 키는 두 출구에서 모두 복원하거나 제거한다
이전 MDC 보관·UUID 게시·chain 정상/예외·finally 복원/제거로 같은 thread 문맥이 변한다.
REQUEST DISPATCH · NORMAL / THROWN CONVERGENCE
MDC 키는 두 출구에서 모두 복원하거나 제거한다
정상 반환과 예외 재전파는 cleanup에서 합류하지만, 관찰 가능한
status와 outcome=thrown은 같은 주장으로 합치지 않습니다.
예외도 finally를 지난 뒤 원래 객체로 전달합니다. 이전 값이 있으면 복원하고 없으면 키를 제거합니다. 정상 반환 자체는 최종 사용자 요청 성공을 보장하지 않습니다.
-
REQUEST ONLY
이 보장은
DispatcherType.REQUEST의 동기 경로다ASYNC와 ERROR dispatch는 기본 흐름 밖이며 dispatcher 등록, 안전한 ID 재부착과 스레드별 정리 정책이 따로 필요합니다.
-
SAVE THEN PUT
내부 ID를 정하고 기존 MDC 값을 먼저 저장한다
기존 MDC를
previous에 저장한 뒤 새requestId를 넣고chain.doFilter를 호출합니다. -
TWO OUTCOMES
반환 경로의 status와 thrown 경로의 outcome을 구분한다
정상 반환 또는 내부에서 처리된 오류는 현재 dispatch의 status를 관찰합니다. 밖으로 던져진 예외는
outcome=thrown으로 보존하고 status를 최종값이라고 부르지 않습니다. -
COMMON CLEANUP
이전 값이 있으면 복원하고 없으면 키를 제거한다
MDC.putCloseable(...).close()는 key를 제거할 뿐 이전 값을 복원하지 않습니다. 따라서 독점 키 계약이 없다면 명시적으로 save와 restore를 수행해야 합니다. -
PRESERVE OUTCOME
cleanup 뒤 원래 응답 또는 원래 예외를 전달한다
정리 과정이 chain의 정상 반환과 예외 재전파 의미를 바꾸면 안 됩니다.
close는 remove이지 restore가 아닙니다. REQUEST 경로의 두 결과는 공통 cleanup에서 합류하고, ASYNC와 ERROR dispatch는 별도 정책으로 남깁니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 응답 header와 MDC에 내부 UUID를 넣고 chain을 호출합니다. 정상 반환은 현재 dispatchStatus, 예외는 outcome thrown만 기록합니다. finally에서 이전 MDC를 복원하거나 키를 제거한 뒤 원래 chain 결과를 전달합니다.
- 데스크톱과 같은 네 노드·네 관계이다. 내부 UUID를 헤더와 MDC에 넣고 chain을 호출한 뒤 정상 반환과 예외 발생이 분기한다. returned는 현재 dispatchStatus만 관찰하고 thrown은 최종 상태를 추측하지 않는다. 두 결과는 공통 finally에서 이전 값을 복원하거나 키를 제거한 뒤 원래 결과를 전달한다. 오른쪽 선은 예외 선택, 왼쪽 선은 정상 결과의 정리 경로이며 세로로 놓인 두 결과 노드를 연달아 실행하지 않는다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| UUID와 이전 MDC 내부 UUID를 header·MDC에 게시하며 이전 값을 보관합니다. | MDC: 내부 UUID · 이전 값: 보관 | 하위 처리에 현재 요청 문맥이 생깁니다. |
| chain 결과 기록 정상은 현재 dispatchStatus, 예외는 outcome thrown만 기록합니다. | 정상: dispatchStatus · 예외: thrown | 실제 chain 결과로 기록을 구분합니다. |
| finally 복원 이전 MDC가 있으면 복원하고 없으면 키를 제거합니다. | 이전 있음: 복원 · 이전 없음: remove | 두 출구 모두 worker 문맥을 원래대로 돌립니다. |
그림처럼 필터는 내부 UUID를 정하고 이전 MDC 값을 저장한 뒤 헤더와 MDC를 설정하고 체인을 호출합니다. 정상 반환이면 현재의 dispatchStatus를, 예외면 outcome=thrown만 기록하며, 두 경로는 공통 finally에서 이전 값을 복원하거나 키를 지운 뒤 원래의 결과를 그대로 넘깁니다. 두 결과는 하나만 선택되는 분기입니다. 이 글이 다루는 범위는 동기 REQUEST 디스패치이며, 비동기 작업으로 넘어간 MDC는 저절로 전파되지 않습니다. ASYNC와 ERROR 재디스패치에서 같은 ID를 이어 가려면 요청 속성 저장과 스레드 컨텍스트 전파, 재디스패치마다 로그를 중복으로 남기지 않는 규칙을 따로 설계해야 합니다.
예를 들어 필터에 들어오기 직전 MDC의 requestId가 outer-request이고, UUID 공급자는 테스트의 INTERNAL_ID를 돌려준다고 가정합니다. UUID 생성과 헤더 설정, MDC와 로그 기록, 정리 자체는 성공하고 ASYNC와 ERROR 디스패치는 시작되지 않습니다. 체인이 ServletException 객체를 던지면 다음 순서로 진행됩니다.
previousRequestId에outer-request를 저장하고, 응답 헤더와 체인 안의 MDC에는 새 내부 UUID를 넣습니다.- 체인의 예외를 잡은 로그는 새 내부 UUID와
outcome=thrown을 기록합니다. 이 필터가 최종 HTTP 상태를 500으로 정했다고 읽지 않습니다. - 같은 예외를 다시 던지는 도중에도
finally가 먼저 실행되어 MDC의requestId를outer-request로 되돌립니다. 이finally는 응답 헤더를 되돌리지 않으므로 헤더에는 새 내부 UUID가 남습니다.
돌아온 뒤 MDC와 응답 헤더의 값이 다른 것은 수명이 다르기 때문입니다. 헤더는 이번 디스패치의 내부 ID이고, 복원된 MDC는 들어오기 전의 호출 문맥입니다. 아래 테스트는 이전 값 복원과 예외 재전파를 각각 검사하며, 두 조건을 함께 둔 이 사례는 코드의 순서를 따라 계산한 예상입니다.
등록 범위와 순서
등록 정보도 기본값에 맡기지 않습니다. RequestTraceFilter에는 @Component가 없으므로 스캔으로 등록되지 않고, 아래처럼 FilterRegistrationBean으로 감싸 직접 등록합니다. 이 방식에서는 필터 객체를 만드는 코드 옆에서 이름, 순서, URL 패턴, 디스패처 타입을 함께 정할 수 있습니다. 운영 코드는 생성자에 UUID::randomUUID를 넘기고, 테스트는 같은 자리에 고정된 UUID를 돌려주는 공급자를 넘깁니다.
package board.config;
import board.web.RequestTraceFilter;
import jakarta.servlet.DispatcherType;
import java.util.UUID;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class WebFilterConfig {
@Bean
public FilterRegistrationBean<RequestTraceFilter>
requestTraceFilter() {
var registration = new FilterRegistrationBean<>(
new RequestTraceFilter(UUID::randomUUID));
registration.setName("requestTraceFilter");
registration.setOrder(10);
registration.setDispatcherTypes(DispatcherType.REQUEST);
registration.addUrlPatterns("/*");
return registration;
}
}이 빈은 이름 requestTraceFilter, 순서 10, URL 패턴 /*, 디스패처 타입 REQUEST를 명시합니다. 숫자 10은 이 애플리케이션의 등록 안에서 의도를 드러내지만, Spring Security의 FilterChainProxy보다 실제로 앞인지 뒤인지를 그 숫자 하나로 알 수는 없습니다. 보안 필터 체인과의 상대 순서가 요구 사항이라면 실제 애플리케이션 컨텍스트와 서블릿 컨테이너에서 확인해야 합니다.
OncePerRequestFilter도 기본적으로 ASYNC와 ERROR 디스패치를 건너뜁니다. 여기에 등록의 디스패처 타입까지 REQUEST로 정해, 코드와 배포 설정이 같은 수명 주기를 가리키게 했습니다.
더 깊이: 순서 10은 보안 필터 체인의 앞일까 뒤일까
등록 순서는 값이 작을수록 먼저 실행됩니다. 그리고 Spring Boot 4.1.2의 속성 목록에서 서블릿 애플리케이션의 보안 필터 체인 순서를 정하는 spring.security.filter.order의 기본값은 -100입니다. 따라서 Spring Security를 추가하고 이 값을 바꾸지 않았다면 보안 체인이 이 필터보다 먼저 실행됩니다. 보안 체인이 인증 실패 등으로 요청을 그 자리에서 끝내면 뒤의 필터는 실행되지 않으므로, 그런 401이나 403 응답에는 이 필터가 만드는 X-Request-Id와 MDC 값이 없습니다. 거절된 요청까지 같은 ID로 추적해야 한다면 이 필터의 순서를 -100보다 작게 두어야 합니다.
Boot의 서블릿 웹 문서는 필터 빈은 보통 순서를 정하지 않아도 안전하다고 하면서도, 순서를 정할 때 지킬 점 두 가지를 적습니다. 요청 본문을 읽는 필터를 Ordered.HIGHEST_PRECEDENCE에 두면 애플리케이션의 문자 인코딩 설정과 어긋날 수 있으므로 피하고, 요청을 감싸는 필터는 OrderedFilter.REQUEST_WRAPPER_FILTER_MAX_ORDER 이하의 순서로 두라는 것입니다. 실제 순서를 확인하려면 logging.level.web=debug로 web 로깅 그룹을 디버그 수준으로 켭니다. 그러면 시작할 때 등록된 모든 필터의 순서와 URL 패턴이 로그에 남으므로, 숫자를 추측하지 않고 그 목록으로 상대 순서를 확인할 수 있습니다.
로컬 로그 설정
MDC에 값을 넣는 것만으로 콘솔에 값이 나타나지는 않습니다. 로그 패턴이 그 키를 출력해야 합니다.
logging:
pattern:
level: "%5p requestId=%X{requestId}"
level:
board.web.RequestTraceFilter: DEBUG
org.springframework.web.servlet.DispatcherServlet: DEBUG
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: DEBUGlogging.pattern.level은 같은 스레드에서 필터 체인 안에 기록된 프레임워크와 핸들러 로그의 레벨 부분에 requestId를 출력합니다. 애플리케이션 시작 로그처럼 요청 바깥에서 MDC가 없는 이벤트에서는 값이 비어 있습니다.
이 파일은 local 프로필용입니다. org.springframework.web 전체나 TRACE 전체를 열지 않고, 요청 진입과 핸들러 매핑, 이 필터의 요약 이벤트에 필요한 범주만 DEBUG로 제한합니다. 운영 환경에 구조화 로그 인코더가 있다면 같은 키를 별도의 JSON 필드로 내보내고, 샘플링과 보존 기간, 접근 권한은 운영 정책으로 확인합니다.
테스트로 확인하는 범위
다음 테스트 파일은 컨트롤러의 응답 타입과 로그 캡처 도우미까지 모두 정의합니다. RestTestClient의 독립형 MVC 서버 구성에 필터를 직접 추가하고, 나머지 수명 주기는 필터와 등록 빈을 좁게 검사합니다.
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import board.config.WebFilterConfig;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.ServletException;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
class RequestTraceFilterTest {
private static final UUID INTERNAL_ID = UUID.fromString(
"123e4567-e89b-12d3-a456-426614174000");
@AfterEach
void clearMdc() {
MDC.clear();
}
@RestController
static class PingController {
@GetMapping("/api/ping")
TraceResponse ping() {
return new TraceResponse(MDC.get(RequestTraceFilter.MDC_KEY));
}
}
record TraceResponse(String requestId) {}
@Test
void internalIdIsSharedAndClientSpoofIsIgnored() {
var filter = new RequestTraceFilter(() -> INTERNAL_ID);
var client = RestTestClient
.bindToController(new PingController())
.configureServer(builder -> builder.addFilters(filter))
.build();
try (var logs = CapturedFilterLogs.open()) {
client.get()
.uri("/api/[email protected]")
.header(RequestTraceFilter.RESPONSE_HEADER, "client-spoof")
.header("Authorization", "Bearer secret-token")
.exchange()
.expectStatus().isOk()
.expectHeader()
.valueEquals(
RequestTraceFilter.RESPONSE_HEADER,
INTERNAL_ID.toString())
.expectBody()
.jsonPath("$.requestId")
.isEqualTo(INTERNAL_ID.toString());
assertThat(MDC.get(RequestTraceFilter.MDC_KEY)).isNull();
assertThat(logs.messages()).containsExactly(
"mvc.request outcome=returned dispatcher=REQUEST "
+ "dispatchStatus=200 requestId="
+ INTERNAL_ID);
assertThat(logs.messages().getFirst())
.doesNotContain(
"client-spoof",
"[email protected]",
"secret-token",
"/api/ping");
}
}
@Test
void priorMdcValueIsRestored() throws Exception {
var filter = new RequestTraceFilter(() -> INTERNAL_ID);
var request = request();
var response = new MockHttpServletResponse();
var observed = new AtomicReference<String>();
MDC.put(RequestTraceFilter.MDC_KEY, "outer-request");
try {
filter.doFilter(
request,
response,
(ignoredRequest, ignoredResponse) -> observed.set(
MDC.get(RequestTraceFilter.MDC_KEY)));
assertThat(observed.get()).isEqualTo(INTERNAL_ID.toString());
assertThat(MDC.get(RequestTraceFilter.MDC_KEY))
.isEqualTo("outer-request");
} finally {
MDC.remove(RequestTraceFilter.MDC_KEY);
}
}
@Test
void originalFailureIsRethrownAndMdcIsCleaned() {
var filter = new RequestTraceFilter(() -> INTERNAL_ID);
var request = request();
var response = new MockHttpServletResponse();
var failure = new ServletException("database-password-must-not-leak");
try (var logs = CapturedFilterLogs.open()) {
var thrown = catchThrowable(() -> filter.doFilter(
request,
response,
(ignoredRequest, ignoredResponse) -> {
throw failure;
}));
assertThat(thrown).isSameAs(failure);
assertThat(MDC.get(RequestTraceFilter.MDC_KEY)).isNull();
assertThat(response.getHeader(RequestTraceFilter.RESPONSE_HEADER))
.isEqualTo(INTERNAL_ID.toString());
assertThat(logs.messages()).containsExactly(
"mvc.request outcome=thrown dispatcher=REQUEST requestId="
+ INTERNAL_ID);
assertThat(logs.messages().getFirst())
.doesNotContain(
"dispatchStatus",
"database-password-must-not-leak");
}
}
@Test
void internalIdGeneratorRunsOnce() throws Exception {
var calls = new AtomicInteger();
var observed = new AtomicReference<String>();
var filter = new RequestTraceFilter(() -> {
calls.incrementAndGet();
return INTERNAL_ID;
});
var response = new MockHttpServletResponse();
filter.doFilter(
request(),
response,
(ignoredRequest, ignoredResponse) -> observed.set(
MDC.get(RequestTraceFilter.MDC_KEY)));
assertThat(calls).hasValue(1);
assertThat(observed.get()).isEqualTo(INTERNAL_ID.toString());
assertThat(response.getHeader(RequestTraceFilter.RESPONSE_HEADER))
.isEqualTo(INTERNAL_ID.toString());
assertThat(MDC.get(RequestTraceFilter.MDC_KEY)).isNull();
}
@Test
void registrationMetadataIsExplicit() {
var registration = new WebFilterConfig().requestTraceFilter();
assertThat(registration.getFilterName())
.isEqualTo("requestTraceFilter");
assertThat(registration.getOrder()).isEqualTo(10);
assertThat(registration.getUrlPatterns()).containsExactly("/*");
assertThat(registration.determineDispatcherTypes())
.containsExactly(DispatcherType.REQUEST);
assertThat(registration.getFilter())
.isInstanceOf(RequestTraceFilter.class);
}
private static MockHttpServletRequest request() {
var request = new MockHttpServletRequest("GET", "/api/ping");
request.setDispatcherType(DispatcherType.REQUEST);
return request;
}
private static final class CapturedFilterLogs implements AutoCloseable {
private final Logger logger;
private final Level previousLevel;
private final ListAppender<ILoggingEvent> appender;
private CapturedFilterLogs(
Logger logger,
Level previousLevel,
ListAppender<ILoggingEvent> appender) {
this.logger = logger;
this.previousLevel = previousLevel;
this.appender = appender;
}
static CapturedFilterLogs open() {
var logger = (Logger) LoggerFactory.getLogger(
RequestTraceFilter.class);
var appender = new ListAppender<ILoggingEvent>();
appender.setContext(logger.getLoggerContext());
appender.start();
var previousLevel = logger.getLevel();
logger.setLevel(Level.DEBUG);
logger.addAppender(appender);
return new CapturedFilterLogs(
logger,
previousLevel,
appender);
}
List<String> messages() {
return appender.list.stream()
.map(ILoggingEvent::getFormattedMessage)
.toList();
}
@Override
public void close() {
logger.detachAppender(appender);
appender.stop();
logger.setLevel(previousLevel);
}
}
}다섯 테스트가 확인하는 것은 다음과 같습니다.
- 클라이언트가 같은 이름의 헤더를 위조해도 내부 UUID가 응답 헤더와 컨트롤러에 공유되고, 정상 반환 뒤 MDC가 비며, 로그에는 허용된 필드만 들어갑니다.
- 들어오기 전에 있던 MDC 값은 내부 ID를 쓴 뒤 다시 복원됩니다.
- 예외 경로는 같은
ServletException객체를 다시 던지고 MDC를 지우며, 정해지지 않은 최종 상태와 예외 메시지를 로그에 넣지 않습니다. - 내부 UUID 공급자는 디스패치 하나에서 한 번만 호출됩니다. 테스트의 공급자는 일부러 고정 UUID를 돌려주므로 호출 횟수와 값의 전달은 검사하지만, 여러 실제 요청 사이의 유일성이나 UUID 충돌 확률을 시험하지는 않습니다. 요청 ID는 인증 수단이나 중복 결제를 막는 멱등성 키도 아닙니다.
- 필터 등록의 이름과 순서, URL 패턴, REQUEST 디스패처 타입이 명시되어 있습니다.
이 테스트의 RestTestClient.bindToController는 독립형 MockMvc 설정입니다. 필터 체인을 지나 응답 헤더와 컨트롤러가 본 MDC, JSON 직렬화, 정상 반환 뒤의 정리를 한 JVM에서 확인합니다. 필터를 직접 호출하는 테스트는 이전 MDC 복원과 같은 예외의 재전파를, 빈 테스트는 등록 객체에 저장된 메타데이터를 확인합니다.
반면 이 테스트는 실제 서블릿 컨테이너가 등록 빈을 적용했는지, 필터의 실제 순서와 URL 매핑, 작업 스레드의 재사용, ASYNC나 ERROR 재디스패치, Spring Security 필터와의 상대 순서, 게이트웨이와 TLS의 신뢰 구간, 운영 로그 인코더는 보여 주지 않습니다. 이것들은 실제 애플리케이션 컨텍스트와 컨테이너, 네트워크 구성을 포함한 통합 테스트와 배포 테스트에서 확인합니다.
마지막으로 확인된 기록에서 조사하기
관찰 지점은 무언가가 실행되었다는 것을 직접 보여 줄 때 가장 믿을 만합니다.
- 믿을 수 있는 인그레스가 접근 이벤트를 기록했다면, 요청은 그 지점까지 도달했습니다.
- 내부
X-Request-Id응답 헤더나 필터의 이벤트가 있다면, 이 REQUEST 필터가 실행되었습니다. - 같은
requestId를 가진 MVC의 핸들러 선택 로그가 있다면, 그 디스패치에서 MVC가 그 지점까지 진행했습니다. - 핸들러가 명시적으로 남긴 같은 ID의 시작과 완료 이벤트가 있다면, 애플리케이션 코드가 그 지점까지 실행되었습니다.
가장 뒤에 확인된 기록 다음부터 후보를 좁힙니다. 다만 어떤 로그가 없다는 사실은 로그 레벨이나 샘플링, 비동기 전송, 버퍼 유실, 다른 스레드로의 전환, 로거 설정 때문에 생길 수도 있습니다. 그래서 로그가 없으니 실행되지 않았다고 단독으로 결론 내리지 않고, 응답과 메트릭, 트레이스, 재현 테스트 같은 다른 신호와 대조합니다.
필터 이벤트의 outcome=returned도 사용자 요청 전체가 최종적으로 성공했다는 뜻이 아니라, 지금의 REQUEST 디스패치가 필터 체인에서 정상적으로 돌아왔다는 뜻입니다. outcome=thrown은 필터 바깥에서 정해질 HTTP 상태를 예측하지 않습니다.
로그·메트릭·분산 트레이스의 역할
세 신호는 기본 단위가 서로 다릅니다.
| 신호 | 기본 단위와 고유 식별자 | 잘 답하는 질문 | 이 글에서 피할 값 |
|---|---|---|---|
| 로그 | 개별 이벤트, 애플리케이션의 requestId | 이 지점에서 어떤 결과와 안전한 진단 필드가 기록되었나 | 본문, 토큰, 쿠키, 원시 쿼리, 예외의 민감한 메시지 |
| 메트릭 | 집계된 카운터, 게이지, 히스토그램의 시계열 | 오류율이나 지연 분포가 시간에 따라 변했나 | requestId, 게시글 ID, 사용자 ID처럼 서로 다른 값의 개수(카디널리티)가 제한 없이 늘어나는 고카디널리티(high cardinality) 태그 |
| 분산 트레이스 | traceId 아래 부모와 자식 spanId로 이어진 구간 | 서비스와 원격 호출 가운데 어느 구간에서 지연이나 오류가 생겼나 | 비밀 값과 무제한 속성, 모든 본문의 무차별 기록 |
로그의 requestId는 애플리케이션의 디스패치 하나를 찾기 위한 상관 키입니다. W3C Trace Context의 trace-id와 parent-id는 분산 호출의 전파와 부모·자식 관계를 위한 별도의 규약입니다. 두 값을 나란히 기록할 수는 있지만, 하나를 다른 하나로 가장하지 않습니다.
경로가 필요하면 원시 URI 대신 카디널리티가 낮고 검증된 경로 템플릿을 쓰고, 사용자가 필요하면 공개 입력 대신 접근이 통제된 내부 주체 식별자를 씁니다. 그 값도 보존 기간과 조회 권한을 정한 뒤에 씁니다.
이 글의 내용은 Spring Boot 시스템 요구 사항(Java와 Spring Framework 7.0.10 이상, Gradle의 지원 범위), Spring Boot Gradle 의존성 관리와 관리 의존성 좌표, 디스패치마다 한 번 실행하고 ASYNC와 ERROR를 기본으로 건너뛰는 OncePerRequestFilter 7.0.10 API, 등록 정보를 정하는 AbstractFilterRegistrationBean과 FilterRegistrationBean, logging.pattern.level을 설명하는 Spring Boot 로깅, get·put·remove·putCloseable의 차이를 보여 주는 SLF4J MDC API와 MDC 소스, RestTestClient 7.0.10 API와 MockMvc, MockMvc와 종단 간 테스트 비교, DEBUG와 TRACE의 공개 수준을 설명하는 Spring MVC 로깅, W3C Trace Context, Spring Security 서블릿 아키텍처를 기준으로 합니다.
정리
- 클라이언트가 보낸
X-Request-Id는 믿지 않고, 필터가 디스패치마다 내부 UUID를 한 번 만들어 응답 헤더와 MDC에 넣습니다. - MDC는 스레드 단위이므로, 들어오기 전의 값을 저장했다가
finally에서 복원하거나 지웁니다.putCloseable의close는 지우기만 합니다. - 정상 반환은 현재의
dispatchStatus를, 예외는outcome=thrown만 기록하고, 로그에는 경로와 본문, 인증 정보, 예외 메시지를 넣지 않습니다. - 필터 등록의 이름과 순서, URL 패턴, REQUEST 디스패처 타입을 명시하되, 보안 체인과의 실제 순서는 실제 컨테이너에서 확인합니다.
- 로그가 없다는 사실만으로 실행되지 않았다고 결론 내리지 않으며,
requestId는 메트릭 태그나traceId로 쓰지 않습니다.
연습 문제
1. 422로 정상 반환한 경우
같은 동기 REQUEST 경로에서 들어오기 전의 MDC 값이 없고, 체인이 응답 상태를 422로 설정한 뒤 예외 없이 돌아온다고 가정합니다. 공급자는 UUID를 한 번 돌려주며, 설정과 기록, 정리는 모두 성공합니다. 기록되는 outcome과 dispatchStatus, 필터가 돌아온 뒤의 MDC와 응답 헤더를 각각 설명하세요. 상태가 4xx라는 이유만으로 outcome=thrown이나 전체 요청의 성공을 결론 낼 수 있나요?
해설 보기
체인이 정상적으로 돌아왔으므로 outcome=returned이고, 기록되는 dispatchStatus는 422입니다. 예외가 없었으므로 thrown 경로는 실행되지 않습니다. 이전 값이 없었으므로 finally는 MDC 키를 지우고, 응답 헤더에는 이번 내부 UUID가 남습니다. 정상 반환은 체인의 제어 흐름에 대한 사실일 뿐 HTTP 성공 상태나 사용자 요청 전체의 성공과 같은 뜻이 아니며, 4xx라는 값만으로 예외가 다시 던져졌다고 추정하지도 않습니다. 다섯 테스트에는 이 422 경우를 직접 실행하는 테스트가 없으므로, 이 풀이는 코드에서 예상한 동작입니다.
2. 같은 스레드의 두 요청
설정과 UUID 공급, 헤더, MDC, 기록, 정리는 모두 성공하고 동기 REQUEST만 처리합니다. 같은 스레드에서 요청 A는 들어오기 전 requestId가 없고, 체인이 상태 404를 설정한 뒤 정상 반환합니다. 이어 요청 B가 시작하기 직전 상위 코드가 requestId=outer를 넣고, B의 체인은 ServletException을 던집니다. A와 B를 처리하는 동안의 ID와 반환 또는 전파 뒤의 MDC, 두 outcome을 설명하세요. A의 404와 B의 예외는 같은 종류의 실패를 뜻하나요?
해설 보기
A를 처리하는 동안 MDC에는 A의 내부 UUID가 있고, 반환 뒤에는 키가 지워집니다. A의 outcome은 returned, 기록되는 dispatchStatus는 404입니다. B를 처리하는 동안에는 B의 내부 UUID가 있고, 예외가 다시 던져지기 전에 outer가 복원됩니다. B의 outcome은 thrown이며 이 필터는 최종 HTTP 상태를 정하지 않습니다. 두 결과는 서로 다른 제어 흐름을 나타내므로 같은 종류의 실패로 묶지 않습니다. 응답 헤더에는 각 디스패치의 내부 UUID가 남아, 복원된 MDC 값과 수명이 다릅니다.
3. 메트릭 태그 수 세기
단일 요청 카운터에 초당 100건을 한 시간 동안 기록합니다. 모든 요청의 내부 ID는 서로 다르고 요청마다 정확히 한 번 기록합니다. requestId를 태그로 쓰면 고유한 태그 조합은 몇 개 생기나요? ID를 빼고 메서드 2종, 경로 템플릿 4종, 상태 분류 3종만 쓰면 가능한 조합의 상한은 몇 개이며, 그 조합이 모두 실제로 나타났다고 말할 수 있나요?
해설 보기
ID를 태그로 쓰면 100 × 3,600 = 360,000개의 태그 조합이 생깁니다. ID를 빼면 가능한 조합의 상한은 2 × 4 × 3 = 24개이며, 실제로 나타난 조합은 이보다 적을 수 있습니다. 같은 조합의 요청은 같은 시계열에 누적됩니다. 이 계산은 카운터 하나와 명시한 태그만 본 모델이며, 히스토그램 버킷이나 인스턴스 같은 추가 차원과 저장소의 메모리는 계산하지 않았습니다.
4. 믿을 수 있는 인그레스의 상관 값
외부 시스템의 상관 값을 externalCorrelationId라는 별도 필드로 유지하도록 설계를 바꿉니다. 내부 requestId는 계속 요청마다 애플리케이션이 새로 만듭니다. 값이 1~64자의 영문, 숫자, 하이픈으로 되어 있는지 검사하는 것만으로 충분할까요? 함께 성립해야 할 배포 조건과 테스트할 경우를 설명하세요.
해설 보기
형식 검사는 입력이 안전한지만 확인할 뿐, 믿을 수 있는 인그레스가 만든 값이라는 것을 보여 주지 못합니다. 인그레스의 값을 쓰려면 다음이 함께 성립해야 합니다. 공개 진입점은 클라이언트가 보낸 상관 헤더를 항상 지우거나 새 값으로 덮어씁니다. 애플리케이션은 공개 클라이언트가 직접 우회해 접근할 수 없는 네트워크 뒤에 있고, 내부 홉의 인증이 확인됩니다. 애플리케이션은 내부 홉 전용 헤더만 별도 상관 필드로 읽으며 길이와 문자 집합을 다시 제한합니다. 이 조건을 확인할 수 없거나 값이 잘못되었으면 외부 상관 필드를 비우고 내부 requestId만 씁니다. 로그에는 두 필드를 서로 다른 이름으로 기록하고, 어느 것도 메트릭 태그로 쓰지 않습니다.
테스트에는 공개 클라이언트가 위조한 값이 내부 ID와 외부 상관 필드에 영향을 주지 않는 경우, 믿을 수 있는 홉에서 덮어쓴 값만 별도 필드로 보존되는 경우, 잘못된 형식이 거부되는 경우를 넣습니다. 독립형 MockMvc 테스트는 헤더를 고르는 로직만 확인할 수 있으며, 직접 우회할 수 없다는 조건과 인그레스가 항상 지우거나 덮어쓴다는 조건은 실제 프록시와 네트워크 정책을 포함한 배포 테스트로 확인해야 합니다.
다음 글 RequestMapping 조건에서는 이 관찰 구조를 그대로 둔 채, RequestMappingHandlerMapping의 경로와 메서드, 미디어 타입 조건과 경로 변수의 인자 변환 실패를 구체적으로 테스트합니다.