Spring MVC는 요청을 어떻게 핸들러에 연결하는가
애플리케이션 시작 시 @RequestMapping을 스캔해 MappingRegistry에 등록하는 과정부터, URL 패턴 매칭·조건 평가·URI 변수 추출까지 요청 라우팅의 전 과정을 추적한다.
- 01 DispatcherServlet은 어떻게 HTTP 요청을 응답으로 만드는가
- 02 Spring MVC는 요청을 어떻게 핸들러에 연결하는가
- 03 Spring MVC는 파라미터를 어떻게 바인딩하는가
- 04 Spring MVC는 반환값을 어떻게 HTTP 응답으로 바꾸는가
- 05 Spring MVC 예외는 어떻게 응답이 되는가
- 06 Spring MVC 요청 파이프라인은 어떻게 설계됐는가
- 07 Spring MVC는 어떻게 요청 하나를 처리하는가
GET /users/42 요청이 들어왔을 때 Spring MVC는 어떻게 UserController.getUser(Long id) 메서드를 찾아 실행하는가? 단순히 “어노테이션을 스캔한다”는 설명으로는 부족하다. 스캔은 언제, 어떤 구조로 저장되며, 요청마다 어떤 순서로 조건을 평가하는가?
시작 시 한 번 — MappingRegistry 등록
요청이 올 때마다 모든 빈을 스캔하면 응답 시간이 폭증한다. Spring MVC는 ApplicationContext.refresh() 완료 직후 RequestMappingHandlerMapping.afterPropertiesSet()을 한 번 실행해 모든 @Controller 빈의 메서드를 순회하고 결과를 MappingRegistry에 저장한다.
afterPropertiesSet()
→ initHandlerMethods()
→ 모든 Bean 중 @Controller 또는 @RequestMapping 있는 것 선별
→ detectHandlerMethods(beanName)
→ ClassUtils.getUserClass() ← CGLIB 프록시면 원본 클래스 추출
→ MethodIntrospector.selectMethods()
→ 각 메서드에 getMappingForMethod() 호출
→ @RequestMapping 있으면 RequestMappingInfo 생성
→ registerHandlerMethod()
→ MappingRegistry.register()
getMappingForMethod()는 메서드 레벨 @RequestMapping과 클래스 레벨 @RequestMapping을 각각 읽어 combine()으로 합친다. @GetMapping("/users/{id}")가 @RequestMapping("/users")가 붙은 클래스 안에 있으면, 등록되는 것은 합쳐진 {GET [/users/{id}]}다. @GetMapping은 별도 메커니즘이 아니라 @RequestMapping(method=GET)을 메타 어노테이션으로 가진 축약형이므로 처리 경로가 동일하다.
MappingRegistry.register()는 쓰기 락을 잡고 Map<RequestMappingInfo, MappingRegistration>에 항목을 추가하면서 동시에 중복 검사를 수행한다. 동일한 RequestMappingInfo에 두 개의 핸들러가 등록되면 이 시점에 IllegalStateException이 발생한다 — 런타임이 아니라 시작 시점에.
요청마다 — 조건 평가 순서
등록된 후 요청이 오면 lookupHandlerMethod()가 실행된다. 모든 RequestMappingInfo에 대해 getMatchingCondition(request)를 호출하고 통과한 후보 중 가장 구체적인 것을 선택한다.
조건 평가는 단락 평가다. 하나라도 null을 반환하면 즉시 탈락한다.
① HTTP 메서드 조건 (RequestMethodsRequestCondition)
② 쿼리 파라미터 조건 (ParamsRequestCondition)
③ 헤더 조건 (HeadersRequestCondition)
④ consumes 조건 — Content-Type 헤더
⑤ produces 조건 — Accept 헤더
⑥ URL 패턴 조건 (PathPatternsRequestCondition)
⑦ 커스텀 조건
URL 패턴이 마지막에 평가된다는 점이 흥미롭다. URL이 일치해도 Content-Type이 맞지 않으면 탈락한다. 조건 불일치가 발생했을 때 URL 자체가 등록된 매핑이 있으면 404가 아닌 더 구체적인 오류를 반환한다.
| 불일치 조건 | HTTP 상태 코드 |
|---|---|
| HTTP 메서드 | 405 Method Not Allowed + Allow 헤더 |
| consumes (Content-Type) | 415 Unsupported Media Type |
| produces (Accept) | 406 Not Acceptable |
| params / headers / URL | 404 Not Found |
HEAD 요청은 GET 핸들러를 실행하되 응답 본문만 차단한다. Content-Length는 실제 본문 크기 그대로 반환된다. OPTIONS 요청은 명시적 핸들러가 없으면 HttpOptionsHandler가 자동으로 등록된 메서드 목록을 Allow 헤더에 채워 응답한다.
URL 패턴 — AntPathMatcher에서 PathPatternParser로
Spring 5.3 이후 기본 매처는 PathPatternParser다. 핵심 차이는 컴파일 시점이다. AntPathMatcher는 매 요청마다 패턴 문자열을 파싱하지만, PathPatternParser는 시작 시 패턴을 트리 구조(PathPattern 객체)로 컴파일해둔다. 요청마다 컴파일된 트리를 순회하므로 재파싱이 없다.
패턴 문법에서 주의할 점은 **의 위치 제약이다. PathPatternParser는 **를 패턴 끝에만 허용한다. /a/**/b 형태는 AntPathMatcher에서만 동작한다.
구체성 우선순위 (높은 것이 먼저 선택됨):
/users/admin 정적 URL
/users/{id:[0-9]+} 정규식 제약 변수
/users/{id} 일반 변수
/users/* 단일 와일드카드
/users/** 이중 와일드카드
PathPattern.matchAndExtract()는 매칭과 URI 변수 추출을 동시에 수행한다. 추출된 변수는 request.setAttribute(URI_TEMPLATE_VARIABLES_ATTRIBUTE, {"id":"42"})로 저장된다. 이후 PathVariableMethodArgumentResolver가 이 속성을 꺼내 ConversionService를 통해 String "42" → Long 42L 변환을 수행한다. 변환에 실패하면 MethodArgumentTypeMismatchException → 400 Bad Request다.
Content Negotiation — 두 레벨에서 일어난다
같은 URL에서 JSON과 XML을 모두 지원해야 한다면 produces 조건으로 핸들러를 분리할 수 있다. 이것이 첫 번째 레벨 — 핸들러 매핑 단계의 Content Negotiation이다.
@GetMapping(value = "/users/{id}", produces = "application/json")
public User asJson(@PathVariable Long id) { ... }
@GetMapping(value = "/users/{id}", produces = "application/xml")
public User asXml(@PathVariable Long id) { ... }
produces 조건이 없는 경우엔 두 번째 레벨에서 처리된다. 핸들러가 실행된 후 AbstractMessageConverterMethodProcessor가 Accept 헤더와 등록된 HttpMessageConverter 목록을 비교해 직렬화 형식을 결정한다. 교집합이 없으면 이 단계에서 406이 반환된다.
Accept: text/html,application/json;q=0.9,*/*;q=0.8 같은 헤더에서 q 값이 클수록 더 선호하는 형식이다. */*는 어떤 형식이든 받겠다는 의미지만 q=0.8로 낮은 우선순위를 가진다.
트레이드오프
시작 시 일괄 스캔은 요청 처리 시 탐색 비용을 O(1)으로 만들지만, 시작 시간이 빈 수에 비례해 늘어난다. 런타임에 매핑을 변경하려면 MappingRegistry의 읽기/쓰기 락을 직접 다뤄야 한다 — 가능하지만 권장되지 않는다.
어노테이션 기반 선언은 코드와 URL이 함께 있어 IDE 지원이 좋지만, 전체 URL 목록을 한곳에서 볼 수 없다. Actuator의 /actuator/mappings 엔드포인트나 RequestMappingHandlerMapping.getHandlerMethods()로 보완할 수 있다.
PathPatternParser로의 전환은 성능을 높이지만 중간 ** 패턴을 사용하는 레거시 코드는 마이그레이션이 필요하다. Spring Security가 여전히 AntPathMatcher를 사용한다면 동일한 URL 패턴이 두 매처에서 다르게 매칭되는 보안 취약점이 생길 수 있다 — Spring Security 5.8+에서 PathPatternRequestMatcher로 일치시키는 것이 권장된다.
정리
@RequestMapping스캔은 애플리케이션 시작 시 단 한 번 실행된다. 요청마다 스캔하지 않는다.- 조건 평가는 단락 평가다. HTTP 메서드 → consumes → produces → URL 패턴 순서로 하나라도 실패하면 그 핸들러는 탈락한다.
PathPatternParser는 패턴을 컴파일해 재파싱을 없앤다.**는 패턴 끝에만 쓸 수 있다.- URI 변수 추출(
{id}→42L)은 HandlerMapping 단계와 ArgumentResolver 단계로 나뉜다. - Content Negotiation은 핸들러 매핑 단계(
produces조건)와 응답 생성 단계(HttpMessageConverter) 두 레벨에서 독립적으로 동작한다.
다음 글에서는 핸들러가 선택된 이후 — @RequestBody, @RequestParam, @ModelAttribute 같은 인자들이 어떤 체인으로 해석되는지, HandlerMethodArgumentResolver의 구조를 추적한다.