[Spring] 코드로 분석해보는 Spring MVC 구조 이해(Front Controller 패턴, DispatchServet)
스프링을 사용해 웹 애플리케이션을 개발해 본 적이 있는 개발자라면, 상당히 간단하게 컨트롤러 클래스를 작성해 본 경험이 있을 것이다. 근데 가만 생각해 보면, 클라이언트의 요청에 맞게 호출될 수 있도록 URL을 매핑해 주고, 비즈니스 로직을 넣고, 또 경우에 따라서는 뷰 로직도 들어가야 하는 “컨트롤러” 클래스가 그렇게 간단할 수 없다.
이는 스프링 MVC 프레임워크가 비즈니스 로직 이외의 반복적이고 귀찮은 작업들을 처리해 주고 있기에 가능한 것이다. 그것도 아주 깔끔한 객체 지향적인 설계로 말이다. 때문에 개발자는 클라이언트의 다양한 HTTP 요청을 입맛에 맞게 처리할 수 있는 컨트롤러를 간단히 구현할 수 있다.
스프링 MVC도 처음에는 이런저런 기능 부족과 불편함 때문에 외면받기도 했다. 하지만 애노테이션 기반의 컨트롤러가 자리 잡으면서, 스프링 기반의 웹 애플리케이션에서는 필수 스펙으로 자리잡았다. 워낙 객체지향 설계가 잘 되어 있던 스프링 MVC는 기술 변경 및 확장에 있어 어려움이 없었다. 애노테이션 기반의 컨트롤러가 다양한 편의성을 제공하는 것도 객체지향적인 설계가 가져다 주는 이점에서 오는 것들이다. 그리고 그 핵심에는 Front Controller 패턴이라는 아키텍처가 있다.
스프링 MVC가 어떤 구조로 이루어져 있고 어떻게 동작하는지 공부해 보면, 객체지향에 대한 공부도 될 뿐더러 추후 관련 장애가 발생했을 때 큰 도움이 될 것이다. 물론 김영한님의 강의 도움을 받았음에도, 엄청난 추상화 덕분에(?) 자세히 알아보는 것은 지금의 수준에서 너무 쉽지 않았기에 핵심만 파악해 보겠다.
Front Controller 패턴
프론트 컨트롤러 패턴은 웹 애플리케이션에서 사용되는 아키텍처 패턴 중 하나로, 모든 클라이언트 요청을 하나의 진입점(컨트롤러)로 집중시켜 처리하는 방식이다. 이 패턴은 효율적인 요청 관리와 중앙 집중식 제어를 가능하게 하여 웹 애플리케이션의 구조를 간소화하고 유지보수가 쉬워진다.
프론트 컨트롤러 패턴의 동작 원리
- 클라이언트가 웹 애플리케이션에 요청을 보냄
- 모든 요청은 프론트 컨트롤러라는 단일 진입점을 통해 들어옴
- 프론트 컨트롤러는 요청을 분석하고, 적절한 핸들러(Controller)로 전달
- 핸들러가 요청을 처리한 후 결과(View)를 클라이언트에게 반환
위 그림처럼 모든 클라이언트의 요청은 프론트 컨트롤러라는 “유일한 입구”로 들어오게 된다. 그 다음 요청 URL에 맞는 컨트롤러를 호출해서 처리하도록 한다. 이런 컨트롤러를 핸들러(Handler)라고 한다. 이 핸들러에서는 특정 요청을 처리하는 비즈니스 로직이 구현되어 있고, 응답 메세지에 맞게 데이터나 뷰(View)를 반환하기도 한다.
💡 핸들러(Handler)라는 용어가 어색할 수 있다. 컨트롤러라고 하면 되는데, 왜 굳이 핸들러라고 할까? 스프링에서 컨트롤러는 클래스 단위로 작성된다. 하지만 HTTP 요청을 처리를 클래스 단위로 할 수도 있지만, 사실 엄밀히 말하자면 메서드 단위로 처리하는게 맞다. 그래서 하나의 컨트롤러 클래스 안에 HTTP 요청을 처리하는 여러 개의 핸들러 메서드가 포함되는 경우가 많다.
즉, "핸들러"는 특정 요청을 “처리(Handle)”하는 단위라는 의미를 강조한다. 따라서 컨트롤러가 아닌 다른 객체나 메서드도 핸들러로 동작할 수 있음을 암시하는 용어다.
프론트 컨트롤러가 갖는 이점
프론트 컨트롤러 패턴을 적용함으로써 여러 이점이 있지만, 먼거 가장 와닿는 이점은 코드 중복을 줄일 수 있다는 점이다. 예를 들어 보자. 다음 클래스들은 스프링 없이 순수한 자바와 서블릿으로 만든 컨트롤러 클래스다.
MvcMemberFormServlet
: 회원 저장 폼 페이지 응답MvcMemberSaveServlet
: 회원 저장 후 결과 응답MvcMemberAllServlet
: 모든 회원의 리스트 응답
참고로 뷰는 JSP를 사용한다고 가정하자. 핵심 내용 이외의 것들은 생략하겠다.
[MvcMemberFormServlet]
import ...
@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 requestDispatcher = request.getRequestDispatcher(viewPath);
requestDispatcher.forward(request, response);
// 뷰 로직 --------------
}
}
requestDispatcher.forward() : 다른 서블릿이나 JSP로 이동할 수 있는 기능이다. 서버 내부에서 다시 호출이 발생한다.
[MvcMemberSaveServlet]
import ...
@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {
private final 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.jsp";
RequestDispatcher requestDispatcher = request.getRequestDispatcher(viewPath);
requestDispatcher.forward(request, response);
// 뷰 로직 --------------------
}
}
[MvcMemberAllServlet]
import ...
@WebServlet(name = "mvcMemberAllServlet", urlPatterns = "/servlet-mvc/members")
public class MvcMemberAllServlet extends HttpServlet {
private final MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 비즈니스 로직 ------------------------------------
List<Member> members = memberRepository.findAll();
// 비즈니스 로직 ------------------------------------
request.setAttribute("members", members);
// 뷰 로직 --------------------
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher requestDispatcher = request.getRequestDispatcher(viewPath);
requestDispatcher.forward(request, response);
// 뷰 로직 --------------------
}
}
클래스 로직을 보면, 실제 비즈니스 로직은 간단하지만 View를 응답하기 위한 코드 때문에 로직이 길어진다. 특히나 이런 로직들은 다른 컨트롤러 클래스들과 중복되는 부분이 많다. 또한 서블릿에 종속적이기 때문에 비즈니스 로직에 HttpServletRequest
, HttpServletResponse
를 사용하지 않는 컨트롤러에서도 파라미터 변수로 받아야 한다. 결과적으로 당장 보이는 문제점들만 나열해도 다음과 같다.
- url 패턴 매칭을 위한 문자열 중복
- 서블릿에 종속적
- 뷰 로직 중복
이를 프론트 컨트롤러 패턴을 적용해 개선해 보자.
ControllerV4
: 다형성을 활용하기 위한 컨트롤러 인터페이스MemberSaveControllerV4
: 회원 저장을 위한 핸들러(컨트롤러)MemberListControllerV4
: 모든 회원 리스트를 출력하기 위한 핸들러(컨트롤러)
(클래스 이름 끝에 V4가 붙는 것은 개선 버전이므로 신경쓰지 않아도 된다)
[ControllerV4]
import ...
public interface ControllerV4 {
String process(Map<String, String> paramMap, Map<String, Object> model);
}
[MemberFormControllerV4]
import ...
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
return "new-form";
}
}
[MemberSaveControllerV4]
import ...
public class MemberSaveControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
// 비즈니스 로직 ----------------------------
Member member = new Member(username, age);
memberRepository.save(member);
// 비즈니스 로직 ----------------------------
model.put("member", member);
return "save";
}
}
[MemberListControllerV4]
public class MemberListControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
// 비즈니스 로직 ----------------------------
List<Member> members = memberRepository.findAll();
// 비즈니스 로직 ----------------------------
model.put("members", members);
return "members";
}
}
일단 여기까지 보면, 먼저 중복됐던 뷰 로직이 사라졌다. 그리고 중복되는 url 패턴 경로 설정 대신에 간단한 논리적 뷰 이름을 반환하도록 변경했다. 무엇보다 서블릿에 종속될 필요도 없어졌다. 이제 잡다한 일들은 프론트 컨트롤러에서 담당하도록 한 것이다. 개발자 입장에서는 컨트롤러를 구현하는데 있어서 비즈니스 로직에 더 철저하게 집중할 수 있는 환경이 됐다.
MyView
: 뷰 경로를 가지고 있고, 클라이언트에게 뷰를 생성한 뒤 응답하는 뷰 로직을 실행하는 클래스FrontControllerServletV4
: 모든 클라이언트의 요청을 받아 적절히 응답하는 프론트 컨트롤러
[MyView]
import ...
public class MyView {
private String viewPath;
public MyView(String viewPath) {
this.viewPath = viewPath;
}
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher requestDispatcher = request.getRequestDispatcher(viewPath);
requestDispatcher.forward(request, response);
}
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
modelToRequestAttribute(model, request);
RequestDispatcher requestDispatcher = request.getRequestDispatcher(viewPath);
requestDispatcher.forward(request, response);
}
private static void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
model.forEach((key, value) -> request.setAttribute(key, value));
}
}
[FrontControllerServletV4]
import ...
@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {
private final Map<String, ControllerV4> controllerMap = new HashMap<>();
public FrontControllerServletV4() {
controllerMap.put("/front-controller/v4/members/new-form", new MemberFormControllerV4());
controllerMap.put("/front-controller/v4/members/save", new MemberSaveControllerV4());
controllerMap.put("/front-controller/v4/members", new MemberListControllerV4());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
// 핸들러 매핑 : 요청 URL에 맞는 핸들러(컨틀롤러) 찾기
ControllerV4 controller = controllerMap.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// paramMap : 컨트롤러에서 HttpServletRequest 대신 사용
Map<String, String> paramMap = createParamMap(request);
// model : 컨트롤러에서 뷰로 전달할 모델
HashMap<String, Object> model = new HashMap<>();
// 핸들러(컨트롤러) 실행 후 논리적인 뷰 이름 저장
String viewName = controller.process(paramMap, model);
// 뷰 경로를 담고 있는 MyView 객체 생성
MyView view = viewResolver(new ModelView(viewName));
// 클라이언트에게 뷰 생성 후 응답
view.render(model, request, response);
}
private HashMap<String, String> createParamMap(HttpServletRequest request) {
HashMap<String, String> paramMap = new HashMap<>();
request.getParameterNames().asIterator()
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
return paramMap;
}
private static MyView viewResolver(ModelView mv) {
return new MyView("/WEB-INF/views/" + mv.getViewName() + ".jsp");
}
}
물론 프론트 컨트롤러를 보면 여러 일들을 담당하고 있어 꽤 복잡하다. 그리고 위 예시 코드에서는 요청에 맞는 컨트롤러를 찾거나 뷰 리졸버같은 코드들이 간단하게 구현되어 있지만, 실제로 스프링에서는 모든 것들이 인터페이스로 구현되어 추상화 되어 있고, 개발자가 세팅한 환경에 맞게 구현 클래스들이 선택되어 최대한으로 객체 지향이 활용된다.
즉, 이런 프론트 컨트롤러같은게 많은 일들을 담당해주고 있는 덕분에 우리같은 개발자들은 너무 간단하게 컨트롤러를 구현할 수 있을 뿐 아니라, 원하는 뷰 템플릿을 설정하는 등, 입맛에 맞게 세팅을 구성할 수 있다.
스프링 MVC에서는 위와 같은 프론트 컨트롤러 역할을 DispatcherServlet
클래스가 하고 있다.
DispatcherServlet의 핵심 동작
DispatcherServlet
클래스는 우리가 위 예시에서 봤던 프론트 컨트롤러와 유사하긴 하지만, 높은 추상화 레벨과 예외처리 등으로 인해 코드 자체는 상당히 복잡하다. 그래도 스프링 코드를 공부해 본다는 것이 의미가 있으므로 핵심만 파악해 보자.
Spring MVC의 전체 구조와 어댑터 패턴
Spring MVC가 동작하는 구조를 살펴보면 위 그림과 대략 같다. 아마도 핸들러와 핸들러 어댑터 부분이 어색하게 느껴질 수 있다. 핸들러는 앞서 설명했듯이 컨트롤러라고 생각하면 된다. 핸들러 어댑터(Adapter)는 아키텍처 중에 어댑터 패턴(Adapter pattern)에서 나온 것이다.
어댑터 패턴에서는 기존의 클래스를 클라이언트가 요구하는 방식으로 살짝 변형시켜주는 역할을 하는 어댑터를 구현한다. 위 구조에서는 DispatcherServlet
클래스가 핸들러(컨트롤러)로부터 요구하는 반환 방식이 있는데, 실제 컨트롤러는 다양한 방식으로 구현되기 때문에 반환 방법이 다를 수 있다. 때문에 핸들러 어댑터가 핸들러의 반환 결과를 받아 DispatcherServlet
이 요구하는 방식으로 바꿔 전달하게 된다.
이 어댑터 패턴 덕분에 컨트롤러는 클래스 레벨, 메서드 레벨 등 다양한 방식으로 구현될 수 있기 때문에 더 넓은 의미인 핸들러라는 용어를 사용하게 된 것이다. 스프링 입장에서도 DispatcherServlet
코드를 크게 손대지 않고, 다양한 방식의 핸들러를 지원하기 위해, 핸들러 어댑터를 구현한 뒤 목록에 추가만 해 주면 되기 때문에 OCP 및 DIP를 훌륭하게 지킨 설계 방식이라고 할 수 있다.
물론 지금 스프링을 사용하는 사람들은 어차피 @RequestMapping
을 사용한 애노테이션 기반 컨트롤러만 사용하기 때문에 잘 이해가지 않는 방식일 수 있다. 하지만 그 이전에는 위 예시에서 처럼 특정 컨트롤러 인터페이스를 구현하는 방식으로 컨트롤러를 많이 구현했고, 그 뒤에도 개선된 방법들이 추가됐다. 그러다 애노테이션 방식이 정착되게 된 건데, 이 과정이 중요하다.
그 이전에 다양한 컨트롤러 방식들을 추가하는 과정에서 어댑터 패턴이 없었으면 어땠을까? 버전을 개선할 때마다 매번 DispatcherServlet
핵심 코드들도 손 봐야 하고, 이전의 코드들은 또 다른 방식으로 지원하기 위해, 머리 아픈 상황이 발생했을 것이다. 하지만 어댑터 패턴 덕분에 새로운 방식의 컨트롤러를 추가하는데 부담이 별로 없다. 이전 코드들도 변경 사항이 전혀 없으므로 여전히 잘 동작한다. 그리고 개발자들에게도 다양한 컨트롤러 방식을 사용할 수 있게끔 가능성도 열어 준다. 심지어 개발자가 직접 핸들러 어댑터를 구현한 뒤, 원하는 핸들러 방식을 설계할 수도 있다. 이게 좋은 객체지향 설계의 힘이다.
지금은 어떻게 객체지향적으로 잘 설계가 된 건지, 이해가 잘 안 될수 있는데, 스프링 MVC가 동작하는 방식을 따라가 보면서 추가로 설명하겠다.
동작 흐름
DispatcherServlet
도 부모 클래스에서 HttpServlet
을 상속 받아서 사용하고, 서블릿으로 동작한다. 실제로 코드를 보면 FrameworkServlet
을 상속받고 있지만, 이 클래스가 다시 HttpServletBean
를 상속받고, 결국 HttpServlet
를 상속받는 구조로 되어 있다.
DispatcherServlet
→FrameworkServlet
→HttpServletBean
→HttpServlet
결국 DispatcherServlet
도 서블릿이기 때문에 service()
을 호출해야 하는데, FrameworkServlet
가 오버라이드 해 둔 걸 호출한다.
public class DispatcherServlet extends FrameworkServlet {
...
/**
* Exposes the DispatcherServlet-specific request attributes and delegates to {@link #doDispatch}
* for the actual dispatching.
*/
@Override
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
logRequest(request);
...
try {
doDispatch(request, response);
}
...
주석을 보면, 실제 디스패치 작업은 doDispatch
라는 메서드로 위임한다고 작성되어 있다. 이 메서드가 사실 DispatcherServlet
의 핵심이다.
(참고로 스프링 부트는 DispatcherServlet
을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns="/" )에 대해서 매핑하기 때문에 모든 클라이언트의 요청은 DispatcherServlet
을 통하게 된다)
아래는 doDispatch()
의 코드에서 핵심 부분만 가져온 코드다.
// DispatcherServlet.doDispatch()
protected void doDispatch(HttpServletRequest request,
HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
ModelAndView mv = null;
// 1. 핸들러 조회
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
// 2. 핸들러 어댑터 조회 - 핸들러를 처리할 수 있는 어댑터
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
// 핸들러 실행 결과를 처리하는 메서드 호출
processDispatchResult(processedRequest, response, mappedHandler, mv,
dispatchException);
}
private void processDispatchResult(HttpServletRequest request,
HttpServletResponse response,
HandlerExecutionChain mappedHandler,
ModelAndView mv, Exception exception) throws Exception {
// ModelAndView를 가지고 뷰 랜더를 위한 뷰 로직
render(mv, request, response);
}
protected void render(ModelAndView mv,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
View view;
String viewName = mv.getViewName();
// 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
// 8. 뷰 렌더링
view.render(mv.getModelInternal(), request, response);
}
- 핸들러 조회 : 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러)를 조회한다.
- 핸들러 어댑터 조회 : 핸들러를 실행할 수 있는 핸들러 어댑터를 조회한다.
- 핸들러 어댑터 실행 : 핸들러 어댑터를 실행한다.
- 핸들러 실행 : 핸들러 어댑터가 실제 핸들러를 실행한다.
ModelAndView
반환 : 핸들러 어댑터는 핸들러가 반환하는 정보를ModelAndView
로 변환해서 반환한다.viewResolver
호출 : 뷰 리졸버를 찾고 실행한다.View
반환 : 뷰 리졸버는 뷰의 논리 이름을 물리 이름으로 바꾸고, 렌더링 역할을 담당하는 뷰 객체를 반환한다.- 뷰 렌더링 : 뷰를 통해서 뷰를 렌더링 한다.
여기에 사용되는 주요 인터페이스들이 있다.
- 핸들러 매핑:
org.springframework.web.servlet.HandlerMapping
- 핸들러 어댑터:
org.springframework.web.servlet.HandlerAdapter
- 뷰 리졸버:
org.springframework.web.servlet.ViewResolver
- 뷰:
org.springframework.web.servlet.View
위 인터페이스들 중, 핸들러 매핑과 핸들러 어댑터 위주로 각각 어떤 역할을 하는지, 실행 흐름을 따라가며 분석해 보겠다.
HandlerMapping
/**
* Interface to be implemented by objects that define a mapping between
* requests and handler objects.
*/
public interface HandlerMapping {
@Nullable
HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception;
HandlerMapping : 요청과 핸들러 객체 사이의 매핑을 정의하는 객체가 구현할 인터페이스
주석에서 설명하고 있듯이, 클라이언트의 요청을 핸들러 객체와 매핑해주는 클래스가 구현할 인터페이스다. 이 인터페이스를 구현할 핸들러 매핑 클래스에는 스프링이 요청 URL 패턴에 맞는 핸들러를 어떻게 찾을 건지에 대한 방법을 정의해 놓아야 한다.
인터페이스의 핵심 메서드는 getHandler
로, 요청 URL 패턴에 매핑된 핸들러를 반환해 준다.
💡 HandlerExecutionChain : 핸들러 객체와 핸들러 인터셉터(Interceptor)로 구성된 핸들러 실행 체인 객체다. 스프링 MVC에서는 인터셉터를 활용하여 HTTP 요청과 응답을 가로챈 뒤, 핸들러가 호출되기 전과 후에 원하는 동작을 실행시킬 수 있다.
doDispatch
메서드에서 핸들러를 조회하는 코드를 보자.
// DispatcherServlet
@Nullable
private List<HandlerMapping> handlerMappings;
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
boolean multipartRequestParsed = false;
// 1. 핸들러 조회
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
// ...
}
@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;
}
위에서 설명했던 getHandler()
를 호출해 핸들러를 조회하고 있다. getHandler
메서드를 보면 handlerMappings
리스트에 있는 핸들러 매핑 구현 클래스들을 순회하고 있다. 스프링에서는 이미 다양한 핸들러 매핑 클래스들을 구현해 놓았다. 그리고 그 핸들러 매핑 클래스들에 우선순위를 매긴 다음 등록해 놓는데, 우선순위가 높은 방법으로 매핑되는 핸들러가 없으면, 그 다음으로 우선순위가 높은 방법으로 찾는다. 이 중에서 스프링 MVC가 디폴트로 설정해 놓은 가장 중요한 핸들러 매핑은 아래 2개다.
RequestMappingHandlerMapping
: 우선순위 0, 애노테이션 기반으로@RequestMapping
를 사용BeenNameUrlHandlerMapping
: 우선순위 1, 스프링 빈의 이름으로 핸들러를 찾음
예를 들어 다음과 같은 컨트롤러(핸들러)가 구현되어 있다고 해 보자.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.Controller;
@Component("/springmvc/old-controller")
public class OldController implements Controller {
@Override
public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
return new ModelAndView("new-form");
}
}
이 컨트롤러는 Controller
라는 인터페이스를 구현하는 방식의 컨트롤러로 handleRequest
메서드에 비즈니스 로직을 추가하면 된다. 그리고 모든 컨트롤러(핸들러)들은 스프링 빈으로 등록되어야 하기 때문에 @Component
으로 등록한다. 이 컨트롤러는 빈의 이름이 /springmvc/old-controller
다. 스프링은 이 핸들러를 언제, 어떻게 찾아서 호출할까?
/springmvc/old-controller
로 요청이 들어옴DispatcherServlet
에서 위 ULR 패턴에 맞는 핸들러를 찾기 위해, 핸들러 매핑 구현 클래스들을 순회- 우선순위가 가장 높은
RequestMappingHandlerMapping
에 정의된 방법으로 찾아봤지만 실패함 - 그 다음
BeenNameUrlHandlerMapping
에 정의된 방법으로 찾아보다가, 스프링 빈의 이름이/springmvc/old-controller
인 핸들러를 찾아서 반환
이런 방식으로 스프링은 클라이언트의 요청에 맞게, 우리가 구현해 놓은 핸들러를 다양한 방법으로, 우선순위에 맞게 찾는다.
HandlerAdapter
핸들러를 성공적으로 조회했다면, 다음으로 핸들러를 처리할 수 있는 핸들러 어댑터를 조회한 뒤, 핸들러를 실행하게 된다.
// 2. 핸들러 어댑터 조회 - 핸들러를 처리할 수 있는 어댑터
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
// 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
핸들러 어댑터도 HandlerAdapter
라는 인터페이스를 구현한 클래스여야 한다.
public interface HandlerAdapter {
boolean supports(Object handler);
@Nullable
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
supports()
: 이 핸들러 어댑터가 파라미터 값으로 주어진 핸들러를 지원하는지 판단handle()
: 핸들러를 실행한 뒤, 결과 값을DispatchServlet
이 원하는데로 가공해서 반환
이 역시 스프링이 다양한 핸들러 어댑터들을 이미 구현해 놓았다. 역시 중요한 핸들러 어댑터들만 확인해 보자.
RequestMappingHandlerAdapter
: 우선순위 0, 애노테이션 기반 핸들러(@RequestMapping
) 처리HttpRequestHandlerAdapter
: 우선순위 1,HttpRequestHandler
처리SimpleControllerHandlerAdapter
: 우선순위 2,Controller
인터페이스를 구현한 핸들러 처리
이제 우리가 핸들러 매핑에서 만들었던 예시 컨트롤러인 OldController
는 어떤 핸들러 어댑터에서 어떻게 처리될 지 예상할 수 있을 것이다. 직접 getHandlerAdapter
메서드를 시작으로 실행 흐름을 코드로 따라가 보자.
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
if (this.handlerAdapters != null) {
// 1. 핸들러 어댑터 구현 클래스들을 우선순위대로 순회
for (HandlerAdapter adapter : this.handlerAdapters) {
// 2. 매핑된 핸들러를 지원하는 핸들러 어댑터가 있는지 조회
if (adapter.supports(handler)) {
return adapter;
}
}
}
throw new ServletException("No adapter for handler [" + handler +
"]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler");
}
handlerAdapters
리스트에 담겨있던 핸들러 어댑터들을 차례대로 순회하면서 OldController
핸들러를 지원하는 핸들러 어댑터가 있는지 확인한다. 이 핸들러는 Controller
인터페이스를 구현했기 때문에 SimpleControllerHandlerAdapter
가 지원하므로 이를 반환한다.
이제 DispatcherServlet
에서 SimpleControllerHandlerAdapter
가 구현한 handle
메서드를 실행할 것이다.
public class SimpleControllerHandlerAdapter implements HandlerAdapter {
@Override
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
return ((Controller) handler).handleRequest(request, response);
}
}
이전에 supports
메서드로 타입 체크를 했기 때문에, 핸들러를 타입 캐스팅 해준 뒤, 개발자가 직접 구현한 handleRequest
호출해 핸들러를 실행해 주기만 하면 된다. 너무 간단하다. 이렇게 간단한 로직을 왜 굳이 핸들러 어댑터로 감싸서 실행하는 걸까? 이 질문의 답은 이미 위 어댑터 패턴에서 설명했다. RequestMappingHandlerMapping
기반의 가장 실용적인 핸들러는 어떻게 구현되는지 한 번 보자.
@Controller
@RequestMapping("/members")
public class MemberControllerV3 {
@GetMapping("/new-form")
public String newForm() {
return "new-form";
}
}
위 코드에서 핸들러는 어떤걸까? MemberControllerV3
클래스 자체를 핸들러라고 할 수도 있지만, 좀더 명확하게는 newForm
메서드를 핸들러라고 할 수 있다. 다른 건 제쳐두고, 이 메서드가 무엇을 반환하는지 자세히 보자. 단지 new-form
이라는 문자열을 반환하고 있다. 굳이 ModelAndView
객체를 만들어 반환하지 않는다. 하지만 코드는 문제없이 동작한다. 이 핸들러를 지원하는 RequestMappingHandlerAdapter
가 문자열을 ModelAndView
객체로 변환시켜 DispatcherServlet
에게 반환해 주기 때문이다. 그리고 다음과 같이 핸들러 기능을 하는 메서드들을 컨트롤러 클래스 안에 여러개 추가할 수도 있다.
@Controller
@RequestMapping("/members")
public class MemberControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@GetMapping("/new-form")
public String newForm() {
return "new-form";
}
@PostMapping("/save")
public String save(@RequestParam("username") String username,
@RequestParam("age") int age,
Model model) {
Member member = new Member(username, age);
memberRepository.save(member);
model.addAttribute("member", member);
return "save-result";
}
@GetMapping
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
}
위 코드를 보면, MemberControllerV3 컨트롤러 클래스 안에, 총 3개의 핸들러 메서드가 있다. 이로써 핸들러와 컨트롤러의 구분이 좀 더 명확해 질 것이다.
이런 방식으로 개발자들의 불편함을 하나하나 덜어주면서 개발자 친화적인 스프링 MVC 프레임워크가 된 것이다. 덕분에 개발자들은 위와 같은 예시 코드처럼 비즈니스 로직에 집중한 코드를 짤 수 있다.
또 하나 주목할 점은, 우리가 처음부터 스프링 MVC가 이런 방식의 컨트롤러를 지원하지 않았다는 것이다. 컨트롤러 인터페이스를 구현하는 방식을 사용하던 때도 있었다. 거기서 멈추지 않고 지속적인 기능 개선 및 확장을 통해 애노테이션 방식을 쓰는 컨트롤러까지 발전한 것이다. 그럴 수 있었던 이유는 분명 스프링이 좋은 객체지향 설계를 바탕에 두고 있다는 점이 중요했다고 생각하다. 바로 이런 부분들이 스프링 MVC의 핵심인 프론트 컨트롤러와 어댑터 패턴, 그리고 궁극적으로 좋은 객체지향 설계의 선한 영향력이다.
때문에 개발자는 입장에서는 더 간단한 로직 작성이 가능해 졌다. 그렇다고 이전에 작성됐던 레거시 코드들이 작동하지 않는 것도 아니다. 바로 이런 부분들이 어댑터 패턴, 그리고 객체지향 설계의 선한 영향력이다.
(물론 우리를 위한 스프링의 이런 편리한 기능들 이면에는 스프링 개발자들의 피와 땀이 있는데, RequestMappingHandlerAdapter
의 구현은 SimpleControllerHandlerAdapter
에 비해 수십 배는 복잡해졌다)
여기까지의 과정을 정리해 보면, 클라이언트의 요청 URL에 맞는 핸들러를 찾아 실행시킨 뒤, 응답을 위한 ModelAndView
객체를 얻었다고 할 수 있다. 다시말해 컨트롤러의 실질적인 비즈니스 로직이 실행된 결과다.
이 이후에는 논리적인 뷰 이름을 물리적인 뷰 경로로 변환해 주는 ViewResolver
를 호출해 View
객체를 얻은 뒤, 응답 방식에 맞게 렌더링한다. ViewResolver
역시 인터페이스고, 다양한 구현 클래스들이 스프링에 등록되어 있다. 위와 같은 방식으로 코드를 따라가 보면, 큰 어려움 없이 찾아갈 수 있을 것이다.