본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
6장 : Spring MVC 요청·응답

MVC 요청 관찰

Spring Boot 4.1 MVC 프로젝트에서 요청 트레이스 필터와 매핑 로그를 구성하고 실제 MVC 요청의 ID·핸들러·상태를 민감 정보 없이 연결합니다.

Spring MVC에서 요청은 DispatcherServlet으로 들어오고, URL과 HTTP 메서드에 맞는 컨트롤러 메서드가 선택됩니다.

선택된 메서드를 핸들러라고 부릅니다.

요청 문자열을 Java 값으로 바꾸는 일을 바인딩이라 하고, JSON처럼 본문의 형식을 나타내는 값을 미디어 타입이라고 합니다.

이 기본 흐름을 확인한 다음 관측 정보를 붙입니다.

바인딩 문제를 고칠 때 애노테이션을 무작정 바꾸기보다 한 요청이 어느 경로와 미디어 타입으로 들어와 어떤 핸들러와 상태로 끝났는지 연결해야 합니다.

게시판 API에 요청 ID 필터를 두고 로컬 환경에서만 매핑 디버그를 엽니다.


Boot MVC 환경 구성

이 장의 기준 빌드는 앞 장과 같은 Java 25, Spring Boot 4.1.0입니다.

Servlet MVC 스타터를 명시합니다.

build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.0'
}

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

repositories {
    mavenCentral()
}

dependencies {
    implementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.0')
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    testImplementation platform(
            'org.springframework.boot:spring-boot-dependencies:4.1.0')
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

Boot 플러그인만 선언한다고 일반 Java 프로젝트의 모든 의존성 버전이 자동으로 관리되는 것으로 가정하지 않습니다.

예제는 플랫폼으로 BOM을 가져옵니다.

실제 프로젝트가 의존성-관리 플러그인이나 Gradle의 다른 BOM 구성을 표준화했다면 하나의 방식으로 유지합니다.

spring-boot-starter-web이라는 예전 이름을 복사하지 않고 Boot 4.1의 Servlet 스택 스타터인 spring-boot-starter-webmvc를 사용합니다.


요청 ID 필터

신뢰 가능한 게이트웨이가 요청 ID를 만들지 않았다면 애플리케이션이 새 ID를 생성합니다.

클라이언트가 보낸 임의 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.function.Supplier;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;

public final class RequestTraceFilter
        extends OncePerRequestFilter {
    static final String HEADER = "X-Request-Id";
    private final Supplier<String> ids;

    public RequestTraceFilter(Supplier<String> ids) {
        this.ids = ids;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain)
            throws ServletException, IOException {
        var requestId = ids.get();
        response.setHeader(HEADER, requestId);
        try (var ignored = MDC.putCloseable(
                "requestId", requestId)) {
            chain.doFilter(request, response);
        }
    }
}

MDC 값은 try 범위에서 제거해 컨테이너 스레드가 다음 요청을 처리할 때 이전 ID가 남지 않게 합니다.

비동기 디스패치와 오류 디스패치에서 ID를 이어야 하는 요구가 있다면 요청 속성에 안전한 ID를 보관하고 디스패치 타입별 필터 동작을 명시합니다.


필터 순서와 범위

src/main/java/board/config/WebFilterConfig.java
package board.config;

import java.util.UUID;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import board.web.RequestTraceFilter;

@Configuration(proxyBeanMethods = false)
class WebFilterConfig {
    @Bean
    FilterRegistrationBean<RequestTraceFilter>
            requestTraceFilter() {
        var registration =
                new FilterRegistrationBean<>(
                        new RequestTraceFilter(
                                () -> UUID.randomUUID()
                                        .toString()));
        registration.setName("requestTraceFilter");
        registration.setOrder(10);
        registration.addUrlPatterns("/*");
        return registration;
    }
}

Spring 보안 필터 체인과 트레이싱 라이브러리가 이미 ID를 관리한다면 중복 필터를 만들지 않습니다.

게이트웨이 ID를 수용할 때는 길이·문자 집합을 제한하고 외부 ID와 내부 트레이스 ID를 구분합니다.

모든 URI와 쿼리를 그대로 로그하면 이메일·검색어·토큰이 남을 수 있습니다.

메서드와 경로 템플릿, 핸들러 이름, 상태, 경과, 인증된 회원의 내부 식별자처럼 필요한 메타데이터를 구조화해 남깁니다.


MVC 필터 경계 검증

Spring Framework 7의 RestTestClient 독립형 빌더에 필터를 등록해 실제 필터 체인을 통과시킵니다.

src/test/java/board/web/RequestTraceFilterTest.java
package board.web;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.slf4j.MDC;
import org.springframework.test.web.servlet.client.RestTestClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

class RequestTraceFilterTest {
    @RestController
    static class PingController {
        @GetMapping("/api/ping")
        TraceResponse ping() {
            return new TraceResponse(
                    MDC.get("requestId"));
        }
    }

    @Test
    void filter와_controller가_같은_request_id를_본다() {
        var filter = new RequestTraceFilter(
                () -> "request-001");
        var client = RestTestClient
                .bindToController(new PingController())
                .configureServer(builder ->
                        builder.addFilters(filter))
                .build();

        client.get()
                .uri("/api/ping")
                .exchange()
                .expectStatus().isOk()
                .expectHeader()
                .valueEquals(
                        "X-Request-Id", "request-001")
                .expectBody()
                .jsonPath("$.requestId")
                .isEqualTo("request-001");

        assertThat(MDC.get("requestId")).isNull();
    }
}

필터가 종료된 뒤 테스트 스레드 MDC가 비어 있어야 합니다.

실제 서버에서는 작업 스레드 재사용 상황을 통합 테스트와 로그로 추가 확인합니다.

실행 결과
RequestTraceFilterTest
  > filter와_controller가_같은_request_id를_본다() PASSED
X-Request-Id = request-001
MDC after chain = null
BUILD SUCCESSFUL

매핑 로그 공개 범위

src/main/resources/application-local.yml
logging:
  level:
    org.springframework.web.servlet.DispatcherServlet: DEBUG
    org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping: DEBUG
    org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor: DEBUG

트레이스 전체를 운영에 켜면 헤더와 모델 정보가 과도하게 출력되고 로그 양이 급증할 수 있습니다.

로컬에서 재현할 때 범주와 시간을 제한합니다.

매핑 등록 로그는 시작, 선택된 핸들러와 결과는 요청 처리 로그에서 봅니다.

관찰 순서는 다음과 같습니다.

  1. 접근/프록시 로그에 요청이 도착했는가.
  2. 애플리케이션 응답에 요청 ID가 있는가.
  3. DispatcherServlet이 어떤 핸들러를 선택했는가.
  4. 인자 리졸버·컨버터가 어디서 실패했는가.
  5. 컨트롤러와 서비스가 실행됐는가.
  6. 최종 상태와 경과가 무엇인가.

로그·메트릭·트레이스

신호잘 답하는 질문
로그한 요청에서 무슨 일이 있었나요청 ID·예외
Metric전체 분포가 변했나상태 개수·지연 시간 백분위수
트레이스여러 서비스 중 어디서 지연됐나프록시→API→DB 구간

모든 요청 본문을 로그로 남겨 검색 기능을 대신하지 않습니다.

구조화된 메트릭 레이블에 게시글 ID 같은 고카디널리티 값을 넣지 않습니다.

트레이스 표본 추출에서 오류를 보존하는 정책을 둡니다.


연습 문제

요청 ID 필터가 클라이언트의 X-Request-Id를 조건부로 받아들이게 하세요.

1~64자의 영문·숫자·하이픈만 허용하고 나머지는 새 UUID로 교체합니다.

정상·너무 긴 값·개행 시도·헤더 없음 네 경우를 MVC 테스트로 검증하고 체인 종료 뒤 MDC가 항상 비는지 확인합니다.

해설 보기

검증된 외부 ID와 생성한 내부 ID를 구분해 로그 속성으로 남길 수 있습니다.

헤더 값이 정규식과 길이를 만족할 때만 사용합니다.

String selectRequestId(
        String candidate,
        Supplier<String> fallback) {
    if (candidate != null
            && candidate.matches("[A-Za-z0-9-]{1,64}")) {
        return candidate;
    }
    return fallback.get();
}

필터의 finally 또는 MDC.putCloseable로 정리를 보장합니다.

컨트롤러가 예외를 던지는 테스트도 추가해 실패 경로에서 MDC가 남지 않는지 확인합니다.

다음 문서에서는 매핑 로그에 나타난 RequestMappingHandlerMapping 조건을 실제 애노테이션으로 설계하고 경로 변수와 메서드·헤더·미디어 타입 충돌을 테스트합니다.