이 글은 Spring MVC를 이해하기 위해 직접 MVC 프레임워크를 만들어보는 과정을 담은 글이다. 지난 글에 이어서 Part 2 를 작성해보려고 한다. 이 글은 인프런 김영한님의 스프링 MVC 1편을 참고하여 작성한 글이다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1/dashboard
Part 1 글은 아래 링크에서 볼 수 있다.
✔️ 프론트 컨트롤러 패턴 개요
우리는 이전 Part 1 에서 MVC 패턴을 적용했었다. 하지만 포워드 코드의 중복, viewPath의 중복 등 공통처리에 있어서 문제가 있었다. 이를 개선하기 위해 우리는 프론트 컨트롤러를 도입하기로 했었다. 아래는 프론트 컨트롤러를 도입하기 전, 도입한 후의 이미지이다.
FrontController의 특징
위 사진 처럼, 이제 모든 컨트롤러는 우리가 도입할 FrontController를 무조건 거쳐가도록 설계할 것이다. 나중에 나오게 될 Spring MVC의 DispatcherServlet이 프론트 컨트롤러 패턴으로 구현되어 있다. 알아만 두고, 특징을 잠시 살펴보자.
- 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받게 된다.
- 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출하게 된다.
- 프론트 컨트롤러에서 공통 처리가 가능하게 된다.
✔️ V1. 프론트 컨트롤러 패턴 도입
이제 우리가 Part 1 에서 작성했던 프로젝트에 프론트 컨트롤러 패턴을 단계적으로 도입해 보자. 기존 코드는 유지 하면서, 패키지를 새로 만들며 단계적으로 발전해 나가는 것이 목표이다. 아래는 프론트 컨트롤러를 도입하게 될 구조이다.
이제 우리 프로젝트에 프론트 컨트롤러를 도입해보자.
frontcontroller라는 새로운 패키지를 만들어 하위 파일들을 작성했다. JSP 파일은 그대로 사용할 것이다. 여기서 우리 프로젝트와 다른 점은, ControllerV1 인터페이스를 사용해서 프론트 컨트롤러가 URL 매핑 정보를 찾아 컨트롤러를 반환할 때 일관성을 유지하기 위해 사용했다.
📝 프론트 컨트롤러 & 회원 컨트롤러 V1 작성 - FrontController 추가
- 회원 등록 : http://localhost:8080/front-controller/v1/members/new-form
- 회원 목록 : http://localhost:8080/front-controller/v1/members
회원 컨트롤러는 우리가 기존에 작성해둔(servletmvc 패키지에 있는) 컨트롤러와 ControllerV1 인터페이스를 구현했다는 것을 제외하면 내부 구조가 똑같다.
[ControllerV1.java] - interface
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
- 서블릿과 비슷한 모양의 컨트롤러 인터페이스를 도입
- 각 컨트롤러들은 이제 이 인터페이스를 구현하면 된다.
- 프론트 컨트롤러가 이 인터페이스를 호출하여 구현체와 상관없이 일관성 있는 코드 작성이 가능해진다.
[MemberFormControllerV1.java]
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.java]
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);
}
}
[MemberListControllerV1.java]
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);
}
}
[FrontControllerV1.java] - 새롭게 도입한 프론트 컨트롤러
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private Map<String, ControllerV1> controllerMap = new HashMap<>();
// URL 매핑 정보를 담아 둔다. 어떤 컨트롤러를 호출 할 것인지 여기서 결정!
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");
// 요청한 URI로 컨트롤러의 인스턴스를 가져온다.
String requestURI = request.getRequestURI();
ControllerV1 controller = controllerMap.get(requestURI); // 인터페이스로 호출
// 매핑 정보가 없으면 -> NOT FOUNT 404
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// 해당하는 컨트롤러 로직 수행!
controller.process(request, response);
}
}
- controllerMap 을 통해 URL 매핑 정보를 담아 둔다.
- 각 컨트롤러를 ControllerV1 인터페이스로 호출(다형성 적용)하여 일관성 있는 코드 가능!
✔️ V2. View 분리
이제 아래 코드와 같이 모든 컨트롤러에서 뷰로 이동하는 중복 코드를 처리해줄 것이다.
String viewPath = "/WEB-INF/views/members.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
이 코드 부분들을 별도로 분리하기 위해 별도로 뷰를 처리하는 객체를 만들어 볼 것이다. 아래는 우리가 뷰를 처리하는 객체를 만들었을 때의 구조를 그림으로 나타낸 것이다.
📝 프론트 컨트롤러 & 회원 컨트롤러 V2 작성 - MyView 클래스 추가
- 회원 등록 : http://localhost:8080/front-controller/v2/members/new-form
- 회원 목록 : http://localhost:8080/front-controller/v2/members
기존 코드 유지를 위하여 v2 패키지를 새롭게 만들어서 코드를 작성할 것이다.
새롭게 추가한 MyView 클래스는 앞으로 버전을 업그레이드하면서 계속 공통적으로 사용할 클래스이다. 그래서 MyView 클래스는 frontcontroller 패키지 하위에 만들어 주도록 한다.
[ControllerV2.java] - interface
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
- MyView를 반환하도록 하는 인터페이스로 변경
[MemberFormControllerV2.java]
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.java]
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);
//Model에 데이터를 보관
request.setAttribute("member", member);
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
[MemberListControllerV2.java]
public class MemberListControllerV2 implements ControllerV2 {
private MemberRepository memberRepository = hello.servlet.domain.member.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");
}
}
[FrontControllerV2.java]
@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {
private Map<String, ControllerV2> controllerMap = new HashMap<>();
// URL 매핑 정보를 담아 둔다. 어떤 컨트롤러를 호출 할 것인지 여기서 결정!
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 {
// 요청한 URI로 컨트롤러의 인스턴스를 가져온다.
String requestURI = request.getRequestURI();
ControllerV2 controller = controllerMap.get(requestURI);
// 매핑 정보가 없으면 -> NOT FOUNT 404
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyView view = controller.process(request, response);
view.render(request, response);
}
}
결과적으로, 프론트 컨트롤러의 도입으로 JSP로 forward 해주는 부분을 MyView 객체의 render()를 호출하는 부분으로 한 곳에서 일관되게 처리할 수 있게 되었다. 각각의 컨트롤러는 MyView 객체를 생성해서 반환하기만 하면 된다.
✔️ V3. Model 추가
서블릿 종속성 제거
프론트 컨트롤러를 제외한 컨트롤러들은 HttpServletRequest, HttpServletResponse 가 꼭 필요할까?
요청 파라미터의 정보는 Map으로 넘기도록 하면, 지금 구조에서는 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다. 또한 request를 Model로 사용하는 대신 Model 객체를 별도로 만들어서 반환하면 된다. 이렇게 하면 구현 코드도 단순해지고, 테스트 코드도 작성이 쉬워진다.
viewPath 중복 제거 - viewResolver 메서드 도입
- /WEB-INF/views/new-form.jsp -> new-form
- /WEB-INF/views/save-result.jsp -> save-result
- /WEB-INF/views/new-form.jsp -> members
현재 컨트롤러에서 지정하는 뷰 이름엔 중복이 있다. 컨트롤러는 뷰의 논리이름(ex) new-from)을 반환하고, 실제 물리적인 위치는 프론트 컨트롤러에서 처리하도록 하자. 이렇게 공통처리를 해두면 뷰의 폴더 위치가 바뀌어도 프론트 컨트롤러만 수정하면 된다.
이제 컨트롤러들의 서블릿 종속성을 제거하고, viewPath의 중복도 제거해 보자.
📝 서블릿 종속성 제거 & viewPath 중복 개선 V3 작성 - ModelView 추가
- 회원 등록 : http://localhost:8080/front-controller/v3/members/new-form
- 회원 목록 : http://localhost:8080/front-controller/v3/members
[ModelView.java]
@Getter
@Setter
public class ModelView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
}
지금까지 컨트롤러에서 서블릿에 종속적인 HttpRequestServlet을 사용했었다. 그리고 Model도 request.setAttribute()를 통해 데이터를 저장하고 뷰에 전달했다. 이제는 HttpRequestServlet이 아니라, Map을 이용해 데이터를 담아둘 것이다.
서블릿의 종속성을 제거하기 위해 Model을 직접 만들고, 추가로 View 이름까지 전달하는 객체를 만든 것이다.
[ControllerV3.java]
public interface ControllerV3 {
ModelView process(Map<String, String> paraMap);
}
- 이제 컨트롤러에 서블릿 기술을 전혀 사용하지 않는다. 프론트 컨트롤러에서만 사용할 것이다.
[MemberFormControllerV3.java]
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paraMap) {
return new ModelView("new-form");
}
}
- ModelView를 생성할 때 이제 JSP의 논리적인 이름을 지정한다. -> new-form
- 실제 물리적 주소는 프론트 컨트롤러(viewResolver) 에서 처리 할 것이다.
[MemberSaveControllerV3.java]
public class MemberSaveControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paraMap) {
String username = paraMap.get("username");
int age = Integer.parseInt(paraMap.get("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
}
}
[MemberListControllerV3.java]
public class MemberListControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paraMap) {
List<Member> members = memberRepository.findAll();
ModelView mv = new ModelView("members");
mv.getModel().put("members", members);
return mv;
}
}
[FrontControllerServletV3.java]
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
private Map<String, ControllerV3> controllerMap = new HashMap<>();
// URL 매핑 정보를 담아 둔다. 어떤 컨트롤러를 호출 할 것인지 여기서 결정!
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 {
// 요청한 URI로 컨트롤러의 인스턴스를 가져온다.
String requestURI = request.getRequestURI();
ControllerV3 controller = controllerMap.get(requestURI);
// 매핑 정보가 없으면 -> NOT FOUNT 404
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// paraMap
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
String viewName = mv.getViewName(); // 논리 이름 new-form
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request, response);
}
private static MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
private static 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;
}
}
- 전체 물리적 주소는 이제 프론트 컨트롤러 viewResolver()에서 처리한다.
- 다른 컨트롤러에서 반환받은 ModelView 객체로 MyView 객체를 생성하고 JSP 랜더링을 한다.
[MyView.java]
public class MyView {
private String viewPath;
public MyView(String viewPath) {
this.viewPath = viewPath;
}
// view로 forward 해주는 부분
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
// view로 forward 해주는 부분 - V3
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
model.forEach(request::setAttribute);
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
- ModelView에서 받은 데이터를 JSP로 넘겨주기 위해 request 객체에 담아주고 forward 한다.
✔️ 단순하고, 실용적이며, 유연한 컨트롤러로 개선해보자
이제 우리는 프론트 컨트롤러에서만 서블릿을 사용하고, 나머지 컨트롤러에서는 서블릿을 사용하지 않는다. 또한, viewPath의 물리적 주소도 프론트 컨트롤러에서 관리하도록 개선하였다. 필요 없는 코드를 지우고, 중복을 제거했다.
다음 글에서는 지금까지 작성했던 구조를 조금 더 개선해 보도록 하자.
MVC 프레임워크 직접 만들기 Part 2 끝.