이 글에서 다루는 것
"Spring 소스 파헤치기" 시리즈의 스물두 번째 글입니다. 21편에서 DispatcherServlet은 요청마다 두 가지를 묻기만 했습니다. HandlerMapping에게 "이 요청은 누가 맡습니까", HandlerAdapter에게 "이 핸들러를 부를 줄 압니까". 이번 글은 그 질문을 받는 쪽, 그 가운데서도 우리가 매일 쓰는 @RequestMapping 방식을 맡는 두 클래스를 읽습니다. 1편 지도에서는 Phase 5(웹)의 U20입니다.
답하려는 질문은 네 가지입니다.
@GetMapping("/users/{id}")를 붙인 메서드는 언제, 어디에 등록되는가- 요청 하나에 맞는 메서드가 여럿이면 무엇을 기준으로 하나를 고르고, 못 고르면 어떻게 되는가
- 고른 메서드를 부를 때
@PathVariable long id,@RequestBody User user같은 인자는 누가 채우는가 - 메서드가 돌려준 값은 어떻게 응답 본문이 되기도 하고 뷰 이름이 되기도 하는가
| 항목 | 내용 |
|---|---|
| 기준 소스 | Spring Framework 7.1.0-SNAPSHOT |
| 커밋 | f447f3c310 (2026-10-01, main 브랜치) |
| 주로 읽는 모듈 | spring-webmvc (web.servlet.handler, web.servlet.mvc.method), spring-web (web.method) |
| 주로 읽는 클래스 | AbstractHandlerMethodMapping, RequestMappingHandlerMapping, RequestMappingInfo, RequestMappingHandlerAdapter, InvocableHandlerMethod, HandlerMethodArgumentResolverComposite |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 다른 버전에서는 줄 번호가 다르고 세부 내용도 달라질 수 있습니다. 같은 커밋의 소스를 받는 방법은 1편에 있습니다.
이 글은 Spring을 처음 보는 분이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "더 깊이 들어가면"에 이름만 적어 두었습니다.
먼저 알아 둘 말
핸들러(handler)는 요청을 실제로 처리하는 객체입니다. DispatcherServlet은 핸들러를 그냥 Object로 다루고, 그래서 "찾는 부품"(HandlerMapping)과 "부를 줄 아는 부품"(HandlerAdapter)이 따로 있습니다. 여기까지는 21편에서 봤습니다.
HandlerMethod 는 "어느 빈의 어느 메서드"를 한 객체로 묶은 것입니다(HandlerMethod 71행). @RequestMapping 방식에서는 컨트롤러 클래스가 아니라 메서드 하나하나가 핸들러이고, 그것을 담는 그릇이 이 클래스입니다.
매핑(mapping)은 "이런 요청은 이 핸들러가 맡는다"는 짝입니다. @RequestMapping 방식에서 "이런 요청"을 나타내는 객체가 RequestMappingInfo 이고, 그 안에는 경로, HTTP 메서드, 파라미터, 헤더 같은 조건(RequestCondition)이 여러 개 들어 있습니다.
인자 리졸버(HandlerMethodArgumentResolver)는 컨트롤러 메서드의 파라미터 하나에 넣을 값을 요청에서 만들어 내는 객체입니다. resolve는 "풀어내다"라는 뜻입니다. @RequestParam용, @PathVariable용처럼 종류마다 하나씩 있습니다.
반환값 핸들러(HandlerMethodReturnValueHandler)는 반대쪽입니다. 컨트롤러 메서드가 돌려준 값을 받아 응답에 쓰거나 "이 이름의 뷰를 그려라"라고 적어 둡니다. 이름에 핸들러가 들어가지만 위의 "핸들러"(컨트롤러 메서드)와는 다른 것입니다.
HttpMessageConverter 는 요청 본문을 객체로, 객체를 응답 본문으로 바꾸는 부품입니다(JSON ↔ 객체 등). 20편의 주제이고, 이 글에서는 "누가 이것을 부르는가"까지만 봅니다.
1. 찾는 쪽과 부르는 쪽 — 두 인터페이스와 그 구현들
두 인터페이스는 아주 작습니다. HandlerMapping에서 구현해야 하는 것은 getHandler(request) 하나이고(187행), HandlerAdapter는 supports(handler)와 handle(request, response, handler)입니다(62행, 76행). DispatcherServlet은 가진 매핑들에게 차례로 물어 처음 null이 아닌 답을 쓰고(1154–1164행), 그 핸들러를 supports하는 첫 어댑터에게 맡깁니다(1185–1195행).
따로 빈을 등록하지 않았을 때의 기본 구현은 DispatcherServlet.properties에 적혀 있습니다(7–14행). 21편에서 본 파일입니다.
| 핸들러의 모양 | 찾는 쪽 (HandlerMapping) |
부르는 쪽 (HandlerAdapter) |
|---|---|---|
@RequestMapping이 붙은 메서드 |
RequestMappingHandlerMapping |
RequestMappingHandlerAdapter |
이름이 /로 시작하는 빈 |
BeanNameUrlHandlerMapping |
빈의 타입에 따라 아래 둘 가운데 하나 |
HttpRequestHandler를 구현한 객체 |
(URL 방식 매핑) | HttpRequestHandlerAdapter (42–44행) |
옛 Controller 인터페이스를 구현한 객체 |
(URL 방식 매핑) | SimpleControllerHandlerAdapter (41–43행) |
함수형 HandlerFunction |
RouterFunctionMapping |
HandlerFunctionAdapter (86–88행) |
아래 세 줄의 어댑터는 supports가 instanceof 한 줄입니다. HttpRequestHandlerAdapter와 SimpleControllerHandlerAdapter는 handle도 그 인터페이스의 메서드를 그대로 부르는 것이 전부이고, HandlerFunctionAdapter는 요청을 ServerRequest로 감싸 함수를 부른 뒤 돌려받은 ServerResponse를 응답에 씁니다. 일이 많은 것은 첫 줄입니다. 메서드마다 파라미터 모양이 다르니, 부르기 전에 인자를 하나하나 마련해야 하기 때문입니다. 이 글의 나머지는 모두 첫 줄 이야기입니다.
RequestMappingHandlerMapping은 네 층의 상속 맨 아래에 있습니다.
왼쪽 줄기가 이 글에서 읽는 네 층입니다. 노란 상자는 매핑을 쌓아 두는 저장소, 회색 상자는 이름만 짚고 넘어가는 다른 구현입니다.
| 층 | 맡은 일 | 하위 클래스에 남긴 것 |
|---|---|---|
AbstractHandlerMapping (82행) |
getHandler()의 틀. 핸들러를 받아 인터셉터와 함께 체인으로 감싼다 (542–591행) |
getHandlerInternal() — 핸들러를 실제로 찾기 |
AbstractHandlerMethodMapping<T> (68행) |
켜질 때 빈을 훑어 메서드를 등록하고, 요청이 오면 가장 잘 맞는 것을 고른다. 매핑의 타입은 T로 비워 둔다 |
무엇이 핸들러인가(isHandler), 메서드의 매핑은 무엇인가(getMappingForMethod), 요청에 맞는가(getMatchingMapping), 누가 더 잘 맞는가(getMappingComparator) (499–534행) |
RequestMappingInfoHandlerMapping (68행) |
T를 RequestMappingInfo로 정하고, 맞는지·누가 더 맞는지를 그 객체에게 그대로 묻는다 (103–113행) |
어노테이션 읽기 |
RequestMappingHandlerMapping (77행) |
@Controller를 핸들러로 보고, @RequestMapping을 읽어 RequestMappingInfo를 만든다 |
— |
"등록하고 고르는 알고리즘"과 "어노테이션을 읽는 일"이 다른 층에 있다는 점이 요점입니다. 가운데 두 층은 어노테이션을 전혀 모릅니다.
2. 켜질 때: 매핑은 afterPropertiesSet에서 쌓입니다
요청이 올 때마다 모든 컨트롤러의 어노테이션을 읽으면 느립니다. 그래서 매핑은 컨테이너가 켜질 때 한 번 만들어 둡니다. 그 자리가 afterPropertiesSet()입니다. AbstractHandlerMethodMapping은 InitializingBean을 구현하고(68행), 컨테이너는 빈의 의존성 주입이 끝나면 이 메서드를 불러 줍니다. 이 콜백이 언제 불리는지는 6편에서 봤습니다.
@Override
public void afterPropertiesSet() {
initHandlerMethods();
}
// ...
protected void initHandlerMethods() {
for (String beanName : getCandidateBeanNames()) {
if (!beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) {
processCandidateBean(beanName);
}
}
handlerMethodsInitialized(getHandlerMethods());
}
getCandidateBeanNames()는 컨테이너에 있는 모든 빈의 이름을 돌려줍니다(getBeanNamesForType(Object.class), 232–236행). 빈마다 processCandidateBean()이 타입을 알아내고, 그 타입이 핸들러이면 detectHandlerMethods()로 넘깁니다(249–263행). 여기서 빈을 만들지는 않습니다. 이름으로 타입만 묻습니다.
파란 상자는 시작점인 초기화 콜백과 하위 클래스가 어노테이션을 읽는 자리, 노란 상자가 저장소입니다.
무엇이 핸들러인가
@Override
protected boolean isHandler(Class<?> beanType) {
return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
}
클래스에 @Controller가 있으면 핸들러입니다. @RestController는 @Controller를 메타 어노테이션으로 달고 있어서(RestController 48–49행) 같이 걸립니다. 어노테이션 위의 어노테이션까지 찾아 주는 AnnotatedElementUtils는 13편에서 봤습니다.
메서드에서 RequestMappingInfo 만들기
detectHandlerMethods()는 클래스의 메서드를 훑으며 메서드마다 getMappingForMethod()를 부르고, null이 아닌 것만 모아 등록합니다(270–297행).
@Override
protected @Nullable RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
validateCglibProxyMethodVisibility(method, handlerType);
RequestMappingInfo info = createRequestMappingInfo(method);
if (info != null) {
RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType);
if (typeInfo != null) {
info = typeInfo.combine(info);
}
if (info.isEmptyMapping()) {
info = info.mutate().paths("", "/").options(this.config).build();
}
String prefix = getPathPrefix(handlerType);
if (prefix != null) {
info = RequestMappingInfo.paths(prefix).options(this.config).build().combine(info);
}
}
return info;
}
순서는 이렇습니다. 메서드의 어노테이션으로 info를 만들고(없으면 null이라 등록되지 않습니다), 클래스에도 @RequestMapping이 있으면 typeInfo.combine(info)로 합칩니다. 클래스의 /users와 메서드의 /{id}가 /users/{id}가 되는 자리가 이 combine입니다(RequestMappingInfo 374–395행).
어노테이션 하나가 RequestMappingInfo가 되는 코드는 어노테이션의 속성을 조건 하나씩에 그대로 옮깁니다.
protected RequestMappingInfo createRequestMappingInfo(
RequestMapping requestMapping, @Nullable RequestCondition<?> customCondition) {
RequestMappingInfo.Builder builder = RequestMappingInfo
.paths(resolveEmbeddedValuesInPatterns(requestMapping.path()))
.methods(requestMapping.method())
.params(requestMapping.params())
.headers(requestMapping.headers())
.consumes(requestMapping.consumes())
.produces(requestMapping.produces())
.version(requestMapping.version())
.mappingName(requestMapping.name());
if (customCondition != null) {
builder.customCondition(customCondition);
}
return builder.options(this.config).build();
}
그런데 우리가 붙인 것은 @GetMapping인데 이 메서드는 RequestMapping을 받습니다. 사이를 잇는 것이 13편의 MergedAnnotations입니다. @GetMapping은 @RequestMapping(method = RequestMethod.GET)을 메타 어노테이션으로 달고, 자기 속성을 @AliasFor로 @RequestMapping의 속성에 이어 둔 어노테이션입니다(GetMapping 52–58행). 호출하는 쪽은 MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, …)로 요소에서 RequestMapping을 찾아, 합쳐진 값을 가진 RequestMapping 객체를 받아 옵니다(265–274행). 그래서 이 코드는 @GetMapping, @PostMapping을 따로 알 필요가 없습니다.
RequestMappingInfo가 가진 조건은 이 커밋에서 다음과 같습니다(86–105행).
| 조건 | @RequestMapping의 속성 |
요청의 무엇을 보나 |
|---|---|---|
경로 (PathPatternsRequestCondition) |
path / value |
URL 경로 |
메서드 (RequestMethodsRequestCondition) |
method |
GET, POST … |
파라미터 (ParamsRequestCondition) |
params |
요청 파라미터의 유무와 값 |
헤더 (HeadersRequestCondition) |
headers |
요청 헤더 |
consumes (ConsumesRequestCondition) |
consumes |
Content-Type |
produces (ProducesRequestCondition) |
produces |
Accept |
버전 (VersionRequestCondition) |
version |
API 버전 |
커스텀 (RequestConditionHolder) |
— | 직접 만든 조건 |
조건마다 하는 일은 셋으로 같습니다. 다른 조건과 합치기(combine), 이 요청에 맞는지 보기(getMatchingCondition), 둘 다 맞을 때 누가 더 잘 맞는지 견주기(compareTo)입니다(RequestCondition 45행, 59행, 67행). 3절에서 뒤의 둘을 씁니다. 필드로는 경로 조건이 하나 더 있습니다(91행). 문자열 패턴 방식의 옛 PatternsRequestCondition인데, 7.0부터 제거 예정으로 표시되어 있고(55행) 한 매핑에서는 PathPatternsRequestCondition과 둘 가운데 하나만 채워집니다. 이 글은 PathPatternsRequestCondition 쪽만 봅니다.
MappingRegistry에 넣기
등록은 AbstractHandlerMethodMapping 안의 내부 클래스 MappingRegistry가 합니다(543행).
public void register(T mapping, Object handler, Method method) {
this.readWriteLock.writeLock().lock();
try {
HandlerMethod handlerMethod = createHandlerMethod(handler, method);
validateMethodMapping(handlerMethod, mapping);
Set<String> directPaths = getDirectPaths(mapping);
for (String path : directPaths) {
this.pathLookup.add(path, mapping);
}
// ...
// Init validation flags
// We do this strictly after using the original instance in the CORS lookups
handlerMethod = handlerMethod.createWithValidateFlags();
this.registry.put(mapping,
new MappingRegistration<>(mapping, handlerMethod, directPaths, name, corsConfig != null));
}
finally {
this.readWriteLock.writeLock().unlock();
}
}
맵이 둘이라는 점을 눈여겨봅니다.
registry—RequestMappingInfo→ 등록 정보(HandlerMethod포함). 전체 목록입니다(545행).pathLookup— 경로 문자열 → 매핑 목록./users/me처럼 패턴 문법이 없는 경로만 들어갑니다(547행, 무엇이 패턴 없는 경로인지는PathPatternsRequestCondition128–140행)./users/{id}는 여기에 없습니다.
pathLookup은 3절에서 지름길로 쓰입니다. 그리고 넣기 전에 validateMethodMapping()이 같은 매핑이 이미 있는지 봅니다(637–646행). 있으면 IllegalStateException이고, 이것이 afterPropertiesSet 안에서 났으니 컨테이너가 켜지지 못합니다.
registry가 전체 목록이고, pathLookup은 패턴 없는 경로만 담은 지름길입니다. 매핑과 경로는 아래 예제의 실제 출력에서 가져왔습니다.
예제: 등록된 매핑 찍어 보기
ex/u20/MappingRegistrationExample.java입니다. 이 예제는 릴리스 버전인 Spring Framework 7.0.9로 실행해 아래 출력을 확인했습니다(이 시리즈가 읽는 7.1.0-SNAPSHOT을 직접 빌드해 돌린 것은 아닙니다). 아래 코드는 요점만 줄인 것입니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@RestController
@RequestMapping("/users")
static class UserController {
@GetMapping("/{id}")
String one(@PathVariable long id) { return "one"; }
@GetMapping("/me")
String me() { return "me"; }
@PostMapping(consumes = "application/json")
String create() { return "create"; }
String notMapped() { return "어노테이션이 없으니 등록되지 않는다"; }
}
/** @Controller 가 아니라 @Component — 메서드에 @GetMapping 이 있어도 핸들러가 아니다. */
@Component
@RequestMapping("/plain")
static class PlainComponent {
@GetMapping("/hello")
String hello() { return "hello"; }
}
@Configuration
static class Config {
@Bean UserController userController() { return new UserController(); }
@Bean PlainComponent plainComponent() { return new PlainComponent(); }
@Bean RequestMappingHandlerMapping handlerMapping() { return new RequestMappingHandlerMapping(); }
}
// ... main 에서
context.refresh(); // 이 안에서 handlerMapping 빈의 afterPropertiesSet() 이 불린다
RequestMappingHandlerMapping mapping = context.getBean(RequestMappingHandlerMapping.class);
// mapping.getHandlerMethods() 를 정렬해 한 줄씩 찍는다
// 이어서 @GetMapping("/same") 메서드가 둘인 컨트롤러로 컨텍스트를 하나 더 켠다
== 등록된 매핑 3개
{GET [/users/me]} -> UserController#me (bean 필드에 든 것: String "userController")
{GET [/users/{id}]} -> UserController#one (bean 필드에 든 것: String "userController")
{POST [/users], consumes [application/json]} -> UserController#create (bean 필드에 든 것: String "userController")
== 경로에 패턴이 없는 매핑의 경로 (getDirectPaths)
[/users/me]
[/users]
== 조건이 완전히 같은 매핑 둘을 등록하면
BeanCreationException <- IllegalStateException
Ambiguous mapping. Cannot map 'duplicateController' method
ex.u20.MappingRegistrationExample$DuplicateController#a()
to {GET [/same]}: There is already 'duplicateController' bean method
ex.u20.MappingRegistrationExample$DuplicateController#b() mapped.
클래스의 /users가 메서드의 경로 앞에 붙었고, @GetMapping은 GET 조건으로 읽혔습니다. 어노테이션이 없는 notMapped와, @Controller가 아닌 PlainComponent의 메서드는 등록되지 않았습니다. 마지막 블록의 예외 사이에는 Spring이 찍는 WARN 로그 한 줄이 더 나오는데 같은 내용이라 뺐습니다.
헷갈리기 쉬운 곳
@RequestMapping만 붙인 클래스는 핸들러가 아닙니다. 이 커밋의isHandler는@Controller만 봅니다. 위 예제의PlainComponent가 그 경우입니다. 클래스의@RequestMapping은 경로를 합칠 때 쓰일 뿐입니다.- 등록되는 것은 빈 객체가 아니라 빈 이름입니다. 위 출력에서
HandlerMethod의bean필드에 든 것이 문자열"userController"입니다.detectHandlerMethods가 빈 이름을 받아 그대로 넘기기 때문입니다(336–344행). 진짜 객체는 요청이 왔을 때createWithResolvedBean()이getBean(이름)으로 가져옵니다(HandlerMethod357–375행). 컨트롤러가 지연 초기화 빈이어도 매핑 등록 때문에 미리 만들어지지 않습니다. RequestMappingHandlerMapping빈이 있어야 이 모든 일이 일어납니다.afterPropertiesSet은 그 빈의 초기화 콜백입니다. 매핑을 훑는 것은 컴포넌트 스캔이 아니라 이 빈입니다.
3. 요청이 왔을 때: 후보를 모으고 가장 잘 맞는 것을 고릅니다
DispatcherServlet이 getHandler(request)를 부르면 AbstractHandlerMapping의 틀이 getHandlerInternal()을 부르고, AbstractHandlerMethodMapping은 읽기 락을 잡은 뒤 lookupHandlerMethod()에 일을 넘깁니다(372–382행).
protected @Nullable HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
List<Match> matches = new ArrayList<>();
List<T> directPathMatches = this.mappingRegistry.getMappingsByDirectPath(lookupPath);
if (directPathMatches != null) {
addMatchingMappings(directPathMatches, matches, request);
}
if (matches.isEmpty()) {
addMatchingMappings(this.mappingRegistry.getRegistrations().keySet(), matches, request);
}
if (!matches.isEmpty()) {
Match bestMatch = matches.get(0);
if (matches.size() > 1) {
Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
// ...
else {
Match secondBestMatch = matches.get(1);
if (comparator.compare(bestMatch, secondBestMatch) == 0) {
Method m1 = bestMatch.getHandlerMethod().getMethod();
Method m2 = secondBestMatch.getHandlerMethod().getMethod();
String uri = request.getRequestURI();
throw new IllegalStateException(
"Ambiguous handler methods mapped for '" + uri + "': {" + m1 + ", " + m2 + "}");
}
// ...
}
request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.getHandlerMethod());
handleMatch(bestMatch.mapping, lookupPath, request);
return bestMatch.getHandlerMethod();
}
else {
return handleNoMatch(this.mappingRegistry.getRegistrations().keySet(), lookupPath, request);
}
}
노란 상자가 2절에서 쌓아 둔 두 맵입니다.
후보 모으기 — 지름길이 먼저
pathLookup에서 요청 경로를 그대로 찾습니다. 맵 조회 한 번입니다. 나온 매핑마다getMatchingMapping()으로 나머지 조건(HTTP 메서드 등)까지 맞는지 봅니다.- 거기서 하나도 못 건졌을 때만
registry의 모든 매핑을 돌며 같은 검사를 합니다.
/users/me와 /users/{id}가 둘 다 있을 때 /users/me 요청이 앞의 것으로 가는 까닭이 여기 있습니다. 1번에서 후보가 나오면 2번을 아예 하지 않으니, /users/{id}는 견줄 기회도 없습니다.
맞는지 보는 일은 RequestMappingInfo.getMatchingCondition()이 합니다(421–466행). 조건을 하나씩 물어 하나라도 null(안 맞음)을 돌려주면 곧바로 null입니다. 소스의 순서는 메서드, 파라미터, 헤더, consumes, produces, 버전, 경로, 커스텀입니다. 모두 맞으면 이 요청에 맞게 줄인 새 RequestMappingInfo 를 돌려줍니다. 예를 들어 경로 패턴을 여러 개 가진 매핑이라면 그 가운데 이 요청에 맞는 것만 남깁니다(PathPatternsRequestCondition 190–197행).
가장 잘 맞는 것 고르기
후보가 둘 이상이면 정렬합니다. 견주는 일은 RequestMappingInfo.compareTo()입니다.
public int compareTo(RequestMappingInfo other, HttpServletRequest request) {
int result;
// Automatic vs explicit HTTP HEAD mapping
if (HttpMethod.HEAD.matches(request.getMethod())) {
result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
if (result != 0) {
return result;
}
}
result = getActivePatternsCondition().compareTo(other.getActivePatternsCondition(), request);
if (result != 0) {
return result;
}
result = this.paramsCondition.compareTo(other.getParamsCondition(), request);
if (result != 0) {
return result;
}
// ...
// Implicit (no method) vs explicit HTTP method mappings
result = this.methodsCondition.compareTo(other.getMethodsCondition(), request);
if (result != 0) {
return result;
}
result = this.customConditionHolder.compareTo(other.customConditionHolder, request);
if (result != 0) {
return result;
}
return 0;
}
줄인 가운데 부분도 같은 모양이라, 견주는 순서는 경로 → 파라미터 → 헤더 → consumes → produces → 버전 → HTTP 메서드 → 커스텀입니다. 앞에서 승부가 나면 뒤는 보지 않습니다. 조건마다 "더 잘 맞는다"의 뜻은 이렇습니다.
| 조건 | 더 잘 맞는 쪽 | 근거 |
|---|---|---|
| 경로 | /** 같은 통째 받기가 아닌 쪽 → 와일드카드·변수가 적은 쪽(변수 하나는 1점, 와일드카드 하나는 100점, 낮을수록 구체적) → 더 긴 쪽 |
PathPattern 87–104행, 138–146행 |
| 파라미터 | 조건식이 더 많은 쪽 | ParamsRequestCondition 131–137행 |
| HTTP 메서드 | 메서드를 적어 둔 쪽이 안 적은 쪽보다 | RequestMethodsRequestCondition 181–194행 |
정렬한 뒤 1등과 2등을 다시 견주어 결과가 0이면 고를 수 없다는 뜻이므로 IllegalStateException("Ambiguous handler methods mapped for …")을 던집니다. 그렇지 않으면 1등이 답입니다. 이어서 handleMatch()가 경로에서 뽑은 변수 값({id} → 42)을 요청 속성 URI_TEMPLATE_VARIABLES_ATTRIBUTE에 적어 둡니다(RequestMappingInfoHandlerMapping 134–154행, 적는 줄은 178행). 이 속성은 5절에서 @PathVariable을 채울 때 다시 나옵니다.
경로를 맞춰 보는 것은 PathPattern입니다. 패턴 문자열을 켜질 때 한 번 파싱해 둔 객체이고, matches()로 맞는지 보고 matchAndExtract()로 변수 값까지 뽑습니다(198행, 220행). AbstractHandlerMapping은 PathPatternParser를 기본으로 갖고 있습니다(95행). 패턴 문법과 파서 속은 이 글에서 다루지 않습니다.
후보가 하나도 없을 때
handleNoMatch()로 갑니다. RequestMappingInfoHandlerMapping은 여기서 그냥 null을 돌려주지 않고 왜 안 맞았는지를 한 번 더 따집니다(245–299행). 경로는 맞는데 다른 조건이 안 맞은 매핑이 있으면 그에 맞는 예외를 던집니다.
| 경로는 맞는데 안 맞은 것 | 던지는 예외 | 상태 코드 |
|---|---|---|
| HTTP 메서드 | HttpRequestMethodNotSupportedException |
405 (117행) |
Content-Type (consumes) |
HttpMediaTypeNotSupportedException |
415 |
Accept (produces) |
HttpMediaTypeNotAcceptableException |
406 |
| 파라미터 (params) | UnsatisfiedServletRequestParameterException |
400 |
경로조차 맞는 것이 없으면 null이고, DispatcherServlet은 다음 HandlerMapping에게 묻습니다. 끝까지 없을 때의 처리(noHandlerFound)는 21편에 있습니다.
예제: 어느 것이 뽑히나
ex/u20/BestMatchExample.java입니다(Spring Framework 7.0.9로 실행). DispatcherServlet 없이 RequestMappingHandlerMapping.getHandler()를 직접 불러 무엇이 뽑혔는지 찍습니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
@RestController
static class ItemController {
@GetMapping("/items/{id}")
String byId(@PathVariable String id) { return "byId"; }
@GetMapping("/items/new")
String form() { return "form"; }
@GetMapping(path = "/items/{id}", params = "view=full")
String byIdFull(@PathVariable String id) { return "byIdFull"; }
@RequestMapping("/items/{id}/any")
String anyMethod(@PathVariable String id) { return "anyMethod"; }
@GetMapping("/items/{id}/any")
String getOnly(@PathVariable String id) { return "getOnly"; }
@GetMapping("/files/**")
String catchAll() { return "catchAll"; }
@GetMapping("/files/*/meta")
String wildcard() { return "wildcard"; }
@GetMapping("/files/{name}/meta")
String variable(@PathVariable String name) { return "variable"; }
// 변수 이름만 다르다 — 등록할 때는 서로 다른 매핑이지만 요청이 오면 똑같이 맞는다
@GetMapping("/dup/{a}")
String dupA(@PathVariable String a) { return "dupA"; }
@GetMapping("/dup/{b}")
String dupB(@PathVariable String b) { return "dupB"; }
}
// ... 요청마다
ServletRequestPathUtils.parseAndCache(request); // 실제로는 DispatcherServlet 이 해 두는 일
HandlerExecutionChain chain = mapping.getHandler(request);
// ...
HandlerMethod handlerMethod = (HandlerMethod) chain.getHandler();
GET /items/new -> form 패턴=/items/new 변수={}
GET /items/42 -> byId 패턴=/items/{id} 변수={id=42}
GET /items/42?view=full -> byIdFull 패턴=/items/{id} 변수={id=42}
GET /items/42/any -> getOnly 패턴=/items/{id}/any 변수={id=42}
DELETE /items/42/any -> anyMethod 패턴=/items/{id}/any 변수={id=42}
GET /files/a/meta -> variable 패턴=/files/{name}/meta 변수={name=a}
GET /files/a/b/c -> catchAll 패턴=/files/** 변수={}
POST /items/42 -> HttpRequestMethodNotSupportedException: Request method 'POST' is not supported
GET /nowhere -> null (이 HandlerMapping 은 모르는 요청)
GET /dup/1 -> IllegalStateException: Ambiguous handler methods mapped for '/dup/1': {java.lang.String ex.u20.BestMatchExample$ItemController.dupB(java.lang.String), java.lang.String ex.u20.BestMatchExample$ItemController.dupA(java.lang.String)}
위에서부터 읽으면 이 절의 규칙이 차례로 나옵니다. /items/new는 패턴 없는 경로라 지름길에서 끝납니다. /items/42?view=full은 경로가 같은 두 후보 가운데 파라미터 조건이 있는 쪽이, GET /items/42/any는 HTTP 메서드를 적어 둔 쪽이 이깁니다. /files/a/meta에는 세 패턴이 다 맞지만 변수(1점)가 와일드카드(100점)를, 와일드카드가 /**를 이깁니다. POST /items/42는 경로만 맞아서 405에 해당하는 예외가 매핑 단계에서 나고, /dup/1은 두 후보가 어느 조건으로도 갈리지 않아 모호합니다.
헷갈리기 쉬운 곳: "Ambiguous"가 둘입니다
Ambiguous mapping |
Ambiguous handler methods mapped for … |
|
|---|---|---|
| 언제 | 켜질 때 (register) |
요청이 왔을 때 (lookupHandlerMethod) |
| 조건 | 두 메서드의 RequestMappingInfo가 equals로 같다 |
서로 다른 매핑이 이 요청에 똑같이 잘 맞는다 |
| 결과 | 컨테이너가 켜지지 않는다 | 그 요청만 실패한다. 다른 요청은 멀쩡하다 |
| 예 | @GetMapping("/same") 둘 |
/dup/{a}와 /dup/{b} |
뒤쪽은 켜질 때 잡히지 않습니다. 위 예제도 /dup/1을 부르기 전까지는 아무 일이 없었습니다. /dup/{a}와 /dup/{b}는 패턴 문자열이 달라 등록할 때는 서로 다른 매핑입니다.
4. 부르는 쪽: RequestMappingHandlerAdapter
핸들러를 받은 DispatcherServlet은 어댑터를 고릅니다. RequestMappingHandlerAdapter의 supports는 부모 클래스에 있고, 핸들러가 HandlerMethod이면 true입니다(AbstractHandlerMethodAdapter 68–70행, supportsInternal은 늘 true, RequestMappingHandlerAdapter 830–832행). handle()은 handleInternal()을 거쳐(835–870행) invokeHandlerMethod()에 닿습니다(889–944행).
파란 상자 둘이 5절과 6절의 주제입니다. 초록 상자가 우리가 쓴 컨트롤러 메서드가 실제로 불리는 줄입니다.
invokeHandlerMethod()에서 큰 줄기에 해당하는 부분입니다.
ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
if (this.argumentResolvers != null) {
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
if (this.returnValueHandlers != null) {
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
invocableMethod.setDataBinderFactory(binderFactory);
invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
invocableMethod.setMethodValidator(this.methodValidator);
// ...
invocableMethod.invokeAndHandle(webRequest, mavContainer);
if (asyncManager.isConcurrentHandlingStarted()) {
return null;
}
return getModelAndView(mavContainer, modelFactory, webRequest);
HandlerMethod(누구의 어느 메서드인가)를 ServletInvocableHandlerMethod(그것을 부를 줄 아는 객체)로 감싸고, 인자 리졸버 묶음과 반환값 핸들러 묶음을 꽂아 준 뒤 invokeAndHandle()을 부릅니다. 줄인 곳에는 ModelAndViewContainer를 만들고 모델을 준비하는 코드와 비동기 처리가 있습니다. ModelAndViewContainer는 호출 한 번 동안 모델과 뷰 이름, 그리고 "응답을 이미 다 썼다"는 표시(requestHandled)를 들고 다니는 메모장입니다(ModelAndViewContainer 51행, 69행).
invokeAndHandle()은 이름 그대로 두 가지를 합니다.
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer,
@Nullable Object... providedArgs) throws Exception {
Object returnValue = invokeForRequest(webRequest, mavContainer, providedArgs);
setResponseStatus(webRequest);
// ...
mavContainer.setRequestHandled(false);
Assert.state(this.returnValueHandlers != null, "No return value handlers");
try {
this.returnValueHandlers.handleReturnValue(
returnValue, getReturnValueType(returnValue), mavContainer, webRequest);
}
// ...
}
- invoke —
invokeForRequest()가 인자를 마련하고(getMethodArgumentValues) 메서드를 부릅니다(doInvoke). 이 메서드는 부모인InvocableHandlerMethod에 있고(171–192행), 실제 호출은 리플렉션 한 줄method.invoke(getBean(), args)입니다(252행). - handle — 돌려받은 값을
returnValueHandlers.handleReturnValue()에 넘깁니다.
끝나면 getModelAndView()가 메모장을 보고 DispatcherServlet에 돌려줄 것을 정합니다. requestHandled가 true이면 null(그릴 뷰가 없다), 아니면 뷰 이름과 모델을 담은 ModelAndView입니다(1041–1061행).
헷갈리기 쉬운 곳
- 클래스 이름이 비슷한 셋의 관계.
HandlerMethod(빈 + 메서드) ←InvocableHandlerMethod(인자를 풀어 부를 줄 안다,spring-web, 63행) ←ServletInvocableHandlerMethod(반환값까지 처리한다,spring-webmvc, 66행). 화살표는 상속입니다. 앞의 둘은spring-web모듈에, 마지막 하나만spring-webmvc에 있습니다. ServletInvocableHandlerMethod는 요청마다 새로 만듭니다. 매핑에 등록된HandlerMethod는 그대로 두고, 요청마다 그것을 감싼 객체를 만들어 리졸버 등을 꽂습니다(910행, 952–954행).
5. 인자는 누가 채우나 — HandlerMethodArgumentResolver
컨트롤러 메서드의 파라미터는 무엇이든 될 수 있습니다. @PathVariable long id, @RequestBody User user, HttpServletRequest request, Model model …. 이것을 if로 나열하지 않고, "이 파라미터는 내가 맡는다"고 답하는 객체들의 목록으로 풉니다. 인터페이스의 메서드는 둘입니다(43행, 60행).
supportsParameter(parameter)— 이 파라미터를 맡을 수 있는가resolveArgument(parameter, mavContainer, webRequest, binderFactory)— 넣을 값을 만들어 돌려준다
파라미터를 도는 쪽은 InvocableHandlerMethod.getMethodArgumentValues()입니다.
protected @Nullable Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer,
@Nullable Object... providedArgs) throws Exception {
MethodParameter[] parameters = getMethodParameters();
if (ObjectUtils.isEmpty(parameters)) {
return EMPTY_ARGS;
}
@Nullable Object[] args = new Object[parameters.length];
for (int i = 0; i < parameters.length; i++) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
args[i] = findProvidedArgument(parameter, providedArgs);
if (args[i] != null) {
continue;
}
if (parameter.getParameterType().equals(HandlerMethod.class) && parameter.isOptional()) {
args[i] = null;
continue;
}
if (!this.resolvers.supportsParameter(parameter)) {
throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver"));
}
try {
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
}
// ...
}
return args;
}
spring-web/src/main/java/org/springframework/web/method/support/InvocableHandlerMethod.java 200–238행
여기서 this.resolvers는 리졸버 하나가 아니라 묶음(HandlerMethodArgumentResolverComposite)입니다. 묶음 자신도 같은 인터페이스를 구현하고, 안에서 목록을 차례로 돌며 처음 supportsParameter가 true인 리졸버를 찾습니다.
public @Nullable HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
if (result == null) {
for (HandlerMethodArgumentResolver resolver : this.argumentResolvers) {
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, result);
break;
}
}
}
return result;
}
찾은 결과는 argumentResolverCache에 파라미터를 열쇠로 적어 둡니다(44–45행). 컨트롤러 메서드의 파라미터 구성은 실행 중에 바뀌지 않으니, 같은 메서드로 오는 두 번째 요청부터는 목록을 돌지 않습니다.
목록은 네 묶음이고, 앞에서부터 묻다가 처음 "맡겠다"는 리졸버에서 멈춥니다.
기본 목록과 그 순서
목록은 어댑터의 afterPropertiesSet()에서 만들어집니다. 따로 정해 준 목록이 없으면 getDefaultArgumentResolvers()의 결과로 묶음을 만듭니다(551–575행). 2절의 매핑과 마찬가지로 초기화 콜백에서 한 번 준비해 두는 것입니다.
private List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() {
List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>(30);
// Annotation-based argument resolution
resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false));
// ...
// Type-based argument resolution
resolvers.add(new ServletRequestMethodArgumentResolver());
resolvers.add(new ServletResponseMethodArgumentResolver());
// ...
// Custom arguments
if (getCustomArgumentResolvers() != null) {
resolvers.addAll(getCustomArgumentResolvers());
}
// Catch-all
resolvers.add(new PrincipalMethodArgumentResolver());
resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true));
resolvers.add(new ServletModelAttributeMethodProcessor(true));
return resolvers;
}
소스의 주석이 네 묶음을 나눠 줍니다. 이 커밋에서 세어 보면 어노테이션 기반 15개, 타입 기반 10개(Kotlin이 있으면 하나 더), 직접 등록한 것, 그리고 맨 뒤의 셋입니다.
먼저 답한 쪽이 맡으므로 순서가 곧 우선순위입니다. 순서가 뜻하는 것은 맨 뒤 셋에서 드러납니다. RequestParamMethodArgumentResolver와 ServletModelAttributeMethodProcessor는 목록에 두 번씩 들어갑니다. 맨 앞에서는 생성자 인자가 false, 맨 뒤에서는 true입니다.
false일 때는@RequestParam이 붙은 파라미터만 맡습니다.true일 때는 어노테이션이 없어도 단순 타입(String, 숫자, 날짜 등)이면 맡습니다(RequestParamMethodArgumentResolver127–152행).ServletModelAttributeMethodProcessor(true)는 어노테이션이 없고 단순 타입이 아닌 것을 맡습니다(ModelAttributeMethodProcessor91–94행).
컨트롤러에 String name이라고만 써도 요청 파라미터 name이 들어오는 것은 이 마지막 둘 덕분입니다. 이들이 맨 뒤에 있는 까닭도 분명합니다. "어노테이션이 없으면 다 받는다"는 리졸버가 앞에 있으면 HttpServletRequest나 Model 같은 파라미터까지 가로채게 됩니다.
직접 등록한 리졸버의 자리는 기본 리졸버 뒤, 맨 뒤 셋의 앞입니다. 그래서 내가 만든 타입(CurrentUser 같은)은 모델 속성으로 넘어가기 전에 내 리졸버가 받을 수 있고, 반대로 @RequestParam이나 @PathVariable이 붙은 파라미터는 내 리졸버에게 물어보지도 않습니다. WebMvcConfigurer.addArgumentResolvers()로 넣는 리졸버도 같은 자리로 갑니다(WebMvcConfigurer 162행, WebMvcConfigurationSupport 657행).
대표 구현 셋
@RequestParam과 @PathVariable — 이름으로 값 하나 꺼내기. 둘은 같은 부모 AbstractNamedValueMethodArgumentResolver를 상속합니다(73행). 부모의 resolveArgument()가 틀이고(103–150행), 순서는 이렇습니다.
- 어노테이션에서 이름, 필수 여부, 기본값을 읽습니다. 이름을 안 적었으면 파라미터 이름을 쓰는데, 컴파일할 때
-parameters옵션이 없어 이름을 알 수 없으면 그렇게 하라는 메시지와 함께 예외를 던집니다(176–189행). resolveName()으로 값을 꺼냅니다. 하위 클래스가 채우는 자리입니다.@RequestParam은request.getParameterValues(name)에서(RequestParamMethodArgumentResolver180행),@PathVariable은 3절에서 매핑이 적어 둔 요청 속성URI_TEMPLATE_VARIABLES_ATTRIBUTE에서 꺼냅니다(PathVariableMethodArgumentResolver93–97행). 매핑이 뽑아 둔 값을 어댑터 쪽 리졸버가 읽는 것이고, 둘 사이를 잇는 것은 요청 속성 하나입니다.- 값이 없으면 기본값을 쓰거나, 필수인데 없으면 예외를 던집니다.
- 꺼낸 값(문자열)을 파라미터 타입으로 변환합니다.
WebDataBinder를 만들어convertIfNecessary()를 부릅니다(274–298행)."42"가long이 되는 자리입니다. 문자열을 목표 타입으로 바꾸는 변환 체계 자체는 14편의 주제였습니다.
기본값이나 이름에 ${…}, #{…}를 쓸 수 있는 것도 이 부모 클래스가 처리합니다(195행부터). #{…}가 SpEL이고, 15편에서 다뤘습니다.
@RequestBody — 본문 통째로 객체 만들기. RequestResponseBodyMethodProcessor가 맡습니다. supportsParameter는 @RequestBody가 있는지만 보고(134–136행), resolveArgument는 readWithMessageConverters()로 본문을 읽은 뒤 검증합니다(152–174행). 읽는 일은 부모 클래스가 가진 HttpMessageConverter 목록을 돌며 canRead인 컨버터에게 맡깁니다(AbstractMessageConverterMethodArgumentResolver 147행부터). 컨버터의 속은 20편에 있습니다.
헷갈리기 쉬운 곳
- 리졸버를 못 찾으면 요청 때 예외입니다.
getMethodArgumentValues가"No suitable resolver"로IllegalStateException을 던집니다. 다만 맨 뒤의ServletModelAttributeMethodProcessor(true)가 단순 타입이 아닌 것을 거의 다 받기 때문에, 실제로는 "리졸버가 없다"보다 "엉뚱하게 모델 속성으로 처리됐다"를 더 자주 만납니다. 리졸버 등록을 빠뜨린 커스텀 타입이 그렇게 됩니다. setCustomArgumentResolvers와setArgumentResolvers는 다릅니다. 앞의 것은 기본 목록에 끼워 넣고, 뒤의 것은 목록 전체를 바꿉니다(212행, 227행). 뒤의 것을 쓰면afterPropertiesSet이 기본 목록을 만들지 않습니다.- Processor라는 이름은 "둘 다 한다"는 뜻입니다.
RequestResponseBodyMethodProcessor,ServletModelAttributeMethodProcessor처럼 이름이 Processor로 끝나는 클래스는 인자 리졸버와 반환값 핸들러를 함께 구현합니다(ModelAttributeMethodProcessor67행,AbstractMessageConverterMethodProcessor80–81행). 그래서 두 목록에 모두 나옵니다.
6. 반환값은 누가 처리하나 — HandlerMethodReturnValueHandler
반대쪽도 같은 모양입니다. 메서드는 둘이고(41행, 56행), 묶음(HandlerMethodReturnValueHandlerComposite)이 목록을 돌며 처음 supportsReturnType이 true인 핸들러에게 맡깁니다(70–91행). 기본 목록은 getDefaultReturnValueHandlers()가 만듭니다(734–780행).
String을 돌려주는 메서드를 예로 들면 순서의 뜻이 보입니다. 목록에서 RequestResponseBodyMethodProcessor(759행)가 ViewNameMethodReturnValueHandler(763행)보다 앞에 있습니다.
같은 String이 어노테이션 하나로 본문이 되기도 하고 뷰 이름이 되기도 합니다.
@Override
public boolean supportsReturnType(MethodParameter returnType) {
return (this.responseBodyControllerCache.computeIfAbsent(returnType.getContainingClass(),
clazz -> AnnotatedElementUtils.hasAnnotation(clazz, ResponseBody.class)) ||
returnType.hasMethodAnnotation(ResponseBody.class));
}
// ...
@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
mavContainer.setRequestHandled(true);
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);
// ...
// Try even with null return value. ResponseBodyAdvice could get involved.
writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);
}
supportsReturnType은 메서드에 @ResponseBody가 있거나 클래스에 있으면 true입니다. @RestController는 @ResponseBody를 메타 어노테이션으로 달고 있으므로(RestController 49행) @RestController의 모든 메서드가 여기에 걸립니다. handleReturnValue의 첫 줄이 setRequestHandled(true), 곧 4절의 메모장에 "응답은 내가 다 썼다"고 적는 것이고, 이어서 writeWithMessageConverters()가 Accept 헤더와 반환 타입에 맞는 컨버터를 골라 본문을 씁니다(AbstractMessageConverterMethodProcessor 205행부터).
@ResponseBody가 없으면 이 핸들러는 false를 돌려주고 다음 차례인 ViewNameMethodReturnValueHandler가 받습니다. 반환 타입이 void이거나 문자열이면 맡고(71–74행), 하는 일은 mavContainer.setViewName(viewName) 한 줄입니다(77–92행). 응답에는 아무것도 쓰지 않습니다. 그 이름으로 뷰를 찾아 그리는 일은 DispatcherServlet으로 돌아간 뒤에 일어나고, 23편의 주제입니다.
예제: 직접 만든 리졸버, 그리고 String의 두 길
ex/u20/ArgumentResolverExample.java입니다(Spring Framework 7.0.9로 실행). 헤더 X-User로 CurrentUser를 만드는 리졸버를 setCustomArgumentResolvers로 넣고, DispatcherServlet에 가짜 요청을 보냅니다.
// 이 글을 위해 쓴 예제입니다 (Spring의 소스가 아닙니다)
/** 요청 헤더 X-User 로 만드는 값 객체. */
record CurrentUser(String name) {
}
static class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
int supportsCalls;
@Override
public boolean supportsParameter(MethodParameter parameter) {
this.supportsCalls++;
System.out.println(" [resolver] supportsParameter(" + parameter.getParameterType().getSimpleName()
+ " " + parameter.getParameterName() + ")");
return parameter.getParameterType() == CurrentUser.class;
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
System.out.println(" [resolver] resolveArgument(" + parameter.getParameterName() + ")");
return new CurrentUser(webRequest.getHeader("X-User"));
}
}
@Controller
static class GreetingController {
@GetMapping("/greet/{times}")
@ResponseBody
String greet(CurrentUser user, @PathVariable int times, @RequestParam(defaultValue = "hi") String word,
String plain) {
return (word + " " + user.name() + " ").repeat(times).trim() + " / plain=" + plain;
}
@GetMapping("/page")
String page() {
return "greeting-page"; // @ResponseBody 가 없으니 뷰 이름
}
}
// ... 설정에서
RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter();
adapter.setCustomArgumentResolvers(List.of(resolver));
먼저 어댑터가 가진 두 목록을 순서대로 찍은 결과입니다.
== 인자 리졸버 29개 (순서대로)
1 RequestParamMethodArgumentResolver
2 RequestParamMapMethodArgumentResolver
3 PathVariableMethodArgumentResolver
4 PathVariableMapMethodArgumentResolver
5 MatrixVariableMethodArgumentResolver
6 MatrixVariableMapMethodArgumentResolver
7 ServletModelAttributeMethodProcessor
8 RequestResponseBodyMethodProcessor
9 RequestPartMethodArgumentResolver
10 RequestHeaderMethodArgumentResolver
11 RequestHeaderMapMethodArgumentResolver
12 ServletCookieValueMethodArgumentResolver
13 ExpressionValueMethodArgumentResolver
14 SessionAttributeMethodArgumentResolver
15 RequestAttributeMethodArgumentResolver
16 ServletRequestMethodArgumentResolver
17 ServletResponseMethodArgumentResolver
18 HttpEntityMethodProcessor
19 RedirectAttributesMethodArgumentResolver
20 ModelMethodProcessor
21 MapMethodProcessor
22 ErrorsMethodArgumentResolver
23 SessionStatusMethodArgumentResolver
24 UriComponentsBuilderMethodArgumentResolver
25 ApiVersionMethodArgumentResolver
26 CurrentUserArgumentResolver
27 PrincipalMethodArgumentResolver
28 RequestParamMethodArgumentResolver
29 ServletModelAttributeMethodProcessor
== 반환값 핸들러 15개 (순서대로)
1 ModelAndViewMethodReturnValueHandler
2 ModelMethodProcessor
3 ViewMethodReturnValueHandler
4 ResponseBodyEmitterReturnValueHandler
5 StreamingResponseBodyReturnValueHandler
6 ResponseEntityReturnValueHandler
7 HttpHeadersReturnValueHandler
8 CallableMethodReturnValueHandler
9 DeferredResultMethodReturnValueHandler
10 AsyncTaskMethodReturnValueHandler
11 ServletModelAttributeMethodProcessor
12 RequestResponseBodyMethodProcessor
13 ViewNameMethodReturnValueHandler
14 MapMethodProcessor
15 ServletModelAttributeMethodProcessor
7.0.9에서 찍은 목록이지만 위에서 읽은 7.1.0-SNAPSHOT 소스의 순서와 같습니다. 내 리졸버는 26번째, 곧 기본 25개 뒤와 맨 뒤 셋 앞에 들어갔습니다(Kotlin이 있을 때만 더해지는 리졸버는 이 실행의 목록에 없습니다). 1번과 28번, 7번과 29번이 같은 클래스입니다. 이어서 같은 요청을 두 번, 그리고 /page를 한 번 보낸 결과입니다.
== GET /greet/2?plain=p (1번째 요청)
[resolver] supportsParameter(CurrentUser user)
[resolver] resolveArgument(user)
[resolver] supportsParameter(String plain)
status=200 body=[hi kim hi kim / plain=p] forwardedUrl=null
== GET /greet/2?plain=p (2번째 요청)
[resolver] resolveArgument(user)
status=200 body=[hi kim hi kim / plain=p] forwardedUrl=null
== GET /page
status=200 body=[] forwardedUrl=greeting-page
== 리졸버의 supportsParameter 가 불린 횟수: 2
세 가지가 보입니다.
- 순서. 내 리졸버에게 물어본 파라미터는
CurrentUser user와String plain둘뿐입니다.@PathVariable int times와@RequestParam String word는 앞쪽 리졸버가 먼저 맡아서 내 차례가 오지 않았습니다.String plain은 내가 거절했고, 28번 리졸버가 요청 파라미터plain=p로 채웠습니다. 경로의"2"는int로 변환되어 인사말이 두 번 나왔습니다. - 캐시. 두 번째 요청에서는
supportsParameter가 한 번도 불리지 않고resolveArgument만 불렸습니다. - String의 두 길.
@ResponseBody가 있는greet의 반환값은 응답 본문(body)이 됐고, 없는page의 반환값은 본문이 비어 있고 그 이름으로 포워드됐습니다(forwardedUrl=greeting-page). 포워드는 이 예제에 뷰 리졸버를 등록하지 않아DispatcherServlet의 기본 뷰 리졸버가 한 일입니다.
헷갈리기 쉬운 곳
- 반환값 핸들러 묶음에는 캐시가 없습니다. 인자 쪽과 달리
selectHandler()는 호출할 때마다 목록을 돕니다(80–91행). 고를 때 반환 타입뿐 아니라 실제 반환값도 봅니다(비동기 반환값인지 먼저 확인합니다). @ResponseBody인데 뷰를 찾으려 든다면 순서가 아니라 어노테이션을 봅니다. 12번이 13번보다 앞이므로@ResponseBody가 제대로 인식되면 뷰 이름으로 넘어갈 수 없습니다.@Controller에@ResponseBody를 빠뜨린 경우가 위 예제의/page와 같은 결과를 냅니다.- 맨 끝의
ServletModelAttributeMethodProcessor(true)는 반환값 쪽에서도 나머지를 받습니다(776행).@ResponseBody없이 단순 타입이 아닌 객체를 돌려주면 응답 본문이 아니라 모델 속성이 됩니다.
더 깊이 들어가면
이 글에서 뺀 것의 이름과 위치만 적어 둡니다. @ModelAttribute 파라미터에 요청 값을 묶는 데이터 바인딩과 검증(ModelAttributeMethodProcessor 106행부터), 그 바인더를 만드는 @InitBinder와 getDataBinderFactory()(RequestMappingHandlerAdapter 991행부터). @ControllerAdvice를 모으는 initControllerAdviceCache()(589행부터)는 23편의 예외 처리와 이어집니다. Callable, DeferredResult 같은 비동기 반환값과 WebAsyncManager(892–899행). CORS 설정을 매핑과 함께 등록하는 initCorsConfiguration()(RequestMappingHandlerMapping 474행부터)과 사전 요청(preflight) 분기. 7.0에 들어온 API 버전 조건 VersionRequestCondition(RequestMappingInfo 103행)과 @HttpExchange로 매핑 만들기(RequestMappingHandlerMapping 391–406행). OPTIONS 요청에 자동으로 답하는 HttpOptionsHandler(RequestMappingInfoHandlerMapping 525행). PathPatternParser의 패턴 문법과 파싱. URL 방식 매핑인 AbstractUrlHandlerMapping(65행)과 BeanNameUrlHandlerMapping(48행), 함수형 엔드포인트의 RouterFunctionMapping(58행). 그리고 @EnableWebMvc가 이 글의 두 빈을 등록하는 WebMvcConfigurationSupport(269행, 648행)가 있습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
HandlerMapping / HandlerAdapter |
찾는 쪽과 부르는 쪽. 핸들러의 모양마다 짝이 있다 |
HandlerMethod |
빈(이름 또는 객체) + 메서드. @RequestMapping 방식의 핸들러 |
| 매핑 계층 네 층 | 틀(AbstractHandlerMapping) → 등록·선택 알고리즘(AbstractHandlerMethodMapping) → 조건에 묻기(RequestMappingInfoHandlerMapping) → 어노테이션 읽기(RequestMappingHandlerMapping) |
| 등록 시점 | afterPropertiesSet(). 모든 빈을 돌며 @Controller의 메서드를 훑는다 |
RequestMappingInfo |
경로·메서드·파라미터·헤더·consumes·produces·버전·커스텀 조건의 묶음. 클래스와 메서드의 것을 combine으로 합친다 |
MappingRegistry |
registry(전체)와 pathLookup(패턴 없는 경로의 지름길) |
lookupHandlerMethod |
지름길에서 못 찾을 때만 전체를 본다. 후보가 여럿이면 compareTo로 정렬해 1등을 고른다 |
Ambiguous mapping |
켜질 때. 두 메서드의 매핑이 완전히 같다 |
Ambiguous handler methods |
요청 때. 서로 다른 매핑이 똑같이 잘 맞는다 |
handleNoMatch |
경로만 맞으면 405·415·406·400에 해당하는 예외, 아니면 null |
invokeHandlerMethod → invokeAndHandle |
인자를 마련해 부르고(invoke), 반환값을 처리한다(handle) |
HandlerMethodArgumentResolver |
파라미터 하나의 값을 만든다. 목록에서 처음 supportsParameter가 true인 것이 맡고, 결과는 캐시된다 |
| 리졸버 순서 | 어노테이션 기반 → 타입 기반 → 직접 등록한 것 → 나머지를 받는 셋 |
HandlerMethodReturnValueHandler |
반환값을 응답이나 뷰 이름으로. @ResponseBody 처리기가 뷰 이름 처리기보다 앞 |
ModelAndViewContainer |
호출 한 번의 메모장. requestHandled가 true이면 뷰를 그리지 않는다 |
다음에 볼 것
이번 글에서 컨트롤러 메서드는 늘 무사히 값을 돌려줬습니다. 하지만 인자를 풀다가, 메서드 안에서, 또는 3절처럼 매핑 단계에서 예외가 날 수 있습니다. 그 예외를 @ExceptionHandler 메서드로 보내는 HandlerExceptionResolver는 이 글의 리졸버·반환값 핸들러 구조를 그대로 다시 씁니다. 다음 글은 그 예외 처리와, 핸들러 앞뒤에 끼어드는 인터셉터, 그리고 6절에서 이름만 적어 두고 돌아간 뷰가 실제로 그려지는 과정을 봅니다.
- 다음 글: Spring 소스 파헤치기 23: 예외 처리, 인터셉터, 뷰 — 요청 처리의 앞뒤와 끝
- 앞 글: 21편: DispatcherServlet — 요청 하나가 응답이 되기까지, 20편: spring-web 기반 — HttpMessageConverter, WebApplicationContext, 필터
- 이 글이 기댄 글: 6편: 생명주기 콜백과 PostProcessor (
afterPropertiesSet), 13편: ResolvableType과 MergedAnnotations, 14편: 타입 변환과 BeanWrapper, 15편: SpEL - 시리즈의 처음: 1편: AI와 함께 소스를 읽는 방법과 분석 지도