CS/Spring

Spring MVC HandlerMapping / HandlerAdapter / HandlerInterceptor — 요청은 어떻게 컨트롤러에 도달하는가

dding-shark 2026. 4. 13. 20:00
728x90

Spring MVC HandlerMapping / HandlerAdapter / HandlerInterceptor — 요청은 어떻게 컨트롤러에 도달하는가


들어가며

HTTP 요청이 DispatcherServlet에 도착한 뒤 @GetMapping이 붙은 메서드에 도달하기까지 사이에는, 생각보다 많은 레이어가 있다. 필터를 통과하고, 핸들러 매핑이 URL을 해석하고, 인터셉터가 끼어들고, 어댑터가 시그니처를 맞춰주고, 그제서야 컨트롤러가 호출된다. 이 체인의 어느 칸에서 무슨 일이 벌어지는지 모르면 405/415/406 같은 오류 코드가 갑자기 튀어나왔을 때 원인을 짚기 어렵다. "왜 이 요청은 메서드 안에 진입도 못 했는데 415가 나오지?"에서 막힌다면 바로 이 체인 이야기다.

스프링 MVC는 이 체인을 교체 가능한 전략 패턴으로 구성한다. HandlerMapping은 URL을 보고 어떤 핸들러에 보낼지 결정하고, HandlerAdapter는 그 핸들러를 실제로 호출할 수 있는 형태로 중계하고, HandlerInterceptor는 호출 전후에 끼어든다. 이 세 개의 역할을 분리해 이해해야 요청 흐름의 모든 오작동이 "어느 레이어에서 일어났는가"로 정리된다. 특히 다음 세 가지는 디버깅에서 거의 매주 밟는다.

  • HandlerMapping은 한 개가 아니다 — 여러 개가 Ordered 순서대로 시도되고, 가장 먼저 매치한 놈이 이긴다
  • preHandlefalse를 반환하면 컨트롤러는 안 불린다 — 하지만 이미 true를 반환했던 인터셉터의 afterCompletion은 여전히 불린다
  • postHandle@RestController의 응답을 고치려는 건 이미 늦었다@ResponseBodyHandlerAdapter 안에서 본문을 다 써버리기 때문이다

이 글은 요청 진입 → HandlerMapping → HandlerAdapter → Interceptor → 응답 작성 → 예외 처리 순으로, Spring Framework 6.x / Spring Boot 3.x / Java 17+ 기준으로 정리한다.


목차


1) DispatcherServlet: 요청 체인의 진입점

스프링 MVC의 모든 HTTP 요청은 DispatcherServlet 하나를 통과한다. 부트에서는 기본 경로가 /로 자동 등록돼 있고, 사용자가 별다른 설정을 하지 않으면 모든 URL이 이 서블릿으로 모인다. 그 안에서 벌어지는 일이 이 글 전체의 주제다. doDispatch() 한 메서드가 체인 전체를 지휘한다.

요약하자면 순서는 이렇다. 첫째, HandlerMapping들에게 "이 요청에 맞는 핸들러가 있냐"고 묻는다. 둘째, 그 핸들러를 실행할 수 있는 HandlerAdapter를 찾는다. 셋째, 매핑에 걸린 HandlerInterceptor들을 훑어 preHandle을 호출한다. 넷째, 어댑터로 핸들러를 실제 실행한다. 다섯째, postHandle을 호출한다. 여섯째, 뷰 렌더링 또는 응답 본문 작성이 이뤄진다. 일곱째, 예외 여부와 상관없이 afterCompletion을 호출한다. 이 일곱 단계가 한 요청의 생애주기다.

// DispatcherServlet.doDispatch (발췌, 의사코드 수준)
HandlerExecutionChain mapped = getHandler(request);       // 1) HandlerMapping
HandlerAdapter ha = getHandlerAdapter(mapped.getHandler()); // 2) HandlerAdapter
if (!mapped.applyPreHandle(request, response)) return;    // 3) preHandle
ModelAndView mv = ha.handle(request, response, handler);  // 4) handler 실행
mapped.applyPostHandle(request, response, mv);            // 5) postHandle
processDispatchResult(request, response, mapped, mv, ex); // 6) 뷰/에러 처리
// 내부에서 afterCompletion 호출                          // 7)

이 코드를 머리에 넣고 가면 이후 모든 절이 "이 일곱 줄 중 어디의 이야기인가"로 읽힌다. 예외가 뜨는 위치, 인터셉터가 끼어드는 시점, 응답 본문이 써지는 순간이 전부 이 흐름의 특정 지점이다.


2) HandlerMapping: 여러 개가 순서대로 시도된다

여기가 가장 자주 오해되는 지점이다. HandlerMapping은 단일 전략이 아니다. 스프링 부트는 기본적으로 여러 개의 HandlerMapping 빈을 등록해두고, 요청이 올 때마다 Ordered에 따라 낮은 숫자부터 차례로 "이 요청 처리할 수 있냐"고 묻는다. 가장 먼저 매치한 매핑이 이긴다. 매치 실패는 다음 매핑으로 넘어가는 신호일 뿐 오류가 아니다. 모든 매핑이 null을 반환해야 비로소 404다.

2-1) 기본 매핑 우선순위

HandlerMapping order 역할
RouterFunctionMapping -1 함수형 라우팅 (RouterFunctions.route()...)
RequestMappingHandlerMapping 0 @RequestMapping / @GetMapping 계열
BeanNameUrlHandlerMapping 2 빈 이름이 /로 시작하면 URL로 매핑
SimpleUrlHandlerMapping (사용자 지정) 정적 리소스, 수동 URL→빈 매핑
WelcomePageHandlerMapping LOWEST_PRECEDENCE - 10 index.html 같은 웰컴 페이지

RouterFunctionMapping-1로 가장 먼저 시도되고, 그 다음이 @RequestMapping을 처리하는 RequestMappingHandlerMapping이다. 이 순서는 함수형 라우트가 어노테이션 라우트보다 먼저 먹는다는 의미다. 같은 URL을 함수형과 어노테이션 양쪽에 걸면 함수형이 이긴다. 의도한 게 아니라면 혼란의 원천이 된다.

2-2) LOWEST_PRECEDENCE의 함정

여기서 틀리기 쉬운 지점이 하나 있다. Ordered.LOWEST_PRECEDENCEInteger.MAX_VALUE다. 커스텀 HandlerMapping을 만들면서 order를 지정하지 않으면 스프링이 이 값을 기본값으로 가정하는 게 아니라, AbstractHandlerMapping의 구현에 따라 LOWEST_PRECEDENCE로 떨어진다. 즉 "가장 나중에 시도되는" 매핑이 된다. 의도가 "내 커스텀 매핑이 먼저 먹어야 한다"였다면, order를 명시하지 않은 그 순간 조용히 실패한다.

// 피하기: order 미지정
@Component
public class MyCustomHandlerMapping extends AbstractHandlerMapping {
    // order 지정 없음 → LOWEST_PRECEDENCE → 거의 안 탄다
    @Override
    protected Object getHandlerInternal(HttpServletRequest req) { ... }
}

// 선호: order를 명시
@Component
@Order(-100) // RouterFunctionMapping(-1)보다도 먼저
public class MyCustomHandlerMapping extends AbstractHandlerMapping {
    @Override
    protected Object getHandlerInternal(HttpServletRequest req) { ... }
}

커스텀 매핑이 "왜 안 타는가" 하는 의문이 들면 가장 먼저 order부터 확인하는 게 맞다. 자세한 정리는 §11 커스텀 HandlerMapping의 order 지정에서 다시 다룬다.


3) RequestMappingInfo: 6조건의 AND 평가와 HTTP 오류 코드

RequestMappingHandlerMapping@RequestMapping 메타데이터를 RequestMappingInfo 객체로 저장한다. 이 객체는 6개의 독립 조건을 들고 있고, 매칭은 전부 AND다. 하나라도 맞지 않으면 그 핸들러는 이 요청의 후보에서 빠진다. 그런데 재미있게도 어떤 조건이 틀렸느냐에 따라 돌아오는 HTTP 상태 코드가 다르다. 이게 405와 415를 구분하는 규칙이다.

3-1) 6개 조건

조건 어노테이션 불일치 시 응답
URL 패턴 @RequestMapping("/users/{id}") 404 Not Found
HTTP 메서드 method = POST 405 Method Not Allowed
Content-Type consumes = "application/json" 415 Unsupported Media Type
Accept produces = "application/json" 406 Not Acceptable
파라미터 params = "v=2" 404 (조건 불충족 핸들러 취급)
헤더 headers = "X-Foo=bar" 404

핵심은 URL만 맞으면 상태 코드가 405/415/406까지 상세해진다는 점이다. 스프링은 "URL에 걸리는 핸들러는 있지만 메서드가 안 맞는다"라는 상태를 알아내어 405를 돌려준다. URL이 아예 안 맞으면 그냥 404다. 이 구분은 RequestMappingInfoHandlerMapping.handleNoMatch()에서 수행된다.

3-2) 405가 나오는 흔한 시나리오

@RestController
public class UserController {

    @GetMapping("/users/{id}")
    public User get(@PathVariable long id) { ... }

    // POST 핸들러가 없다
}

클라이언트가 POST /users/1을 보내면 응답은 405다. 404가 아니다. 왜냐하면 URL /users/{id}에 걸리는 핸들러(GET)는 존재하기 때문이다. 스프링은 "URL은 매치되는데 method만 다르다"를 감지해 405를 돌려주고, Allow 헤더에 GET을 명시한다. 클라이언트가 오타를 쳐서 엉뚱한 URL을 쏜 게 아니라, 해당 리소스에 이 메서드가 정의돼 있지 않다는 뜻을 HTTP 프로토콜 수준에서 알려주는 설계다.

3-3) 415와 406이 나오는 시나리오

@PostMapping(value = "/users", consumes = MediaType.APPLICATION_JSON_VALUE,
             produces = MediaType.APPLICATION_JSON_VALUE)
public User create(@RequestBody UserCommand cmd) { ... }

클라이언트가 Content-Type: application/xml로 POST를 쏘면 415다. consumesapplication/json만 허용하기 때문이다. 반대로 Accept: application/xml로 요청하면 406이다. produces가 JSON만 낼 수 있다고 선언했기 때문이다. 두 경우 모두 핸들러 메서드 자체는 호출되지 않는다. HandlerMapping 레이어에서 거부당한다.

여기서 틀리기 쉬운 건 "왜 내가 찍은 컨트롤러 로그가 안 보이지?"라는 의문이다. 답은 단순하다. 컨트롤러에 진입도 못 했으니까. 415/406은 진입 이전의 프로토콜 협상 실패다.


4) PathPatternParser vs AntPathMatcher: Spring 6의 기본 전환

URL 패턴을 해석하는 엔진은 역사적으로 AntPathMatcher였다. **, *, {var} 같은 글로브를 단순 정규식 수준으로 풀어내는 구현이다. Spring 5.3부터 새 엔진 PathPatternParser가 도입됐고, Spring Framework 6 / Spring Boot 3부터는 PathPatternParser가 기본이 됐다. 둘은 호환 가능한 표현식이 대부분이지만, 세부 동작에서 의도적으로 엄격해졌다.

4-1) 왜 바꿨는가

AntPathMatcher는 트리 구조를 만들지 않고 문자열 비교로 패턴을 푼다. 복잡한 ** 조합에서 백트래킹 비용이 크고, 무엇보다 URL 파싱과 패턴 매칭이 분리돼 있지 않아 인코딩된 슬래시나 세미콜론 세그먼트 같은 경계 케이스에서 의도와 다르게 동작했다. PathPatternParser는 패턴을 미리 파스 트리로 컴파일해두고, 요청 URL을 RequestPath 객체로 구조화해서 매칭한다. 성능과 정확성 양쪽에서 개선이다.

자세한 비교는 공식 문서에 정리돼 있다.

4-2) Trailing slash 매칭이 제거됐다

여기가 가장 많이 터지는 호환성 함정이다. AntPathMatcher 시절에는 useTrailingSlashMatch가 기본 true였다. 즉 @GetMapping("/users")/users/로 와도 매치됐다. Spring 6부터는 이 플래그가 **기본 false**이고, 나아가 deprecated다. /users로 선언한 핸들러에 /users/로 들어오면 404가 돌아간다.

@GetMapping("/users")
public List<User> list() { ... }

// 클라이언트 요청:
// GET /users  → 200
// GET /users/ → 404 (Spring 6 기본)

이미 운영 중이던 서비스에서 Spring 5 → 6 업그레이드 후 이 문제로 다량의 404가 찍히는 일이 흔하다. 임시 호환을 위한 토글은 있다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void configurePathMatch(PathMatchConfigurer configurer) {
        configurer.setUseTrailingSlashMatch(true); // deprecated, 임시용
    }
}

임시책이다. 장기적으로는 클라이언트 쪽에서 trailing slash를 보내지 않도록 URL을 정리하거나, 리버스 프록시 레이어에서 슬래시를 정규화하는 쪽이 답이다. 플래그는 향후 제거될 가능성이 높다.

4-3) 경로 변수 안에 슬래시를 담지 못한다

AntPathMatcher**로 경로 변수를 흉내내는 편법이 있었다. PathPatternParser에서는 {*path} 형태로 명시적으로 선언해야 한다.

// AntPathMatcher 시절 관용구
@GetMapping("/files/**") // 뒤의 모든 경로를 변수처럼 쓰려고 했음

// PathPatternParser 권장
@GetMapping("/files/{*path}")
public Resource serve(@PathVariable String path) { ... }

{*path}는 맨 끝에만 쓸 수 있고, 슬래시를 포함한 전체 나머지 경로를 캡처한다. 정적 리소스를 하위 트리로 내려줄 때 자주 쓰는 패턴이다.


5) HandlerAdapter 4종과 supports() 판별

HandlerMapping이 찾아준 "핸들러"는 사실 타입이 정해져 있지 않다. @Controller 빈의 메서드일 수도, HttpRequestHandler를 구현한 빈일 수도, 함수형 HandlerFunction일 수도 있다. 이들 모두를 DispatcherServlet이 직접 알 필요는 없고, 대신 핸들러를 호출할 줄 아는 어댑터를 찾는다. 이게 HandlerAdapter다.

HandlerAdapter supports()가 true인 경우
RequestMappingHandlerAdapter HandlerMethod (어노테이션 기반 컨트롤러 메서드)
HandlerFunctionAdapter HandlerFunction (함수형 라우팅)
HttpRequestHandlerAdapter HttpRequestHandler 구현체 (정적 리소스 서빙 등)
SimpleControllerHandlerAdapter Controller 인터페이스 구현 (레거시)

DispatcherServlet은 등록된 어댑터들에게 순서대로 supports(handler)를 물어 true를 돌려주는 첫 놈을 쓴다. 대부분의 실무 코드는 RequestMappingHandlerAdapter 하나에서 끝난다. 나머지 셋은 각자 역할이 있지만 일상적으로는 눈에 띄지 않는다.

5-1) RequestMappingHandlerAdapter가 실제로 하는 일

이 어댑터는 HandlerMethod 하나를 호출하기 위해 메서드 인자를 전부 조립한다. @PathVariable, @RequestParam, @RequestBody, @RequestHeader 같은 어노테이션마다 등록된 HandlerMethodArgumentResolver가 각자 값을 만들어낸다. 반환값도 마찬가지로 HandlerMethodReturnValueHandler들이 타입에 따라 처리한다. @ResponseBody가 붙었으면 RequestResponseBodyMethodProcessorHttpMessageConverter로 직렬화해 응답 본문에 그대로 쓴다.

이 타이밍이 중요하다. @ResponseBody 처리는 HandlerAdapter.handle() 안에서 완료된다. 어댑터가 반환하기 전에 응답 본문이 이미 써진다는 뜻이다. 이 사실이 §9 postHandle이 @ResponseBody에서 무력화되는 이유의 핵심 근거가 된다.


6) HandlerInterceptor: preHandle / postHandle / afterCompletion

HandlerInterceptor는 핸들러 호출 전후에 끼어드는 DispatcherServlet 내부의 훅이다. 서블릿 필터와 비슷해 보이지만 경계가 다르다. 필터는 서블릿 컨테이너 레벨에서 동작하고, 인터셉터는 DispatcherServlet 안, 즉 HandlerMapping 이후에 동작한다. 이 차이는 §8 Filter vs Interceptor vs ControllerAdvice에서 자세히 본다.

public interface HandlerInterceptor {
    default boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { return true; }
    default void postHandle(HttpServletRequest req, HttpServletResponse res, Object handler, ModelAndView mv) {}
    default void afterCompletion(HttpServletRequest req, HttpServletResponse res, Object handler, Exception ex) {}
}

세 메서드의 계약은 다음과 같다.

메서드 호출 시점 반환/파라미터 특징
preHandle 핸들러 호출 false 반환 시 체인 중단, 핸들러 호출 안 함
postHandle 핸들러 호출 , 뷰 렌더링 ModelAndView 참조 수정 가능, @ResponseBody면 의미 없음
afterCompletion 뷰 렌더링까지 끝난 후 Exception ex로 성공/실패 여부 확인 가능

preHandletrue를 돌려주면 다음 인터셉터로 진행하고, 전부 통과해야 컨트롤러가 불린다. false를 돌려주는 순간 그 자리에서 체인이 멈추고, 컨트롤러는 호출되지 않는다. 응답을 이 인터셉터가 직접 써야 한다. 아니면 빈 200이 나간다.

6-1) preHandle=false 반환의 진짜 의미

여기서 두 번째 들어가며 불릿이 현실이 되는 순간이다. preHandlefalse를 반환해서 체인이 멈췄을 때, 이미 그 앞에서 true를 반환한 인터셉터들은 afterCompletion을 보장받는다. 즉 "A(true) → B(false)"의 순서라면, A의 afterCompletion은 여전히 호출된다. B 이후의 것들은 preHandlepostHandleafterCompletion도 호출되지 않는다.

public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        if (!isAuthenticated(req)) {
            res.setStatus(401);
            return false; // 여기서 체인 중단
        }
        return true;
    }
}

이 인터셉터 앞에 있던 로깅 인터셉터가 preHandle에서 요청 시작 타임스탬프를 찍어뒀다면, 그 인터셉터의 afterCompletion에서 "응답 완료" 로그도 반드시 찍힌다. 짝이 맞지 않을 걱정은 안 해도 된다. 이 보장이 리소스 정리(자원 해제, MDC 정리, 트랜잭션 토큰 해제)를 afterCompletion에 걸어도 안전하게 만드는 이유다.

6-2) postHandle과 afterCompletion의 차이

postHandle성공 경로에서만 호출된다. 핸들러가 예외를 던지면 스프링은 postHandle을 건너뛰고 바로 afterCompletion으로 간다. 따라서 "요청이 어떻게 끝났든 무조건 실행"이 필요하면 afterCompletion이 맞다. postHandle은 "성공한 응답을 추가로 가공"이 목적인 훅이다.


7) 여러 인터셉터의 실행 순서와 역순 규칙

인터셉터가 여러 개 걸린 경우, preHandle등록 순서로 실행되고 postHandleafterCompletion역순으로 실행된다. 이 규칙이 양파 껍질(onion) 구조를 만든다.

15_spring-mvc-handler-chain-01

A → B → C 순으로 preHandle이 불리고, 핸들러 실행 후 C → B → A 순으로 postHandle이 불린다. 그리고 응답이 완전히 끝난 뒤 다시 C → B → A 순으로 afterCompletion이 불린다. 열린 순서의 반대로 닫힌다는 규칙이 일관된다.

7-1) 등록은 어떻게 하는가

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoggingInterceptor())
                .addPathPatterns("/**")
                .order(0);

        registry.addInterceptor(new AuthInterceptor())
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**")
                .order(1);

        registry.addInterceptor(new RateLimitInterceptor())
                .addPathPatterns("/api/**")
                .order(2);
    }
}

order를 명시하지 않으면 등록한 순서가 그대로 실행 순서가 된다. 실무에서는 로깅 → 인증 → 인가 → 레이트 리밋 → 비즈니스 훅 순서가 관용적이다. 인증 실패로 preHandle=false가 나오면 뒤의 무거운 검사(레이트 리밋 카운터 증가 등)가 자동으로 생략되는 설계다.

7-2) 부분 실행의 짝 맞춤

다시 강조하면, 체인 중간에서 preHandle=false가 나와도 이미 preHandle=true를 돌려준 인터셉터의 afterCompletion은 호출된다. 예를 들어 A(true) → B(false) → C(호출 안 됨) 순서라면, A의 afterCompletion은 호출되고 B, C의 afterCompletion은 호출되지 않는다. 자원 해제 코드는 반드시 afterCompletion에 걸어야 이 보장을 받는다는 뜻이다. preHandle에서 열고 postHandle에서 닫으면, 핸들러가 예외를 던지는 순간 누수가 난다.


8) Filter vs Interceptor vs ControllerAdvice: 레이어 비교

세 레이어는 "요청/응답에 끼어든다"는 공통점이 있지만 경계가 전부 다르다. 구분 못 하면 "왜 여기서는 @PathVariable이 안 보이지?" 같은 당황스러운 순간이 온다.

15_spring-mvc-handler-chain-02

8-1) 세 레이어의 경계

레이어 동작 위치 보이는 것 대표 용도
Filter 서블릿 컨테이너 HttpServletRequest/Response CORS, 인증 토큰 파싱, 요청 래핑(Body 재사용), 로깅
Interceptor DispatcherServlet 내부 handler(= HandlerMethod) 접근 가능 인증/인가 공통 검사, MDC, 메트릭 훅
ControllerAdvice 컨트롤러 레이어 @PathVariable, @RequestBody 등 바인딩 완료 상태 전역 예외 처리(@ExceptionHandler), 응답 가공(ResponseBodyAdvice), 요청 전처리(@InitBinder, @ModelAttribute)

Filter가 가장 바깥이다. 스프링 없이도 동작하는 표준 서블릿 API다. 대신 스프링 MVC의 HandlerMethod가 뭔지는 모른다. 인증 토큰을 파싱해서 SecurityContext에 심는 일처럼, 요청이 아직 핸들러에 할당되기 전에 해야 하는 일은 여기가 자리다.

Interceptor는 HandlerMapping 이후, HandlerAdapter 호출 전이다. 즉 "어떤 컨트롤러 메서드에 갈지"는 이미 정해졌고, 그 HandlerMethod 객체에 접근할 수 있다. @RequireRole 같은 커스텀 어노테이션이 메서드에 붙었는지 체크하는 등 메서드 메타데이터 기반 검사는 여기가 제격이다. 단, 여전히 @RequestBody는 파싱되기 전이다.

ControllerAdvice는 컨트롤러와 같은 레벨이다. 메서드 인자가 전부 바인딩되고, 검증도 끝난 상태에서 동작한다. @ExceptionHandler가 핸들러 메서드에서 던진 예외를 받고, ResponseBodyAdvice는 응답 본문 직렬화 직전에 개입한다.

8-2) 어디에 어떤 책임을 둘까

  • HTTP 레벨 공통 처리(CORS, 압축, gzip, 요청 본문 래핑) → Filter
  • 컨트롤러 메타데이터 기반 검사(로깅, 메트릭, 커스텀 어노테이션 해석) → Interceptor
  • 도메인 예외 → HTTP 응답 매핑ControllerAdvice (@ExceptionHandler)
  • 응답 본문의 공통 래핑({"data": ..., "code": ..., "ts": ...}) → ControllerAdvice (ResponseBodyAdvice)

이 매핑을 지키면 레이어끼리 책임이 섞이지 않는다. 반대로 "응답 본문을 Interceptor에서 고치려고" 하는 순간 다음 섹션의 함정에 빠진다.


9) postHandle이 @ResponseBody에서 무력화되는 이유

이 글의 세 번째 들어가며 불릿이 바로 이 섹션이다. HandlerInterceptor.postHandle@RestController의 응답을 고치려는 시도는 거의 항상 실패한다. 이유는 단순하다.

9-1) @ResponseBody의 응답 본문은 어댑터 안에서 써진다

§5 HandlerAdapter에서 언급했듯, @ResponseBody가 붙은 메서드의 반환값은 RequestMappingHandlerAdapter.handle() 내부에서 HttpMessageConverter로 직렬화돼 응답 본문에 그대로 써진다. 어댑터가 ModelAndView(null)를 DispatcherServlet에 돌려준 시점에는 응답 본문이 이미 HttpServletResponse에 flush까지 된 상태일 수 있다. 그 다음에 postHandle이 불린다.

15_spring-mvc-handler-chain-03

postHandleModelAndView 파라미터는 @ResponseBody 경로에서는 **항상 null**이다. 뷰 이름을 바꿀 수도, 모델을 추가할 수도 없다. 그렇다고 response.getWriter()를 다시 열어 뭔가 쓰려고 하면 IllegalStateException이 튀어나오거나, 써지더라도 이미 전송된 본문 뒤에 조각이 덧붙는 이상한 응답이 된다.

9-2) 흔한 안티패턴

// 피하기: postHandle에서 @RestController 응답을 고치려는 시도
public class ResponseWrapperInterceptor implements HandlerInterceptor {
    @Override
    public void postHandle(HttpServletRequest req, HttpServletResponse res,
                           Object handler, ModelAndView mv) {
        // mv는 @ResponseBody 경로에서 null
        // res 본문은 이미 써졌거나 flush됨
        // → 여기서 래핑은 불가능
    }
}

이 코드는 "왜 안 되지?"의 원인이 프레임워크의 타이밍 설계에 있다는 걸 이해하면 고칠 방향이 명확해진다. 본문을 고치고 싶으면 본문이 써지기 전에 개입해야 한다. 그 지점이 다음 섹션이다.


10) ResponseBodyAdvice: postHandle의 올바른 대안

@RestController의 응답을 전역으로 가공하려면 ResponseBodyAdvice를 쓴다. 이 인터페이스는 HttpMessageConverter가 본문을 직렬화하기 직전에 호출되는 훅이다. 즉 §9의 타이밍 다이어그램에서 "HA -> MC: write" 직전 칸에 끼어든다.

@ControllerAdvice
public class ApiResponseWrappingAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType,
                            Class<? extends HttpMessageConverter<?>> converterType) {
        // 이미 ApiResponse로 감싸진 건 건너뛴다
        return !returnType.getParameterType().equals(ApiResponse.class);
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType,
                                   MediaType selectedContentType,
                                   Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                   ServerHttpRequest request, ServerHttpResponse response) {
        return ApiResponse.ok(body);
    }
}

이 Advice가 있으면 모든 @RestController 반환값이 자동으로 {"status": "OK", "data": ...} 형태로 감싸진다. 핸들러 메서드 시그니처는 건드리지 않는다. 컨트롤러는 여전히 도메인 객체만 반환하고, 공통 래핑은 여기가 책임진다.

10-1) supports() 필터의 중요성

supports()에서 반환 타입을 걸러내지 않으면 이미 래핑된 응답을 또 래핑하거나, 파일 다운로드용 Resource까지 JSON으로 감싸는 사고가 난다. 무엇을 가공하고 무엇을 패스할지를 명시적으로 선언하는 게 관용이다.

10-2) String 반환의 함정

반환 타입이 String일 때 beforeBodyWrite에서 다른 타입으로 바꿔 돌려주면 StringHttpMessageConverter가 이미 선택된 상태에서 다른 타입을 처리하려다 ClassCastException이 발생한다. String 반환 핸들러가 섞여 있으면 supports()에서 그 케이스를 배제하거나, 공통 래핑 대상에서 제외한다는 명시적 정책이 필요하다.


11) 커스텀 HandlerMapping의 order 지정

커스텀 HandlerMapping을 만드는 상황은 드물지만, 만들 때는 order를 반드시 명시해야 한다. §2에서 이야기한 "무음 실패"의 정체를 이 섹션에서 마무리한다.

@Component
public class FeatureFlagHandlerMapping extends AbstractHandlerMapping {

    public FeatureFlagHandlerMapping() {
        // RequestMappingHandlerMapping(0)보다 먼저 시도되어야
        // 기능 플래그로 요청을 가로채 대체 핸들러로 돌릴 수 있다
        setOrder(-50);
    }

    @Override
    protected Object getHandlerInternal(HttpServletRequest req) {
        if (isShadowMode(req)) {
            return shadowHandler;
        }
        return null; // null이면 다음 매핑으로 넘어간다
    }
}

setOrder(-50) 없이 이 빈을 등록하면 RequestMappingHandlerMapping이 먼저 매치해버리고, 커스텀 매핑은 호출조차 되지 않는다. 로그를 아무리 찍어도 안 나오니 "매핑이 등록 안 된 건가?" 쪽으로 의심이 가기 쉽지만, 빈은 멀쩡히 있고 HandlerMapping 리스트에도 들어가 있다. 단지 순번이 늦어서 차례가 안 오는 것뿐이다. 이게 무음 실패의 정체다.

11-1) 디버깅 체크리스트

  • ActuatorEndpoint.mappings에서 HandlerMapping 리스트와 order를 확인한다
  • setOrder(...)를 명시했는지 확인한다 (기본값 LOWEST_PRECEDENCE를 의심)
  • getHandlerInternal에 로그를 찍어 실제로 호출되는지 본다
  • 매치되는데 이후 단계에서 막히는 거라면 HandlerAdapter.supports() 쪽을 본다

12) 실무에서 이렇게 읽고 쓴다

  • 404가 나오면 먼저 HandlerMapping을 의심한다. URL 오타/trailing slash/프로파일 차이 중 하나다
  • 405/415/406은 컨트롤러 진입 전 실패다. 핸들러 로그가 안 찍힌다고 당황하지 말고 RequestMappingInfo의 어느 조건이 안 맞는지 본다
  • Spring 6 업그레이드 후 404가 급증하면 trailing slash부터 확인한다. 임시로 setUseTrailingSlashMatch(true)를 켜고, 장기적으로는 클라이언트 URL을 정규화한다
  • 인터셉터의 자원 해제는 반드시 afterCompletion에 건다. postHandle은 핸들러 예외 시 건너뛰기 때문에 누수 위험이 있다
  • 인증 인터셉터는 preHandle에서 false를 반환하고 응답을 직접 쓴다. 이때 앞서 true를 돌려준 인터셉터의 afterCompletion은 여전히 호출되므로 짝 맞춤은 프레임워크가 책임진다
  • @RestController 응답의 공통 래핑postHandle이 아니라 ResponseBodyAdvice다. 실패한 팀은 대부분 이 둘을 혼동한다
  • 커스텀 HandlerMapping이 "안 탄다"면 setOrder() 미지정을 의심한다. LOWEST_PRECEDENCE 기본값 때문에 순번이 뒤로 밀린다
  • 함수형 라우팅과 @RequestMapping을 같은 URL에 둔다면 RouterFunctionMapping(-1)이 먼저 이긴다는 사실을 기억한다
  • 인터셉터 로깅preHandle에서 MDC.put, afterCompletion에서 MDC.clear가 관용이다. postHandle에서 clear하면 예외 경로에서 컨텍스트가 누수된다

13) 한 줄 정리

요청이 컨트롤러에 도달하기까지는 HandlerMapping이 URL을 해석하고, HandlerAdapter가 호출을 중계하고, HandlerInterceptor가 전후에 끼어드는 세 레이어를 순서대로 통과한다. 이 순서를 알고 나면 404와 405, 415와 406의 차이가 "어느 레이어가 막았는가"로 구별된다. postHandle@ResponseBody를 고칠 수 없는 이유도, preHandle=false 이후 afterCompletion이 여전히 불리는 이유도 전부 이 체인의 타이밍 설계에서 나오는 필연이다.


태그: Spring MVC, DispatcherServlet, HandlerMapping, HandlerAdapter, HandlerInterceptor, PathPatternParser, ResponseBodyAdvice, ControllerAdvice, RequestMappingInfo, Spring 6

728x90