이전 포스팅에서 서블릿 + JSP를 활용한 MVC 패턴으로 웹 애플리케이션을 설계해봤다.
컨트롤러와 뷰의 역할을 나누어 설계함으로써. JSP파일에서는 오직 뷰 렌더링에만 집중할 수 있었다.
하지만 각종 컨트롤러(new-form, save, list)를 보면 중복되는 코드들이 있었다.
심지어 모두 서블릿으로 작성되었는데, response 객체는 사용하지도 않는 컨트롤러도 있다.
이러한 점들을 개선하기 위해 프론트 컨트롤러(Front-Controller)를 도입한 MVC 패턴으로 다시 만들어보고자 한다.
총 5가지의 버전을 통해 점진적으로 발전시켜 나갈 것이다!
이전 포스팅 : https://dong-woo.tistory.com/79
[Spring] 서블릿, JSP, MVC 패턴으로 회원 관리 웹 애플리케이션 만들기
지난 포스팅에서 서블릿을 통해 HTTP 요청 메세지를 어떻게 처리하고 응답을 보내는지 알아봤다. 본 포스팅에서는 간단한? 회원 관리 웹 애플리케이션을 만들어볼 것이다! 서블릿, JSP, MVC 패턴을
dong-woo.tistory.com
- 프론트 컨트롤러란?
- 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출해준다.
- 프론트 컨트롤러를 제외한 나머지 컨트롤러를 서블릿을 사용하지 않아도 된다 -> 불필요한 request, response 객체를 사용하지 않아도 된다.
- 기존 모든 컨트롤러에서 디스패처를 사용한 서블릿 -> JSP 이동도 프론트 컨트롤러에서 해준다.
- 모든 클라이언트 요청을 프론트 컨트롤러에서 처리해주는 방식이다!
- V1 (프론트 컨트롤러 도입)
// 프론트 컨트롤러 V1
@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);
}
}
// 회원 저장 컨트롤러 V1
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);
}
}
- JSP로 이동한 뒤 뷰 렌더링을 처리하는 과정은 바뀐 점이 없다.
- 관심사는 JSP로 모델을 보내는 과정이다.
- 프론트 컨트롤러를 도입함으로써 나머지 컨트롤러가 서블릿을 사용하지 않는다. (아직 파라미터로 request, response 객체를 받긴 함)
- 하지만 아직 request.setAttribute()를 통해서 모델에 데이터를 담고 있다는 점, 이후 디스패처를 사용해 JSP로 이동하는 부분이 모든 컨트롤러에 공통적으로 남아있다.
- V2에서는 이 View에 대한 공통부분을 처리해보자.
- V2 (View 분리)
// 프론트 컨트롤러 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 {
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);
}
}
// 전체회원 목록조회 컨트롤러 V2
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");
}
}
// 컨트롤러에서 뷰를 분리하기 위한 MyView
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);
}
}
- V2의 목표는 컨트롤러에서 디스패처를 사용해 View로 전환하는 공통과정들을 없애는 것이다.
- V1의 컨트롤러에서는 반환 타입이 void였다.
- 내부적으로 필요한 로직을 실행하고 디스패처를 통해 JSP로 이동하면 끝이기 때문에 별도의 반환 타입이 필요하지 않았다.
- V2에서는 디스패처를 통해 JSP로 이동하는 부분을 싹 지우고 viewPath만 가진 채 MyView 객체를 반환해준다.
- 이후 프론트 컨트롤러에서 MyView.render() 를 호출하여 뷰로 이동하는 것이다!
- 결과적으로 컨트롤러에서는 비지니스 로직을 처리하기만 하면 되고, 뷰 처리는 프론트 컨트롤러로 넘기게 됐다.
- V3 (Model 추가)
// 프론트 컨트롤러 V3
@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;
}
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");
}
}
// 전체 회원 리스트 조회 컨트롤러 V3
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;
}
}
// MyView 변경
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 void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
model.forEach((key, value) -> request.setAttribute(key, value));
}
// ModelView 추가
@Getter @Setter
public class ModelView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
}
- 가장 핵심인 V3 이다.
- 여러가지의 변화가 생기는데 하나씩 알아보자.
- 첫 번째로, viewPath를 넘길 때 맨 뒤에 /new-form, /save, /members 만 바뀌는데 기존 방식은 모든 경로를 다 써줬다.
- 앞 단의 모든 경로를 제거하고 간단하게 맨 끝 viewPath만 넘기도록 수정했다. 어떻게?
- 프론트 컨트롤러에 뷰 리졸버(viewResolver)를 생성해 전체 viewPath로 변경되도록 만들었다.
- 두 번째로, 구현체에서 파라미터로 넘어온 request, response 객체를 사용하지 않는 경우가 있었다.
- 효율적으로 만들기 위해 서블릿 요청, 응답 객체를 파라미터로 넘기는게 아니라 프론트 컨트롤러에서 요청 파라미터를 다 뜯어서 맵으로 만들어준 뒤에 넘겨주는 방식을 사용했다.
- 근데 이렇게 되면 실제로 컨트롤러에서 V2처럼 request.setAttribute()를 사용해 모델에 데이터를 담지 못한다.
- 별도의 모델이 필요하다. 그래서 ModelView를 만든 것이다!
- 이제는 컨트롤러에서 비지니스 로직을 처리한 뒤 간단하게 바뀐 viewPath를 지정해주고, mv.getModel().put()으로 모델에 데이터를 담아준다.
- 컨트롤러는 V2처럼 MyView를 반환해주는 것이 아니라 ModelView를 반환해주면 된다.
- 세 번째로, 이렇게 ModelView를 통해 모델에 데이터를 담았지만 실제로 JSP가 모델 데이터를 읽을 때는 request.getAttribute() 방식으로 읽게된다. 따라서 뷰 렌더링을 해줄 때는 모델에 담겨있는 데이터를 request.setAttribute()를 통해 옮겨주는 과정을 거쳐야한다.
- 이를 위해 MyView 클래스가 수정되었다! 기존처럼 단순히 request, response를 파라미터로 받아서 렌더링 하는게 아닌 model, request, response 세 가지를 전부 파라미터로 받아 내부적으로 모델 데이터를 request 객체에 담아서 보내는 과정이 필요하다.
- 이제 대부분의 역할이 잘 구분지어졌다.
- 다만 ModelView를 계속 사용하는게 불편하기도 하다!
- V4 (V3의 ModelView를 안쓰기 위해 리팩토링)
- 앞서 만든 V3 컨트롤러는 서블릿 종속성을 제거하고 뷰 경로의 중복을 제거하는 등, 잘 설계된 컨트롤러이다.
- 하지만 실제 컨트롤러 인터페이스를 구현하는 개발자 입장에서 보면, 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금은 번거롭다.
- 이번 V4는 V3와 거의 동일하지만 조금만 변경해서 실제 구현하는 개발자들이 매우 편리하게 개발할 수 있게 만든다.
- 프론트 컨트롤러에서 컨트롤러로 파라미터를 넘길 때, 이제는 모델을 같이 넘겨준다.
- 따라서 모델에 데이터를 담고 모델을 반환하는 과정이 필요가 없어진다.
- 이제는 단순히 String 으로 viewPath만 반환해줄 수 있는 것이다!
- 기존 V3구조에서 모델을 파라미터로 넘겨주고, 뷰의 논리이름을 반환한다는 작은 아이디어를 적용했을 뿐인데 컨트롤러를 구현하는 개발자 입장에서 보면 이제 군더더기 없는 코드를 작성할 수 있다.
- V5 (어댑터 패턴을 적용)
V3, V4를 거치면서 정말 괜찮은 MVC 패턴을 만들 수 있었다.
유일한 단점은 V3를 사용하면 컨트롤러에서 ModelView를 반환하는 패턴만 사용할 수 있고, V4를 사용하면 컨트롤러에서 viewPath를 String으로 반환하는 패턴만 사용할 수 있다는 점이다.
마지막으로, 어댑터 패턴을 사용해 두 가지의 반환패턴을 모두 적용할 수 있는 최종 V5를 만들어보자!
// 프론트 컨트롤러 V5
@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());
//v4
handlerMappingMap.put("/front-controller/v5/v4/members/new-form", new MemberFormControllerV4());
handlerMappingMap.put("/front-controller/v5/v4/members/save", new MemberSaveControllerV4());
handlerMappingMap.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);
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");
}
}
// V4 핸들러 어댑터
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);
Map<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;
}
}
- 마치 110V를 220V환경에서 사용할 수 있도록 만들어주는 어댑터처럼, V5에는 핸들러 어댑터라는 것이 등장한다.
- 핸들러는 컨트롤러와 같다. 조금 더 넓은 범위를 뜻한다고 생각하면 된다.
- 생각해보자. V4는 V3와 다르게 String 형식으로 viewPath를 반환했다. 어떻게 두 가지 버전을 같이 사용할까?
- V4 핸들러 어댑터에서, V4 컨트롤러의 프로세스를 통해 나온 viewPath를 새로운 ModelView 객체에 합쳐서 반환해주면 된다!
- 프론트 컨트롤러 입장에서는 V3방식을 사용했나, V4방식을 사용했나가 중요하지 않고 핸들러 어댑터를 통해서 모두 같은 ModelView객체가 반환되기 때문에 여러가지의 버전을 같이 사용할 수 있는 것이다.
- 정리
- V1 : 기존 구조를 최대한 유지하며 프론트 컨트롤러를 도입했다.
- V2 : 컨트롤러 내 단순 반복되는 뷰 로직을 분리했다.
- V3 : 컨트롤러의 서블릿 종속성을 제거하고, 뷰 이름 중복을 제거했다.
- V4 : V3와 거의 동일하나 ModelView를 반환하는 스타일이 아닌 viewPath 자체를 String 으로 반환하는 구조로 변경했다.
- V5 : 어댑터를 도입해, 프레임워크를 더욱 유연하고 확장성 있게 설계했다.
- 여기서 더 발전시킬 수는 있지만, 스프링 MVC의 핵심 구조를 파악하는데 필요한 부분을 모두 만들어봤다.
- V1부터 V5까지 발전시키며 작성한 코드는 스프링 MVC 프레임워크의 핵심 코드의 축약 버전이고, 구조도 거의 같다.
- 다음 포스팅부터는 진짜 스프링 MVC를 학습해보자!
출처 : 인프런, 김영한의 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술
'Spring' 카테고리의 다른 글
[Spring] 스프링 MVC 기본 기능 - HTTP 요청, 응답 처리하기 (0) | 2023.07.14 |
---|---|
[Spring] 스프링 MVC 구조 이해하기 (1) | 2023.07.11 |
[Spring] 서블릿, JSP, MVC 패턴으로 회원 관리 웹 애플리케이션 만들기 (0) | 2023.07.06 |
[Spring] 서블릿(Servlet) (2) | 2023.07.05 |
[Spring] 스프링 빈 스코프 (0) | 2023.07.03 |