[스프링/Spring] FrontController 패턴과 스프링 MVC

kindof

·

2021. 11. 5. 01:39

0. 들어가면서

스프링 MVC 프레임워크의 핵심은 모델(Model), 뷰(View), 컨트롤러(Controller)입니다. 어떻게 보면 이 세 가지 요소가 스프링 MVC 자체를 의미한다고 볼 수도 있죠.

 

그런데, 이를 역으로 생각해보면 스프링 MVC 프레임워크가 자리 잡기 전에는 개발을 할 때 여러 문제점이나 어려움이 있었다는 뜻이겠죠.

 

그래서 이번 포스팅에서는 이러한 문제점들을 진단해보고, 스프링 MVC 프레임워크가 어떤 원리로 동작하는지 FrontController 패턴과 함께 살펴보려고 합니다. 특히, 어떤 어노테이션이나 다른 스프링의 기능없이 다형성을 적극 활용해서 스프링 MVC 프레임워크를 따라해보겠습니다.

 

이를 경험해보면 스프링 MVC가 어떤 원리로 동작하는지를 이해하는 데 도움이 되리라 생각합니다.

 

 

1. 스프링 MVC 이전의 환경

과거에는 얼마나 고통받는 코드로 개발을 했어야했는지, 그리고 왜 스프링 MVC 프레임워크가 필요하고 어떤 원리로 동작하는지 이해하기 위해 옛날의 코드로 돌아가보겠습니다.

 

간단하게 회원의 이름과 나이를 폼(Form) 형태로 입력하고, 이를 저장하여 회원들의 정보를 보여주는 로직을 생각해보려고 합니다. 스프링 MVC 프레임워크 확립 이전 단계에서는 Model, View, Controller의 분리가 명확히 되지 않았던 때였고, 서블릿과 JSP를 통해 비즈니스 요구사항을 구현해야 했습니다.

 

우선 위 요구사항을 바탕으로 정말 간단한 회원 도메인과 레포지토리를 구현해보겠습니다(앞으로의 코드는 MVC를 설명하는 데 초점이 맞춰져 있기 때문에 실제 개발에서는 이렇게 간단하게 코드를 작성하진 않는다는 점 참고해주세요).

 

[Member.java]

package sh.servlet.domain.member;
import lombok.Getter;
import lombok.Setter;

@Getter @Setter
public class Member {

    private Long id;
    private String username;
    private int age;

    public Member(){}

    public Member(String username, int age){
        this.username = username;
        this.age = age;
    }
}

 

[MemberRepository.java]

package sh.servlet.domain.member;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    private static final MemberRepository instance = new MemberRepository();
    public static MemberRepository getInstance(){
        return instance;
    }

    private MemberRepository(){};

    public Member save(Member member){
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id){
        return store.get(id);
    }

    public List<Member> findAll(){
        return new ArrayList<>(store.values());
    }

    public void clearStore(){
        store.clear();
    }
}

 

그리고 아래와 같이 1) 회원 정보 폼, 2) 회원 정보 저장, 3) 회원 정보 조회에 대한 JSP가 존재했을 겁니다.

 

[1] 회원 정보 폼 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>

 

[2] 회원 저장 JSP

<%@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>

 

[3] 회원 조회 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를 사용할 수 있게 해주는 서블릿 기반 Controller를 살펴보겠습니다.

 

[MemberFormServlet]

package sh.servlet.web.servletmvc;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

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

 

[MemberSaveServlet]

package sh.servlet.web.servletmvc;


import sh.servlet.domain.member.Member;
import sh.servlet.domain.member.MemberRepository;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

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

    }
}

 

[MemberListServlet]

package sh.servlet.web.servletmvc;

import sh.servlet.domain.member.Member;
import sh.servlet.domain.member.MemberRepository;

import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;

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

 

세세한 구현은 차치하고, 위와 같이 구현한 코드들을 보면 무슨 문제점이 보이시나요? 크게 아래와 같은 네 가지 문제점이 직관적으로 느껴지시리라 생각하는데요.

 

1. 각 Servlet의 service() 메서드에서 공통되는 부분이 많다.

2. viewPath를 입력할 때 공통되는 부분이 반복된다.

3. HttpServletRequest request, HttpServletResponse response 중에서 안 쓰는 코드가 존재한다.

4. 각 컨트롤러에서 공통으로 처리해야 하는 부분이 많다면 모든 컨트롤러가 복잡해진다.

 

정리하자면, 위와 같이 서블릿을 통한 단순 MVC 구현에서는 클라이언트의 호출에 따라 각 컨트롤러가 서블릿을 통해 일일이 작업을 수행하면서 필요없는 코드의 낭비나 자바의 다형성을 활용하지 못하는 환경이었다는 것입니다.

 

그래서 우리는 이 문제를 해결하기 위해 프론트 컨트롤러(FrontController)라는 디자인 패턴을 도입하게 되는데요. 이 프론트 컨트롤러의 역할을 이해하는 것이 지금 현재 사용하는 MVC 프레임워크의 핵심적인 기능들을 이해하는 데 많은 도움이 됩니다.

 

 

2. 프론트 컨트롤러(FrontController)를 이용한 MVC

프론트 컨트롤러(Front Controller)는 이름 그대로 모든 컨트롤러의 앞 단에서 서블릿 하나로 클라이언트의 모든 요청을 받습니다. 그리고 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출하는 패턴입니다. 이를 통해 프론트 컨트롤러는 각 컨트롤러에서 부담해야 하는 공통적인 로직을 혼자 처리해줄 수 있게 되었고, 나머지 컨트롤러들은 불필요한 서블릿을 사용하지 않아도 되는 장점이 생기게 되었습니다.

 

그리고 궁극적으로 스프링 MVC의 DispatcherServlet이 FrontController 패턴으로 구현되어 있는데요. 자, 그렇다면 프론트 컨트롤러가 정확히 어떤 구조인지, 어떤게 프론트 컨트롤러가 위와 같은 역할을 했는지 위 코드를 리팩토링 해보면서 알아보겠습니다.

FrontController

위 그림은 FrontController를 통해 어떻게 MVC 패턴이 효율적으로 구현되는지 보여주는데요. 위 그림에 대해 간략히 설명을 하고 코드를 살펴보겠습니다.

 

1. 클라이언트의 HTTP 요청은 FrontController에서 받습니다.

 

2. FrontController는 requestServlet에서 파라미터를 파싱하고, 사용자 요청에 맞는 컨트롤러를 호출하면서 paramMap과 model 객체를 넘겨줍니다.

 

3. 사용자 요청에 해당하는 컨트롤러는 paramMap에서 데이터를 받아서 비즈니스 로직을 수행하고 model 객체에 데이터를 다시 담아서 넘겨줍니다. 이 때, 클라이언트에게 돌려줄 ViewName을 같이 보내줍니다. 이 때, ViewName은 Prefix나 Suffix를 제외한 부분만을 리턴합니다.

 

4. viewResolver는 컨트롤러가 반환한 ViewName을 정확한 원본 ViewName(MyView)로 만들어줍니다.

 

5. FrontController는 MyView 객체를 통해 HttpServletRequest에 model에 대한 데이터를 담고 dispatcher를 통해 view를 Forwarding합니다.

 

이제 코드를 통해서 위 내용이 어떻게 구현되는지 살펴보겠습니다.

 

[FrontController.java]

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

프론트 컨트롤러의 전체 코드인데요. 각 부분을 조금씩 떼어서 자세히 살펴보겠습니다.

FrontController - 초기화

프론트 컨트롤러는 위와 같이 urlPatterns를 설정함으로써 클라이언트의 요청을 모두 받는 관문이 됩니다. 그리고 controllerMap이라는 Map 하나를 가지고 있는데요.

 

프론트 컨트롤러가 초기화될 때, 해당 Map 객체에 자신이 요청해줄 수 있는 컨트롤러들을 담아두게 됩니다. 

FrontController - service()

프론트 컨트롤러의 service() 메서드는 아래와 같은 역할을 수행하는데요. 하나씩 살펴보겠습니다.

 

(1) 프론트 컨트롤러는 request 서블릿에서 현재 요청의 URI를 파싱합니다. 그리고 프론트컨트롤러가 가지고 있던 controllerMap에서 매칭되는 컨트롤러를 가져오게 되죠.

 

(2) 그리고 createParamMap이라는 메서드를 호출하여 paramMap을 만듭니다. paramMap은 변수의 이름에서 알 수 있듯이, 해당 request가 가지고 있는 파라미터들의 정보를 담게 됩니다. 아래는 createParamMap() 메서드입니다.

createParamMap

 

(3) 클라이언트가 실제로 요청을 한 컨트롤러가 process() 메서드를 통해 viewName을 반환해줍니다. 아래는 유저 전체를 조회하는 컨트롤러의 process() 메서드입니다.

controller.process()

해당 컨트롤러에서는 레포지토리를 조회하여 유저들을 모델에 담아주고, "members"라는 viewPath를 반환해줍니다.

 

각 컨트롤러마다 process()에 해당하는 비즈니스 로직이 다르기 때문에 개별 컨트롤러는 아래와 같은 컨트롤러 인터페이스에 의존하고, process() 메서드를 오버라이딩하여 구현하게 됩니다. 아래는 개별 컨트롤러의 인터페이스입니다.

public interface ControllerV4 {
    /**
     *
     * @param paramMap
     * @param model
     * @return viewname
     */
    String process(Map<String, String> paramMap, Map<String, Object> model);

}

 

(4) 마지막으로, 프론트 컨트롤러는 개별 컨트롤러에게 넘겨받은 viewName에 대해 뷰 리졸버(ViewResolver)를 통해 원본 URI을 알아냅니다. 

private MyView viewResolver(String viewName) {
    return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}

 

(5) 이제, 프론트 컨트롤러의 역할은 끝났습니다. 클라이언트의 요청을 받아서 request 서블릿의 파라미터를 파싱해주고, 개별 컨트롤러에게 비즈니스 로직을 수행하여 model에 값을 담게 했습니다. 그리고 개별 컨트롤러에서 반환해준 viewName을 통해 MyView라는 객체를 생성하여 렌더링을 하게 됩니다. 

 

그러면 이제 MyView라는 객체를 살펴보겠습니다.

 

[MyView.java]

public class MyView {
    private String viewPath;

    public MyView(String viewPath){
        this.viewPath = viewPath;
    }

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

MyView는 HtppServletRequest, HttpServletResponse를 사용하는데요. 프론트 컨트롤러가 이미 개별 컨트롤러에서 받아온 model에 담겨있는 값을 request 서블릿에 저장하고 Dispatcher를 통해 사용자가 요청한 viewPath로 포워딩 해주는 역할을 합니다.

 


3. 정리

글을 작성하면서도 약간 헷갈려서 몇 번 다시 읽어봤는데요. 리팩토링 된 코드는 기존보다 아키텍쳐가 복잡해진 탓에 몇 가지 인터페이스와 클래스를 추가로 구현해야 했습니다. 

 

하지만 이를 통해 얻는 장점은 명확해졌습니다.

 

1. 개별 컨트롤러에서 공통으로 수행해야 하는 로직이 사라졌습니다.

 

2. 개별 컨트롤러들은 이제 서블릿이 필요없어졌습니다. 프론트 컨트롤러에서 넘겨준 paramMap, model을 그대로 이용할 수 있죠.

 

3. 클라이언트의 요청에 따른 viewPath를 일일이 변경하지 않아도 됩니다. 뷰 리졸버를 통해 해결할 수 있습니다.

 

 

스프링 MVC 패턴을 적극적으로 이용해보신 분이라면 위와 같은 코드의 리팩토링이 스프링 MVC 패턴과 유사해졌음을 느끼실 수 있을 것 같습니다. 우리가 당연하게 생각했던 모델, 뷰, 컨트롤러의 분리와 각 구조가 서로 각자의 역할만에 충실하게 된 관심사의 분리는 이러한 코드의 리팩토링 속에서 발전해온 것이죠.

 

 

이번 포스팅에서 작성한 프론트 컨트롤러는 아직 모든 상황을 컨트롤할 수 있는 컨트롤러는 아닙니다. 하지만 맨 처음에 각 컨트롤러가 서블릿을 이용해서 클라이언트의 요청을 받고, 처리하고, 뷰를 작성하고 했던 상황을 생각해보면, 이제 개별 컨트롤러는 훨씬 가볍고 자유로워졌음을 느끼시면 좋겠습니다.

 

감사합니다.

 

 

3. Reference

자바 ORM 표준 JPA 프로그래밍 스프링 데이터 예제 프로젝트로 배우는 전자정부 표준 데이터베이스 프레임 - 김영한