이 글은 김영한님의 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 강의를 요약한 것이며 강의 자료 및 출처는 가장 아래에서 확인할 수 있습니다.
1단계. Servlet을 사용한 간단한 HTML 리턴
/**
* 단순하게 회원 정보를 입력할 수 있는 HTML Form을 만들어 응답
*/
@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class MemberFormServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
response.setContentType("text/html");
response.setCharacterEncoding("utf-8");
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");
}
}
위 코드는 Servlet을 사용하여 단순하게 회원 정보를 입력하여 HTML 페이지를 리턴하는 코드이다.
코드에서 볼 수 있듯이, 자바 코드로 HTML을 만들어 내는것은 매우 복잡하고 비효율적이다.
차라리 HTML 문서에 동적으로 변경해야 하는 부분을 자바 코드로 넣는다면 더 편리해질 것이다.
이런 원리에서 나온것이 템플릿 엔진(JSP, Thymeleaf, Freemaker 등등...)이다.
따라서, 템플릿 엔진을 사용해서 HTML 문서에서 필요한 곳만 코드를 적용해서 변경해보자.
💡1단계 정리
서블릿은 웹 요청을 간단하게 처리해준다. 따라서 서블릿에서 비즈니스 로직을 처리하고 HTML까지 리턴하는 방식으로 구현해보았다.
그러나 자바 코드에 HTML을 구현하기는 너무 불편하다. HTML문서에 동적인 표현을 할수있는 템플릿 엔진이 필요하다.
2단계. 템플릿 엔진(JSP)을 이용한 페이지 노출
간단한 회원 저장 페이지를 구현한 jsp파일을 보자
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
// request, response 사용 가능
MemberRepository memberRepository = MemberRepository.getInstance();
System.out.println("save.jsp");
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
System.out.println("member = " + member);
memberRepository.save(member);
%>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
성공
<ul>
<li>id=<%=member.getId()%></li>
<li>username=<%=member.getUsername()%></li>
<li>age=<%=member.getAge()%></li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
<% %>내부에서 자바 코드를 이용하여 데이터를 가져오는 것을 확인할 수 있다.
이제 서블릿만으로 개발할때처럼 뷰화면을 위한 HTML을 만드는 작업이 자바 코드에 섞여 지저분하고 복잡했던것과 달리 뷰를 생성하는 HTML작업을 깔끔하게 가져가고, 중간중간 동적으로 변경이 필요한 부분에만 자바 코드를 적용했다.
그러나 코드를 다시보자.
코드의 상위 절반은 회원을 저장하기 위한 비즈니스 로직이 포함되어있고, 나머지 하위 절반만 HTML을 노출하기 위한 뷰 영역이다. 또한 JAVA 코드와 데이터를 조회하는 레포지토리등 내부 코드가 JSP에 노출되어 있다.
즉,JSP가 너무 많은 역할을 하고있다. 이러면 나중에 수백 수천줄이 넘어가는 JSP를 떠올렸을때 유지보수가 매우 힘들다.
따라서 비즈니스 로직은 서블릿 처럼 다른곳에서 처리하고, JSP는 목적에 맞게 HTML로 화면을 그리는 일에 집중하면 어떨까? UI를 일부 수정하는 일과 비즈니스 로직을 수정하는 일은 각각 다르게 발생할 가능성이 매우 높다.또한 대부분 서로 영향을 주지 않는다. 즉, 둘은 각각 라이프 사이클이 서로 다르다.
이런생각에 착안하여 MVC패턴이 나오게된다.
MVC패턴이란?
MVC패턴은 하나의 서블릿이나 JSP로 처리하던 것을 컨트롤러와 뷰영역으로 서로 역할을 나눈 것을 말한다.
웹 애플리케이션은 보통 MVC 패턴을 사용한다.
컨트롤러 : HTTP요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
모델 : 뷰에 출력할 데이터를 담아둔다. 뷰가 필요한 데이터를 모두 모델에 담아서 전달해주는 덕분에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고,화면을 렌더링 하는 일에 집중할 수 있다.
뷰 : 모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. HTML을 생성하는 부분이 이에 해당된다.
왼쪽이 MVC 패턴전이라면, 오른쪽은 MVC패턴을 적용한 모습이다.
💡2단계 정리
템플릿 엔진 JSP를 사용하여 동적인 부분만 자바 코드를 활용하여 구현해보았다. 자바로 HTML을 생성하지 않아서 보다 편리해졌다.
그러나 Java코드와 내부 비지니스 로직이 JSP파일에 포함되어 있다. JSP 역할이 커져서 나중에 유지보수가 힘들다. 실무에선 비즈니스 로직과 UI를 수정하는 일이 각각 상관없이 발생할 가능성이 매우 높다.
역할을 확실히 나눌 필요가 있다.
3단계 . MVP 패턴을 적용한 뷰와 컨트롤러 분리
MVP패턴을 적용한 간단한 회원 저장과 목록 노출 코드를 차례대로 보자.
먼저, 컨트롤러는 아래와 같다.
@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);
System.out.println("member = " + member);
memberRepository.save(member);
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
@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 {
System.out.println("MvcMemberListServlet.service");
List<Member> members = memberRepository.findAll();
request.setAttribute("members", members);
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
request.setAttribute()에 뷰로 전달할 객체를 담은 뒤,dispatcher.forward()를 통해 뷰를 찾는다.
코드에서 볼 수 있듯이, 여기선 HttpServletRequest가 Model이 된다. 이후 forward()로 전달받은 뷰는 request.getAttribute()로 데이터를 꺼내쓰면 된다.
이제 JSP파일을 보자. JSP파일은 분량상 저장은 생략하고 목록 노출페이지만 예시로 들었다.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<a href="/index.html">메인</a>
<table>
<thead>
<th>id</th>
<th>username</th>
<th>age</th>
</thead>
<tbody>
<c:forEach var="item" items="${members}">
<tr>
<td>${item.id}</td>
<td>${item.username}</td>
<td>${item.age}</td>
</tr>
</c:forEach>
</tbody>
</table>
</body>
</html>
jsp는 ${}문법을 제공하는데 이 문법을 사용하면 request의 attribute()에 담긴 데이터를 편리하게 조회할 수 있다.
이제 MVC 패턴을 적용한 덕분에 컨트롤러의 역할과 뷰를 렌더링 하는 역할을 명확하게 구분할 수 있다.
특히 뷰는 화면을 그리는 역할에 충실한 덕분에, 코드가 깔끔하고 직관적인것을 확인할 수 있다. 단순하게 모델에서 필요한 데이터를 꺼내쓰면 되기때문이다.
그런데 컨트롤러를 보면 아래 코드가 중복되는것을 확인할 수 있다.
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
각각 View로 이동해야하기 때문에 viewPath와 포워드가 항상 중복으로 사용된다.
특히 viewPath는 jsp가 아닌 thymeleaf와 같은 다른 뷰로 변경한다면 전체 코드를 모두 변경해야 한다.
또한 아래 코드는 사용할 때도 있고, 사용하지 않을 때도 있다.
HttpServletRequest request, HttpServletResponse response
위 코드를 사용하면 테스트 케이스를 작성하기도 어렵다.
정리하자면, 현재 상태로는 공통적인 처리가 어렵다.
이 문제를 해결하려면 컨트롤러 호출 전에 먼저 공통 기능을 처리해야할 필요가 있다.
수문장 역할을 하는 프론트 컨트롤러 패턴을 도입하면 이문제를 깔끔하게 해결할 수 있다.
스프링 MVC의 핵심도 프론트 컨트롤러에 있다.
💡3단계 정리
비즈니스 로직은 서블릿에서 처리하고 HTML화면 구성은 뷰에서 처리하도록 변경하였다. 뷰에서 데이터들을 가져올땐 서블릿에서 넣어준 Model을 가져와 간편하게 사용하도록 하였다.
그러나 모든 컨트롤러에서 뷰의 이동과 관련된 중복 코드와 서블릿 종속성등 불편한점이 보인다.
따라서 이런 공통적인 기능을 처리할 수문장이 필요하다.
4단계 . 프론트 컨트롤러를 도입한 MVP 패턴 최적화
공통 처리를 위해 프론트 컨트롤러를 도입한 예제를 살펴보자. 구조는 아래와 같다.
먼저, 아래와 같이 서블릿과 비슷한 모양의 컨트롤러 인터페이스를 도입한다.
각 컨트롤러들은 이 인터페이스를 구현하고, 프론트 컨트롤러는 들어온 요청에 따라 이 인터페이스만 호출하여 분기처리를 할 수 있다.
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
회원 등록 컨트롤러를 살펴보자.
public class MemberSaveControllerV1 implements ControllerV1 {
private MemberRepository memberRepositor = MemberRepository.getInstance();
@Override
public void process(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);
memberRepositor.save(member);
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcherr = request.getRequestDispatcher(viewPath);
dispatcherr.forward(request,response);
}
}
방금 만든 인터페이스를 상속받아 공통 메서드를 구현하는 모습을 확인할 수 있다.
이제 입구는 프론트 컨트롤러 하나이기 때문에, @WebServlet이 사라진 모습을 확인할 수 있다.
이제 중요한 프론트 컨트롤러를 살펴보자.
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private Map<String, ControllerV1> controllerMap = new HashMap<>();
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());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("FrontControllerServletV1.service");
String requestURI = request.getRequestURI();
ControllerV1 controller = controllerMap.get(requestURI);
if (controller == null){
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
controller.process(request, response);
}
}
위 URL패턴으로 접근시 생성자가 호출되고, 생성자에선 모든 컨트롤러의 경로가 포함되어 있는 컨트롤러를 Map에 넣어준다. 이후, 들어온 URI를 가져와 map에서 컨트롤러를 탐색하고, 해당 컨트롤러의 .process()를 실행하게 된다.
이제 프론트 컨트롤러를 사용하여 @WebServlet()을 하나로 축소시켰다. 하지만 위 3단계에서 거론되었던 뷰로 이동하는 코드 중복과 필요없는 파라미터(HttpServletRequest,HttpServletResponse)가 공통 인터페이스에 있어 이 부분이 아직 해결되지 않았다. 다음 단계에서 별도로 뷰를 처리하는 객체를 만들어보자.
💡4단계 정리
모든 컨트롤러가 클라이언트에서 요청을 받는게 아닌 하나의 컨트롤러가 받도록하여 공통적인 처리를 해당 컨트롤러에서 간편하게 수행할수 있도록 하였다.
이제 뷰와 관련된 공통적인 코드를 처리해보자.
5단계 . 별도로 뷰를 처리하는 객체를 도입
이제 3단계에서 문제가됐던 아래 코드를 별도로 처리해보자.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
위 부분을 처리하기 위해 아래와 같은 구조로 진행한다.
각 컨트롤러에서 직접 JSP를 forward하는것이 아닌, 새로운 객체가 JSP로 forward하는것을 볼 수 있다.
변경된 인터페이스를 보자.
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
4단계에선 리턴값이 void로 없었다면, 현재는 MyView객체를 리턴하는것을 확인할 수 있다.
MyView는 아래와 같이 view의 경로를 담는 viewPath를 필드로 가지고, 중복되었던 코드를 .render()메서드로 통합하여 가지고있다.
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객체를 리턴하고,
public class MemberSaveControllerV2 implements ControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(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);
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
프론트 컨트롤러에선 MyView객체를 받아서 .render()시켜주면 된다.
@WebServlet(name = "frontControllerServlet", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {
private Map<String, ControllerV2> controllerMap = new HashMap<>();
public FrontControllerServletV2() {
controllerMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
controllerMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
controllerMap.put("/front-controller/v2/members", new MemberListControllerV2());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV2 controller = controllerMap.get(requestURI);
if(controller == null){
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyView view = controller.process(request,response); // MyView객체 리턴
view.render(request, response); // View로 이동
}
}
이제 각 컨트롤러는 복잡한 dispatcher.forward()를 직접 생성해서 호출하지 않아도 된다.
단순히 MyView 객체를 생성한후, 뷰 이름만 넣고 반환하면 된다.
자,이제 뷰의 이동 중복 코드는 사라졌으니 서블릿 종속성(HttpServletRequest, HttpServletResponse)을 제거할 단계가 남았다. 그런데 한가지 걸리는 부분이 있다면 컨트롤러에서 지정하는 뷰 이름에 중복이 있는 것을 확인할 수 있다.
/WEB-INF/views/new-form.jsp new-form
/WEB-INF/views/save-result.jsp save-result
/WEB-INF/views/members.jsp members
3단계에서 언급했듯이 jsp를 thymeleaf로 변경된다면 모든 컨트롤러에서 변경이 필요할 것이다.
따라서 다음단계에선 컨트롤러에서는 뷰의 논리 이름을 반환하고, 실제 물리 위치 이름은 프론트 컨트롤러에서 처리하도록 단순화하도록 한다.
💡5단계 정리
실질적인 URL을 필드로 갖는 MyView객체를 생성하여 각 컨트롤러에서 리턴하게 한후, 디스패쳐의 forward()와 같이 뷰를 찾는 공통적인 부분을 MyView객체내의 메서드에 구현하였다. 이제 모든 컨트롤러들은 뷰의 이동과 관련된 중복 코드가 사라졌고, MyView만 return하도록 변경되었다.
그러나 만약 .jsp에서 .thymeleaf로 변경된다면 모든 컨트롤러에서 MyView를 생성할때 코드의 변경이 일어날 것이다. 따라서 이를 제거하고 아직 숙제로 남아있는 각 컨트롤러의 서블릿 종속성을 제거할 필요가 있다.
6단계. 서블릿 종속성 제거 및 실제 뷰 경로를 리턴하는 객체 생성
서블릿 종속성을 제거하고, 각 컨트롤러는 실제 위치가 아닌 논리 이름을 반환하도록 구현하기 위해 아래와 같은 구조로 변경한다.
전 단계와 비교하자면 viewResolver가 추가된 것을 확인할 수 있다.
각 컨트롤러에선 논리 뷰 이름이 포함된 ModelView를 반환하고, 프론트 컨트롤러에서 viewResolver에게 넘기도록 한다.
또한 서블릿 종속성을 제거하기 위해 프론트 컨트롤러에서 각 컨트롤러들을 호출할 때, 파라미터 정보들을 Map으로 생성한뒤 넘기도록 한다.
먼저, 공통 인터페이스를 살펴보자
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap);
}
실제 뷰의 경로가 담겨있는 MyView를 리턴하는것이 아닌, Model과 논리적인 뷰 이름 두개의 필드를 가지고 있는 ModelView를 리턴하도록 한다. 또한 각 내부컨트롤러들은 서블릿 종속성이 없어야하기 때문에 프론트 컨트롤러에선 HttpServletRequest에 포함된 파라미터를 Map으로 변형시켜 넘기도록 한다.
내부 컨트롤러의 모습을 살펴보자.
public class MemberSaveControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paramMap) {
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
Member member = new Member(username,age);
memberRepository.save(member);
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
}
}
ModelView객체를 생성할때 실질적인 뷰이름을 포함하여 생성한뒤, 필요한 Model을 추가하는것을 확인할 수 있다.
프론트 컨트롤러의 모습은 아래와 같다.
@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); // mv.getViewName() = new-form
String viewName = mv.getViewName();
MyView view = viewResolver(viewName); // MyView.getViewPath = "/../.."
view.render(mv.getModel(), request, response);
}
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;
}
private MyView viewResolver(String viewName){
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
먼저, 들어온 파라미터들을 paramMap에 담아 내부 컨트롤러를 호출한다.
그후 내부 컨트롤러에서 리턴받은 ModelView를 이용해 논리 뷰 명을 viewResolver()로 실질 URL로 변경하고, 모델과 같이 .render()로 넘겨준다. 여기서 각 내부 컨트롤러에서 생성한 Model은 .setAttribute()하는 부분이 빠져있는데 아래 MyView에서 render()하는 과정에 포함되어있다.
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);
}
public void render(Map<String, Object> model,HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException{
modelToRequestAttribute(model, request);
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
}
private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request){
model.forEach((key,value) -> request.setAttribute(key, value));
}
}
이제 여기까지 왔다면 서블릿 종속성을 제거하고 뷰 경로의 중복을 제거하는 등, 잘 설계된 컨트롤러라고 볼 수 있다.
그런데 조금 걸리는것이 있다면 항상 ModelView객체를 생성하고 반환해야 하는 부분이 있다.
개발자가 단순하고 편리하게 사용할 수 있도록 이 부분을 조금만 변경해보자.
💡6단계 정리
모든 컨트롤러는 모델과 논리적인 URL을 필드로 가지는 ModelView객체를 생성하여 리턴하도록 하였다. 논리적인 URL은 프론트 컨트롤러에 있는 ViewResolver에 의해 실질적인 URL로 변경된다. 이로인해 템플릿 엔진 변경시 모든 컨트롤러를 변경할 필요가 없어졌고, 서블릿 종속성이 제거되었다.
그런데 각 컨트롤러에서 ModelView같은 객체가 아닌 그냥 논리 뷰 이름 String값만 리턴하면 더 간편해질 것 같다. 따라서 이것만 개선해볼 필요가 있다.
7단계 . 프론트 컨트롤러에서 모델 생성 및 내부 컨트롤러는 논리 뷰 이름만 리턴
전 단계와 비교하자면 프론트 컨트롤러에서 Model을 자체적으로 생성하여 내부 컨트롤러로 넘기는모습을 확인할 수 있다.
먼저, 공통 인터페이스를 살펴보자
public interface ControllerV4 {
String process(Map<String, String> paramMap, Map<String, Object> model);
}
논리 뷰 이름만 리턴하기 위해 리턴값이 MyView에서 String으로 변경되었다.
또한 각 내부 컨트롤러에서 추가할 Model을 담을 Map을 인자로 넘기도록 변경되었다.
아래 내부 컨트롤러를 살펴보자.
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-result";
}
}
전 단계와 비교하자면, ModelView에 논리 뷰 이름을 담아 리턴하는것이 아닌, 논리 뷰 이름 자체만 리턴하는것을 확인할 수 있다.
또한 response에 포함될 모델객체를 model에 담고있음을 확인할 수 있다.
프론트 컨트롤러를 살펴보자.
@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {
private 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();
ControllerV4 controller = controllerMap.get(requestURI);
if(controller==null){
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
Map<String, String> paramMap = createParamMap(request);
Map<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap,model);
MyView view = viewResolver(viewName);
view.render(model, request, response);
}
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;
}
private MyView viewResolver(String viewName){
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
들어온 모든 파라미터를 Map으로 생성하고, Model을 포함할 Map또한 프론트 컨트롤러에서 먼저 생성하는 것을 볼 수 있다. 이 두 map을 공통 인터페이스의 인자로 넘겨서 논리 뷰 이름을 가져온뒤, viewResolver에 의해 실질적인 URL경로로 변경되어 .render()되는것을 볼 수 있다.
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);
}
public void render(Map<String, Object> model,HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException{
modelToRequestAttribute(model, request);
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
}
private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request){
model.forEach((key,value) -> request.setAttribute(key, value));
}
}
이제 매우 단순하고 실용적인 컨트롤러 구조를 띄고있다고 볼수있다. 그러나 아직 숙제는 남아있다. 만약 어떤 개발자는 6단계 방식으로 개발하고 싶고, 어떤 개발자는 7단계 방식으로 개발하고 싶다면 어떻게 할까?
💡 7단계 정리
각 내부 컨트롤러에서 Model을 직접 생성했던것과 달리 프론트 컨트롤러에서 미리 생성하여 인자로 넘기도록 하였다. 각 내부 컨트롤러들은 모델을 필요로 할시 인자에 넣어주고 논리 뷰 이름만 리턴하도록 하였다. 이제 ModelView객체가 아닌 논리 뷰 이름만 리턴하도록 하여 더 간편해졌다.
그런데 만약 어떤 개발자는 6단계 방법을 따르고싶고 어떤 개발자는 7단계 방법을 따르고 싶다면 어떻게 해야할까? 이럴때 있어서 프레임워크의 성장은 끝이 없다. 이를 개선한 어댑터 패턴을 알아보자.
8단계. 어댑터 패턴 적용
핸들러 어댑터의 추가와 컨트롤러를 핸들러라는 명칭으로 변경된것을 확인할 수 있다.
6단계 방법과 7단계 방법은 각 컨트롤러가 처리하는 방식이 다르기 때문에 요청이 왔을때 핸들러 어댑터가 처리 가능한 핸들러로 분기를 처리 해준다.
여기서 핸들러는 컨트롤러라고 보면된다. 명칭은 어댑터가 있기 때문에 어떤것이든 해당하는 종류의 어댑터만 있으면 다 처리한다는 의미로 변경되었다.
핸들러 어댑터를 살펴보자.
public interface MyHandlerAdapter {
// handler는 컨트롤러를 의미하며 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단하는 메서드
boolean supports(Object handler);
/**
* 어댑터는 실제 컨트롤러를 호출하고, 그 결과로 ModelView를 반환한다.
* 실제 컨트롤러가 ModelView를 반환하지 못하면, 어댑터가 직접 생성해서라도 반환해야한다.
* 전에는 프론트 컨트롤러가 실제 컨트롤러를 호출했지만 이젠 이 어댑터를 통해 실제 컨트롤러가 호출된다.
*/
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServerException, IOException;
}
supports는 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단한다.
handle()의 handler인자는 프론트 컨트롤러에서 실제 내부 컨트롤러를 지정해준다.
어댑터를 상속받는 7단계 핸들러를 살펴보자.
public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof ControllerV4);
}
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServerException, IOException {
ControllerV4 controller = (ControllerV4) handler;
Map<String, String> paramMap = createParamMap(request);
Map<String, Object> model = new HashMap<>();
String viewName = controller.process(paramMap, model);
/**
* 어댑터 변환
*
* 어댑터가 호출하는 ControllerV4는 뷰의 이름을 반환한다.
* 그러나 어댑터는 뷰의 이름이 아닌 ModelView를 만들어 반환해야 한다.
* 마치 110v 전기 콘센트를 220v 전기 콘센트로 변경하듯이 ModelView로 만들어 형식을 맞춰 반환해야한다.
*/
ModelView mv = new ModelView(viewName);
mv.setModel(model);
return mv;
}
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;
}
}
supports에서 처리가 가능한지의 여부를 따지고 있다.
또한 실질적인 내부 컨트롤러인 handler인자를 받아서 .process()로 우리가 위에서 구현했던 7단계 내부 컨트롤러로 이동하게 된다. 여기서 이전에는 프론트 컨트롤러가 실제 컨트롤러를 호출했지만 이제는 이 어댑터를 통해 실제 컨트롤러가 호출되는 것을 확인할 수 있다.
프론트 컨트롤러를 살펴보자.
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
// 매핑 정보의 값이 ControllerV3, ControllerV4와 같은 인터페이스에서 아무 값이나 받을수 있는 Object로 변경되엇다.
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());
//7단계 추가
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()); // 7단계 추가
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// handlerMappingMap에서 URL에 매핑된 핸들러(컨트롤러) 객체를 찾아서 반환한다.
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);
MyView view = viewResolver(mv.getViewName());
view.render(mv.getModel(), request, response);
}
private Object getHandler(HttpServletRequest request){
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
// 핸들러를 처리할 수 있는 어댑터를 adapter.supports(handler)를 통해 찾는다.
private MyHandlerAdapter getHandlerAdapter(Object handler){
for(MyHandlerAdapter adapter : handlerAdapters){
if(adapter.supports(handler)){
return adapter;
}
}
throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler=" + handler);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
정리하자면 이렇다.
먼저 생성자를 통해 모든 핸들러(내부 컨트롤러)가 매핑되어있는 handlerMap, 모든 어댑터가 매핑되어있는 adapterMap을 생성한다. 이후 요청이 오면 URI를 통해 실제 핸들러(내부 컨트롤러)를 찾아내고 그 핸들러를 .getHandlerAdapter()를 통해 핸들러를 처리할 수 있는 어댑터를 찾아낸다. 어댑터는 공통적으로 ModelView를 반환하고, viewResolver()를 거쳐 .render()로 인해 실제 뷰로 이동된다.
이제 6단계,7단계 어떤 컨트롤러가와도 어댑터로 인해 프레임워크가 유연하고 확장성 있게 설계되었다.
8단계는 스프링 MVC의 핵심 구조의 축약 버전을 생성했다고 볼 수 있다. 구조도 거의 동일하다.
💡8단계 정리
6단계,7단계 각각 방법이 미미하게 다르다. 서로 다른 개발자들이 각각의 방식으로 개발한다면 이를 통합할 인터페이스가 필요하다. 따라서 어댑터패턴을 도입해 프레임워크를 유연하고 확장성있게 설계하였다.
스프링 MVC의 핵심 구조와 거의 동일한 단계이다.
실제 스프링 MVC 전체 구조
8단계 구조와 비교했을때, 프론트 컨트롤러는 DispatcherServlet로 변경되었고 나머지도 비슷하게 변경되었다.
위에서도 언급했지만 프론트 컨트롤러가 중요하다고 했던점은 스프링 MVC의 핵심은 디스패처 서블릿(DispatcherServlet)에 있기때문이다.
디스패쳐 서블릿도 위에서 직접 구현한 프론트 컨트롤러와 동일하게 부모 클래스에서 HttpServlet을 상속 받아서 사용하며, 서블릿으로 동작한다.
스프링 부트는 DispacherServlet을 자동으로 서블릿으로 등록하고, 모든 경로에 대해서 매핑한다.
디스패쳐 서블릿의 상속 관계
DispatcherServlet → FrameworkServlet → HttpServletBean → HttpServlet
디스패쳐 서블릿의 요청 흐름
1. 서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출된다.
2. DispatcherServlet의 부모인 FrameworkServlet에서 부모인HttpServlet이 제공하는 service()가 오버라이드 되어있다.
3. FrameworkServlet.service()를 시작으로 여러 메서드가 호출되면서 DispatcherServlet.doDispatch()가 호출된다.
4. doDispatch()내부 코드에서 모든 로직(위의 사진에 있는 모든 요청)이 실행된다.
그런데 과거에 스프링을 사용하지 않았다면 핸들러 매핑, 핸들러 어댑터를 어떻게 접목시켜야할지 감이안오기 때문에 의문이 들것이다. 따라서 스프링이 과거에 서블릿없이 어떤식으로 웹 요청을 받았는지 살펴보자.
@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 null;
}
}
Controller 인터페이스를 상속받고 있으며, 컨트롤러를 빈으로 등록하는것을 확인할 수 있다.
여기서 사용된 Controller 인터페이스는 과거에 웹 요청을 처리하기 위해 스프링이 제공했던 인터페이스이며 우리가 흔히 사용하는 @Controller와는 다르다.
이제 핸들러 매핑이 해당 컨트롤러를 찾아 등록할수 있어야하며, 핸들러 어댑터는 Controller 인터페이스를 처리할수있는 어댑터를 찾아야 할 것이다.
스프링부트가 자동으로 등록하는 핸들러 매핑과 핸들러 어댑터는 다음과 같다.(실제로 많지만 일부 생략)
스프링부트가 자동 등록하는 핸들러 매핑과 어댑터
HandlerMapping
0 = RequestMappingHandlerMapping : 어노테이션 기반의 컨트롤러 @RequestMapping에서 사용
1 = BeanNameUrlHandlerMapping : 스프링 빈의 이름으로 핸들러 탐색
HandlerAdapter
0 = RequestMappingHandlerAdapter : 어노테이션 기반의 컨트롤러 @RequestMapping에서 사용
1 = HttpRequestHandlerAdapter : HttpRequestHandler 처리
2 = SimpleControllerHandlerAdapter : Controller 인터페이스 처리
부트는 위와 같이 자동으로 등록한후, 요청이 오면 순서대로 찾고 만약 없다면 다음 순서로 넘어간다.
이제 위의 컨트롤러에 사용된 핸들러 매핑과 어댑터를 찾을 수 있을것이다.
1 = BeanNameUrlHandlerMapping : 스프링 빈의 이름으로 핸들러 탐색
2 = SimpleControllerHandlerAdapter : Controller 인터페이스 처리
위 예시를 통해, 핸들러와 어댑터가 실제로 어떻게 사용되는지 감이 올것이다. 현재 실무에선 대부분 @RequestMapping을 사용하기 때문에 RequestMappingHandlerMapping , RequestMappingHandlerAdapter를 사용할것이다.
자료 출처
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
'Spring' 카테고리의 다른 글
Spring MVC 정리 4) 예외 처리 (0) | 2023.12.18 |
---|---|
Spring MVC 정리 3) 검증 (0) | 2023.11.06 |
Spring MVC 정리 1)웹 어플리케이션의 이해 (0) | 2023.10.20 |
빈의 생명주기와 스코프 , DL(의존성 검색) (0) | 2023.09.22 |
의존성 주입으로 살펴보는 컴포넌트와 자동 주입 (0) | 2023.09.15 |