김영한 강사님의 '스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술' 강의 정리
강의를 듣고 개인적으로 정리한 글입니다. 코드와 그림 출처는 김영한 강사님께 있습니다. 문제 있을 시 알려주세요.
템플릿 엔진이란?
- HTML에서 필요한 곳만 코드를 적용해서 동적 변경이 가능하게 해주는 소프트웨어
- JSP, Thymeleaf 등이 있다.
- JSP는 너무 많은 역할을 떠안아 유지보수가 힘든 단점이 있다. 그래서 역할 분리를 위해 나온 것이 MVC 패턴이다.
- 요즘에는 Thymeleaf를 많이 사용한다.
MVC 패턴이 필요한 이유
- JSP와 같이 너무 많은 역할을 담당하면 한 곳에 많은 코드가 집적되어 유지보수가 어렵다.
- UI와 비즈니스 로직 등 변경의 사이클이 다른 부분을 하나로 관리하면 유지보수가 어렵다.
- 뷰 템플릿은 화면 렌더링에 최적화 돼있어 이 부분만 담당하는 것이 가장 효과적이다.
즉 유지보수와 개발을 쉽게하기 위해서는 역할분리를 하는 MVC 패턴이 필요하다.
MVC 패턴
MVC 패턴은 JSP가 처리하던 것을 Model, View, Controller 라는 영역으로 역할을 분리하는 것을 말한다. 웹 애플리케이션은 MVC 패턴을 사용하는 것이 일반적이다.
- Controller : HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
- Model : 뷰에 출력할 데이터를 담아둔다.
- View : 모델에 담겨있는 데이터를 사용해서 화면을 그린다.
가장 기본적인 MVC 패턴 구조는 아래 그림과 같다.
- Controller - 서블릿
- Model - HttpServletRequest 객체
- View - JSP
간단하게 회원가입 시스템을 구현한 예제는 다음과 같다.
- 아이디와 패스워드를 입력하는 회원가입 페이지를 처리하는 코드
@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);
}
}
- 회원가입을 정보를 받아 저장한 후 완료 페이지를 처리하는 코드
@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);
//Model에 데이터를 보관한다.
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 {
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);
}
}
/WEB-INF/views/에 저장된 JSP 코드.
상대경로를 사용해서 재활용할 수 있게 구현한다.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<!-- 상대경로 사용, [현재 URL이 속한 계층 경로 + /save] -->
<form action="save" method="post">
username: <input type="text" name="username" />
age: <input type="text" name="age" />
<button type="submit">전송</button>
</form>
</body>
</html>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
성공
<ul>
<li>id=${member.id}</li>
<li>username=${member.username}</li>
<li>age=${member.age}</li>
</ul>
<a href="/index.html">메인</a>
</body>
</html>
<%@ 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>
MVC 컨트롤러의 단점을 개선해보자
[1] 공통처리가 어렵다
- 기능이 복잡해질 수록 컨트롤러에서 공통으로 처리해야 하는 부분이 더 많아진다.
- 메서드로 분리하기 보다는(호출 중복 및 호출하지 않는 실수 등의 문제로) 컨트롤러 호출 전에 먼저 공통 기능을 처리하는 편이 좋다.
해결: FrontController 도입
- Map 데이터에 URL과 컨트롤러를 매핑해둔다.
- 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출한다
- 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다(직접 호출 해줄거니까)
@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);
}
}
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
//서블릿과 비슷한 모양의 컨트롤러 인터페이스를 도입한다. 각 컨트롤러들은 이 인터페이스를 구현하면
//된다. 프론트 컨트롤러는 이 인터페이스를 호출해서 구현과 관계없이 로직의 일관성을 가져갈 수 있다.
}
public class MemberFormControllerV1 implements ControllerV1 {
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
}
}
public class MemberSaveControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = 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);
memberRepository.save(member);
//Model에 데이터를 보관한다.
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
public class MemberFormControllerV1 implements ControllerV1 {
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request,response);
}
}
[2] 뷰로 이동하는 코드가 중복된다
JSP의 경우 실제 뷰가 동작 되도록 랜더링 하는 foward 코드가 중복된다.
해결: MyView 클래스로 분리하자
프론트 컨트롤러에서 MyView
클래스에 viewPath 를 저장하고, render함수를 호출하면
실제 뷰가 동작되도록 랜더링하도록 하자. (JSP의 경우 foward작동)
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);//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));
}
}
@WebServlet(name = "frontControllerServletV2", 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);
view.render(request,response);
}
}
public class MemberFormControllerV2 implements ControllerV2 {
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
return new MyView("/WEB-INF/views/new-form.jsp");
}
}
그러면 이런 구조가 된다.
[3] viewPath가 중복된다
viewPath가 중복되면 다른 뷰로 변경 시 Controller 마다 전체 코드를 다 바꿔야 한다.
해결: viewResolver 메서드로 논리뷰를 받아 절대뷰로 바꾸자
- 논리뷰 :"members"
- 절대뷰 : "/WEB-INF/views/members.jsp"
/* FrontController.java */
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
vieResolver 함수는FrontController
에서 컨트롤러가 반환한 뷰의 논리 이름 을 실제 물리 뷰 경로로 변경 한 후, MyView
객체에 담아서 반환한다.
이렇게 해두면 향후 뷰의 폴더 위치가 함께 이동해도 FrontController
만 고치면 된다.
[4] 컨트롤러가 서블릿에 종속되어 있다
HttpServletRequest
,HttpServletResponse
를 사용하지 않을 때에도 작성HttpServletRequest
,HttpServletResponse
를 사용하는 코드는 테스트 케이스 작성하기도 어렵다.
해결 : request 객체 말고, 별도 Model 객체를 반환하자
FrontController
에서 요청파라미터를 request, response 대신 Map으로 Controller
에 넘긴다.
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
...
@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);//컨트롤러에 파라미터 전달 후 ModelView 받아옴
//논리 이름 -> 물리 이름 : viewResolver
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;
}
}
그러면 Controller
는 ModelView
에 논리뷰와 파라미터를 담아 FrontController
에 반환한다.
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;
}
}
@Getter @Setter //Lombok
public class ModelView {
private String viewName; //논리뷰
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
}
여기 까지 바꿨을 때 구조는 다음과 같다.
[5] 한 가지 방식의 컨트롤러 인터페이스만 사용할 수 있다
ControllerV3
, ControllerV4
등 완전히 다른 인터페이스를 상속하는 컨트롤러가 있다면 어떻게 처리해야하는가? 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경해보자
해결 : 어댑터 패턴을 사용해서 여러 개 호환되게 해보자
- 핸들러 어댑터: 어떤 한 종류의 핸들러를 처리할 수 있는지를 핸들러 어댑터로 구분한다.
- 예를 들어
ControllerV3HandlerAdpater
는ControllerV3
인터페이스를 상속하는 컨트롤러만 처리할 수 있다.
- 예를 들어
- 핸들러(=컨트롤러): 어댑터가 있으면 컨트롤러 뿐만 아니라 어떤 것이든 다 처리할 수 있으므로 앞으로 더넓은 의미의 뜻인 핸들러로 얘기하자.
어댑터 패턴을 적용하면 다음과 같은 구조가 된다.
먼저 FrontController
의 Map 데이터에 URL과 핸들러를 매핑시켜놓고 List 데이터에 핸들러 어댑터 목록을 추가한다.
- HTTP request가 들어오면
FrontController
는 핸들러매핑정보에서 URL로 사용할 핸들러를 찾아온다. - 사용할 핸들러를 처리할 수 있는 핸들러 어댑터를 찾아온다.
- 핸들러어댑터의 handle 메서드를 호출해 파라미터와 핸들러를 전달한다.
- 핸들러를 작동시키고 (process 메서드 호출)
ModelView
를 받아FrontController
에 반환한다.
@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); //핸들러(컨트롤러)에 파라미터 전달 후 ModelView 받아옴
//논리 이름 -> 물리 이름 : viewResolver
String viewName = mv.getViewName();
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request,response);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
MyHandlerAdapter a;
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");
}
}
public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof ControllerV3);
}
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
ControllerV3 controller = (ControllerV3) handler;
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
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;
}
}
사실 이렇게 MVC를 개선한 결과는 스프링 MVC의 구조이다
우리가 개선한 것 → | 스프링 MVC |
---|---|
FrontController | DispatcherServlet |
handlerMappingMap | HandlerMapping |
MyHandlerAdapter | HandlerAdapter |
ModelView | ModelAndView |
viewResolver (메서드) | ViewResolver (인터페이스) |
MyView | View (인터페이스) |
'Web > Spring' 카테고리의 다른 글
[Inflearn] 스프링 MVC (5) HTTP 요청 (0) | 2022.01.28 |
---|---|
[Inflearn] 스프링 MVC (4) 요청 매핑 (0) | 2022.01.28 |
[Inflearn] 스프링 MVC (3) 로깅 (0) | 2022.01.27 |
[Inflearn] 스프링 MVC (2) 구조 이해 (0) | 2022.01.27 |
[Inflearn] 스프링 설정 & 생성 (0) | 2022.01.03 |