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

안동민 개발노트

본문 시작
2장 : Bean과 의존성 주입

MVC 요청 파이프라인과 횡단 관심사

상관관계 ID, 요청 시간 측정, 인증처럼 여러 Controller에 공통인 로직을 각 메서드에 복사하면 빠르게 어긋납니다. Spring MVC는 요청의 서로 다른 단계에 개입할 수 있는 Filter, HandlerInterceptor, HandlerMethodArgumentResolver를 제공합니다.

이 구성요소들은 이름만 다른 같은 기능이 아닙니다. 실행 시점에 따라 볼 수 있는 정보와 책임져야 할 실패 범위가 다릅니다.


요청 경계마다 사용할 수 있는 정보가 다르다

Filter는 DispatcherServlet보다 바깥에서 원시 요청과 응답을 다루므로 상관관계 ID나 보안 컨텍스트처럼 전체 요청에 필요한 정보를 만들기 좋습니다. Interceptor는 어떤 handler가 선택됐는지 알 수 있어 Controller별 시간 측정과 접근 정책에 적합합니다. ArgumentResolver는 요청의 정보를 Controller가 사용할 도메인 친화적 매개변수로 변환합니다.

Controller는 이 앞 단계가 마련한 값과 검증된 입력으로 HTTP 계약을 수행해야 합니다. 여기까지 온 뒤에는 Servlet 세부 정보보다 상태 코드와 응답 모델에 집중하는 편이 경계를 선명하게 유지합니다.

아래 흐름에서는 Filter가 문자열 ID를 요청 속성에 저장하고, ArgumentResolver가 이를 CorrelationId 값 객체로 바꾸어 Controller 매개변수에 전달합니다. Interceptor는 같은 요청 속성을 건드리지 않고 실행 시간만 기록합니다.


상관관계 ID를 필터와 인터셉터로 전달한다

먼저 모든 요청에 상관관계 ID를 보장하는 Filter와 선택된 handler의 실행 시간을 기록하는 Interceptor를 작성합니다.

src/main/java/com/andongmin/studylog/web/CorrelationIdFilter.java
package com.andongmin.studylog.web;

import java.io.IOException;
import java.util.UUID;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {
    public static final String ATTRIBUTE = CorrelationIdFilter.class.getName();

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String id = request.getHeader("X-Correlation-Id");
        if (id == null || id.isBlank()) {
            id = UUID.randomUUID().toString();
        }
        request.setAttribute(ATTRIBUTE, id);
        response.setHeader("X-Correlation-Id", id);
        chain.doFilter(request, response);
    }
}
src/main/java/com/andongmin/studylog/web/RequestTimingInterceptor.java
package com.andongmin.studylog.web;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class RequestTimingInterceptor implements HandlerInterceptor {
    private static final String START = RequestTimingInterceptor.class.getName();

    @Override
    public boolean preHandle(HttpServletRequest request,
            HttpServletResponse response, Object handler) {
        request.setAttribute(START, System.nanoTime());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
            HttpServletResponse response, Object handler, Exception failure) {
        long start = (long) request.getAttribute(START);
        String target = handler instanceof HandlerMethod method
                ? method.getMethod().getName() : handler.getClass().getSimpleName();
        System.out.printf("target=%s status=%d elapsedMicros=%d%n",
                target, response.getStatus(), (System.nanoTime() - start) / 1_000);
    }
}
src/main/java/com/andongmin/studylog/web/CorrelationId.java
package com.andongmin.studylog.web;

public record CorrelationId(String value) {
}
src/main/java/com/andongmin/studylog/web/CorrelationIdArgumentResolver.java
package com.andongmin.studylog.web;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
public class CorrelationIdArgumentResolver
        implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType() == CorrelationId.class;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter,
            ModelAndViewContainer container, NativeWebRequest webRequest,
            WebDataBinderFactory binderFactory) {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        return new CorrelationId(
                (String) request.getAttribute(CorrelationIdFilter.ATTRIBUTE));
    }
}
src/main/java/com/andongmin/studylog/web/WebConfiguration.java
package com.andongmin.studylog.web;

import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration(proxyBeanMethods = false)
public class WebConfiguration implements WebMvcConfigurer {
    private final RequestTimingInterceptor timing;
    private final CorrelationIdArgumentResolver correlationIds;

    public WebConfiguration(RequestTimingInterceptor timing,
            CorrelationIdArgumentResolver correlationIds) {
        this.timing = timing;
        this.correlationIds = correlationIds;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(timing).addPathPatterns("/api/**");
    }

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(correlationIds);
    }
}
src/main/java/com/andongmin/studylog/web/CorrelationController.java
package com.andongmin.studylog.web;

import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CorrelationController {
    @GetMapping("/api/correlation")
    Map<String, String> correlation(CorrelationId correlationId) {
        return Map.of("correlationId", correlationId.value());
    }
}

Filter가 chain.doFilter를 호출하지 않으면 DispatcherServlet과 Controller에는 도달하지 않습니다. 반대로 resolver가 실패하면 Filter가 이미 만든 응답 헤더는 남을 수 있습니다. 어느 로그와 헤더까지 관찰되는지가 멈춘 단계를 찾는 단서입니다.


정상·차단·예외 경로의 순서를 추적한다

서버와 요청은 두 터미널로 나눕니다. 지정한 ID가 응답 헤더와 JSON 본문에 모두 나타나고, 서버 로그에는 correlation handler와 실행 시간이 기록되어야 합니다.

터미널 1 — 서버 실행
./mvnw -q spring-boot:run
터미널 2 — 요청 추적
curl -i -H "X-Correlation-Id: lesson-2-4" \
  http://localhost:8080/api/correlation
Controller 확인 결과
응답 헤더와 JSON의 correlationId에는 lesson-2-4가 유지되고, 로그에는 선택된 handler와 elapsedMicros가 남아야 합니다.

횡단 관심사를 놓을 위치를 선택하는 기준

인증 전 원시 요청은 Filter, handler 정보가 필요한 로깅은 Interceptor, 반복되는 매개변수 변환은 ArgumentResolver에 둡니다. 단순히 먼저 실행하고 싶다는 이유로 Filter를 선택하면 MVC 예외 처리와 handler 정보를 사용하지 못할 수 있습니다.


요청 경계를 구분했으니 다음 장에서는 Controller 뒤의 서비스가 PostgreSQL 상태를 한 트랜잭션으로 변경하도록 영속 경계를 연결합니다.