Spring

쉽게 이해해보는 DispatcherServlet

teo_99 2023. 10. 7. 15:05

스프링을 공부하면서 DispatcherServlet에 대한 이야기는 많이 들었음에도 불구하고 이해가 쉽게 되지 않았던 적이 많았습니다. 이름 자체부터 추상적이기도 하고, HandlerMapping, HandlerAdapter 등 낯선 용어들이 너무 많았기 때문입니다. 따라서 이번 아티클에서는 누구나 쉽게 이해할 수 있도록 DispatcherServlet의 역할에 대해 작성해보고자 합니다.

저만의 언어로 풀어쓴 것이기에 많은 부분을 추상화했다는 점 참고해 주시면 감사하겠습니다.

DispatcherServlet

모든 클래스에는 해당 클래스의 역할을 기술하는 네이밍이 주어집니다. 그렇다면 DispatcherServlet이라는 이름을 잘 해석하면 해당 클래스의 역할을 유추해 볼 수 있지 않을까요?

 

Dispatcher는 직역하면 '발송하는 사람' 이라는 의미를 갖고 있습니다. Servlet은 그러면 무엇을 뜻할까요? Servlet을 이해하기 위해서는 웹 애플리케이션 서버가 어떻게 요청을 처리하는지 흐름을 알고 있어야 합니다. 해당 내용에 관해서는 이전에 다룬 아티클이 있기에 첨부하겠습니다. (CGI에서부터 Spring에 이르기까지)

 

Servlet은 웹 요청을 처리하기 위한 하나의 자그마한 자바 프로그램을 의미합니다. 자바 프로그램이라고 하니까 어렵게 다가오는 면이 있는데, 쉽게 말해서 요청을 처리할 수 있는 자바 클래스라고 생각하시면 됩니다. 동일한 역할은 아니지만 스프링의 컨트롤러를 떠올리면 이해가 수월할 것입니다.

 

그러면 DispatcherServlet의 의미를 다시 한 번 생각해 봅시다. Dispatcher(발송하는) + Servlet(서블릿), 즉 무언가를 발송하는 서블릿을 의미합니다. 무엇을 발송할까요? 바로 HTTP 요청을 발송합니다.

 

HTTP 요청을 발송하는 이유

이를 알기 위해서는 스프링의 등장 배경에 대한 이해가 필요할 것 같습니다. 스프링 등장 이전에는 J2EE 사양에 맞춰서 웹 애플리케이션 서버를 구축해야 했습니다. 그런데 서블릿이나 EJB 사양에 맞춰서 개발을 진행하다 보니 유지보수하기 어려운 코드들이 탄생했고, 순수한 POJO 스타일의 프로그래밍이 어려웠습니다.

 

그래서 앞선 문제들을 해결하기 위해서 스프링이 등장했는데요. 어떻게, 무엇을 해결했는지는 추후에 설명할 예정이니, 여기서는 '스프링이 HTTP 요청을 발송해주는 DispatcherServlet을 활용해 어떤 문제들을 해결했다' 정도만 이해하고 넘어가시면 충분할 것 같습니다.

 

핵심은 유연성

이전까지의 내용을 정리하면 다음과 같습니다.

  • DispatcherServlet은 HTTP 요청을 어디론가 발송해주는 역할이다.
  • 스프링과 DispatcherServlet의 등장으로 '어떤 문제'들이 해결되었다.

도대체 DispatcherServlet은 어떤 문제들을 해결한 것일까요? 

 

결론부터 말씀드리자면 DispatcherServlet은 유연한 코드를 작성하게 해 줍니다. 스프링을 한 번쯤이라도 사용한 경험이 있으시다면 느껴보셨겠지만, 별 다른 설정 없이 @Controller 어노테이션과 path 정보만 추가해 주는 것으로 웹 요청 처리가 가능합니다. 이런 유연함을 제공할 수 있게 하는 게 결국은 DispatcherServlet이 있기 때문입니다. 

 

다음은 DispatcherServlet의 JavaDocs 내용 중 일부입니다.

웹 요청 처리를 위해 등록된 핸들러에 디스패치하여 편리한 매핑 및 예외 처리 기능을 제공합니다.

 

유연성을 제공하기 위한 노력 1 - HandlerMapping

스프링 어플리케이션을 만들고 있다고 가정해 봅시다. 저희는 HTTP 요청이 저희가 만든 HelloController로 들어오기를 바랍니다. 

앞서 DispatcherServlet은 요청을 토스해주는 역할이라고 배웠으니, 위와 같은 그림을 떠올릴 수 있을 것 같습니다. 그런데 여기서 의문점이 생기지 않나요?

  • 우리가 만든 HelloController를 어떻게 인식해서 HTTP 요청을 토스해 줄 수 있을까?

사실 스프링에서는 컨트롤러를 어노테이션 기반으로 등록할 수도 있고, xml 기반으로도 등록할 수 있습니다. 이렇게 등록된 컨트롤러 정보를 결국에는 DispatcherServlet이 결국에는 알고 있어야 요청을 토스해 줄 수 있는 건데, 중간에 어떤 매개인이 있어야 할 것 같다는 생각이 듭니다. 따라서 스프링은 그런 매개인 역할을 하는 인터페이스를 하나 두었는데, 그게 바로 HandlerMapping입니다.

public interface HandlerMapping {

	@Nullable
	HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception;
}

DispatcherServlet은 HandlerMapping을 통해 요청을 처리할 핸들러를 조회합니다. @Controller 어노테이션을 붙인 클래스는 HandlerMapping의 구현체인 RequestMappingHandlerMapping에 의해 관리되는데, 구현부를 살짝만 살펴보겠습니다.

 

RequestMappingHandlerMapping의 슈퍼타입 중 하나인 AbstractHandlerMethodMapping 클래스를 보면 mappingRegistry라는 필드가 존재합니다.

public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMapping implements InitializingBean {

	private final MappingRegistry mappingRegistry = new MappingRegistry();

	// ... 생략
    
	class MappingRegistry {

		private final Map<T, MappingRegistration<T>> registry = new HashMap<>();

		private final MultiValueMap<String, T> pathLookup = new LinkedMultiValueMap<>();

		private final Map<String, List<HandlerMethod>> nameLookup = new ConcurrentHashMap<>();

		private final Map<HandlerMethod, CorsConfiguration> corsLookup = new ConcurrentHashMap<>();
        
       	 // ... 생략
   	 }
}

AbstractHandlerMethodMapping은 해당 mappingRegistry를 통해 실제 핸들러(컨트롤러)들을 관리합니다. 여러 필드가 존재하는데 그 중 pathLookup이라는 필드를 통해서 URL과 핸들러를 매핑합니다. AbstractHandlerMethodMapping은 이런 MappingRegistry를 사용하여 가장 적합한 핸들러를 찾습니다.

 

DispatcherServlet은 아래처럼 여러 HandlerMapping들의 구현체를 갖고 있습니다. 그리고 HTTP 요청이 들어오면 HandlerMapping 리스트를 순회하면서 요청을 적절하게 처리할 수 있는 핸들러를 찾게 됩니다. getHandler 메소드 부분을 보시면 해당 내용을 쉽게 확인하실 수 있습니다.

참고로, HandlerMapping 구현체들은 적절하게 처리할 수 있는 핸들러가 없을 경우 null을 반환하도록 설계되어 있습니다.
public class DispatcherServlet extends FrameworkServlet {
	
	/** List of HandlerMappings used by this servlet. */
	@Nullable
	private List<HandlerMapping> handlerMappings;

	@Nullable
	protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
		if (this.handlerMappings != null) {
			for (HandlerMapping mapping : this.handlerMappings) {
				HandlerExecutionChain handler = mapping.getHandler(request);
				if (handler != null) {
					return handler;
				}
			}
		}
		return null;
	}   
}

설명이 길었는데 정리하자면 다음과 같습니다.

  • DispatcherServlet에서 유연하게 핸들러(컨트롤러)들을 찾기 위한 방법이 필요하다.
  • 해당 역할을 수행해주는게 HandlerMapping 인터페이스이다.
  • DispatcherServlet은 여러 HandlerMapping들을 리스트로 가지고 있고, 매번 HTTP 요청이 발생할 때마다 요청을 처리하기에 적절한 핸들러를 갖고 있는 HandlerMapping을 찾는다.

 

유연성을 제공하기 위한 노력 2 - HandlerAdapter

여태까지 학습한 내용을 토대로 그림을 다시 한번 그려봅시다. 이제 DispatcherServlet은 HTTP 요청을 전달할 핸들러를 쉽게 찾을 수 있게 되었습니다. 

그런데 고민이 또 한 가지 생깁니다. 사실 컨트롤러는 한 가지 구현 방식만 있는 게 아닙니다. 현재는 어노테이션 기반의 컨트롤러를 사용하고 있지만, 과거에는 인터페이스 기반의 컨트롤러를 사용했습니다. 

 

핸들러마다 이렇게 구현 방식이 다르다보니 다음과 같은 고민을 하게 됩니다.

  • 서로 다른 구현 방식을 가진 핸들러들을 추상화해서 다룰 순 없을까? 

이런 고민 끝에 등장한 것이 바로 HandlerAdpter 인터페이스입니다. 네이밍을 보면 알 수 있듯이 어댑터의 역할을 한다고 이해하시면 쉬울 것 같습니다. DispatcherServlet은 핸들러를 직접 실행시켜서 결과를 받는 게 아니라 HandlerAdapter에게 실행을 위임합니다.

 

인터페이스만 간략하게 보면 다음과 같은데, 간단하게 1. 해당 HandlerAdpater가 처리를 지원하는지 여부(supports)와 2. 실제로 핸들러를 실행시키는 기능(handle)만 제공합니다.

public interface HandlerAdapter {

	boolean supports(Object handler);

	@Nullable
	ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
    
}

DispatcherServlet은 HandlerMapping의 경우와 마찬가지로 여러 HandlerAdapter 중에서 처리가 가능한 HandlerAdapter를 뽑아 핸들러 실행을 위임시키게 됩니다. 

public class DispatcherServlet extends FrameworkServlet {

	@Nullable
	private List<HandlerAdapter> handlerAdapters;
    
    protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
		if (this.handlerAdapters != null) {
			for (HandlerAdapter adapter : this.handlerAdapters) {
				if (adapter.supports(handler)) {
					return adapter;
				}
			}
		}
		throw new ServletException();
	}
}

정리하자면 다음과 같습니다.

  • 핸들러(컨트롤러)마다 구현 방식이 다르니 DispatcherServlet에서 공통적으로 다룰 수 있는 방법이 필요하다.
  • 따라서 중간에 HandlerAdapter라는 인터페이스를 두고 어댑터처럼 활용한다.
  • DispatcherServlet은 처리가 가능한 HandlerAdapter를 찾아서 HTTP 요청 처리를 위임한다.

그리고 이 내용을 기반으로 그림을 보완하면 다음과 같은 형태가 됩니다.

유연성을 제공하기 위한 노력 3 - ViewResolver

앞서서 스프링은 어떻게 요청을 처리할 수 있는 핸들러를 찾고, 실행시키는 과정을 추상화했는지 알아보았습니다. 하지만 실행한다고 해서 HTTP 응답이 저절로 생겨나는 것은 아닙니다. 따라서 HTTP 응답을 만들어낼 수 있는 무언가가 필요합니다.

 

사실 컨트롤러의 응답은 여러 종류가 있습니다. Thymeleaf 등의 엔진을 사용하신 분들은 아시겠지만, 컨트롤러에서 String 타입의 정적파일명을 반환하면 자동으로 페이지가 렌더링됩니다. 

 

이전까지는 HandlerAdapter를 '핸들러를 대신해서 실행시키는 역할' 정도로만 설명했습니다. 하지만 인터페이스의 handle 메소드를 자세히 보셨으면 아시다시피 실행시키는 것뿐만 아니라 ModelAndView라는 객체를 반환합니다.

public interface HandlerAdapter {

	boolean supports(Object handler);

	@Nullable
	ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;   
}

 ModelAndView는 네이밍에서부터 알 수 있다시피 '모델과 뷰'를 감싼 객체입니다. JavaDocs의 설명은 다음과 같습니다.

MVC 프레임워크에서 모델과 뷰를 함께 전달하기 위한 객체입니다. 모델과 뷰는 완전히 다른 객체이지만 하나의 반환값으로 표현하기 위해 ModelAndView를 사용합니다.

 

이렇게 반환된 ModelAndView 객체에는 실제 뷰 렌더링에 사용될 모델과 뷰 이름이 저장되어 있습니다. 그리고 이런 정보들을 활용해 DispatcherServlet은 렌더링을 수행합니다. 하지만 뷰 이름만으로는 어떤 뷰를 사용할지 정확히 알기가 어려운데, 이 문제를 해결하기 위해 스프링은 ViewResolver를 사용합니다. ViewResolver는 뷰 이름을 통해 해당하는 뷰를 반환하는 인터페이스입니다.

 

DispatcherServlet은 HandlerMapping, HandlerAdapter와 마찬가지로 ViewResolver 구현체 리스트를 가지고 있고, 뷰 이름을 통해 뷰를 찾아야 할 때마다 리스트를 순회하면서 적절히 처리해 줄 수 있는 ViewResolver 구현체를 찾습니다. 그리고 찾게 된 View를 통해 렌더링을 수행하게 됩니다.

public class DispatcherServlet extends FrameworkServlet {

	@Nullable
	private List<ViewResolver> viewResolvers;
    
	@Nullable
	protected View resolveViewName(String viewName, @Nullable Map<String, Object> model,
			Locale locale, HttpServletRequest request) throws Exception {

		if (this.viewResolvers != null) {
			for (ViewResolver viewResolver : this.viewResolvers) {
				View view = viewResolver.resolveViewName(viewName, locale);
				if (view != null) {
					return view;
				}
			}
		}
		return null;
	}
}

 

하지만 HTTP 응답 중에는 View를 사용하지 않는 경우도 많은데요, 특히나 프론트엔드, 백엔드 서버가 나뉘어 있는 경우에는 Server-Side Rendering을 수행할 일이 거의 없으니 대부분을 JSON 응답으로 내려주게 됩니다.

 

이런 경우에는 ModelAndView에 NULL 값이 할당되지만, HandlerAdpater 내부에 있는 MessageConverter 객체들을 사용해 적절한 HTTP Body를 생성하게 됩니다.

 

그래서 최종적으로 DispatcherServlet을 통한 Spring MVC에서의 요청 및 응답 구조는 다음과 같은 그림으로 표현할 수 있습니다. 

정리

이번 아티클에서는 DispatcherServlet의 역할에 대해 알아보았습니다. 스프링은 DispatcherServlet이라는 Servlet을 둠으로써 DispatcherServlet 뒷단으로는 엄청난 유연성을 부여했습니다. HandlerMapping을 통해 핸들러를 찾는 과정을 추상화하고, HandlerAdapter를 통해 핸들러를 실행시키는 작업마저도 추상화했습니다.

 

따라서 스프링을 사용하는 개발자들은 POJO 형태의 코드를 작성하기가 수월해졌습니다. 단순히 어노테이션을 몇개만 붙였을 뿐인데, 자동으로 URI 바인딩이 되고 응답을 생성해 줄 수 있었던 이유는 DispatcherServlet을 포함한 Spring MVC 구성요소들이 존재했기 때문입니다.

 

사실 위 내용은 DispatcherServlet 및 Spring MVC의 핵심 플로우만 설명한 것이기 때문에 Spring의 예외처리 방식이라든지, Interceptor 등이 동작하는 과정은 기술하지 않았습니다. 추후 기회가 된다면 추가적인 아티클을 작성하도록 하겠습니다.

 

마치며

위 내용은 우아한테크코스 MVC 미션을 진행하면서 학습했던 내용을 기반으로 작성한 것입니다. 스프링을 처음 접하시는 분들도 이해가 될 수 있도록 글을 작성하려고 했는데, 혹시 틀렸거나 부족한 부분이 있다면 피드백 남겨주시면 감사하겠습니다.

 

 

참고자료

구구의 Web MVC 미션

Spring Java Docs

파즈 - HTTP Message Converter가 적용되는 시점에 대해