[Spring MVC] MVC 프레임워크 만들기 ①

홍정완·2022년 9월 29일

Spring

목록 보기
24/32
post-thumbnail

프론트 컨트롤러 패턴 소개


이전 포스팅에서 핵심은 공통 처리에 대한 어려움이었다.
ViewPath부터 (request, response) 객체 forward 코드까지 중복되는 게 많았다.

이러한 문제를 해결하기 위해서는 공통적으로 처리될 수 있는 부분은 컨트롤러가 호출되기 전에 처리되어야 한다.


프론트 컨트롤러(Front Controller) 패턴을 사용해서 해결할 수 있는데, 스프링 MVC의 핵심도 프론트 컨트롤러에 있다.




기존의 MVC Pattern만 적용한 상태


단점

  • 공통 코드가 모든 컨트롤러에 포함되어 있고, 클라이언트는 각각의 컨트롤러를 호출해야 한다.



프론트 컨트롤러 도입 후


  • 공통 코드를 프론트 컨트롤러에서 처리해, 클라이언트 요청을 한 곳으로 집중시킨다.



프론트 컨트롤러 도입 - v1


  • 기존 코드를 최대한 유지하면서 프론트 컨트롤러를 도입해 보자.

V1 구조



1. ControllverV1


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

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


public interface ControllerV1 {

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



2. 회원 등록, 저장, 전체 조회 컨트롤러


이전 챕터에서 구현한 로직을 최대한 유지해 보자.


  • 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);
        dispatcher.forward(request, response);
    }
    
}



  • MemberSaveControllerV1 - 회원 가입 컨트롤러
public class MemberSaveControllerV1 implements ControllerV1 {

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

  • MemberListControllerV1 - 회원 목록 조회 컨트롤러
public class MemberListControllerV1 implements ControllerV1 {

    MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public void process(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);
    }
    
}

내부 로직은 기존 서블릿과 거의 같다.



3. FrontControllerServletV1 - 프론트 컨트롤러


@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 {
        
        String requestURI = request.getRequestURI();
        
        ControllerV1 controller = controllerMap.get(requestURI);
		if (controller == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(request, response);
    }
    
}

urlPatterns = "/front-controller/v1/*"

  • *는 상위 경로 /front-controller/v1/를 포함한 하위 경로 모두 이 서블릿에서 받아들인다는 의미다.

    • /front-controller/v1
    • /front-controller/v1/depth1
    • /front-controller/v1/depth1/depth2
    • 모두 해당 서블릿에서 받아들인다.



private Map<String, ControllerV1> controllerMap = new HashMap<>()

  • key : 매핑 URL
  • value : 호출될 컨트롤러



service()

  • 먼저 requestURI를 조회해서 실제 호출할 컨트롤러를 controllerMap에서 찾는다.
  • 만약 없다면 404(SC_NOT_FOUND) 상태 코드를 반환한다.
  • 컨트롤러를 찾고 controller.process(request, response);을 호출해서 해당 컨트롤러를 실행한다.



JSP

  • JSP는 기존에 만들었던 MVC 것을 그대로 사용한다.



4. 동작 확인

  • 등록 URL : http://localhost:8080/front-controller/v1/members/new-form
  • 목록 URL : http://localhost:8080/front-controller/v1/members



View 분리 - v2


프론트 컨트롤러 패턴을 도입해서 공통 처리를 진행했지만,
아직도 모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있다


String viewPath = "경로";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
  • 위와 같은 부분을 분리하기 위해 별도로 뷰를 처리하는 객체를 만들자.



V2 구조


컨트롤러에서 직접 JSP를 forward 해주는 과정이 사라지고,
컨트롤러에서 MyView 인터페이스의 render() 메서드를 호출해 JSP로 forward 하도록 구조가 변경되었다.


1. MyView


  • 클래스 생성 시 jsp 경로를 viewPath로 받아 생성되며, render() 메서드 호출 시 request, response를 인자로 forward 한다.

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



2. ControllerV2


  • V1과 유사하지만 반환 타입이 MyView라는 점이 다르다.

public interface ControllerV2 {

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



3. 회원 등록, 가입, 목록 조회 컨트롤러


  • MemberFormControllverV2 - 회원 등록 폼

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

}



  • MemberSaveControllerV2 - 회원 가입
public class MemberSaveControllerV2 implements ControllerV2 {

    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);
        Member savedMember = memberRepository.save(member);

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

        return new MyView("/WEB-INF/views/save-result.jsp");
    }
    
}



  • MemberListControllerV2 - 회원 목록 조회
public class MemberListControllerV2 implements ControllerV2 {

    MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        
        List<Member> members = memberRepository.findAll();

        request.setAttribute("members", members);

        return new MyView("/WEB-INF/views/members.jsp");
    }
 
}

이제 모든 컨트롤러에서 공통적으로 구현돼있던 dispatcher.forward()가 사라지고 반환 타입인 MyView 객체를 생성하여 반환해 주고 있다.



4. FrontControllerServletV2

@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 myView = controller.process(request, response);

        myView.render(request, response);
    }
 
}

MyView myView = controller.process(request, response);

  • V1과는 다르게 MyView 객체를 반환 타입으로 반환받는다.
  • 반환받은 MyView 객체의 render(request, response) 메서드를 호출하면 forward 로직이 수행되어 JSP가 실행된다.

  • JSP 페이지 렌더링 로직이 MyView 객체를 사용함으로 각각의 컨트롤러가 forward 로직을 제대로 수행하고 있는지 신경 쓸 필요가 없다.



Model 추가 - v3


이번에는 서블릿 종속성 및 뷰 이름 중복을 제거해 보자.


서블릿 종속성 제거


컨트롤러 입장에서 HttpServletRequest, HttpServletResponse이 필요하지 않는 상황이 있다.

컨트롤러가 서블릿 기술을 전혀 사용하지 않도록 변경하면 구현 코드도 매우 단순해지고, 테스트 코드 작성이 쉽다.



뷰 이름 중복 제거


컨트롤러에서 지정하는 뷰 이름에 중복이 있는 것을 확인할 수 있다.
컨트롤러는 뷰의 논리 이름을 반환하고, 실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 단순화하자.

나중에 폴더 위치가 변경돼도 프론트 컨트롤러만 고치면 된다.


/WEB-INF/views/new-form.jsp 👉 new-form
/WEB-INF/views/save-result.jsp 👉 save-result
/WEB-INF/views/members.jsp 👉 members



V3 구조


ModelView


서블릿의 종속성을 제거하기 위해 Model을 직접 만들고, View 이름까지 전달하는 객체를
만들어보자.

이번에는 컨트롤러에서 HttpServletRequest를 사용할 수 없기 때문에 Model이 별도로 필요하다.



1. ModelView


  • 별도의 Model과 View 이름까지 저장하는 객체

@Getter @Setter
public class ModelView {

    private String viewName;
    private Map<String, Object> model = new HashMap<>();

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

}



2. ControllerV3


public interface ControllerV3 {

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

Request, Response 객체를 인자 값으로 받지 않기에 서블릿 기술로부터 종속성이 사라졌다.

HttpServletRequest에서 필요한 정보는 프론트 컨트롤러에서 paramMap에 담아 호출하면 된다.



3. 회원 등록 폼, 저장, 목록 조회 컨트롤러


  • MemberFormControllerV3 - 회원 등록 폼

  • ModelView 생성자로 들어가는 이름은 view의 논리명으로 프론트컨트롤러에서 viewResolver에 의해 물리명으로 조합된다.


public class MemberFormControllerV3 implements ControllerV3 {

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



  • MemberSaveContrllerV3 - 회원 가입

  • 전달받은 paramMap에서 필요한 요청 파라미터를 꺼내어 사용한다.

  • Model의 담고 싶은 데이터는 ModelView 객체의 Model 필드에 담도록 한다.


public class MemberSaveControllerV3 implements ControllerV3 {

    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);
        Member save = memberRepository.save(member);

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



  • MemberListControllerV3 - 회원 목록 조회

public class MemberListControllerV3 implements ControllerV3 {

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



4. FrontControllerV3


@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) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

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

        String viewName = mv.getViewName();
        MyView myView = viewResolver(viewName);

        myView.render(mv.getModel(), request, response);
    }


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


    private HashMap<String, String> createParamMap(HttpServletRequest request) {
    
        HashMap<String, String> paramMap = new HashMap<>();
        
        request.getParameterNames().asIterator()
                .forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
                
        return paramMap;
    }
    
}

HashMap<String, String> paramMap = createParamMap(request);

  • request에서 파라미터 정보를 담아 Map으로 담아준다.

MyView myView = viewResolver(viewName);

  • 컨트롤러가 반환한 논리 뷰 이름을 실제 물리 뷰 경로로 변경해 주는 메서드.
  • 실제 물리 경로가 설정된 MyView 객체를 반환해 준다.

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

  • 이전에 사용하던 render() 메서드와는 파라미터가 다르다.
  • 오버 로딩을 통해 해당 3개 인자 값에 대응하는 render() 메서드를 만들어줘야 한다.



5. MyView

render(mv.getModel(), 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);
    }

    public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        modelToRequestAttribute(model, request);
        render(request, response);
    }

    private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
        model.forEach(request::setAttribute);
    }
    
}



6. 동작 확인

  • 등록 URL : http://localhost:8080/front-controller/v3/members/new-form
  • 조회 URL : http://localhost:8080/front-controller/v3/members
profile
습관이 전부다.

0개의 댓글