김영한님의 스프링 MVC 1편 강의를 듣고 정리하는 글입니다
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
1. 개요
이전 포스팅에서 jsp와 서블릿을 사용하여 mvc 패턴을 적용해보았다.
https://soonmin.tistory.com/80
하지만 jsp와 서블릿만을 이용한 mvc 패턴은 한계가 있었다.
서블릿 + jsp mvc 한계
이전 포스팅에서 정리했지만, 코드를 보면서 한번 더 정리해보자.
@Override
protected void service(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);
}
1. 각 api마다 view path를 지정하고, forward 하는 코드의 중복이 발생
2. .view path를 지정할 때 jsp 확장자까지 붙혀줘야 하는데, 다른 템플릿 엔진으로 변경하면 jsp를 모두찾아서 변경해줘야 한다.
3. 위 코드에서는 HttpRequest와 HttpResponse를 모두 사용하지만, 사용 안하는 경우에는 굳이 필요가 없다. 불필요한 코드를 무조건 포함 해야하는 문제점이 있다.
크게 위 3가지 한계를 프론트 컨트롤러 패턴을 적용해서 단계별로 해결해보자!
2. 프론트 컨트롤러 패턴 소개
프론트 컨트롤러 패턴은 서블릿 하나로만 클라이언트 요청을 받고, 요청에 맞는 컨트롤러를 찾아주는 방식이다. 그럼 중복된 코드는 프론트 컨트롤러에서 처리할 수 있다.
요즘에 많이 사용되는 스프링mvc도 프론트 컨틀로러 패턴이 적용되어 있다. 스프링을 공부해본 사람이라면 DispatcherServlet에 대해서 알고 있을 것이다. 이 DispatcherServlet이 프론트 컨트롤러 패턴으로 구현되어 있다.
3. 프론트 컨트롤러 적용
그럼 각 컨트롤러를 만든 후, 프론트 컨트롤러도 만들어보자. 예제는 이전 포스팅 jsp + 서블릿으로 만든 회원관리 예제와 동일한 예제이다.
Controller
- ControllerV1은 인터페이스로 모든 컨트롤러는 ControllerV1을 상속받는다.
- 뒤에서 프론트 컨트롤러를 구현할 때 알수 있겠지만, 프론트 컨트롤러에서 Controller를 담을 때 다형성을 활용하기 때문이다.
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
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);
}
}
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);
}
}
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);
}
}
프론트 컨트롤러
- urlPatterns를 보면 알 수 있듯이 모든 요청은 프론트 컨트롤러에서 받는다.
- url 별로 처리할 컨트롤러를 맵에 담아준다.
- 실제 요청이 들어오면 service가 호출되는데, 이 때 요청 url을 읽어서 맵에서 찾아서 해당 컨트롤러의 process를 호출한다. (못찾을 경우 404 NOT_FOUND를 응답하도록 설정)
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private Map<String, ControllerV1> controllerV1Map = new HashMap<>();
public FrontControllerServletV1() {
controllerV1Map.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
controllerV1Map.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
controllerV1Map.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 = controllerV1Map.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
controller.process(request, response);
}
}
4. 뷰 분리
viewPath를 지정하고 forward하는 코드가 여전히 중복해서 사용된다. viewPath로 forward 하는 부분만 클래스를 만들어서 역할을 분리해보자.
MyView
- 생성자를 통해 viewPath를 전달받는다.
- viewPath를 가지고 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);
}
}
컨트롤러
- process는 MyView를 반환하도록 변경한다.
- 각 컨트롤러에서는 MyView(viewPath)만 지정하고 foward하는 부분은MyView에서 처리하게 된다.
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
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");
}
}
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); // Model에 데이터 보관
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
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");
}
}
프론트 컨트롤러
- 컨트롤러에서 반환하는 MyView의 render(forward 하는 코드) 를 호출하면 된다.
@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {
private Map<String, ControllerV2> controllerV1Map = new HashMap<>();
public FrontControllerServletV2() {
controllerV1Map.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
controllerV1Map.put("/front-controller/v2/members/save", new MemberSaveControllerV2());
controllerV1Map.put("/front-controller/v2/members", new MemberListControllerV2());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV2 controller = controllerV1Map.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyView view = controller.process(request, response);
view.render(request, response);
}
}
5. Model 추가
컨트롤러에서 viewPath를 지정할 때 여전히 /WEB-INF/views/*.jsp와 같이 중복이 있다.
또한, 컨트롤러에서는 여전히 상황에 따라 불필요한 HttpRequest와 HttpResponse 무조건 가지고 있다.
해결해보자.
MyView
- model을 사용하기 위해 render를 오버로딩해서 추가해주었다.
- 전달 받은 모델을 실제 모델(reuqest)에 저장한다.
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);
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
private static void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
model.forEach((key, value) -> request.setAttribute(key, value));
}
}
ModelView
- viewName은 viewPath에 논리이름이 저장된다. ex) /WEB-INF/views/new-form.jsp -> new-from
- model 맵은 실제 rqeuest에 저장되는 model들을 저장할 때 사용한다.
@Setter
@Getter
public class ModelView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
}
컨트롤러
- 컨트롤러는 ModelView를 반환하도록 변경되었다.
- 불필요한 HttpRequest와 HttpResponse가 제거되었다.
- 기존에는 모델을 저장할 때 request.setAttribute()로 저장했는데 ModelView의 model에 저장하도록 변경됨.
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap);
}
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form");
}
}
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);
// Model에 데이터 보관
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
}
}
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;
}
}
프론트 컨트롤
- 컨트롤러에서 반환하는 ModelView에서 view(논리)는 viewResolver을 통해 논리이름을 실제viewPath(물리)로 변경한다.
- createParamMap은실제 클라이언트의 request의 파라미터는 요청 정보를 읽어 컨트롤러에 전달하기 위해 사용한다.
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
private Map<String, ControllerV3> controllerV1Map = new HashMap<>();
public FrontControllerServletV3() {
controllerV1Map.put("/front-controller/v3/members/new-form", new MemberFormControllerV3());
controllerV1Map.put("/front-controller/v3/members/save", new MemberSaveControllerV3());
controllerV1Map.put("/front-controller/v3/members", new MemberListControllerV3());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String requestURI = request.getRequestURI();
ControllerV3 controller = controllerV1Map.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
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 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;
}
}
6. 유연한 컨트롤러
지금까지 다양한 controller 유형을 사용해봤는데, 상황마다 서로 다른 controller를 사용할 경우에는 프론트 컨트롤러만을해결되지 않는 문제가 있다. 다양한 컨트롤러 유형을 수용할 수 있도록 어뎁터 패턴을 적용해보자.
핸들러 어뎁터
- supports는 핸들러를 지원하는지 검사하는 코드로, 여기서 핸들러는 컨트롤러를 뜻한다.
- 기존에는 프론트 컨트롤러에서 컨트롤러를 호출했지만, 핸들러 어뎁터를 적용하면서, handle이란 메서드에서 실제 컨트롤러를 호출한다.
- 사용하고자 하는 컨트롤러 유형별로 MyHandlerAdapter를 구현해주면 된다.
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) 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;
}
}
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;
}
}
프론트 컨트롤러
- 프론트 컨트롤러는 핸들러(컨트롤러)를 담기 위한 맵을 가진다. 여기서 Object 인 이유는 여러 유형을 담기위해서다.
- 그리고 지원하고자 하는 핸들러 어뎁터를 담아준다.
- getHandler를 통해 요청 url을 가지고 사용할 핸들러(컨트롤러)를 찾는다.
- 찾은 핸들러(컨트롤러)가 지원되는지 검사한다.
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private Map<String, Object> hadnlerMappingMap = new HashMap<>();
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
initHandlerMappingMap();
initHandlerAdapters();
}
private void initHandlerMappingMap() {
hadnlerMappingMap.put("/front-controller/v5/v3/members/new-form", new MemberFormControllerV3());
hadnlerMappingMap.put("/front-controller/v5/v3/members/save", new MemberSaveControllerV3());
hadnlerMappingMap.put("/front-controller/v5/v3/members", new MemberListControllerV3());
hadnlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
hadnlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
hadnlerMappingMap.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();
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request, response);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return hadnlerMappingMap.get(requestURI);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다.=" + handler);
}
}
핸들러 어뎁터를 추가하면서, 다른 컨트롤러 유형이나 최근에 많이 사용되는 어노테이션 기반을 컨트롤러도 핸들러 어뎁터만 구현해주면 사용할 수 있기 때문에, 매우 유연하고 확장성 있는 프레임워크가 될 수 있었다.
지금까지 정리한 내용이 스프링 mvc의 핵심 코드이며, 거의 같은 구조를 가지고 있다.
'Programming > Spring' 카테고리의 다른 글
[Spring] Spring 단위 테스트 작성 (JUnit5, Mockito) (0) | 2024.04.04 |
---|---|
[Spring] 스프링 MVC 1편(MVC 구조 이해) (0) | 2024.04.02 |
[Spring] 스프링 MVC 1편(서블릿, JSP MVC 패턴) (1) | 2024.02.27 |
[Spring] 스프링 MVC 1편(서블릿) (0) | 2024.02.02 |
[Spring] 스프링 MVC 1편(웹 애플리케이션 이해) (0) | 2024.01.22 |