주제 : 스프링의 MVC 기술들을 한번에 빠르게 훑고 간다.
목표 : 이전에 공부했던 스프링 웹 MVC를 복습하면서 전반적인 감을 찾는다!
프론트 컨트롤러 도입 전
프론트 컨트롤러 도입 후
프론트 컨트롤러 패턴 특징
- 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받음(공통 처리 가능)
- 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출
- 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 됨
스프링 웹 MVC의 DispatcherServlet이 FrontController 패턴으로 구현되어 있음
v1구조(컨트롤러를 인터페이스로 사용)
ControllerV1
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); dispatcher.forward(request, response); } }
- request.getRequestDispatcher(viewPath)는 해당 경로의 JSP 파일을 요청하기 위한 RequestDispatcher를 얻습니다.
- RequestDispatcher가 요청을 받은 후, forward(request, response) 메서드를 사용하여 해당 요청과 응답을 다른 서블릿이나 JSP 파일로 전송합니다.
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); memberRepository.save(member); request.setAttribute("member", member); String viewPath = "/WEB-INF/views/save-result.jsp"; RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath); dispatcher.forward(request, response); } }
- private로 선언된 MemberRepository memberRepository는 싱글톤 패턴을 적용하기 위해 사용됩니다.
MemberListControllerV1 - 회원 목록 컨트롤러
public class MemberListControllerV1 implements ControllerV1 { private 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); } }
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 { 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(name = "frontControllerServletV1", urlPatterns = "/front-controller/ v1/*") : 여기서 /front-controller/ v1/* 하위 호출은 모두 불러온다.
- FrontControllerServletV1 생성자: FrontControllerServletV1 클래스의 생성자에서는 controllerMap을 초기화하고 각 요청 경로에 해당하는 컨트롤러 객체를 매핑합니다
- service() 메서드: service() 메서드는 모든 HTTP 요청에 대해 호출되는 메서드입니다. 요청의 URI를 가져온 후, 해당 URI에 해당하는 컨트롤러를 controllerMap에서 찾습니다. 찾은 컨트롤러가 존재하지 않으면, 404 상태 코드를 응답으로 보내고 요청 처리를 중단합니다. 찾은 컨트롤러가 존재하면, 해당 컨트롤러의 process() 메서드를 호출하여 요청을 처리합니다. 이때 요청과 응답 객체가 전달됩니다.
모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있고 깔끔하지 않다.
이 부분을 깔끔하게 분리하기 위해 별도로 뷰를 처리하는 객체를 만들자.String viewPath = "/WEB-INF/views/new-form.jsp"; RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath); dispatcher.forward(request, response);
MyView
뷰 객체는 이후 다른 버전에서도 함께 사용하므로 패키지 위치를frontcontroller에 두었다.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 interface ControllerV2 { MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException; }
- 반환을 MyView로 해준다. 원래 void로 알아서 다 반환해주었는데 지금은 그렇게 하지 않는다.
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"); } }
- String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
이게 return new MyView("/WEB-INF/views/new-form.jsp"); 한 줄로 줄었다!- 이제 각 컨트롤러는 복잡한
dispatcher.forward()를 직접 생성해서 호출하지 않아도 된다. 단순히MyView객체를 생성하고 거기에 뷰 이름만 넣고 반환하면 된다.
MemberSaveControllerV2 - 회원 저장
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); request.setAttribute("member", member); return new MyView("/WEB-INF/views/save-result.jsp"); } }
MemberListControllerV2 - 회원 목록
public class MemberListControllerV2 implements ControllerV2 { private 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"); } }
프론트 컨트롤러V2
@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 { System.out.println("FrontControllerServletV1.service"); String requestURI = request.getRequestURI(); ControllerV2 controller = controllerMap.get(requestURI); if (controller == null) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } // controller.process(request, response); MyView view = controller.process(request, response); view.render(request, response); } }
- controller.process(request,response)가 바뀜.
- ControllerV2의 반환 타입이
MyView이므로 프론트 컨트롤러는 컨트롤러의 호출 결과로MyView를 반환받는다. 그리고view.render()를 호출하면forward로직을 수행해서 JSP가 실행된다.
- 서블릿 종속성 제거
컨트롤러 입장에서 HttpServletRequest, HttpServletResponse이 꼭 필요할까?
요청 파라미터 정보는 자바의 Map으로 대신 넘기도록 하면 지금 구조에서는 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다.- 뷰 이름 중복 제거
컨트롤러는 뷰의 논리 이름을 반환하고, 실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 단순화 하자.
- /WEB-INF/views/new-form.jsp -> new-form
- /WEB-INF/views/save-result.jsp -> save-result
- /WEB-INF/views/members.jsp -> members
ModelView
지금까지 컨트롤러에서 서블릿에 종속적인 HttpServletReqeust를 사용했다.
그리고 Model도 request.setAttribute() 를 통해 데이터를 저장하고 뷰에 전달했다.
서블릿의 종속성 제거를 위해 Model을 직접 만들고, 추가로 View 이름까지 전달하는 객체를 만들어보자.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; } }
- 뷰의 이름과 뷰를 렌더링할 때 필요한 model 객체를 가지고 있다. model은 단순이 map으로 되어 있으므로 컨트롤러에서 뷰에 필요한 데이터를 key,value로 넣어주면 된다.
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 이라는 view의 논리적인 이름을 지정한다.- 실제 물리적인 이름은 프론트 컨트롤러에서 처리한다.
MemberSaveControllerV3 - 회원 저장
public class MemberSaveControllerV3 implements ControllerV3 { private MemberRepository memberRepository = MemberRepository.getInstance(); @Override // 여기 MyView에서 ModelView로 바뀌고 request.getparameter가 paramMap으로 바뀜 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); // 여기도 MyView에서 ModelView로 ModelView mv = new ModelView("save-result"); mv.getModel().put("member", member); return mv; } }MemberListControllerV3 - 회원 목록
public class MemberListControllerV3 implements ControllerV3 { private 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; } }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) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } // MyView view = controller.process(request, response); // view.render(request, response); Map<String, String> paramMap = createParamMap(request); ModelView mv = controller.process(paramMap); String viewName = mv.getViewName(); MyView view = viewResolver(viewName); view.render(mv.getModel(), request, response); } 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; } private MyView viewResolver(String viewName) { return new MyView("/WEB-INF/views/" + viewName + ".jsp"); } }
view.render(mv.getModel(), request, response)코드에서 컴파일 오류가 발생할 것이다. 다음 코드를 참고해서 MyView 객체에 필요한 메서드를 추가하자.
뷰 리졸버
MyView view = viewResolver(viewName)
컨트롤러가 반환한 논리 뷰 이름을 실제 물리 뷰 경로로 변경한다. 그리고 실제 물리 경로가 있는 MyView 객체를 반환한다.
- 기본적인 구조는 V3와 같다. 대신에 컨트롤러가
ModelView를 반환하지 않고,ViewName만 반환한다.ControllerV4
public interface ControllerV4 { // ModelView process(Map<String, String> paramMap); String process(Map<String, String> paramMap, Map<String, Object> model); }
- 이전에는 paramMap만 넘겼는데 지금은 model이 넘어온다.(Map<String, Object> model 추가)
- 이제는 model까지 만들어서 넘겨주는 것이다. 그리고 뷰의 이름만 반환해주면 된다.
- 모델은 뷰가 표현할 데이터를 담고 있는 객체입니다.
- ControllerV4 인터페이스에서는 모델을 Map<String, Object> 형태로 받습니다.
실제 구현 코드를 보자
MemberFormControllerV4 - 회원등록 컨트롤러public class MemberFormControllerV4 implements ControllerV4 { @Override public String process(Map<String, String> paramMap, Map<String, Object> model) { return "new-form"; // 여기 } }-정말 단순하게
new-form이라는 뷰의 논리 이름만 반환하면 된다.MemberSaveControllerV4 - 회원저장 컨트롤러
public class MemberSaveControllerV4 implements ControllerV4 { private MemberRepository memberRepository = MemberRepository.getInstance(); @Override // 여기 Map<String, Object> model 추가 되고 public ModelView 가 아니라 String 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); //ModelView mv = new ModelView("save-result"); //mv.getModel().put("member", member); //return mv; model.put("member", member); return "save-result"; } }model.put("member", member)
모델이 파라미터로 전달되기 때문에, 모델을 직접 생성하지 않아도 된다.MemberListControllerV4
public class MemberListControllerV4 implements ControllerV4 { private MemberRepository memberRepository = MemberRepository.getInstance(); @Override public String process(Map<String, String> paramMap, Map<String, Object> model) { List<Member> members = memberRepository.findAll(); model.put("members", members); return "members"; } }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) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); return; } Map<String, String> paramMap = createParamMap(request); Map<String, Object> model = new HashMap<>(); //추가 // ModelView mv = controller.process(paramMap); // String viewName = mv.getViewName(); // MyView view = viewResolver(viewName); // view.render(mv.getModel(), request, response); String viewName = controller.process(paramMap, model); //mv.getViewName() -> controller.process(paramMap, model) MyView view = viewResolver(viewName); view.render(model, request, response); // mv.getModel() -> model } 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; } private MyView viewResolver(String viewName) { return new MyView("/WEB-INF/views/" + viewName + ".jsp"); } }
- 모델 객체 전달
Map<String, Object> model = new HashMap<>(); //추가
모델 객체를 프론트 컨트롤러에서 생성해서 넘겨준다. 컨트롤러에서 모델 객체에 값을 담으면 여기에 그대로 담겨있게 된다.- 뷰의 논리 이름을 직접 반환
String viewName = controller.process(paramMap, model);
MyView view = viewResolver(viewName);
어댑터 패턴
지금까지 우리가 개발한 프론트 컨트롤러는 한가지 방식의 컨트롤러 인터페이스만 사용할 수 있다.ControllerV3와ControllerV4는 완전히 다른 인터페이스이다. 어댑터 패턴은 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경해준다.
public interface MyHandlerAdapter { boolean supports(Object handler); ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException; }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) { 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; } }@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()); } private void initHandlerAdapters() { handlerAdapters.add(new ControllerV3HandlerAdapter()); } @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); MyView view = viewResolver(mv.getViewName()); 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를 찾을 수 없습니다. handler=" + handler); } private MyView viewResolver(String viewName) { return new MyView("/WEB-INF/views/" + viewName + ".jsp"); } }