이전에 서블릿을 Controller로, JSP를 View 화면 렌더링용으로 사용하는 MVC 패턴을 도입했었다.
이번 섹션에선 MVC 패턴을 사용하면서 발생하는 문제점을 개선한 MVC 프레임워크를 서블릿을 통해 차근차근 만들어 볼 예정이다. 단계별로 프레임워크를 만들고 나면 나중에 스프링 MVC의 구조를 이해하는 데 훨씬 도움이 된다.
1. 프론트 컨트롤러 패턴 소개
프론트 컨트롤러를 도입하기 전엔 요청이 올 때마다 각각 공통 로직(View 이동 )을 앞에 깔아 두고 Controller 로직을 적용해야 했다.
프론트 컨트롤러를 도입하면 공통의 관심사를 별도로 모아 처리하는 서블릿을 두고, 프론트 컨트롤러로 각각의 Controller를 호출하도록 구현할 수 있다.
특징
프론트 컨트롤러 서블릿 하나로 모든 클라이언트의 요청을 받은 뒤, 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아 호출한다. 입구를 하나로 만들었기 때문에 서블릿에서 공통 처리를 하면 코드의 중복 사용을 없앨 수 있다. 또, 프론트 컨트롤러를 제외한 나머지 Controller는 서블릿을 사용하지 않아도 된다.
참고
스프링 웹 MVC의 핵심도 FrontController이다.
스프링 웹 MVC의 DispatcherServlet이 FrontController 패턴으로 구현돼 있다.
2. 프론트 컨트롤러 도입 (v1)
v1 구조
기존 코드를 최대한 유지하면서, 프론트 컨트롤러를 도입하고 점진적으로 리팩터링 한다. v1 구조는 아래와 같다.
- 프론트 컨트롤러에서 URL과 Controller를 매핑해 둔 정보에 접근해, 요청에 맞는 URL에 연결해야 할 Controller를 찾는다.
- 이후 해당 Controller를 호출한 뒤, Controller에서 로직을 실행하고 JSP에 forward해 응답한다.
ControllerV1 Interface
우선, 서블릿과 비슷한 모양의 Controller Interface를 도입한다. 프론트 컨트롤러로 이 Interface를 호출해서 구현과 관계없이 로직의 일관성을 가져갈 수 있다.
- 이후 기존 로직(서블릿)을 최대한 유지하면서 아래 Interface를 구현한 회원 등록 & 저장 & 목록 Controller를 만든다.
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException;
}
프론트 컨트롤러 (v1)
분석
- urlPatterns = "/front-controller/v1/*"
- 경로 뒤에 '*'을 사용하면 /front-controller/v1을 포함한 하위 모든 요청은 이 서블릿에서 받아들인다.
- ex. /front-controller/v1, /front-controller/v1/ww, /front-controller/v1/ww/w
- controllerMap
- key: 매핑 URL / value: 호출될 Controller
- service()
- 먼저 requestURI를 조회(request.getRequestURI())해서 실제 호출할 Controller를 controllerMap에서 찾는다.
- 만약 없다면 404(SC_NOT_FOUND) 상태 코드를 반환한다.
- Controller를 찾고, controller.process(request, response);를 호출해 해당 Controller에서 로직을 실행한다.
- 각각의 Controller를 Interface 객체에 담을 수 있기 때문에 다형성을 활용할 수 있다.
- 먼저 requestURI를 조회(request.getRequestURI())해서 실제 호출할 Controller를 controllerMap에서 찾는다.
- JSP
- 이전 MVC에서 사용했던 것을 그대로 사용한다.
@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);
}
}
기존 코드에 프론트 컨트롤러만 도입한 단계라 복잡해 보일 수 있지만, 버전별로 점점 리펙터링하면서 깔끔하게 변한다. 코드 구조를 바꿔야 할 때, 기존 코드를 최대한 유지하면서 구조부터 바꾸는 게 실무에서 쓰는 방식이다. 구조를 바꿨을 때 문제가 없다면 이후 세세한 부분을 고쳐나가면 된다.
참고
JSP에서 Form을 사용할 때 대부분의 경우에는 절대 경로를 쓰고, 비슷한 패턴이 보이는 경우엔 상대 경로를 쓰는 게 좋다.
3. View 분리 (v2)
v2 구조
ControllerV1 Interface를 구현한 Controller 객체들을 보면, View로 이동하는 부분에 중복이 있고, 깔끔하지 않다.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
이 부분을 분리하기 위해 별도로 View를 처리할 객체를 만들자. v2 구조는 아래와 같다.
- 프론트 컨트롤러에서 URL과 Controller를 매핑해 둔 정보에 접근해, 요청에 맞는 URL에 연결해야 할 Controller를 찾는다.
- 이후 해당 Controller를 호출한 뒤, 호출 결과로 MyView 객체를 반환받는다.
- MyView 객체의 render()를 호출하면 해당 객체의 viewPath와 RequestDispatcher를 통해 forward() 로직을 수행해 JSP가 실행된다.
View 객체 분리
View 객체는 이후 다른 버전에서도 함께 사용하므로 패키지 위치를 frontcontroller에 둔다.
- 위의 3줄짜리 코드에서 공통된 부분을 render()에서 처리한다. viewPath는 request마다 다르므로 생성자를 통해 MyView 객체가 만들어질 때 초기화되도록 하면 된다.
- 각각의 Conroller에서 MyView 객체를 생성하고, 거기에 View 경로만 넣고 반환하게 만들면 복잡하고, 중복되는 코드 없이 깔끔하게 구조를 바꿀 수 있다.
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);
}
}
ControllerV2 Interface
다음 버전의 Controller Interface는 MyView 객체를 반환한다는 특징이 있다.
- 중복되는 viewPath 코드와 복잡한 RequestDispatcher 코드를 쓰지 않고 return new MyView(""); 코드를 통해 간단하게 바꿀 수 있다.
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException;
}
아래처럼 반환하면 된다.
return new MyView("/WEB-INF/views/members");
프론트 컨트롤러 (v2)
프론트 컨트롤러의 도입으로 MyView 객체의 render()를 호출하는 부분을 모두 일관되게 처리할 수 있다. 각각의 Controller는 MyView 객체를 생성만 해서 반환하면 된다.
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 view = controller.process(request, response);
view.render(request, response);
}
4. Model 추가 (v3)
v3 개선점
a. 서블릿 종속성 제거
Controller 입장에서 HttpServletRequest와 HttpServletResponse는 필요하지 않다. 요청 파라미터 정보는 자바의 Map으로 대신 넘기도록 하면, 지금 구조에서는 Controller가 서블릿 기술을 몰라도 동작할 수 있다.
그리고 Request 객체를 Model로 사용(setAttribute())하는 대신에 별도의 Model 객체를 만들어 반환하면 된다. 이렇게 구현하면 코드도 단순해지고, 테스트 코드 작성이 쉽다.
b. 뷰 이름 중복 제거
Controller는 View의 논리 이름을 반환하고, 실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 단순화하면 된다. 이렇게 구현하면 나중에 View 폴더 위치가 이동하더라도 프론트 컨트롤러만 고치면 된다. 이게 정말 좋은 설계라고 할 수 있다.
ex. /WEB-INF/views/new-form.jsp → new-form
v3 구조
v3 구조는 아래와 같다.
- 프론트 컨트롤러에서 URL과 Controller를 매핑해 둔 정보에 접근해, 요청에 맞는 URL에 연결해야 할 Controller를 찾는다.
- 이후 해당 Controller를 호출한 뒤, 호출 결과로 ModelView 객체를 반환받는다.
- viewResolver에서 View의 논리 이름을 실제 물리 이름으로 바꾸고, 해당하는 MyView를 반환한다.
- MyView 객체의 render()를 호출하면 해당 객체의 viewPath와 RequestDispatcher를 통해 forward() 로직을 수행해 JSP가 실행된다.
ModelView 객체 도입
서블릿의 종속성을 제거하기 위해 Model을 직접 만들고, 추가로 View 이름까지 전달하는 객체를 만들어보자. v3에선 Controller에서 HttpServletRequest(setAttribute())를 사용할 수 없다. 따라서 별도의 Model 객체가 필요하다.
- ModelView 객체도 다른 버전에서 사용하므로 패키지를 frontcontroller에 둔다.
- View의 이름과 View를 렌더링 할 때 필요한 model 객체를 갖고 있다. model은 단순히 Map으로 돼있으므로 Controller에서 View에 필요한 데이터를 key, value로 넣어주면 된다.
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;
}
ControllerV3 Interface
서블릿 기술(HttpServletRequest, HttpServletResponse)을 사용하지 않는 Controller다. 구현도 매우 쉬워지고 테스트 코드 작성도 쉬워진다는 장점이 있다.
- HttpServletRequest가 제공하는 파라미터는 프론트 컨트롤러가 paramMap에 담아서 호출하면 된다.
- 응답 결과로 View 이름과 View에 전달할 Model 데이터를 포함하는 ModelView 객체를 반환한다.
public interface ControllerV3 {
ModelView process(Map<String, Object> paramMap);
}
예시 코드는 아래와 같다.
// 파라미터 정보는 Map에 담겨있으므로 Map에서 요청 파라미터를 조회하면 된다.
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
Member member = new Member(username, age);
memberRepository.save(member);
// model은 단순한 Map이므로 model에 View에서 필요한 member 객체를 담고 반환한다.
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
프론트 컨트롤러 (v3)
createParamMap()
- HttpServletRequest에서 파라미터 정보를 꺼내 Map으로 변환한다. 이후 해당 Map(paramMap)을 Controller에 전달하면서 호출한다.
MyView view = viewResolver(viewName)
- Controller가 반환한 논리 View 이름을 실제 물리 View 이름으로 변경한다. 이후 실제 물리 경로가 있는 MyView 객체를 반환한다.
- 논리 View 이름: members
- 물리 View 이름: /WEB-INF/views/members.jsp
- 만약 View 경로가 바뀌어도 해당 메서드 안에 있는 경로를 수정하면 되기 때문에 일이 간단해진다.
view.render(mv.getModel(), request, response)
- View 객체를 통해 HTML 화면을 렌더링 한다.
- View 객체에 model 정보를 함께 받는 render()를 추가해야 한다.
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;
}
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);
}
// request에서 파라미터를 모두 뽑아 paramMap에 넣어서 반환
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;
}
// View의 논리 이름을 물리 이름으로 변환해 반환
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
MyView 메서드 추가
render()
- model 정보를 함께 받는 render()를 추가해야 한다.
modelToRequestAttributes()
- JSP는 request.getAttribute()로 데이터를 조회하기 때문에, model의 데이터를 꺼내 request.setAttribute()로 담아둔다.
- 이후, JSP로 forward해서 JSP를 렌더링 한다.
public void render(Map<String, Object> model, HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
modelToRequestAttributes(model,request);
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
private void modelToRequestAttributes(Map<String, Object> model,
HttpServletRequest request) {
model.forEach((key, value) -> request.setAttribute(key.toString(), value));
}
참고
프론트 컨트롤러의 코드는 복잡해졌지만, 각각의 구현 Controller를 보면 굉장히 간단해진 것을 알 수 있다.
5. 단순하고 실용적인 컨트롤러 (v4)
실제 Controller Interface를 구현하는 개발자 입장에서 보면, 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금은 번거롭다. 좋은 프레임워크는 아키텍처도 중요하디만, 그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다. 소위 실용성이 있어야 한다.
v4 구조
v4 구조는 아래와 같다. 기본적인 구조는 v3와 같지만, Controller가 ModelView를 반환하지 않고 ViewName만 반환한다.
- 프론트 컨트롤러에서 URL과 Controller를 매핑해 둔 정보에 접근해, 요청에 맞는 URL에 연결해야 할 Controller를 찾는다.
- 이후 해당 Controller를 호출한 뒤, 호출 결과로 ViewName를 반환받는다.
- viewResolver에서 View의 논리 이름을 실제 물리 이름으로 바꾸고, 해당하는 MyView를 반환한다.
- MyView 객체의 render()를 호출하면 해당 객체의 viewPath와 RequestDispatcher를 통해 forward() 로직을 수행해 JSP가 실행된다.
ControllerV4 Interface
v4는 Interface에 ModelView가 없다. model 객체는 파라미터로 전달되기 때문에 그냥 사용하면 되고, 결과로 View의 이름만 반환해주면 된다.
public interface ControllerV4 {
/**
* @param paramMap
* @param model
* @return viewName
*/
String process(Map<String,String> paramMap, Map<String, Object> model);
}
예시 코드는 아래와 같다. model이 파라미터로 전달되기 때문에, model을 직접 생성하지 않아도 된다.
String username = paramMap.get("username");
int age = Integer.parseInt(paramMap.get("age"));
Member member = new Member(username, age);
memberRepository.save(member);
model.put("member", member);
return "save-result";
프론트 컨트롤러 (v4)
v3와 거의 동일하다. 달라진 점은 아래와 같다.
- model 객체를 프론트 컨트롤러에서 생성해서 넘겨준다. Controller에서 model 객체에 값을 담으면 여기에 그대로 담겨있게 된다.
- Controller가 직접 View의 논리 이름을 반환하므로, 이 값을 사용해서 실제 물리 View를 찾을 수 있다.
...
@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);
// model 객체 전달
Map<String, Object> model = new HashMap<>();
// Controller에서 View의 논리 이름을 직접 반환
String viewName = controller.process(paramMap, model);
MyView view = viewResolver(viewName);
view.render(model, request, response);
}
...
정리
v4 Controller는 매우 단순하고 실용적이다. 기존 구조에서 model을 파라미터로 넘기고, View의 논리 이름을 반환한다는 작은 아이디어를 적용하면서 Controller를 구현하는 개발자 입장에서 보면 깔끔한 코드를 작성할 수 있게 됐다. 프레임워크가 점진적으로 발전하면서 이런 방법들을 찾을 수 있게 된 것이다. 프레임워크나 공통 기능이 수고로워야, 사용하는 개발자가 편리해진다.
여기까지 이해했다면 MVC 구조는 거의 이해했다고 볼 수 있다. 스프링 MVC를 이해하려면 여기에 추가로 어댑터라는 개념을 이해해야 한다.
- Interface 모양(v1, v2, v3, v4)에 제한을 두지 않고, 어떤 Controller든 호출할 수 있는 유연한 프론트 컨트롤러를 도입하게 된다.