스프링 MVC 패턴을 코드로 구현해보자
시작하기 앞서
- 본 글은 JAVA 국비학원을 수강중인 학생이 김영한님의 인프런 강의 스프링 MVC 1편 수업을 듣고 공부한 내용을 정리하기 위해 적은 글임을 밝힙니다.
- 본 글은 스프링 MVC 프레임워크의 내부 패턴이 무엇인지, 스프링 프레임워크의 골격적인 부분을 살펴보기 위한 공부 과정에 관한 글입니다.
본 공부를 통해 알게 된 점
- 스프링은 Servlet 위에서 만들어진 MVC 프레임워크이다.
- 스프링의 View, Model, ModelAndView와 같은 개념과 handler, handlerAdapter, handlerMapper와 같은 개념이 무엇인지 알게 되었다.
- 고도로 추상화되고 자동화되어 있는 스프링 MVC 프레임워크의 내부 로직을 살펴봄으로써 스프링 Controller의 대략적인 생명주기를 알 수 있었다.
본 글의 특징
본 글은 김영한님의 강의 흐름을 그대로 따라가며 진행된다. 순수한 Servlet을 통한 애플리케이션 구성에서부터 FrontController, View, ViewResolver, Handler, HandlerAdapter 등, MVC 패턴에 대한 역할이 잘 배분된 애플리케이션까지 총 6단계에 걸쳐 코드를 리펙토링한다. 리펙토링이 막바지에 이르면 스프링 프레임워크와 내부적인 메커니즘이 비슷한 MVC 패턴을 만날 수 있을 것이다.
본문
여기 회원가입, 모든 회원 조회 페이지가 있다고 가정해보자. 각각의 페이지는 독립적인 html 파일 안에 담겨 있다. 각각의 파일 이름은 "member-form", "member-list"이다.
그리고 여러분은 웹 어플리케이션 서버(was)인 톰캣과 자바언어를 이용해 위의 두 html 파일들을 서버상에 올려 클라이언트에게 서비스 해야 하는 개발자이다. 지금은 스프링 MVC 프레임워크가 없는 새로운 세계, 여러분은 순수한 Servlet을 이용해 각각의 파일들을 서버상에 서비스해야 한다.
Version0 / Servlet
서블릿은 개발자들이 지루하고 반복적인 HTTP 메세지를 로직상에서 파싱하고 사용할 수 있게 조작하는 일을 대신해주기 위해 고안되었다. 만약 서블릿이 존재하지 않는다면 개발자는 클라이언트의 요청이 올 때, 그 요청의 HTTP 헤더와 바디를 해석해야만 한다. 이는, 매우 반복적이고 지루한 일이다.
서블릿은 HttpServletRequest와 HttpServletResponse 객체를 통해 HTTP 메세지를 쉽게 사용할 수 있도록 도와준다. 정말 다행이다. 만약 서블릿이 없었으면 할 일이 훨씬 많았을텐데, 하고 여러분은 생각한다.
아래의 코드는 각각의 html 파일을 웹 클라이언트에게 서비스하기 위한 로직이다.
1. member-form을 처리하는 MemberFormServlet
@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
// member-form 파일의 모든 html 정보를 손으로 적는다.
PrintWriter w = response.getWriter();
w.write("<!DOCTYPE html>\n" +
"<html>\n" +
"<head>\n" +
" <meta charset=\"UTF-8\">\n" +
" <title>Title</title>\n" +
"</head>\n" +
"<body>\n" +
"<form action=\"/servlet/members/save\" method=\"post\">\n" +
" username: <input type=\"text\" name=\"username\" />\n" +
" age: <input type=\"text\" name=\"age\" />\n" +
" <button type=\"submit\">전송</button>\n" +
"</form>\n" +
"</body>\n" +
"</html>\n");
}
}
2. member-list를 처리하는 MeberListServlet
@WebServlet(name = "memberListServlet", urlPatterns = "/servlet/members")
public class MemberListServlet extends HttpServlet {
// 싱글톤으로 구현된 meberRepository
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
protected void service(HttpServletRequest request, HttpServletResponse
response)
throws ServletException, IOException {
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
List<Member> members = memberRepository.findAll();
PrintWriter w = response.getWriter();
w.write("<html>");
w.write("<head>");
...
...
...
for (Member member : members) {
w.write(" <tr>");
w.write(" <td>" + member.getId() + "</td>");
w.write(" <td>" + member.getUsername() + "</td>");
w.write(" <td>" + member.getAge() + "</td>");
w.write(" </tr>");
}
w.write(" </tbody>");
w.write("</table>");
w.write("</body>");
w.write("</html>");
}
}
.. 페이지가 두장이었으니 망정이지, 페이지가 많았으면 야근을 할 뻔 했다.
※ Servlet 페이지에서 발생하는 무시무시한 write 패턴을 극복하기 위해 JSP 기술이 고안되었다. html 대신 jsp를 활용하면 자바코드를 비교적 손쉽게 웹페이지화할 수 있다. RequestDispatcher 객체를 통해 웹문서를 읽어 화면에 표현이 가능하다. 앞으로는 Servlet의 write 패턴이 아니라 RequestDispatcher 패턴을 사용한다.
Version1 / 프론트 컨트롤러의 도입
여러분은 곧 version0의 치명적인 문제점을 알게 된다. 서블릿을 구현한 모든 클래스에 모든 라우팅 관련 로직, 비지니스 로직등을 실으니 중복되는 코드들이 너무나도 많았던 것이다. 또한 서비스가 확대됨에 따라 늘어나는 코드의 양은 애플리케이션의 유지보수 가능성을 심각하게 저해하고 있었다.
잠시 고민하던 당신은 클라이언트의 요청 앞단에서 발생하는 로직이 중복된다는 것을 발견하고, 이들을 한 클래스로 묶어 공동적인 업무를 담당하게 하는 것을 어떨까 하는 생각을 하게 된다. 그리고 앞단에 중복되는 코드를 뽑아 한 개의 객체에 전담하게 되니 프론트 컨트롤러의 탄생이었다.
FrontController
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private Map<String, ControllerV1> controllerMap = new HashMap<>(); --- <1>
public FrontControllerServletV1() {
controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
} --- <2>
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV1 controller = controllerMap.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
controller.process(request, response); --- <3>
}
}
코드를 잠시 살펴보자.
- <1> controller의 매핑 정보를 보관하고 있는 controllerMap 객체이다.
- <2> FrontControllerServletV1이 호출되면 (클라이언트가 /front-controller/v1/ 이하의 url을 호출하면) 생성자에 의해 controller들이 매핑 정보에 편입된다.
- <3> 매핑정보에 의해 찾아진 목표 controller의 로직을 실행해 클라이언트에게 서비스한다. (process는 controller의 내부 로직을 실행하는 메소드. member-form 혹은 member-list를 서비스하기 위한 로직이 담겨있을 것이다.)
version0에 비해 많이 개선된 형태이지만 조금 아쉽다. 앞단의 로직을 공동 영역으로 뽑아 프론트 컨트롤러를 만들었다면 클라이언트에게 서비스되는 전, 반복되는 코드들도 하나로 묶을 수 있지 않을까?
Version2 / 뷰의 도입
페이지가 콘트롤러단에서 어떠한 로직을 거치든, 그것이 복잡한 로직이든 아주 단순한 로직이든 상관없이 모든 페이지들이 웹 클라이언트에 서비스되기 전 공통적으로 수행해야 하는 로직이 있다. 바로 물리적인 웹페이지를 읽어 HTTP 메세지를 작성하는 일(물리적인 웹페이지가 없더라도 반드시). 바로 페이지 렌더링 말이다.
코드로 살펴보도록 할까.
MyView
public class MyView {
private String viewPath;
public MyView(String viewPath) {
this.viewPath = viewPath;
}
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
}
}
MyView 클래스는 스프링 MVC에서 View 인터페이스에 대응되는 클래스이다. 우리의 MyView는 물리적인 웹페이지의 위치를 읽어 와 render 메소드를 통해 HTTP 메세지를 만들어 클라이언트에게 송신한다.
Version3 / Model And View
사실 version2만 해도 상당한 준수한 모습을 보이는 설계라고 생각한다. 그러나 우리가 누구인가. 우리는 개발자이다. 개발자는 코드의 리펙토링을 위해 물불을 가리지 않는다.
사실, 위의 버젼들이 나쁘지는 않지만 계속해서 거슬리던 부분이 있었다. 프론트 컨트롤러가 있는데, HttpServletRequest와 HttpServletResponse가 왜 개별적인 controller들에게 넘어와야 하는 것일까. 뭔가 마음에 들지 않는다, 방법이 없을까, 방법이.
잠시 고민을 하던 여러분은 한가지 깨달음으로 무릎을 친다. 프론트 컨트롤러 단에서 HttpServletRequest, HttpServletResponse(이름들도 길다..)를 매핑해 새로운 객체에 띄어 보내면 되지 않을까.
.. 그리고 하나 더. controller에서 중복되어서 넘어오는 물리적인 파일의 위치도 마음에 들지 않는다. 가능하다면 이 부분도 개선하고 싶다. 더 짧게, 더 간단하게. 명심하자, 개발자는 중복을 좋아하지 않는다. (사담 - 중복을 줄이고자 하는 인간의 도전이 지금의 스프링을 만들었다. 프로그래밍을 공부할수록 인간의 지성에 경의를 품게 된다.)
아래의 코드를 살펴보자.
FrontControllerV3
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
private Map<String, ControllerV3> controllerMap = new HashMap<>();
public FrontControllerServletV3() {
controllerMap.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
controllerMap.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
controllerMap.put("/front-controller/v3/members", new MemberListControllerV3());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV3 controller = controllerMap.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
String viewName = mv.getViewName();
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request, response);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames().asIterator()
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
return paramMap;
}
}
member-list 파일을 위한 MemberListControllerV3
public class MemberListControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paraMap) {
List<Member> members = memberRepository.findAll();
ModelView mv = new ModelView("member-list");
mv.getModel().put("member", members);
return mv;
}
}
조금 복잡해 보이기는 하지만 눈여겨 볼 부분은 두 부분이다.
첫 번째, 위쪽의 코드에서 새로 생긴 paramMap 객체. 이 객체는 HttpServletRequest를 통해 얻을 수 있는 모든 request 파라미터를 createParamMap 메소드를 통해 받는다. 참고로 createParamMap에 사용된 문법은 자바 8 버젼 이후에 나온 람다식이다.
두 번째, 아래 코드에서 새로 생긴 ModelView라는 객체. 후술하겠지만 ModelView는 우리가 새로 만들게 될 클래스로 controller단에서 얻은 파일의 주소, 비지니스 로직의 결과를 수송하는 역할을 맡는다.
아무튼, 중요한 부분은 ModelView mv = new ModelView("member-list")이다. member-list는 특정한 파일의 논리적인 위치로 더 이상 controller는 실제 물리적인 파일 위치를 보내지 않는다.
※ 버젼들의 모든 코드를 올리는 것이 아니기 때문에 실제 물리적인 위치값을 독자분들은 아실 수 없을 것이다. member-list의 실제 물리적 위치 주소는 WEB-INF/views/member-list.jsp였다. 그 중 앞의 WEB-INF/...와 뒤의 .jsp를 떼 member-list라는 논리적인 주소를 만들었다.
Version4 / "나는 Model And View 쓰기 싫은데.." Model의 도입
항상 ModelAndView를 사용하는 것은 쉬운 일이 아니었다. 일단 코드를 읽기가 어려웠고, ModelAndView를 생성하는 코드를 모든 controller 내에서 만들어줘야 했기 때문에 미미하기는 하지만 (개발자들이 가장 싫어하는) 중복이 발생하게 되었던 것이다.
발군의 개발자였던 여러분은 또다시 새로운 활로를 발견한다. 프론트 컨트롤러가 있지 않은가. 개별적인 컨트롤러들은 단순히 값만 반환하고 프론트 컨트롤러에서 하나의 ModelAndView를 생성해 이를 활용하는 방안은 어떨까. 개별적인 컨트롤러에서는 단순히 파일의 논리적(이거나 물리적인) 위치값만을 문자열로 반환하고 말이다.
여러분은 ModelAndView 대신 비지니스 로직에 의해 클라이언트로 데이터를 송신하는 Model이라는 새로운 객체를 고안한다. Model은 단순한 빈 상자로, 컨트롤러는 Model을 통해 데이터를 프론트 컨트롤러로 전송하고 프론트 컨트롤러가 Model에 있는 데이터를 꺼내 직접 ModelAndView에 값을 적재하는 형식을 개발하게 된 것이다.
이런 개념으로 개별 컨트롤러의 로직은 획기적으로 간결해질 수 있었는데, 가장 극단적으로 체감할 수 있는 코드를 하나 살펴보도록 하자.
member-form을 위한 MemberFormControllerV4
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paraMap, Map<String, Object> model) {
return "new-form";
}
}
끝이냐고? 끝이다. 정말로. 스크롤을 올려 version0의 MemberFormController를 살펴보도록 하자. 정말로 간결해졌다.
자 이제, 마지막 단계 version5를 살펴보자. 참고로 version5는 코드를 간결하게 만들어주는 리펙토링은 아니다. 확장성을 위해 고안된 새로운 개념 handler를 도입한 리펙토링이다.
Version5 / 스프링 프레임워크로. 핸들러와 핸들러 어댑터
설계를 한번 살펴보도록 할까. 굉장히 복잡해 보이지만 version3과 version4와 크게 다르지 않다.
핸들러는 컨트롤러보다 큰 개념으로 핸들러 어댑터에 의해 컨트롤러 위치에 올 수 있는 객체가 더 큰 단위로 확장되었기에 컨트롤러 대신 핸들러라고 표현되어 있다.
백문이 불여일견. 코드를 한번 살펴보자.
FrontControllerServletV5
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private final Map<String, Object> handlerMappingMap = new HashMap<>();
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
initHandlerMappingMap();
initHandlerAdapters();
}
private void initHandlerMappingMap() {
handlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
handlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
// v4 추가
handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members", new MemberListControllerV4());
}
private void initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
handlerAdapters.add(new ControllerV4HandlerAdapter());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Object handler = getHandler(request);
if (handler == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyHandlerAdapter adapter = getHandlerAdapter(handler);
ModelView mv = adapter.handle(request, response, handler);
String viewName = mv.getViewName();
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request, response);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler = " + handler);
}
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
자세히 보면 느끼겠지만 version3과 version4에 비교해 변경된 부분은 핸들러(컨트롤러)를 매핑하는 곳과 핸들러 어댑터를 조회하고 적절한 어댑터를 찾는 로직 뿐이다.
.. 사실 이 코드만 보고는 알 수 없다. 아래의 코드는 핸들러 어댑터의 역할을 코드로 정의한 HandlerAdapter 인터페이스를 보여준다.
MyHandlerAdapter
public interface MyHandlerAdapter {
boolean supports(Object handler);
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}
핸들러 어댑터의 역할은 두 가지. 자신이 특정 핸들러에 적합한 어댑터인지 검출하는 supports 메소드, 그리고 자신이 적절한 어댑터임을 안 이후 특정 핸들러의 로직을 실행하는 handle 메소드.
version5의 전체적인 로직 메커니즘은 다음과 같다.
- 클라이언트가 /front-controller/v5/ 이하의 url을 호출한다.
- FrontControllerServletV5가 호출되며 생성자에 의해 핸들러 매핑 정보와 핸들러 어댑터 목록이 메모리 상에 올라간다. (initHandlerMappingMap()과 initHandlerAdapters()에 의해)
- requestURI 값을 조회하여 특정 핸들러를 handlerMappingMap 내에서 찾는다.
- 3번에서 도출된 특정 핸들러를 모든 어댑터에 넣어보고, 자신에 맞는 어댑터를 찾는다. (getHandlerAdapter()에 의해)
- 핸들러 어댑터 내의 handle 메소드를 호출하여 특정 핸들러의 로직을 실행한다. (이후 핸들러 내에서 비지니스 로직이 처리되고 view의 논리적인 주소와 데이터가 반환된다.)
- 이후 과정은 version3 혹은 version4와 같다. (ModelAndView를 만들어 View를 통해 렌더링)
결론
이상으로 스프링 프레임워크에 사용되는 MVC 패턴의 핵심적인 골격을 코드를 통해 차근차근 구현해보았다. 이 과정에서 나는 고도로 추상화되고 자동화된 스프링 MVC 프레임워크 내의 메커니즘이 어떠한지 이해할 수 있었다. 예를 들면 왜 Controller를 사용할 때 String을 반환해야 하는지, @Controller 애노테이션이 무엇인지, 찍으면 어떤 일이 일어나는지 등등.
또한 다형성을 활용한 객체 지향 프로그래밍에 대해 조금은 더 자세하게 이해할 수 있는 시간이었다. 처음에는 어려워서 눈에 들어오지도 않던 코드들이, 반복해서 공부할수록 눈에 들어오는 게 참 신기했던 그런 시간이었다. 쌩 Servlet에서 저렇게 구조적으로 안정적이면서도 확장성에 열려 있는 설계가 나올 수 있다는 부분에 감탄을 했던 시간이기도 했다. 다시 한번 말하지만, 프로그래밍을 공부하면 할수록 인간의 지성에 감탄하게 된다.