7 min read

<Spring> Spring MVC의 DispatcherServlet 핵심로직 들여다보기

Spring MVC를 공부해보니 DispatcherServlet이라는 클래스가 맡고 있는 역할이 중요하다는것을 알게 된다.해당 포스트는 DispatcherServlet을 중심으로 어떻게 Spring MVC가 HTTP 요청을 처리하는지 정리한 글이다.

MVC 패턴

본격적으로 Spring 얘기를 하기 전에 MVC 패턴이 어떤 요구사항에 의해 발생한 것인지 살펴보자. 독자의 대부분이 해당 패턴이 Model-View Controller의 약자라는 것은 알고 계실것이다. 이러한 패턴은 하나의 View에서 너무 많은 책임을 지고 있는 단점에 의해 발생하였다. 쉽게 말해 jsp로 페이지를 만들면 jsp 파일 내에 java로 구성된 비지니스 로직과 함께 사용자에게 렌더링될 페이지에 대한 View에 대한 내용이 함께 존재한다.즉,서로 다른 목적을 가진 두 영역이 하나의 파일에 의해 관리되고 변경 주기가 동일시된다. 이러한 책임 관계를 분리시키기 위해 MVC 패턴이 도입되었다고 이해하면 된다.자세한 포스팅은 이전에 작성한 해당 글을 참고해보면 된다.

Servlet으로 구현된 원시적인 형태의 MVC 패턴

서블릿 객체를 활용하여 간단하게 MVC 패턴을 구현할 수 있다.예제의 요구사항은 다음과 같다.(예제의 경우 강의를 참고하였다.)

  • MvcMemberFormServlet : 회원가입 Form 제공, 저장을 누를 경우 save 호출
  • MvcMemberListServlet : 회원 리스트 제공
  • MvcMemberSaveServlet : 회원 저장

HttpServlet을 상속한 각각의 클래스들은 아래와 같다. 세 가지 클래스 모두 공통적으로 service()를 오버라이딩 하여 구현하고 있다.

  1. MvcMemberFormServlet
@WebServlet(name = "mvcMemberFormServlet", urlPatterns = "/servlet-mvc/members/new-form")
public class MvcMemberFormServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String viewPath = "/WEB-INF/views/new-form.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);//해당 jsp로 넘어감
    }
}
  1. MvcMemberListServlet
@WebServlet(name = "mvcMemberListServlet",urlPatterns = "/servlet-mvc/members")
public class MvcMemberListServlet extends HttpServlet {
    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        List<Member> members = memberRepository.findAll();
        request.setAttribute("members",members);//Model에 담기
        String viewPath = "/WEB-INF/views/members.jsp";
        RequestDispatcher requestDispatcher = request.getRequestDispatcher(viewPath);
        requestDispatcher.forward(request, response);
    }
}
  1. MvcMemberSaveServlet
@WebServlet(name = "mvcMemberSaveServlet",urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        request.setAttribute("member",member);

        String viewPath = "/WEB-INF/views/save-result.jsp";
        RequestDispatcher requestDispatcher = request.getRequestDispatcher(viewPath);
        requestDispatcher.forward(request,response);
    }
}

위 구현을 보면 중복되는 부분이 존재한다. RequestDispatcher 객체를 사용하여 로직의 흐름을 뷰 렌더링으로 이동하는 부분과 viewPath를 설정하는 부분이다.viewPath의 경우 prefix와 suffix를 뽑아내어 최적화 할 수 있다. 위와 같은 부분을 최적화 시키기 위해 Controller들의 수문장 역할을 하며 중복되는 부분에 대한 처리를 해주기 위한 클래스가 바로 DispatcherServlet이다.

DispatcherServlet

모든 Controller의 수문장이라는 표현은 모든 Http 요청이 DispatcherServlet를 거쳐서 처리된다는 뜻이다.즉,스프링부트가 DispatcherServlet을 Bean으로 등록할 때 urlPattern=“/”으로 등록한다는 의미이다. DispatcherServlet이 호출될 경우의 요청흐름은 아래와 같다.

  • 외부로부터 요청 발생, 모든 요청은 DispatcherServlet의 service() 호출
  • DispatcherServlet의 부모인 FrameworkServlet의 service() 호출
  • super.service()를 통해 부모인 HttpServlet의 service() 호출
  • request의 method에 따라 각각의 메서드(doGet,doPost 등등) 호출
  • FrameworkServlet가 구현한 doXXX() 메서드는 내부에서 processRequest() 호출
  • processRequest()는 내부에서 DispatcherServlet의 doService() 호출
  • doService()는 doDispatch() 호출

doDispatch()의 로직을 보면, 해당 글의 주제인 Spring MVC 패턴에서의 요청이 어떻게 처리되는지 이해할 수 있다. 다만 해당 로직을 보기 전에 먼저 알아야할 클래스가 있다.

Handler와 HandlerAdaptor

앞서 살펴본 doDispatch 로직을 파악하기 위해서는 Handler에 대한 이해가 필수이다.이를 위해 스프링에서 과거에 사용하던 방식을 재현해보자.@Controller가 아닌 Controller 인터페이스를 구현하여 하나의 컨트롤러를 생성할 수 있다.

메서드의 이름도 인터페이스를 오버라이딩해야해서 handleRequest로 고정되고 리턴값도 ModelAndView로 고정된 것을 확인 할 수 있다.

@Component("/springmvc/old-controller")
public class OldController implements Controller {
    @Override
    public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
        System.out.println("OldController.handleRequest");
        return new ModelAndView("new-form");
    }
}

예전 방식이지만, 설정된 url로 요청을 보내보면 정상적으로 new-form.jsp 파일을 렌더링 해주는것을 확인할 수 있다.

참고로 application.properties에 아래와 같은 설정을 해줘야한다.

spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp

또 다른 방법으로 Controller를 만들어보자.

@Component("/springmvc/request-handler")
public class MyHttpRequestHandler implements HttpRequestHandler {
    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("MyHttpRequestHandler.handleRequest");
    }
}

HttpRequestHandler라는 인터페이스를 구현하였는데,해당 docs에도 적혀있듯이 이는 view를 리턴하지 않는 경우 사용될 수 있다.실제로 추상 메서드의 시그니처를 보면 void로 설정된 것을 확인할 수 있다. 이 또한 생성 후 url에 요청을 보내보면 로그에 call 된것과 빈 화면을 확인할 수 있다.

그리고 마지막으로 대부분 익숙한 방식인 @RequestMapping을 사용해보자.

@Controller
@RequestMapping("/springmvc/members")
public class SpringMemberController {
    private final MemberRepository memberRepository = MemberRepository.getInstance();

    @GetMapping("/new-form")
    public String newForm() {
        return "new-form";
    }
}

현재 총 3가지의 컨트롤러를 생성해보았는데 이들 모두 서로 다른 방식으로 생성되고 용도 또한 다르다.결국 DispatcherServlet라는 빈에서 들어온 요청이 어떤 방식으로 구현되었는지 파악하고 이에 알맞는 방식으로 처리해줘야한다. 즉,적절한 Controller인지 확인해야하고 해당 Controller에 맞는 방식을 선택해서 처리해야한다.

Handler라는 것은 결국 Controller의 다른 이름이다. 여러개의 Controller가 존재할 경우 해당 Controller에 알맞은 방식(Adaptor)를 찾아야 하고,모든 Controller마다 알맞은 방식(Adaptor)가 존재하기에 Handler라는 이름으로 확장하였다고 이해해보자. 즉,Handler가 있으면 이에 알맞는 HandlerAdaptor가 존재한다.

DispatcherServlet은 결국 들어온 요청이 자신이 가진 Handler 정보들 중에 있는지 찾아보고 해당 Handler를 처리할 수 있는 HandlerAdaptor가 있는지 확인한다.그리고 최종적으로 해당 HandlerAdaptor를 통해 실제 Handler를 호출한다.

doDispatch()

지금 말한 로직을 doDispatch() 메서드의 코드를 통해 살펴보자. 현재 내용에 상관없는 내용은 주석처리하였다.

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
		//...
		try {
			ModelAndView mv = null;
			Exception dispatchException = null;

			try {
				processedRequest = checkMultipart(request);
				multipartRequestParsed = (processedRequest != request);

				// Determine handler for the current request.
				mappedHandler = getHandler(processedRequest);
				if (mappedHandler == null) {
					noHandlerFound(processedRequest, response);
					return;
				}

				// Determine handler adapter for the current request.
				HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
				// ...

				// Actually invoke the handler.
				mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

				//...

				applyDefaultViewName(processedRequest, mv);
				mappedHandler.applyPostHandle(processedRequest, response, mv);
			}
			//...
			processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
		}
	//...
	}

getHandler()를 호출하여 곧바로 해당 요청에 알맞은 Handler를 찾아온다. 참고로handlerMappings라는 List에 현재 Bean으로 등록된 Handler들이 존재한다.해당 리스트를 순회하며 알맞은 Handler 오브젝트를 찾는다.이후 찾은 mappedHandler를 인자로 하여 getHandlerAdapter() 호출하며 HandlerAdaptor를 찾는다. 이후 Adaptor의 handle() 메서드를 호출하며 실제로 해당 컨트롤러를 호출한다. 이 흐름까지가 요청이 들어오고 구현한 메서드가 호출되는데까지의 로직이다.

이후의 로직은 handle()의 결과로 받은 ModelAndView 인스턴스를 processDispatchResult()의 인자로 넣어 내부에서 render()를 호출하며 실제 화면을 그리는 메서드를 호출해준다.