프론트 컨트롤러 패턴이란? 이전까지 클라이언트 호출이 오면 공통 로직을 포함한 모든 것이 컨트롤러마다 만들어야 했다.
프론트 컨트롤러는 이런 공통 로직을 하나로 모은 서블릿이다.
프론트 컨트롤러 특징
- 서블릿 하나로 클라이언트의 요청을 받음
- 프론트 컨트롤로가 요청에 맞는 컨트롤러를 찾아서 호출
- 공통 처리 가능
- 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿 없어도 됨
1. 프론트 컨트롤러 도입 - 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());
}
...
}
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
public MemberFormControllerV1 implements ControllerV1 {
...
}
public MemberSaveControllerV1 implements ControllerV1 {
...
}
public MemberListControllerV1 implements ControllerV1 {
...
}
생성자를 통해 url 정보에 맞게 컨트롤러를 담아줬다. 이제 호출하는 코드를 작성하자
@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);
}
실행순서는 다음과 같다.
- localhost:8080/front-controller/v1/new-form의 요청이 오면 위의 FrontContoller가 실행된다.
- 생성자에서 매핑정보를 우선 생성한다.
- URL주소를 key, ControllerV1 인터페이스 구현체를 value로 Map에 넣어둔다.
- service()가 실행된다.
- request.getRequestURI()를 통해 요청한 URI를 꺼내온다.
- 꺼내온 URI로 controllerMap에서 구현체를 가져온다.(다형성 적용)
- 매핑된 구현체가 없으면 404 처리를 하고, 구현체가 있으면 오버라이딩 메소드(process())를 실행하여 이전에 만들었던 JSP forward로직을 실행한다.
이전과 다르게 하나의 서블릿으로 처리가 되었다. 하지만 하나의 과정이 추가되면서 중복되는 코드들이 보인다. 이제 뷰와 모델로 분리하면서 중복되는 코드를 없애보자.
1. View 분리 - v2
프론트 컨트롤러로 여러 컨트롤러를 제어하지만 뷰로 이동되는 코드가 중복이 된다. 이를 개선해보자.
뷰 객체인 MyView를 다음과 같이 만든다.(frontcontroller 패키지 밑에 만들어 v1,2,3,4 모두 사용하도록 만든다)
public class MyView {
private String viewPath;
public MyView(String viewPath) {
this.viewPath = viewPath;
}
//각 컨트롤러마다 호출했던 forward 로직을 여기서 처리한다.
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
이전에는 ControllerV1의 process는 반환하는 게 없었는데 컨트롤러마다 뷰를 독립하기 위해 변경되었다.
@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("FrontControllerServletV2.service");
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도 로직 마지막 부분에 MyView로 forward를 실행하게 처리한다.
public class MemberListControllerV2 implements ControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
request.setAttribute("members", memberRepository.findAll());
return new MyView("/WEB-INF/views/members.jsp");
}
}
public class MemberSaveControllerV2 implements ControllerV2 {
...
}
public class MemberFormControllerV2 implements ControllerV2 {
...
}
MyView를 반환하게 되어 중복 코드 코드들이 많이 사라졌다.
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
...
// 아래의 코드가 반환되는 MyView로 대체되었다.
// String viewPath = "/WEB-INF/views/members.jsp";
// RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
// dispatcher.forward(request, response);
return new MyView("/WEB-INF/views/members.jsp");
}
3. Model 추가 - v3
변경하고 싶은 부분이 두 가지가 있다.
첫번째로 컨트롤러는 HttpServletRequest, HttpServletResponse에 종속적이다. 모델도 컨트롤러에서 request.setAttribute()를 통해 사용했다. 컨트롤러가 서블릿 기술을 사용하지 않도록 변경해보자.
두번째로 컨트롤러에서 리턴하는 뷰네임에 중복이 있다. "/WEB-INF/views/"나 ".jsp"가 그러하다. 이 부분을 제거하기 위해 컨트롤러는 논리이름만 반환한다. 예를 들어 다음과 같다.
//기존 중복 포함된 반환명
//return "/WEB-INF/views/new-form.jsp"
//중복 제거한 논리명
//return "new-form"
서블릿 종속성 제거
프론트 컨트롤러가 생기면서 기존의 컨트롤러의 서블릿 의존이 없어도 될 정도가 되었다.
이제 컨트롤러의 서블릿 종속성을 제거해보자.
뷰 이름 중복 제거
컨트롤러에서 지정되는 뷰 이름이 매번 물리적인 경로를 다 넣다 보니 /WEB-INF/views/... 중복이 되고 있다.
프론트 컨트롤러에서 중복 경로를 처리하고, 컨트롤러는 뷰의 논리 이름을 반환하게 단순화를 진행해보자.
기존 컨트롤러의 서블릿을 대체하기 위한 ModelView를 만들어보자 (스프링의 ModelAndView를 떠올리면 된다.)
public class ModelView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
public Map<String, Object> getModel() {
return model;
}
public void setModel(Map<String, Object> model) {
this.model = model;
}
public String getViewName() {
return viewName;
}
}
이 ModelView는 컨트롤러의 서블릿 객체를 대체하는 역할을 한다. ModelView를 반환하는 ControllerV3 인터페이스를 생성하고 컨트롤러가 상속받게 구현한다
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 MemberListControllerV3 implements ControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public ModelView process(Map<String, String> paramMap) {
ModelView mv = new ModelView("members");
mv.getModel().put("members", memberRepository.findAll());
return mv;
}
}
public class MemberSaveControllerV3 implements ControllerV3 {
...
}
이전과 다르게 컨트롤러에 서블릿 객체가 없어지면서 파라미터로 Map 형식으로 받게 되었다. 현재 구조에 맞게 호출하는 프론트컨트롤러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 {
System.out.println("FrontControllerServletV3.service");
String requestURI = request.getRequestURI();
ControllerV3 controller = controllerMap.get(requestURI);
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
// Map에 담아 컨트롤러에 보내준다
Map<String, String> paramMap = createParamMap(request);
ModelView mv = controller.process(paramMap);
String viewName = mv.getViewName(); // 논리이름 new-form, members 등 가져오기
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;
}
}
request의 모든 정보를 map 에 담는 로직이 추가되었고 이를 컨트롤러에 보내게 되었다. 기존의 MyView는 컨트롤러에서 반환하지 않고, 프론트컨트롤러에서 viewResolver로 반환하게 변경했다. 프론트 컨트롤러가 좀 더 복잡해졌지만, 기존의 컨트롤러는 불필요한 중복 코드가 없어져서 많이 깔끔해졌다. 마지막으로 MyView.rander()를 오버 로딩한다.
public class MyView {
...
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를 생성해서 반환해야 한다. 다음 시간에는 이를 개선한 v4를 알아보자.
4. 단순하고 실용적인 컨트롤러 - v4
v3는 많이 개선이 되었지만, 사용자가 ModelView 객체를 생성하고 반환하는 행위가 반복이 된다. 좋은 프레임워크는 아키텍처도 중요하지만, 개발자가 단순하고 편리(실용성)하게 사용할 수 있어야 한다.
ModelView 대신 ViewName만 반환되게 개선해보자.
public interface ControllerV4 {
/**
* @param paramMap
* @param model
* @return viewName
*/
String process(Map<String, String> paramMap, Map<String, Object> model);
}
String으로 viewName을 반환한다. 그리고 파라미터에 Map<String, Object> model을 추가했다. ModelView의 model을 저기에 담아준다.
public class MemberSaveControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
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);
return "save-result";
}
}
public class MemberListControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
model.put("members", memberRepository.findAll());
return "members";
}
}
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
return "new-form";
}
}
컨트롤러마다 ModelView 대신 viewName을 반환하는 구조면서, 기존의 model 객체는 넘겨온 model로 대체했다. 프론트컨트롤러도 간단히 수정하면 끝난다.
@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {
...
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("FrontControllerServletV4.service");
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<>();
String viewName = controller.process(paramMap, model);
MyView view = viewREsolver(viewName);
view.render(model, request, response);
}
...
}
이전에는 ModelView를 받아서 다시 값을 꺼내는 과정이 생략되어 조금 더 깔끔하고 개발자 입장에서 개발이 더 편해진 구조가 되었다.
5. 유연한 컨트롤러 - v5
이전까지 컨트롤러의 구조가 정해진 상태였다.
// V4 방식만 호출할 수 있다.
private Map<String, ControllerV4> controllerMap = new HashMap<>();
V4가 아닌 다른 버전인 V1,V2,V3를 모두 사용할 방법이 필요하고, 이를 구현할 방법은 어댑터 패턴이다
어댑터 패턴
110v, 220v 전기콘센트를 다른 전기나 기계 장치를 연결해주는 도구가 어댑터다. (핸드폰 충전기, 노트북 충전기를 생각하라)
개발에서 어댑터 패턴 클래스를 사용하여 클라이언트가 원하는 인터페이스로 연결해준다.
즉, 클라이언트는 어댑터를 호출하면 어댑터는 클라이언트가 원하는 인터페이스를 대신 호출한다.
이전과 다르게 Controller 대신 Handler 용어를 사용한다.
핸들러 어댑터: 중간에 어댑터 역할을 하는 어댑터가 추가되었는데 이름이 핸들러 어댑터이다. 여기서 어댑터 역할을 해주는 덕분에 다양한 종류의 컨트롤러를 호출할 수 있다.
핸들러: 컨트롤러의 이름을 더 넓은 범위인 핸들러로 변경했다. 그 이유는 이제 어댑터가 있기 때문에 꼭 컨트롤러의 개념 뿐만 아니라 어떠한 것이든 해당하는 종류의 어댑터만 있으면 다 처리할 수 있기 때문이다.
핸들러어댑터를 구현해보자
public interface MyHandlerAdapter {
boolean supports(Object handler);
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}
supports는 핸들러를 지원하는지 판단한다. handle는 해당되는 핸들러를 실행하고 ModelView로 반환해야 하는데 V3는 ModelView를 반환하게 짜였지만 V4는 viewName만 반환하는 구조다. V4도 ModelView로 반환하게 수정해야 할까? 우선 V3 핸들러를 만들어보자.
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;
}
}
실행 순서는 다음과 같다.
1) supports를 통해 어댑터가 지원하는 인터페이스인지 확인한다.
2) 클라이언트의 호출받아 기존 프론트컨트롤러V3 대체한다.
3) 결과를 ModelView에 담아 반환한다.
이제 모든 걸 처리할 수 있는 프론트컨트롤러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 initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
}
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());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("FrontControllerServletV5.service");
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 MyHandlerAdapter getHandlerAdapter(Object handler) {
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler=" + handler);
}
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
- initHandlerMappingMap() : 이전 버전들의 controllerMap이다. 다른 점은 <Key, Value>가 <String, ControllverV>가 아니라 <String, Object>라는 점이다. 여기서는 V3,V4만 예를들지만 추후 V1,2,5를 다 넣을 수 있도록 처리한다.
- initHandlerAdapter() : 만든 핸들러 어댑터를 담는다.
- service()
- getHandler() : 요청에 맞는 컨트롤러를 가져온다.
- getHandlerAdapter() : handlerAdapters에 지금은 ControllerHandlerAdapterV3, ControllerHandlerAdapterV4만 있지만 V4,V5등도 추가될 것이다.
- List<MyHandlerAdapter> 타입의 handlerAdapters를 반복하며 넘겨받은 handler가 어떤 버전의 컨트롤러인지 체크(support())한 후, 해당 어댑터를 반환한다. 반환한 어댑터의 handle() 호출을 통해 컨트롤러 로직 수행 후 ModelView를 반환받는다. 이후 똑같은 render() 처리가 진행된다.
이제 V4를 적용해보자. 우선 V4를 매핑할 수 있게 프론트컨트롤러V5를 살짝 수정해보자.
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
...
private void initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
handlerAdapters.add(new ControllerV4HandlerAdapter());
}
private void initHandlerMappingMap() {
// V3
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());
}
...
}
수정은 단순하다. handlerAdapters와 handlerMappingMap에 V4 관련 어댑터와 컨트롤러를 추가하면 끝이다. 이제 V4어댑터를 작성해보자.
public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
...
@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;
}
...
}
기존 컨트롤러V4는 viewName을 반환하는 구조로 ModelView가 아니다. V4어댑터는 클라이언트가 ModelView를 받아 처리할 수 있도록, ModelView 객체를 생성하여 반환하는 과정이 추가되어있다. 말 그대로 어댑터의 진정한 역할이다.
여기서 가장 중요한 부분은 ControllerV3HandlerAdapter, ControllerV4HandlerAdapter의 handle() 부분이다.
이 부분을 보면 각 컨트롤러 process() 처리 후 반환타입이 다르다.
이런 차이를 어댑터를 통해 형식을 ModelView로 맞추어 반환해주기 때문에 어댑터의 필요한 이유가 된다.
버전별로 모두 버전업을 해봤는데 이 v5 코드가 스프링 MVC의 축약된 핵심 버전으로 이해하면 되고 구조도 비슷하다.
'👩🏫 Study > 스프링부트 강의' 카테고리의 다른 글
[4. 스프링 MVC 1편] 스프링 MVC 웹 페이지 만들기 (0) | 2023.10.11 |
---|---|
[4. 스프링 MVC 1편] 스프링 MVC - 기본 기능 (0) | 2023.10.08 |
[4. 스프링 MVC 1편] 서블릿, JSP, MVC패턴 (0) | 2023.10.06 |
[4. 스프링 MVC 1편] 웹 애플리케이션 이해 (0) | 2023.10.06 |
[3. HTTP 웹 기본 지식] HTTP 상태코드와 헤더 (0) | 2023.10.06 |