namalzi

← 글 목록

Spring 소스 파헤치기 22: 핸들러 매핑과 어댑터 — @RequestMapping은 어떻게 찾고 어떻게 부르나

Spring · 게시

@GetMapping을 붙인 메서드가 언제 어디에 등록되는지, 요청이 오면 후보 가운데 어느 메서드가 뽑히는지, 뽑힌 메서드의 인자는 누가 채우고 반환값은 누가 응답으로 바꾸는지를 RequestMappingHandlerMapping과 RequestMappingHandlerAdapter의 소스로 따라갑니다. 매핑이 모호할 때 나는 두 가지 예외와, 직접 만든 인자 리졸버가 끼어드는 자리도 예제로 확인합니다.

이 글에서 다루는 것

"Spring 소스 파헤치기" 시리즈의 스물두 번째 글입니다. 21편에서 DispatcherServlet은 요청마다 두 가지를 묻기만 했습니다. HandlerMapping에게 "이 요청은 누가 맡습니까", HandlerAdapter에게 "이 핸들러를 부를 줄 압니까". 이번 글은 그 질문을 받는 쪽, 그 가운데서도 우리가 매일 쓰는 @RequestMapping 방식을 맡는 두 클래스를 읽습니다. 1편 지도에서는 Phase 5(웹)의 U20입니다.

답하려는 질문은 네 가지입니다.

항목 내용
기준 소스 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은 네 층의 상속 맨 아래에 있습니다.

HandlerMapping 구현의 상속 구조. HandlerMapping 인터페이스를 AbstractHandlerMapping이 구현하고, 그 아래로 AbstractHandlerMethodMapping, RequestMappingInfoHandlerMapping, RequestMappingHandlerMapping이 차례로 상속한다. AbstractHandlerMethodMapping은 MappingRegistry를 가진다. AbstractHandlerMapping 아래에는 AbstractUrlHandlerMapping과 RouterFunctionMapping도 있다. 왼쪽 줄기가 이 글에서 읽는 네 층입니다. 노란 상자는 매핑을 쌓아 두는 저장소, 회색 상자는 이름만 짚고 넘어가는 다른 구현입니다.

층 맡은 일 하위 클래스에 남긴 것
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());
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java 206–224행

getCandidateBeanNames()는 컨테이너에 있는 모든 빈의 이름을 돌려줍니다(getBeanNamesForType(Object.class), 232–236행). 빈마다 processCandidateBean()이 타입을 알아내고, 그 타입이 핸들러이면 detectHandlerMethods()로 넘깁니다(249–263행). 여기서 빈을 만들지는 않습니다. 이름으로 타입만 묻습니다.

매핑이 등록되는 흐름. 컨테이너가 afterPropertiesSet을 부르면 initHandlerMethods가 모든 빈 이름을 돌고, isHandler로 @Controller가 있는지 본다. 없으면 건너뛰고, 있으면 detectHandlerMethods가 메서드를 훑어 getMappingForMethod로 RequestMappingInfo를 만든 뒤 MappingRegistry.register로 넘긴다. 같은 매핑이 이미 있으면 Ambiguous mapping 예외가 나고, 없으면 registry와 pathLookup 두 맵에 들어간다. 파란 상자는 시작점인 초기화 콜백과 하위 클래스가 어노테이션을 읽는 자리, 노란 상자가 저장소입니다.

무엇이 핸들러인가

	@Override
	protected boolean isHandler(Class<?> beanType) {
		return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java 176–179행

클래스에 @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;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java 199–218행

순서는 이렇습니다. 메서드의 어노테이션으로 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();
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java 364–382행

그런데 우리가 붙인 것은 @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();
			}
		}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java 600–635행

맵이 둘이라는 점을 눈여겨봅니다.

pathLookup은 3절에서 지름길로 쓰입니다. 그리고 넣기 전에 validateMethodMapping()이 같은 매핑이 이미 있는지 봅니다(637–646행). 있으면 IllegalStateException이고, 이것이 afterPropertiesSet 안에서 났으니 컨테이너가 켜지지 못합니다.

MappingRegistry 의 두 맵에 아래 예제의 매핑 셋이 들어간 모양. registry 는 RequestMappingInfo 를 열쇠로 MappingRegistration(HandlerMethod 포함)을 들고, {GET [/users/me]} → UserController#me, {GET [/users/{id}]} → UserController#one, {POST [/users], consumes [application/json]} → UserController#create 가 들어 있다. pathLookup 은 패턴 없는 경로를 열쇠로 매핑 목록을 들고, /users/me 는 {GET [/users/me]} 를, /users 는 {POST [/users], …} 를 가리킨다. /users/{id} 는 패턴이라 pathLookup 에 없다 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 로그 한 줄이 더 나오는데 같은 내용이라 뺐습니다.

헷갈리기 쉬운 곳

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);
		}
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/handler/AbstractHandlerMethodMapping.java 393–434행

요청에 맞는 핸들러 메서드를 찾는 흐름. 먼저 pathLookup에서 경로를 그대로 찾아 맞는 후보를 모으고, 후보가 없으면 registry의 모든 매핑을 검사한다. 후보가 0개면 handleNoMatch, 1개면 그것이 답, 2개 이상이면 compareTo로 정렬해 1등과 2등이 똑같이 맞으면 Ambiguous handler methods 예외를 던지고 아니면 1등을 고른다. 고른 뒤 handleMatch가 경로 변수를 요청 속성에 적는다. 노란 상자가 2절에서 쌓아 둔 두 맵입니다.

후보 모으기 — 지름길이 먼저

  1. pathLookup에서 요청 경로를 그대로 찾습니다. 맵 조회 한 번입니다. 나온 매핑마다 getMatchingMapping()으로 나머지 조건(HTTP 메서드 등)까지 맞는지 봅니다.
  2. 거기서 하나도 못 건졌을 때만 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;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/RequestMappingInfo.java 476–519행

줄인 가운데 부분도 같은 모양이라, 견주는 순서는 경로 → 파라미터 → 헤더 → 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행).

핸들러 메서드를 부르는 흐름. DispatcherServlet이 ha.handle을 부르면 AbstractHandlerMethodAdapter.handle, RequestMappingHandlerAdapter.handleInternal, invokeHandlerMethod를 거쳐 ServletInvocableHandlerMethod.invokeAndHandle이 불린다. invokeForRequest 안에서 getMethodArgumentValues가 파라미터마다 리졸버에게 값을 받고 doInvoke가 리플렉션으로 메서드를 부른다. 반환값은 returnValueHandlers.handleReturnValue가 처리하고, getModelAndView가 결과를 DispatcherServlet에 돌려준다. 파란 상자 둘이 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);

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java 910–943행

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);
		}
		// ...
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ServletInvocableHandlerMethod.java 114–144행

  1. invoke — invokeForRequest()가 인자를 마련하고(getMethodArgumentValues) 메서드를 부릅니다(doInvoke). 이 메서드는 부모인 InvocableHandlerMethod에 있고(171–192행), 실제 호출은 리플렉션 한 줄 method.invoke(getBean(), args)입니다(252행).
  2. handle — 돌려받은 값을 returnValueHandlers.handleReturnValue()에 넘깁니다.

끝나면 getModelAndView()가 메모장을 보고 DispatcherServlet에 돌려줄 것을 정합니다. requestHandled가 true이면 null(그릴 뷰가 없다), 아니면 뷰 이름과 모델을 담은 ModelAndView입니다(1041–1061행).

헷갈리기 쉬운 곳

5. 인자는 누가 채우나 — HandlerMethodArgumentResolver

컨트롤러 메서드의 파라미터는 무엇이든 될 수 있습니다. @PathVariable long id, @RequestBody User user, HttpServletRequest request, Model model …. 이것을 if로 나열하지 않고, "이 파라미터는 내가 맡는다"고 답하는 객체들의 목록으로 풉니다. 인터페이스의 메서드는 둘입니다(43행, 60행).

파라미터를 도는 쪽은 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;
	}

spring-web/src/main/java/org/springframework/web/method/support/HandlerMethodArgumentResolverComposite.java 129–141행

찾은 결과는 argumentResolverCache에 파라미터를 열쇠로 적어 둡니다(44–45행). 컨트롤러 메서드의 파라미터 구성은 실행 중에 바뀌지 않으니, 같은 메서드로 오는 두 번째 요청부터는 목록을 돌지 않습니다.

파라미터 하나의 리졸버를 찾는 순서. 먼저 argumentResolverCache에 이 파라미터가 있는지 보고 있으면 적어 둔 리졸버를 쓴다. 없으면 어노테이션으로 고르는 리졸버 15개, 타입으로 고르는 리졸버 10개, 직접 등록한 리졸버, 나머지를 받는 리졸버 3개의 순서로 물어, 처음 true를 돌려준 리졸버를 캐시에 적고 resolveArgument를 부른다. 아무도 없으면 No suitable resolver 예외가 난다. 목록은 네 묶음이고, 앞에서부터 묻다가 처음 "맡겠다"는 리졸버에서 멈춥니다.

기본 목록과 그 순서

목록은 어댑터의 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;
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerAdapter.java 648–694행

소스의 주석이 네 묶음을 나눠 줍니다. 이 커밋에서 세어 보면 어노테이션 기반 15개, 타입 기반 10개(Kotlin이 있으면 하나 더), 직접 등록한 것, 그리고 맨 뒤의 셋입니다.

먼저 답한 쪽이 맡으므로 순서가 곧 우선순위입니다. 순서가 뜻하는 것은 맨 뒤 셋에서 드러납니다. RequestParamMethodArgumentResolver와 ServletModelAttributeMethodProcessor는 목록에 두 번씩 들어갑니다. 맨 앞에서는 생성자 인자가 false, 맨 뒤에서는 true입니다.

컨트롤러에 String name이라고만 써도 요청 파라미터 name이 들어오는 것은 이 마지막 둘 덕분입니다. 이들이 맨 뒤에 있는 까닭도 분명합니다. "어노테이션이 없으면 다 받는다"는 리졸버가 앞에 있으면 HttpServletRequest나 Model 같은 파라미터까지 가로채게 됩니다.

직접 등록한 리졸버의 자리는 기본 리졸버 뒤, 맨 뒤 셋의 앞입니다. 그래서 내가 만든 타입(CurrentUser 같은)은 모델 속성으로 넘어가기 전에 내 리졸버가 받을 수 있고, 반대로 @RequestParam이나 @PathVariable이 붙은 파라미터는 내 리졸버에게 물어보지도 않습니다. WebMvcConfigurer.addArgumentResolvers()로 넣는 리졸버도 같은 자리로 갑니다(WebMvcConfigurer 162행, WebMvcConfigurationSupport 657행).

대표 구현 셋

@RequestParam과 @PathVariable — 이름으로 값 하나 꺼내기. 둘은 같은 부모 AbstractNamedValueMethodArgumentResolver를 상속합니다(73행). 부모의 resolveArgument()가 틀이고(103–150행), 순서는 이렇습니다.

  1. 어노테이션에서 이름, 필수 여부, 기본값을 읽습니다. 이름을 안 적었으면 파라미터 이름을 쓰는데, 컴파일할 때 -parameters 옵션이 없어 이름을 알 수 없으면 그렇게 하라는 메시지와 함께 예외를 던집니다(176–189행).
  2. resolveName()으로 값을 꺼냅니다. 하위 클래스가 채우는 자리입니다. @RequestParam은 request.getParameterValues(name)에서(RequestParamMethodArgumentResolver 180행), @PathVariable은 3절에서 매핑이 적어 둔 요청 속성 URI_TEMPLATE_VARIABLES_ATTRIBUTE에서 꺼냅니다(PathVariableMethodArgumentResolver 93–97행). 매핑이 뽑아 둔 값을 어댑터 쪽 리졸버가 읽는 것이고, 둘 사이를 잇는 것은 요청 속성 하나입니다.
  3. 값이 없으면 기본값을 쓰거나, 필수인데 없으면 예외를 던집니다.
  4. 꺼낸 값(문자열)을 파라미터 타입으로 변환합니다. 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편에 있습니다.

헷갈리기 쉬운 곳

6. 반환값은 누가 처리하나 — HandlerMethodReturnValueHandler

반대쪽도 같은 모양입니다. 메서드는 둘이고(41행, 56행), 묶음(HandlerMethodReturnValueHandlerComposite)이 목록을 돌며 처음 supportsReturnType이 true인 핸들러에게 맡깁니다(70–91행). 기본 목록은 getDefaultReturnValueHandlers()가 만듭니다(734–780행).

String을 돌려주는 메서드를 예로 들면 순서의 뜻이 보입니다. 목록에서 RequestResponseBodyMethodProcessor(759행)가 ViewNameMethodReturnValueHandler(763행)보다 앞에 있습니다.

String을 돌려준 컨트롤러 메서드가 처리되는 두 길. 메서드나 클래스에 @ResponseBody가 있으면 RequestResponseBodyMethodProcessor가 requestHandled를 true로 하고 HttpMessageConverter로 본문을 쓰며 getModelAndView는 null을 돌려준다. 없으면 ViewNameMethodReturnValueHandler가 문자열을 뷰 이름으로 적어 두고 getModelAndView가 ModelAndView를 돌려주어 DispatcherServlet이 뷰를 그린다. 같은 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);
	}

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java 138–214행

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

세 가지가 보입니다.

헷갈리기 쉬운 곳

더 깊이 들어가면

이 글에서 뺀 것의 이름과 위치만 적어 둡니다. @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절에서 이름만 적어 두고 돌아간 뷰가 실제로 그려지는 과정을 봅니다.

다른 글