Web/Spring

[Inflearn] 스프링 MVC (1) MVC 프레임 워크 만들기

UL :) 2022. 1. 26. 18:31

김영한 강사님의 '스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술' 강의 정리

강의를 듣고 개인적으로 정리한 글입니다. 코드와 그림 출처는 김영한 강사님께 있습니다. 문제 있을 시 알려주세요.

템플릿 엔진이란?

  • HTML에서 필요한 곳만 코드를 적용해서 동적 변경이 가능하게 해주는 소프트웨어
  • JSP, Thymeleaf 등이 있다.
  • JSP는 너무 많은 역할을 떠안아 유지보수가 힘든 단점이 있다. 그래서 역할 분리를 위해 나온 것이 MVC 패턴이다.
  • 요즘에는 Thymeleaf를 많이 사용한다.

 

MVC 패턴이 필요한 이유

  1. JSP와 같이 너무 많은 역할을 담당하면 한 곳에 많은 코드가 집적되어 유지보수가 어렵다.
  2. UI와 비즈니스 로직 등 변경의 사이클이 다른 부분을 하나로 관리하면 유지보수가 어렵다.
  3. 뷰 템플릿은 화면 렌더링에 최적화 돼있어 이 부분만 담당하는 것이 가장 효과적이다.

즉 유지보수와 개발을 쉽게하기 위해서는 역할분리를 하는 MVC 패턴이 필요하다.

 

MVC 패턴

MVC 패턴은 JSP가 처리하던 것을 Model, View, Controller 라는 영역으로 역할을 분리하는 것을 말한다. 웹 애플리케이션은 MVC 패턴을 사용하는 것이 일반적이다.

  • Controller : HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.
  • Model : 뷰에 출력할 데이터를 담아둔다.
  • View : 모델에 담겨있는 데이터를 사용해서 화면을 그린다.

가장 기본적인 MVC 패턴 구조는 아래 그림과 같다.

  • Controller - 서블릿
  • Model - HttpServletRequest 객체
  • View - JSP

간단하게 회원가입 시스템을 구현한 예제는 다음과 같다.

  1. 아이디와 패스워드를 입력하는 회원가입 페이지를 처리하는 코드
@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);
    }
}
  1. 회원가입을 정보를 받아 저장한 후 완료 페이지를 처리하는 코드
@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);
    }
}
  1. 저장된 회원 정보 리스트를 보여주는 페이지를 처리하는 코드
@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;
    }
}

그러면 ControllerModelView에 논리뷰와 파라미터를 담아 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 등 완전히 다른 인터페이스를 상속하는 컨트롤러가 있다면 어떻게 처리해야하는가? 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경해보자

해결 : 어댑터 패턴을 사용해서 여러 개 호환되게 해보자

  • 핸들러 어댑터: 어떤 한 종류의 핸들러를 처리할 수 있는지를 핸들러 어댑터로 구분한다.
    • 예를 들어ControllerV3HandlerAdpaterControllerV3 인터페이스를 상속하는 컨트롤러만 처리할 수 있다.
  • 핸들러(=컨트롤러): 어댑터가 있으면 컨트롤러 뿐만 아니라 어떤 것이든 다 처리할 수 있으므로 앞으로 더넓은 의미의 뜻인 핸들러로 얘기하자.

어댑터 패턴을 적용하면 다음과 같은 구조가 된다.

먼저 FrontController의 Map 데이터에 URL과 핸들러를 매핑시켜놓고 List 데이터에 핸들러 어댑터 목록을 추가한다.

  1. HTTP request가 들어오면 FrontController는 핸들러매핑정보에서 URL로 사용할 핸들러를 찾아온다.
  2. 사용할 핸들러를 처리할 수 있는 핸들러 어댑터를 찾아온다.
  3. 핸들러어댑터의 handle 메서드를 호출해 파라미터와 핸들러를 전달한다.
  4. 핸들러를 작동시키고 (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(인터페이스)