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

홍정완·2022년 9월 29일
0

Spring

목록 보기
24/32
post-thumbnail
post-custom-banner

프론트 컨트롤러 패턴 소개


이전 포스팅에서 핵심은 공통 처리에 대한 어려움이었다.
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 구조


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


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


  • 별도의 ModelView 이름까지 저장하는 객체

@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
습관이 전부다.
post-custom-banner

0개의 댓글