MVC 프레임워크 만들기

뚝딱이·2022년 7월 27일
0

스프링 MVC

목록 보기
4/23

프론트 컨트롤러 패턴

앞서 우리는 요청이 컨트롤러를 통해 들어오도록 했는데, 어느 컨트롤러로 들어올지 정하지 않아, 모든 컨트롤러로 들어올 수 있었다.

하지만 이러한 로직은 공통로직을 한곳에 모으지 못하고 각각 다 넣어야하기 때문에 비효율적이다.

따라서 입구가 하나인 컨트롤러를 만들면 되는데, 그게 바로 프론트 컨트롤러다.

위의 그림과 같이 프론트 컨트롤러로 모든 요청이 들어오면 공통 로직을 실행 한다. 그 후, 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아 호출한다.

따라서 프론트 컨트롤러외의 컨트롤러들은 서블릿을 사용하지 않아도 된다. 서블릿의 역할은 프론트 컨트롤러에서 다하고, 컨트롤러에선 비즈니스 로직과 관련된것에 집중하도록 한다.

프론트 컨트롤러를 단계적으로 도입해보자.

v1

V1에선 FrontController를 도입해 매핑정보에서 컨트롤러를 조회해 호출하는 것을 구현해보자. 그 외에는 전에 구현한것과 같다.

먼저 각 컨트롤러들은 FrontController에게 호출받기 때문에 일관된 로직을 가지는 것이 좋다. 따라서 인터페이스를 만들어 관리한다.

public interface ControllerV1 {

    void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

위의 인터페이스를 토대로 컨트롤러를 만들어보자.

MemberFormControllerV1

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);//controller에서 view로 이동할 때 사용
        dispatcher.forward(request,response);
    }
}

MemberSaveControllerV1

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

v1은 기존의 코드들을 최대한 유지하면서 FrontController만 도입하는 것이기 때문에 각각의 컨트롤러들의 내부 로직의 변화는 거의 없다.

그렇다면 FrontController를 이제 만들어보자.

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

        ///front-controller/v1/members/new-form
        String requestURI = request.getRequestURI();

        //MemberFormControllerV1
        //인터페이스 타입으로 받을 수 있음 -> 부모는 자식을 받을 수 있다.
        ControllerV1 controller = controllerMap.get(requestURI);
        if(controller == null){//controller가 없으면
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        //MemberFormControllerV1의 process 호출
        controller.process(request,response);

    }
}

FrontController는 서블릿으로 만들어졌고, urlPatterns = "/front-controller/v1/*"을 볼 수 있는데 v1/*은 v1을 포함한 하위 모든 요청은 위의 서블릿에서 받는다는 뜻이다.

처음에 ControllerMap이 생성되었는데, 여기에 호출할 컨트롤러들을 넣어 놓는다. (key: 매핑 URL, value:호출할 컨트롤러) 생성자에 총 세 컨트롤이 생성되어 매핑 URL과 함께 put된것을 확인할 수 있다.

요청이 들어오면 service 메서드가 실행되는데, String requestURI = request.getRequestURI();을 통해 requestURI를 조회한다. 조회한 URI로 호출할 컨트롤러를 Controller에서 찾는다. 만약 컨트롤러가 없다면 404 상태코드를 반환하고 없다면 호출한 컨트롤러의 process를 실행한다.

v2

위의 v1의 각각의 컨트롤러에서 아래의 코드가 중복되는 것을 볼 수 있다.

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

따라서 별도로 뷰를 처리하는 객체를 만들어 보자.

이번엔 controller가 jsp로 바로 forward하지 않고 frontcontroller에 myview를 반환한다. 그러면 frontController가 render를 호출하여 myview를 실행하고, myview가 jsp forward를 해 jsp가 실행된다.

그렇다면 위의 중복되는 코드를 처리할 MyView를 만들어 보자.

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는 viewPath를 받아 해당 경로의 JSP로 forward한다.

이제 컨트롤러를 만들어볼건데, 위의 그림에서도 봤듯이 v2의 컨트롤러는 MyView를 반환한다.

MemberFormControllerV2

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에 forward하고 싶은 JSP의 경로를 담아 반환했다.

FrontController는 컨트롤러에서 MyView를 받아 이를 바탕으로 MyView의 render를 호출하면된다.

그렇다면 바뀐 부분은

        MyView view = controller.process(request, response);
        view.render(request, response);

이다.

기존엔 controller.process(request,response);만 실행해 process 안에서 forward까지 다 담당했지만, v2에선 MyView의 render가 forward해준다.

v3

위에서 계속 봤던 MemberFormController는 request와 response가 필요없다. 요청 파라미터 정보를 자바의 Map으로 대신 넘기도록 하면 지금 구조에선 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다. request 객체를 Model로 사용하여 request가 했던 역할을 Model이 하는 것이다.

그리고 컨트롤러가 지정하는 뷰이름이 중복되는 것을 확인할 수 있다. /WEB-INF/views/~.jsp
따라서 컨트롤러는 뷰의 논리이름을 반환하도록 하고 실제 물리 이름의 위치는 프론트 컨트롤러에서 처리하도록 단순화하자. 이렇게 하면 나중에 jsp가 아닌 다른 뷰 템플릿으로 변경되어도 프론트 컨트롤러만 변경하면 된다.

프론트 컨트롤러의 역할이 많아진 듯하나, 프론트 컨트롤러의 역할은 이렇게 자질구레한 것들을 하고 다른 각각의 컨트롤러는 핵심에만 집중할 수 있도록 한다.

v3의 구조는 위와 같다. 변경된 점은 controller는 MyView를 반환하지 않고 ModelView를 반환한다. FrontController는 ModelView를 갖고 viewResolver를 호출해 뷰의 논리 이름을 물리 이름으로 바꾸고 MyView를 반환한다. MyView를 받은 FrontController는 render를 호출하여 MyView를 통해 forward한다.

이제까진 HttpServletRequest를 사용했으나, Model을 직접 만들어 사용해보자. Model엔 뷰 이름과 model을 담는다.

ModelView

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

이때 viewName은 논리이름을 말한다.

이제 controller 인터페이스를 만들어보자. v3의 컨트롤러 인터페이스의 process는 ModelView를 반환하며, 파라미터로 Map(request의 정보가 다 있는)이 들어간다.

ControllerV3

public interface ControllerV3 {

    ModelView process(Map<String, String> paramMap);
}

MemberFormControllerV3

public class MemberFormControllerV3 implements ControllerV3 {

    @Override
    public ModelView process(Map<String, String> paramMap) {
        return new ModelView("new-form");
    }
}

ModelView를 반환하는 것을 볼 수 있다. 이때 "new-form"이라는 논리이름을 사용한다.

MemberSaveControllerV3

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

}

request의 정보가 담겨 있는 paramMap에서 username과 age를 받아와 repository의 save를 이용해 회원의 정보를 등록한다. 그리고 논리이름 "save-result"를 담은 ModelView를 생성 후 반환한다. 그리고 생성한 모델을 저장해야하므로 put을 이용해 저장한다.

FrontControllerServletV3

@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {

    private Map<String, ControllerV3> controllerMap = new HashMap<>(); //매핑 정보

    public FrontControllerServletV3() {
        controllerMap.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
        controllerMap.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
        controllerMap.put("/front-controller/v3/members", new MemberListControllerV3());
    }

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

        String requestURI = request.getRequestURI();

        ControllerV3 controller = controllerMap.get(requestURI);
        if(controller == null){//controller가 없으면
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        Map<String, String> paramMap = createParamMap(request);
        ModelView mv = controller.process(paramMap);

        String viewName = mv.getViewName();//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;
    }
}

변경된 부분부터 살펴보자면,

Map<String, String> paramMap = createParamMap(request); : request의 정보를 paramMap에 저장
ModelView mv = controller.process(paramMap); : 컨트롤러 실행
String viewName = mv.getViewName(); : 논리이름 가져오기
MyView view = viewResolver(viewName); : 물리 이름으로 변경
view.render(mv.getModel(), request, response); : JSP forward

이때 render가 아래와 같은 코드로 바뀌는데, 이는 JSP는 앞서말했듯 request의 저장공간에서 정보를 가져와 사용하므로(getAttribute로 데이터를 조회하므로) setAttribute를 해줘야하기 때문이다.

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

v4

v3에서는 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금 번거롭다. 따라서 더 실용성있게 컨트롤러가 viewName만 반환하도록 바꿔보자.

v3에선 컨트롤러가 ModelView를 생성했지만 v4에선 프론트 컨트롤러가 모델을 생성하고 컨트롤러를 호출할 때 같이 넘겨준다. 따라서 컨트롤러에선 ViewName만 반환하면 된다. 즉 ModelView가 필요없다.

컨트롤러 인터페이스부터 살펴보자.

public interface ControllerV4 {

    /**
     *
     * @param paramMap
     * @param model
     * @return
     */
    String process(Map<String, String> paramMap, Map<String, Object> model);
}

request의 정보가 담긴 paraMap과 model이 파라미터로 들어가는 것을 확인할 수 있다.

MemberFormControllerV4

public class MemberFormControllerV4 implements ControllerV4 {
    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {
        return "new-form";
    }
}

ModelView 생성없이 논리이름만 반환하는 것을 확인할 수 있다.

MemberSaveControllerV4

public class MemberSaveControllerV4 implements ControllerV4 {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public String process(Map<String, String> paramMap, Map<String, Object> model) {

        String username = paramMap.get("username");
        int age = Integer.parseInt(paramMap.get("age"));

        Member member = new Member(username, age);
        memberRepository.save(member);

        model.put("member", member);
        return "save-result";
    }
}

v3에선 아래와 같이 member를 저장한 후에 ModelView를 생성후 ModelView에서 Model을 가져와 member를 넣었어야 했다. 하지만 v4에선 위와 같이 model에 바로 member를 넣고 논리이름을 반환하면 된다.

ModelView mv = new ModelView("save-result");
        mv.getModel().put("member",member);
        return mv;

FrontControllerServletV4

@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){//controller가 없으면
            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;
    }
}

변경된 것은 paramMap 말고도 Model을 생성해서 컨트롤러 호출시에 같이 넣어줬다는 것이다. 또한 render의 파라미터로 ModelView에서 model을 가져오지 않고 바로 model을 넣어주었다.

v5

그런데 어떤 개발자는 v3로 개발하고 싶고, 어떤 개발자는 v4로 개발하고 싶다면 어떻게 해야될까. 지금의 v4는 v3도 같이 개발할 수 없고 v3에서도 v4를 개발할 수 없다.

따라서 이런것이 호환될 수 있도록 하는 것이 어댑터 패턴이다.

v5에선 컨트롤러의 이름을 더 넓은 범위의 핸들러로 변경했다. 어댑터가 있기 때문에 컨트롤러외에도 해당하는 종류의 어댑터만 있으면 다 처리할 수 있기 때문이다.

핸들러 어댑터는 컨트롤러를 여러개 사용할 수 있도록해준다. 따라서 핸들러 어댑터 목록에 사용할 핸들러들을 넣어놓고 어댑터를 이용해 핸들러를 호출한다.

프론트 컨트롤러가 어댑터를 호출하니, 어댑터의 일관된 로직을 위해 인터페이스를 생성하자.

MyHandlerAdapter

public interface MyHandlerAdapter {
    boolean supports(Object handler);

    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}

supports : 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단하는 메서드이다. 처리할 수 있으면 True를 반환

handle : 컨트롤러를 호출한다. v3의 경우 ModelView를 반환하지만, v4는 ModelView를 반환하지 않는다. 따라서 어댑터가 ModelView를 만들어 반환한다.

v5이전엔 모두 프론트 컨트롤러가 컨트롤러를 호출했으나, v5에선 어댑터가 컨트롤러를 호출한다.

ControllerV3HandlerAdapter

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

supports : ControllerV3를 처리할 수 있는 어댑터인지 확인한다.
handle : 컨트롤러를 ControllerV3로 캐스팅하고 request의 정보를 paramMap에 담아 컨트롤러를 실행한다. v3 컨트롤러는 ModelView를 반환하므로 그대로 반환하면된다.

ControllerV4HandlerAdapter

public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }

    @Override
    public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
        ControllerV4 controller = (ControllerV4) handler;

        Map<String, String> paramMap = createParamMap(request);
        HashMap<String, Object> model = new HashMap<>();
        String viewName = controller.process(paramMap, model);

        ModelView mv = new ModelView(viewName);
        mv.setModel(model);

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

supports : ControllerV4를 처리할 수 있는 어댑터인지 확인한다.
handle : 컨트롤러를 ControllerV4로 캐스팅하고 request의 정보를 paramMap에 담고 model을 생성해 컨트롤러를 실행한다. v4 컨트롤러는 ModelView를 반환하지 않으므로 ModelView를 생성해 model과 viewName을 넣어 반환하면된다.

FrontControllerServletV5

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

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

        String viewName = mv.getViewName();//mv.getViewName()은 논리이름만 가져옴
        MyView view = viewResolver(viewName);

        view.render(mv.getModel(), request, response);

        }

    private Object getHandler(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter adapter : handlerAdapters) {
            if (adapter.supports(handler)) {
                return adapter;
            }
        }
        throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다.");
    }

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

}

private final Map<String, Object> handlerMappingMap = new HashMap<>(); : 매핑 정보를 담는다. 원래는 Object 자리에 ControllerV3같은 특정 인터페이스가 왔으나, 어느 컨트롤러든 받을 수 있게 하기 위해 Object를 넣었다.

private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>(); : 어댑터를 담는다.

public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();

    }

initHandlerMappingMap(); : 핸들러 매핑을 초기화 한다.
initHandlerAdapters(); : 어댑터를 초기화한다.


        Object handler = getHandler(request);

        if(handler == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

request의 URI를 받아 그에 맞는 핸들러 매핑정보를 받는다. 없다면 404 상태코드를 반환한다.

MyHandlerAdapter adapter = getHandlerAdapter(handler); : 컨트롤러에 맞는 어댑터를 가져온다.

ModelView mv = adapter.handle(request, response, handler); : 컨트롤러 실행

        String viewName = mv.getViewName();//mv.getViewName()은 논리이름만 가져옴
        MyView view = viewResolver(viewName);

논리 이름 가져와 물리이름으로 변경

view.render(mv.getModel(), request, response); : JSP forward


출처 : 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술

profile
백엔드 개발자 지망생

0개의 댓글