포스트

Spring

spring-internals-lab로 다시 읽는 Spring 8 - DispatcherServlet과 MVC 요청 흐름

시리즈 spring-internals-lab로 다시 읽는 Spring 9편 중 8편 spring-internals-lab로 다시 읽는 Spring
  1. 1 spring-internals-lab로 다시 읽는 Spring 1 - 이 프로젝트는 무엇을 검증하려는가
  2. 2 spring-internals-lab로 다시 읽는 Spring 2 - BeanDefinition과 등록 단계
  3. 3 spring-internals-lab로 다시 읽는 Spring 3 - refresh()와 Bean 생성 파이프라인
  4. 4 spring-internals-lab로 다시 읽는 Spring 4 - 의존성 주입과 후보 선택
  5. 5 spring-internals-lab로 다시 읽는 Spring 5 - Bean lifecycle과 후처리기
  6. 6 spring-internals-lab로 다시 읽는 Spring 6 - 프록시와 AOP
  7. 7 spring-internals-lab로 다시 읽는 Spring 7 - @Transactional의 실체
  8. 8 spring-internals-lab로 다시 읽는 Spring 8 - DispatcherServlet과 MVC 요청 흐름
  9. 9 spring-internals-lab로 다시 읽는 Spring 9 - Spring Boot는 무엇을 자동으로 조립하는가

GitHub 저장소

이번 글의 질문

Spring MVC를 처음 배울 때는 @GetMapping과 @RequestBody부터 보게 된다. 하지만 이 시점에는 아직 가장 중요한 질문이 비어 있다.

  • 요청은 어떤 순서로 컨트롤러 메서드에 도달하는가
  • HandlerMapping과 HandlerAdapter는 왜 분리되어 있는가
  • 리터럴 경로와 변수 경로가 겹치면 누가 이기는가
  • 인터셉터는 어디서 실행되고, 예외나 차단 상황에서는 무엇이 생략되는가

이번 글은 dispatcher-servlet-trace와 mini-webmvc를 바탕으로, MVC를 Front Controller(모든 요청을 한 진입점에서 받는 구조)와 책임 분리 파이프라인으로 읽는다.

DispatcherServlet 자체는 의외로 단순하다

핵심 흐름만 줄이면 거의 이렇게 볼 수 있다.

1
2
3
4
5
6
handler = getHandler(request);
adapter = getHandlerAdapter(handler);
mappedHandler.applyPreHandle(...);
result = adapter.handle(...);
mappedHandler.applyPostHandle(...);
processDispatchResult(...);

DispatcherServlet은 일을 직접 처리하지 않고, 처리할 객체를 찾아서 위임한다.

flowchart TD
    A["HTTP Request"] --> B["HandlerMapping으로 핸들러 찾기"]
    B --> C["HandlerAdapter로 실제 호출"]
    C --> D["반환값 처리"]
    D --> E["응답 생성"]

공식 문서도 같은 구조로 설명한다. DispatcherServlet은 “provides a shared algorithm for request processing, while actual work is performed by configurable delegate components”(요청 처리의 공통 알고리즘만 제공하고, 실제 일은 설정 가능한 위임 컴포넌트가 한다)(DispatcherServlet).

HandlerMapping과 HandlerAdapter를 왜 굳이 나눌까

타입질문
HandlerMapping이 요청에 맞는 핸들러가 무엇인가
HandlerAdapter그 핸들러를 실제로 어떻게 호출할 것인가

이 둘을 분리하지 않으면, 새로운 핸들러 형태가 생길 때마다 라우팅 로직까지 같이 수정해야 한다.

반대로 분리하면 다음이 가능하다.

  • 애노테이션 기반 핸들러
  • HttpRequestHandler
  • 레거시 Controller

같은 요청 매핑 시스템 안에서 서로 다른 “호출 방식”을 공존시킬 수 있다. 그래서 MVC의 유연성은 애노테이션 문법보다 찾기와 실행을 나눈 설계에서 나온다.

실제 관찰에서 드러난 세 가지 포인트

dispatcher-servlet-trace 실험에서 확인한 것은 세 가지다.

1. 리터럴 경로가 변수 경로보다 우선한다

/users/me와 /users/{id}가 같이 있으면 등록 순서가 아니라 리터럴 경로가 우선된다.

만약 등록 순서에 의존했다면 컴포넌트 스캔 순서나 설정 구조가 바뀔 때 라우팅 결과도 흔들릴 수 있다.

Spring은 대신 “더 구체적인 패턴이 이긴다”는 고정 규칙을 쓴다. 공식 문서 기준으로 URI 변수와 와일드카드가 적을수록 더 구체적이다(Pattern Comparison). /users/me는 변수가 0개, /users/{id}는 1개이므로 /users/me가 앞에 온다.

2. 매핑 못 찾으면 404로 끝난다

매칭되는 핸들러가 없으면 곧바로 404다. 컨트롤러 예외 처리까지 가지 않고, 핸들러 탐색 단계에서 끝난다.

3. 처리하지 못한 예외는 그대로 전파될 수 있다

컨트롤러가 던진 모든 예외가 자동으로 정돈된 HTTP 응답이 되는 건 아니다. 적절한 ExceptionResolver가 처리하지 못하면 예외는 그대로 밖으로 나간다.

공식 문서에 따르면 resolver가 null을 돌려주면 다음 resolver가 시도하고, 끝까지 처리되지 않은 예외는 서블릿 컨테이너까지 올라간다(Exceptions). 그래서 MVC의 예외 처리는 “무조건 500 응답 생성”보다 변환 시도에 가깝다.

인터셉터는 양파 구조지만, 예외가 나면 일부 단계는 생략된다

인터셉터의 핵심 메서드는 세 가지다.

  • preHandle
  • postHandle
  • afterCompletion

하지만 이 셋이 항상 다 호출되는 건 아니다.

상황호출 결과
정상 처리preHandle -> postHandle -> afterCompletion
preHandle=false컨트롤러 미호출, 이미 통과한 인터셉터만 afterCompletion
컨트롤러 예외postHandle 생략, afterCompletion은 실행 가능

표의 “이미 통과한 인터셉터만”은 Javadoc의 규칙과 같다. afterCompletion은 그 인터셉터의 preHandle이 true를 반환한 경우에만 호출된다(HandlerInterceptor).

이 구조는 트랜잭션의 try/finally와 비슷하다. postHandle은 정상 결과를 다듬는 훅이고, afterCompletion은 뒷정리 훅에 가깝다. 다만 @ResponseBody 메서드는 HandlerAdapter 안에서 응답을 이미 커밋하므로, postHandle에서 헤더를 바꾸기엔 늦다(Interception).

flowchart TD
    A["preHandle 1"] --> B["preHandle 2"]
    B --> C{"false 반환?"}
    C -- yes --> D["이미 통과한 인터셉터 afterCompletion"]
    C -- no --> E["컨트롤러 호출"]
    E --> F{"예외 발생?"}
    F -- no --> G["postHandle 역순"]
    G --> H["afterCompletion 역순"]
    F -- yes --> H

JSON 응답도 자동이 아니라 협력 객체 덕분이다

실험에서 실제로 겪은 함정이 있다. Jackson이 없으면 @RestController가 있다고 해서 JSON 응답이 저절로 만들어지지 않는다.

아래는 모두 별도 협력 객체의 역할이다.

  • @RequestBody 역직렬화
  • 객체를 JSON으로 직렬화
  • ResponseEntity 처리

그래서 @RestController는 그 자체로 기능이라기보다, 여러 메시지 변환기와 반환값 처리기가 함께 동작한 결과에 가깝다.

mini-webmvc는 구조의 핵심만 남긴다

mini-webmvc의 MiniDispatcherServlet은 아주 작은 반복문으로 시작한다.

1
2
3
4
for (HandlerMapping mapping : handlerMappings) {
    HandlerMethod handler = mapping.getHandler(request);
    if (handler != null) return handler;
}

이 구현이 보여주는 사실은 단순하다.

  • Front Controller 자체는 복잡하지 않다.
  • 복잡성은 handler mapping, adapter, argument resolution, return value handling 쪽에 쌓인다.

DispatcherServlet은 흐름을 조율하는 coordinator다.

정리

Spring MVC는 컨트롤러 메서드를 호출하는 프레임워크라기보다, 라우팅, 호출, 변환, 예외 처리, 인터셉션을 각각 다른 객체에 맡긴 파이프라인이다.

다음 글에서는 마지막으로 Spring Boot 쪽을 본다. SpringApplication, 자동 설정, 조건부 설정이 어떻게 기존 컨테이너 위에 얇게 얹히는지 정리한다.

참고

Spring과 JVM 백엔드
이 글은 저작권자의 CC BY 4.0 라이선스를 따릅니다.

변경이력

1번 수정

  1. docs(notes): cite spring mvc docs in the dispatcher servlet post

댓글

아직 댓글이 없습니다