본문으로 건너뛰기

안동민 개발노트

본문 시작

MVC 요청 관찰

Spring Boot 4.1 MVC의 REQUEST 디스패치에서 내부 요청 ID와 MDC 수명 주기를 안전하게 관리하고, 로그·메트릭·트레이스의 증거 범위를 구분합니다.

Spring MVC 요청을 진단할 때 가장 먼저 필요한 것은 로그를 많이 남기는 일이 아니라 같은 요청에서 나온 증거를 연결하는 일입니다.

이 문서에서는 애플리케이션이 직접 만든 requestId를 응답 헤더와 MDC에 함께 넣습니다. 클라이언트가 보낸 X-Request-Id는 신뢰하지 않고, 필터가 실행될 때마다 새 내부 ID를 정확히 한 번 생성합니다. 같은 스레드에서 실행되는 MVC와 핸들러 로그는 이 MDC 값을 출력할 수 있습니다.

세 식별자의 역할부터 분리합니다.

식별자소유자와 범위이 문서의 규칙
외부 상관 후보브라우저·호출자·프록시가 보낸 값기본 구현에서는 읽지 않는다. 필요하면 별도 필드로 보존하되 신뢰 경계 계약을 먼저 증명한다.
requestId한 애플리케이션 REQUEST 디스패치애플리케이션이 UUID를 새로 만들고 응답 헤더와 MDC에 사용한다.
traceId·spanId분산 추적 시스템의 트레이스와 작업 구간계측 라이브러리가 관리한다. requestId로 대체하거나 두 값을 같은 필드에 섞지 않는다.
신뢰 경계 밖의 외부 요청 ID는 내부 requestId와 분리됩니다. 요청은 ingress, RequestTraceFilter, 내부 구현을 펼치지 않은 Spring MVC, handler와 application을 지나며 도착, 내부 ID, 선택된 handler, 처리 결과라는 양성 증거를 남깁니다. requestId는 분산 추적의 traceId나 spanId가 아니며, 마지막으로 실제 확인된 체크포인트부터 진단 범위를 좁힙니다. 특정 로그가 없다는 사실만으로 코드가 실행되지 않았다고 단정할 수는 없습니다.

TRUSTED CORRELATION · POSITIVE CHECKPOINTS

요청 증거는 경계마다 하나씩 남는다

외부 식별자는 trust boundary에서 분리하고, 마지막으로 실제 확인된 증거부터 다음 조사 범위를 좁힙니다.

요청 경계별 양성 증거와 신뢰 경계 외부 요청은 신뢰 경계를 지나 RequestTraceFilter에서 내부 requestId를 얻고, 불투명한 Spring MVC와 handler 및 application 경계를 통과합니다. 각 경계의 양성 증거를 확인하되 로그 부재만으로 미실행을 단정하지 않으며 requestId와 traceId, spanId를 구분합니다. INBOUND INTERNAL ID CHAIN HANDLER RETURN / THROW MVC OUTCOME OUTBOUND TRUST BOUNDARY UNTRUSTED INPUT Ingress external ID는 참고값 THREAD SCOPE RequestTraceFilter internal requestId OPAQUE BOUNDARY Spring MVC route · selected handler OPAQUE BOUNDARY Handler / App application outcome CORRELATION requestId ≠ traceId / spanId DIAGNOSIS 마지막 양성 체크포인트부터 좁힌다 CAUTION 로그 부재 ≠ 미실행 증명 증거는 도착 · 내부 ID · 선택 handler · 현재 dispatch 결과이며, 라우팅과 실행 내부 알고리즘은 이 그림의 범위가 아닙니다.

01 · TRUST BOUNDARY

외부 요청 ID는 신뢰된 내부 ID가 아니다

문법 검사는 로그 주입을 줄일 뿐 진위나 유일성을 증명하지 않습니다. 신뢰된 ingress가 값을 제거하거나 대체한다는 계약이 없다면 외부 값은 externalRequestId처럼 별도 필드로 취급합니다.

02 · FILTER CHECKPOINT

Filter가 내부 requestId와 스레드 범위를 만든다

내부 requestId는 응답과 요청 처리 로그를 연결합니다. 분산 추적의 traceIdspanId는 별도 전파 규칙을 가진 다른 식별자입니다.

03 · MVC / APPLICATION

route, selected handler, outcome은 서로 다른 양성 증거다

Spring MVC와 application 내부 알고리즘은 펼치지 않습니다. 이 문서에서는 요청이 해당 경계를 통과했다는 관찰 증거만 연결합니다.

04 · LAST POSITIVE CHECKPOINT

마지막으로 실제 확인된 증거부터 조사한다

다음 로그가 없다는 사실은 범위를 좁히는 단서일 뿐 미실행의 증명이 아닙니다. 설정, sampling, 제어 흐름도 함께 확인해야 합니다.

이 sequence는 REQUEST 경계의 관찰 순서만 보여 줍니다. 신뢰 정책과 식별자 종류를 섞지 않고, 부재 추론보다 실제로 확인된 증거를 우선합니다.

그림의 경계는 의도적입니다. 신뢰된 인그레스, 요청 필터, Spring MVC, 핸들러 또는 애플리케이션만 표시하고 MVC 내부의 모든 리졸버를 총순서로 단정하지 않습니다. 관찰된 마지막 양성 증거는 조사 범위를 줄여 주지만, 다음 로그가 없다는 사실만으로 다음 코드가 실행되지 않았다고 결론 내릴 수는 없습니다.


실행 기준 고정

예제는 Java 25, Spring Boot 4.1.1의 BOM, Spring Framework 7.0.9, JUnit 6.0.3을 한 의존성 그래프로 사용합니다. 버전이 없는 각 모듈은 같은 Boot BOM에서 해석됩니다.

settings.gradle
rootProject.name = 'mvc-request-observation'
build.gradle
plugins {
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.1')
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    testImplementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.1')
    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.1 BOM을 플랫폼으로 가져와 모듈 버전을 한곳에서 정렬합니다. spring-boot-starter-web 같은 이전 이름을 우회로로 남기지 않고 Boot 4의 Servlet MVC 스타터인 spring-boot-starter-webmvc를 사용합니다.


내부 요청 ID와 MDC 수명 주기

RequestTraceFilter는 외부 요청 헤더를 읽지 않습니다. Supplier<UUID>가 반환한 UUID만 사용하므로 요청 ID의 길이와 문자 집합도 고정됩니다.

src/main/java/board/web/RequestTraceFilter.java
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에서 복원하거나, 이전 값이 없을 때만 제거합니다.

정상 반환 로그의 dispatchStatus는 현재 REQUEST 디스패치가 반환한 시점의 상태입니다. 반대로 체인이 예외를 던지면 이후 예외 리졸버나 컨테이너가 상태를 결정할 수 있으므로 최종 상태를 추측하지 않고 outcome=thrown만 기록합니다. 잡은 예외 객체는 바꾸거나 감싸지 않고 그대로 다시 던집니다.

로그 필드는 outcome, dispatcher, 정수 상태, 내부 UUID로 제한했습니다. 원시 경로, 쿼리 문자열, 요청 본문, Authorization 헤더, 쿠키, 예외 메시지는 이 경계 로그에 넣지 않습니다.

DispatcherType.REQUEST의 동기 처리에서 필터는 내부 requestId를 결정하고 기존 MDC 값을 저장한 뒤 새 값을 넣고 chain을 호출합니다. chain이 정상적으로 반환되거나 내부에서 오류를 처리하면 현재 dispatch의 response status를 관찰합니다. 예외가 밖으로 던져지면 outcome을 thrown으로 보존하고 그 시점의 status를 최종값이라고 부르지 않습니다. 두 경로는 이전 MDC 값 복원 또는 키 제거에서 합류한 뒤 원래 응답이나 예외를 전달합니다. putCloseable의 close는 이전 값을 복원하지 않고 키를 제거하며, ASYNC와 ERROR dispatch는 이 REQUEST 전용 흐름의 범위 밖입니다.

REQUEST DISPATCH · NORMAL / THROWN CONVERGENCE

MDC 키는 두 출구에서 모두 복원하거나 제거한다

정상 반환과 예외 재전파는 cleanup에서 합류하지만, 관찰 가능한 status와 outcome=thrown은 같은 주장으로 합치지 않습니다.

REQUEST dispatch의 MDC 정상 및 예외 정리 REQUEST dispatch에서 내부 ID를 MDC에 넣은 뒤 chain이 반환되거나 예외를 던지는 두 경로가 공통 cleanup으로 합류합니다. cleanup은 이전 값을 복원하거나 키를 제거하고 원래 응답 또는 예외를 전달하며, ASYNC와 ERROR dispatch는 별도 정책입니다. ENTER SCOPE CHAIN EXIT RETURNED THROWN SAVED RETURN SAVED THROW SCOPE CUT ASYNC · ERROR dispatch 기본 REQUEST 흐름 밖 별도 등록 · 재부착 · 정리 정책 CLOSE SEMANTICS putCloseable.close() key remove 이전 값 복원 아님 DISPATCHER TYPE · REQUEST 내부 ID 결정 · previous MDC 저장 MDC.put → chain.doFilter 현재 스레드에만 붙는 scope chain은 어떻게 끝났는가? 반환됨 · 내부에서 처리됨 status = 이 dispatch에서 관찰한 값 밖으로 던져짐 outcome=thrown · final status 주장 금지 previous 복원 또는 key 제거 두 출구가 공유하는 cleanup 응답 반환 원래 예외 재전파
  1. REQUEST ONLY

    이 보장은 DispatcherType.REQUEST의 동기 경로다

    ASYNC와 ERROR dispatch는 기본 흐름 밖이며 dispatcher 등록, 안전한 ID 재부착과 스레드별 정리 정책이 따로 필요합니다.

  2. SAVE THEN PUT

    내부 ID를 정하고 기존 MDC 값을 먼저 저장한다

    기존 MDC를 previous에 저장한 뒤 새 requestId를 넣고 chain.doFilter를 호출합니다.

  3. TWO OUTCOMES

    반환 경로의 status와 thrown 경로의 outcome을 구분한다

    정상 반환 또는 내부에서 처리된 오류는 현재 dispatch의 status를 관찰합니다. 밖으로 던져진 예외는 outcome=thrown으로 보존하고 status를 최종값이라고 부르지 않습니다.

  4. COMMON CLEANUP

    이전 값이 있으면 복원하고 없으면 키를 제거한다

    MDC.putCloseable(...).close()는 key를 제거할 뿐 이전 값을 복원하지 않습니다. 따라서 독점 키 계약이 없다면 명시적으로 save와 restore를 수행해야 합니다.

  5. PRESERVE OUTCOME

    cleanup 뒤 원래 응답 또는 원래 예외를 전달한다

    정리 과정이 chain의 정상 반환과 예외 재전파 의미를 바꾸면 안 됩니다.

close는 remove이지 restore가 아닙니다. REQUEST 경로의 두 결과는 공통 cleanup에서 합류하고, ASYNC와 ERROR dispatch는 별도 정책으로 남깁니다.

두 분기는 하나의 MDC 복원 또는 제거 경계로 합쳐집니다. 이 장의 소유 범위는 동기 REQUEST 디스패치입니다. 비동기 작업으로 넘어간 MDC는 자동 전파되지 않으며, ASYNC와 ERROR 재디스패치에서 같은 ID를 이어 가려면 요청 속성 저장, 스레드 컨텍스트 전파, 재디스패치별 중복 로그 규칙을 별도 계약으로 설계해야 합니다.


등록 범위와 순서

등록 정보도 기본값에 맡기지 않습니다.

src/main/java/board/config/WebFilterConfig.java
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보다 실제로 앞인지 뒤인지를 그 숫자 하나로 증명하지는 않습니다. 보안 필터 체인과의 상대 순서가 요구사항이면 실제 애플리케이션 컨텍스트와 Servlet 컨테이너에서 검증해야 합니다.

OncePerRequestFilter 자체도 ASYNC와 ERROR 디스패치를 기본적으로 건너뛰는 정책을 가집니다. 여기에 등록 디스패처 타입까지 REQUEST로 고정해 코드와 배포 설정이 같은 수명 주기를 말하게 했습니다.


로컬 로그 설정

MDC에 값을 넣는 것만으로 콘솔에 값이 나타나지는 않습니다. 로그 패턴이 그 키를 출력해야 합니다.

src/main/resources/application-local.yml
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: DEBUG

logging.pattern.level은 같은 스레드에서 필터 체인 안에 기록된 프레임워크와 핸들러 로그의 레벨 부분에 requestId를 실제로 출력합니다. 요청 바깥의 시작 로그처럼 MDC가 없는 이벤트에서는 값이 비어 있습니다.

이 파일은 local 프로필용입니다. org.springframework.web 전체나 TRACE 전체를 열지 않고 요청 진입, 핸들러 매핑, 이 필터의 요약 이벤트에 필요한 범주만 DEBUG로 제한합니다. 운영 환경의 구조화 로그 인코더가 있다면 동일한 키를 별도 JSON 필드로 내보내고, 샘플링·보존 기간·접근 권한을 운영 정책으로 검증합니다.


완전한 MVC 경계 테스트

다음 테스트 파일은 컨트롤러 응답 타입과 로그 캡처 도우미까지 모두 정의합니다. RestTestClient의 독립형 MVC 서버 구성에 필터를 직접 추가하고, 나머지 수명 주기 계약은 필터와 등록 빈을 좁게 검사합니다.

src/test/java/board/web/RequestTraceFilterTest.java
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/ping?email=alice@example.com")
                    .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",
                            "alice@example.com",
                            "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);
        }
    }
}

다섯 테스트의 계약은 다음과 같습니다.

  1. 클라이언트가 같은 이름의 헤더를 위조해도 내부 UUID가 응답 헤더와 컨트롤러에 공유되고, 정상 반환 뒤 MDC가 비며, 경계 로그가 허용된 필드만 포함합니다.
  2. 진입 전에 있던 MDC 값은 내부 ID를 사용한 뒤 다시 복원됩니다.
  3. 예외 경로는 동일한 ServletException 객체를 다시 던지고 MDC를 제거하며, 결정되지 않은 최종 상태와 예외 메시지를 로그에 넣지 않습니다.
  4. 내부 UUID 공급자는 한 디스패치에서 한 번만 호출됩니다.
  5. 필터 등록의 이름, 순서, URL 패턴, REQUEST 디스패처 타입이 명시되어 있습니다.

이 테스트의 RestTestClient.bindToController는 독립형 MockMvc 설정입니다. 필터 체인을 통과해 응답 헤더, 컨트롤러가 본 MDC, JSON 직렬화, 정상 반환 뒤 정리를 한 JVM에서 증명합니다. 직접 필터 테스트는 이전 MDC 복원과 동일 예외 재전파를 증명하고, 빈 테스트는 등록 객체에 저장된 메타데이터를 증명합니다.

그러나 이 테스트는 라이브 Servlet 컨테이너가 등록 빈을 실제로 적용했는지, 필터의 실효 순서와 URL 매핑, 작업 스레드 재사용, ASYNC 또는 ERROR 재디스패치, Spring Security 필터와의 상대 순서, 게이트웨이·TLS 신뢰 경계, 운영 로그 인코더를 증명하지 않습니다. 그 항목은 실제 애플리케이션 컨텍스트, 컨테이너, 네트워크 경계를 포함한 통합·배포 테스트의 책임입니다.


마지막 양성 증거로 조사하기

관찰 지점은 실행 여부를 긍정적으로 보여 줄 때 가장 강합니다.

  1. 신뢰된 인그레스가 접근 이벤트를 기록했다면 요청이 그 경계에는 도달했습니다.
  2. 내부 X-Request-Id 응답이나 필터의 경계 이벤트가 있다면 이 REQUEST 필터가 실행되었습니다.
  3. 같은 requestId를 가진 MVC 선택 로그가 있다면 해당 디스패치에서 MVC가 그 지점까지 진행했습니다.
  4. 핸들러가 명시적으로 남긴 같은 ID의 시작·완료 이벤트가 있다면 애플리케이션 코드가 그 지점까지 실행되었습니다.

가장 뒤에 확인된 양성 증거 다음부터 후보를 좁힙니다. 다만 어떤 로그가 없다는 사실은 로그 레벨, 샘플링, 비동기 전송, 버퍼 유실, 다른 스레드로의 전환, 로거 설정 때문에 생길 수도 있습니다. 따라서 “로그가 없으므로 실행되지 않았다”는 단독 결론은 허용하지 않고, 응답·메트릭·트레이스·재현 테스트 같은 독립 증거와 대조합니다.

경계 이벤트의 outcome=returned도 전체 사용자 요청의 최종 성공을 뜻하지 않습니다. 현재 REQUEST 디스패치가 필터 체인으로 정상 복귀했다는 뜻입니다. outcome=thrown은 이 경계 밖에서 결정될 HTTP 상태를 예측하지 않습니다.


로그·메트릭·분산 트레이스의 고유 역할

세 신호는 서로 다른 기본 단위를 가집니다.

신호기본 단위와 고유 식별자잘 답하는 질문이 문서에서 피할 값
로그개별 이벤트, 애플리케이션 requestId이 경계에서 어떤 결과와 안전한 진단 필드가 관찰되었나본문, 토큰, 쿠키, 원시 쿼리, 예외의 민감 메시지
메트릭집계된 카운터·게이지·히스토그램 시계열오류율이나 지연 분포가 시간에 따라 변했나requestId, 게시글 ID, 사용자 ID 같은 고카디널리티 태그
분산 트레이스traceId 아래 부모·자식 spanId로 연결된 구간서비스와 원격 호출 중 어느 구간에서 지연·오류가 생겼나비밀 값과 무제한 속성, 모든 본문의 무차별 기록

로그의 requestId는 한 애플리케이션 디스패치를 찾기 위한 상관 키입니다. W3C Trace Context의 trace-idparent-id는 분산 호출의 전파와 부모·자식 관계를 위한 별도 규약입니다. 두 값을 나란히 기록할 수는 있지만 하나를 다른 하나로 가장하지 않습니다.

경로가 필요하면 원시 URI 대신 낮은 카디널리티의 검증된 경로 템플릿을, 사용자가 필요하면 공개 입력 대신 접근 통제된 내부 주체 식별자를 선택합니다. 그 값도 보존 기간과 조회 권한을 정한 뒤 사용합니다.


연습: 신뢰된 인그레스 상관 값

외부 시스템의 상관 값을 externalCorrelationId라는 별도 필드로 유지하는 설계를 작성하세요. 내부 requestId는 계속 애플리케이션에서 매 요청 새로 생성합니다.

단순히 1~64자의 영문·숫자·하이픈 형식인지 확인하는 것은 입력 안전성 검사일 뿐, 신뢰된 인그레스가 만든 값이라는 증명이 아닙니다. 인그레스 값을 사용하려면 다음 배포 계약이 함께 성립해야 합니다.

  1. 공개 경계는 클라이언트가 보낸 상관 헤더를 항상 제거하거나 새 값으로 덮어씁니다.
  2. 애플리케이션은 공개 클라이언트가 직접 우회 접근할 수 없는 네트워크 경계 뒤에 있고, 내부 홉의 인증이 검증됩니다.
  3. 애플리케이션은 내부 홉 전용 헤더만 별도 상관 필드로 읽으며 길이와 문자 집합을 다시 제한합니다.
  4. 경계 계약이 확인되지 않거나 값이 잘못되면 외부 상관 필드를 비우고 내부 requestId만 사용합니다.
  5. 로그에는 두 필드를 서로 다른 이름으로 기록하고, 어느 것도 메트릭 태그로 사용하지 않습니다.

테스트에는 공개 클라이언트의 위조 값이 내부 ID와 외부 상관 필드에 영향을 주지 않는 경우, 신뢰된 홉에서 덮어쓴 값만 별도 필드로 보존되는 경우, 잘못된 형식이 거부되는 경우를 포함하세요. 독립형 MockMvc 테스트는 헤더 선택 로직만 확인할 수 있습니다. “직접 우회가 없다”와 “인그레스가 항상 제거 또는 덮어쓴다”는 조건은 실제 프록시와 네트워크 정책을 포함한 배포 테스트로 증명해야 합니다.


공식 근거

다음 문서에서는 이 관찰 경계를 바꾸지 않고 RequestMappingHandlerMapping의 경로, 메서드, 헤더, 미디어 타입 조건과 바인딩 실패를 구체적으로 테스트합니다.