티스토리 뷰

MVC 패턴 개선점

앞서 소개한 MVC 패턴을 적용한 코드에서 컨트롤러의 역할과 뷰를 렌더링하는 역할을 명확하게 구분할 수 있었다. 특히 뷰는 화면을 그리는 역할에 충실한 덕분에, 코드가 깔끔하고 직관적이다. 단순하게 모델에서 필요한 데이터를 꺼내고 화면을 만드는 역할을 한 것이다. 그런데 컨트롤러는 코드만 딱 봐도 중복이 많고 필요하지 않는 코드들도 많이 보인다.

프론트 컨트롤러 도입 전

 

1. 포워드 중복

View로 이동하는 코드가 항상 중복호출 되었다. 물론 중복으로 호출하는 부분을 함수화하여 공통화해도 되지만, 해당 함수도 항상 직접 호출해야 한다.

RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);

 

2. ViewPath 중복 및 사용하지 않는 코드

뷰의 경로를 지정해주는 부분을 보면  "/WEB-INF/views/new-form.jsp" 와 같이 항상 절대 경로를 지정해주었다. 그리고 만약 jsp가 아닌 thymleaf 같은 다른 뷰로 변경한다면 전체 코드를 다 변경해야 한다.

String viewPath = "/WEB-INF/views/new-form.jsp";

또한 서블릿을 사용하기 위해 service 함수를 항상 오버라이딩 하는데 request, response 파라미터를 사용할 때도 있고 사용하지 않을 때도 있다.

protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {}

 

정리하자면, 기능이 복잡해질 수록 컨트롤러에서 공통으로 처리해야 하는 부분이 점점 증가한다는 것이다. 이 문제를 해결하려면 컨트롤러 호출 전에 먼저 공통 기능을 처리해야 한다. 즉 수문장(브론즈?) 역할을 하는 기능이 필요하다 !

프론트 컨트롤러(Front Controller) 패턴을 도입하면 이런 문제를 깔끔하게 해결할 수 있다.

 

-> 입구를 하나로 만들자 !

프론트 컨트롤러 도입 후


프론트 컨트롤러 도입

먼저 프론트 컨트롤러 패턴 특징을 알아보자.

  • 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받음
  • 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출
  • 입구를 하나로
  • 공통 처리 가능
  • 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 됨

 

MVC 패턴을 점진적으로 발견시켜 나가보자 !

 


프론트 컨트롤러 Version 1

public interface ControllerV1 {
    void process(HttpServletRequest request, HttpServletResponse response) 
    	throws ServletException, IOException;
}

서블릿과 비슷한 모양의 ControllerV1 인터페이스를 도입한다. 각 컨트롤러들은 이 인터페이스를 구현하면 된다. 프론트 컨트롤러는 이 인터페이스를 호출해서 구현과 관계없이 로직의 일관성을 가져갈 수 있다.

다음으로 회원을 저장하는 컨트롤러와 프론트 컨트롤러를 살펴보자. 회원을 저장하는 컨트롤러의 로직은 기존의 서블릿과 거의 동일하다.

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

프론트 컨트롤러를 살펴보면 우선 @WebServlet 어노테이션의 urlPattern이  "/front-controller/v1/*" 으로 설정된다.

즉, front-controller/v1을 포함한 하위의 모든 요청은 이 서블릿에서 받아들인다는 것이다.

예를 들어 "/front-controller/v1/a" 라는 urlPattern이 요청 들어온 경우 이 서블릿에서 처리한다.

 

다음으로 살펴볼 부분은 private Map<String, ControllerV1> controllerMap = new HashMap<>(); 이다. controllerMap 에 key, value의 쌍으로 데이터를 저장하는데 key에는 매핑 URL, value에는 호출될 컨트롤러를 넣는다.

Default 생성자를 보면 이해할 수 있을 것이다. 

 

마지막으로 service 함수를 살펴보자. service 함수는 기존 서블릿에서 제공하는 함수를 오버라이드한 것과 동일하다.

로직을 한번 살펴보자.

1. requestURI를 조회해서 실제 호출할 컨트롤러를 controllerMap에서 찾는다. 만약 없으면 404 반환

2. 컨트롤러를 찾고 controller.process(request, response)를 호출해서 해당 컨트롤러 실행

3. 해당 컨트롤러 process 함수에서 해당 컨트롤러가 행동해야하는 로직대로 처리

 

나름대로 프론트 컨트롤러를 배치해 중복된 요소를 많이 제거한 것 같지만 모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있고 깔끔하지 않다. 

String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);

이 부분을 좀 더 깔끔하게 분리하기 위해 별도로 뷰를 처리하는 객체를 만들어보자


프론트 컨트롤러 Version 2

 

 

 

public interface ControllerV2 {
    MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
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);
    }
}

V1과 비슷한 모양의 ControllerV2 인터페이스를 도입한다. 각 컨트롤러들은 이 인터페이스를 구현하면 된다. 프론트 컨트롤러는 이 인터페이스를 호출해서 구현과 관계없이 로직의 일관성을 가져갈 수 있다.

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

        // Model에 데이터를 보관한다.
        request.setAttribute("member", member);

        return new MyView("/WEB-INF/views/save-result.jsp");
    }
}
@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);
    }
}

프론트 컨트롤러 V1과 거의 흡사한 로직을 가지고 있는데 ControllerV2가 V1에서는 void 반환형이었지만 V2에서는 뷰 객체를 반환한다. MyView 타입의 객체를 반환하므로 프론트 컨트롤러는 컨트롤러의 호출 결과로 MyView를 반환받음. 그리고 view.render()를 호출하면 forward 로직을 수행해서 JSP가 실행된다. 프론트 컨트롤러의 도입으로 MyView 객체의 render()를 호출하는 부분을 모두 일관되게 처리할 수 있다. 각각의 컨트롤러는 MyView 객체를 생성만 해서 반환하면 된다.

 

V2에서 아쉬운 점이 있다면 컨트롤러가 반드시 HttpServletRequest, HttpServletResponse가 필요할까? 라는 점이다. Request 파라미터 정보는 자바의 Map으로 대신 넘기도록 하면 지금 구조에서는 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다. 이러한 개선점을 적용시킨 다음 V3 버젼을 다음 포스팅에서 알아보자 !

댓글