본문 바로가기
spring

mvc1- 서블릿, JSP, MVC

by 오우지 2022. 2. 19.

서블릿

서블릿 만으로 웹 애플리케이션을 만든다고 하면 HTML 폼을 자바 코드에 직접 넣어줘야 한다.

@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");

        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");
    }
}

이렇게 사용하면 서블릿 덕분에 동적으로 원하는 HTML을 만들 수 있다. 정적인 HTML 문서라면 동적 HTML을 만드는 것은 불가능 할 것이다. 하지만 이것은 매우 복잡하고 비효율적이다. HTML 문서에 동적으로 변경하는 부분만 자바 코드를 넣을 수 있다면 더 편리할 것이다. 그래서 나온 것이 템플릿 엔진이다.

템플릿 엔진에는 JSP, Thymeleaf, Freemarker, Velocity 등이 있는데 같은 화면을 JSP로 만들어보자

 

<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page import="hello.servlet.domain.member.MemberRepository" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
    //request, response 사용 가능
    MemberRepository memberRepository = MemberRepository.getInstance();

    System.out.println("MemberSaveServlet.service");
    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));

    Member member = new Member(username, age);
    memberRepository.save(member);
%>
<html>
<head>
    <title>Title</title>
</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 만드는 작업이 자바 코드에 섞여서 지저분하고 복잡했다. JSP를 사용한 덕분에 뷰를 생성하는 HTML 작업을 깔끔하게 가져가고 중간중간 동적인 변경이 필요한 부분에만 자바 코드를 사용할 수 있었다. 하지만 여기에도 몇가지 문제가 있다. 

 

JSP 파일을 보면 코드의 상위 절반은 회원을 저장하기 위한 비즈니스 로직이고 하위 절반만 HTML 뷰 영역이다. 이게 수백, 수천줄이 넘어간다고 하면 지옥과 같을 것이다.

 


MVC 패턴

비즈니스 로직은 서블릿처럼 다른 곳에서 처리하고 JSP는 목적에 맞게 HTML로 화면을 그리는 일에 집중하게 리팩토링 해보자.

 

MVC 패턴 - 개요

1. 서블릿이나 JSP 만으로 비즈니스 로직과 뷰 로직을 처리하면 유지보수가 어렵다.

2. 변경의 라이프 사이클이 다르다. UI를 수정하는 일과 비즈니스 로직을 수정하는 일은 다르게 발생할 가능성이 높고 대부분 서로에게 영향을 주지 않기 때문에 라이프사이클이 다른 부분을 하나의 코드로 관리하는 것은 유지보수 측면에서 좋지 않다.

 

그래서 컨트롤러와 뷰의 영역을 서로 나눠서 만드는데 웹 애플리케이션은 보통 이 MVC 패턴을 사용한다.

 

컨트롤러: HTTP 요청을 받아서 파라미터를 검증하고 비즈니스 로직을 실행한다. 그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.

모델: 뷰에 출력할 데이터를 담아준다. 뷰가 필요한 데이터를 모두 모델에 담아서 전달해주는 덕분에 뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 집중할 수 있다.

뷰: 모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. 

 

 

 

mvc 패턴 1
mvc 패턴 2

 

서블릿을 컨트롤러로 사용하고 JSP를 뷰로 사용해서 MVC 패턴을 적용해보면 Model은 HttpServletRequest  객체를 사용하고 request는 내부에 데이터 저장소를 가지고 있기 때문에 request.setAttribute(), request.getAttribute()를 사용하면 데이터를 보관하고 조회할 수 있다.

 

우선 컨트롤러를 만들어서 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);
    }
}

경로가 /WEB-INF면 외부에서 직접 JSP를 호출할 수 없다. 

 

redirect vs forward

리다이렉트는 클라이언트에 응답이 나갔다가 클라이언트가 다시 redirect로 요청하는 것이고 포워드는 서버 내부에서 일어나는 호출이기 때문에 클라이언트가 인지하지 못한다.

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</head>
<body>
<form action="save" method="post">
    username: <input type="text" name="username"/>
    age: <input type="text" name="age"/>
    <button type="submit">전송</button>
</form>
</body>
</html>

form의 action은 절대 경로가 아니라 상대 경로로 시작하는데 이렇게 사용하면 현재 URL이 속한 계층 경로 + save가 호출된다.

 

@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);
        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);
    }
}

저장 로직을 호출하면  request에서 username과 age를 가져와 Repo에 저장해준다. 그리고 결과 출력을 위해 서블릿의 저장 공간에 member라는 key로 데이터를 담아 forward 해준다.

 

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
    <title>Title</title>
</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>

컨트롤러와 뷰 로직이 분리되어 향후 화면 수정에 뷰 로직만 변경하면 된다.

 

MVC 패턴의 한계

컨트롤러에 중복이 많고 필요하지 않은 부분들이 있다.

1. 포워드 중복

2. ViewPath에 중복

3. 사용하지 않는 코드

4. 공통 처리의 어려움

 

이 문제를 해결하려면 컨트롤러 호출 전에 먼저 공통 기능을 처리해야 한다. 수문장 역할을 하는 프론트 컨트롤러 패턴을 도입하면 이런 문제를 깔끔하게 해결할 수 있다.

 

다섯단계로 나눠 스프링 MVC와 같은 패턴을 구현한다.


V1. 프론트 컨트롤러 도입

 

서블릿과 비슷한 모양의 컨트롤러 인터페이스를 도입한다. 각 컨트롤러는 이 인터페이스를 구현하면 된다.

프론트 컨트롤러는 이 인터페이스를 호출해 구현과 관계없이 로직의 일관성을 가져갈 수 있다.

 

@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");

        String requestURI = request.getRequestURI();

        ControllerV1 controller = controllerMap.get(requestURI);
        if(controller == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(request, response);
    }
}

생성자에서 URL들을 다 넣어놓고 들어온 세부 요청에 따른 컨트롤러 배정 해 주는것을 알 수 있다.

 


V2. View 분리

모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있고 깔끔하지 않다.

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 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를 구현하고 컨트롤러를 구현한 MemberForControllerV2를 만들면

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");
    }
}

다음과 같이 공통 로직을 분리하고 코드를 깔끔하게 작성할 수 있다.

컨트롤러의 반환 타입이 MyView이기 때문에 프론트 컨트롤러는 컨트롤러의 호출 결과로 MyView를 반환받는다. 그리고 view.render()를 호출하면 forward 로직을 수행해서 JSP가 실행된다.

 


V3. Model 추가

1. 서블릿 종속성 제거

컨트롤러 입장에서 HttpServletRequest, HttpServletResponse가 꼭 필요하지 않다. 요청 파라미터 정보는 Map으로 대신 넘기도록 하면 지금 구조에서는 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다.

 

2. 뷰 이름 중복 제거

컨트롤러에서 지정하는 뷰 이름에 중복이 있는것을 확인할 수 있는데 컨트롤러는 뷰의 논리 이름을 반환하고 실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 단순화하자. 이렇게 해두면 폴더 위치가 변경돼도 프론트 컨트롤러만 고치면 된다.

ModelView

지금까지 컨트롤러에서 서블릿에 종속적인 HttpServletRequest를 사용했고 Model도 request.setAttribute를 통해 데이터를 저장하고 전달했지만 서블릿의 종속성을 제거하기 위해 Model을 직접 만들고 추가로 View 이름까지 전달하는 객체를 만들어보자.

 

public class ModelView {
    private String viewName;
    
    private Map<String, Object> model = new HashMap<>();

    public ModelView(String viewName){
        this.viewName = viewName;
    }

    public String getViewName() {
        return viewName;
    }

    public void setViewName(String viewName) {
        this.viewName = viewName;
    }

    public Map<String, Object> getModel() {
        return model;
    }

    public void setModel(Map<String, Object> model) {
        this.model = model;
    }
}
public class MemberListControllerV3 implements ControllerV3 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public ModelView process(Map<String, String> paramMap) {
        List<Member> members = memberRepository.findAll();
        ModelView mv = new ModelView("members");
        mv.getModel().put("members", members);

        return mv;
    }
}

이렇게 작성하면 return을 ModelView을 사용해 서블릿 종속성과 뷰 이름 중복을 모두 프론트 컨트롤러에서 처리할 수 있다.

 


V4. 단순하고 실용적인 컨트롤러

v3 컨트롤러는 잘 설계된 컨트롤러지만 컨트롤러 인터페이스를 구현하는 개발자 입장에서 항상 ModelView 객체를 생성하고 반환해야 해서 번거롭다. 따라서 기본적인 구조는 V3와 같지만 컨트롤러가 ModelView를 반환하지 않고 ViewName만 반환한다.

 

@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;
        }

//		  v3는 이랬다.
//        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);

        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 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;
    }
}

이렇게 적어줌으로써 컨트롤러를 구현할 때 굳이 ModelView를 만들어 줄 필요 없이 String만 return해줘도 된다.

 


V5. 유연한 컨트롤러

어떤 개발자는 V3를 사용하고 싶고 어떤 개발자는 V4을 사용하고 싶다면 어떻게 해야 할까?

 

우리가 개발한 프론트 컨트롤러는 한가지 방식의 컨트롤러 인터페이스만 사용할 수 있지만 어댑터 패턴을 사용해 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경할 수 있다.

 

핸들러 어댑터: 중간에 어댑터 역할을 하는 어댑터가 추가됐는데 이것 덕분에 다양한 종류의 컨트롤러를 호출할 수 있다.

핸들러: 컨트롤러의 이름을 더 넓은 범위인 핸들러로 변경했다. 이제 어댑터가 있기 때문에 컨트롤러 개념 뿐 아니라 어떤 것이든 해당하는 종류의 어댑터만 있으면 처리할 수 있기 때문이다.

 

'spring' 카테고리의 다른 글

mvc 1 - 스프링 MVC  (0) 2022.02.24
mvc2 - 필터, 인터셉터, 예외처리  (0) 2022.02.21
HTTP 웹 기본지식-3 헤더  (0) 2022.02.17
mvc2 - Bean Validation  (0) 2022.02.17
mvc2 - 쿠키, 세션 로그인  (0) 2022.02.14