본 포스팅은 인프런 - 스프링 MVC 1편을 강의를 바탕으로 공부하고 정리한 글입니다.
이전 포스팅에서 MVC 패턴의 한계점인 공통 처리가 힘들다는 부분을 해결하기 위해 프론트 컨트롤러 패턴을 사용한다고 했다.
프론트 컨틀롤러 패턴이 무엇인지 알아보고 코드에 적용해보자.
프론트 컨트롤러 패턴
- 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받는다.
- 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출해주는 역할을 한다.
- 프론트 컨트롤러를 통해 요청을 받는 입구를 하나로 만들어 공통 처리가 가능하다.
- 여러 컨트롤러에 산재되있던 공통 처리 코드를 프론트 컨트롤러 하나로 모을 수 있다.
- 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다.
스프링 웹 MVC의 DispatcherServlet도 FrontController 패턴으로 구현되어 있다.
✅ 기존의 MVC 패턴만 적용한 상태
공통 코드가 모든 컨트롤러에 산재되어 있어 클라이언트가 각각의 컨트롤러를 호출해야 한다는 단점이 있다.
✅ 프론트 컨트롤러 도입 상태
공통 코드가 프론트 컨트롤러에 모이고 클라이언트의 요청은 모두 프론트 컨트롤러를 통해 받는다.
이제 실제 코드로 프론트 컨트롤러를 단계적으로 도입해보며 알아보자.
v1 - 프론트 컨트롤러 도입
v1 단계에서는 기존 코드를 최대한 유지하면서, 프론트 컨트롤러를 도입하는 것을 목표로 한다.
컨트롤러 인터페이스
📁 web/fromtcontroller/v1/ControllerV1
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
- 서블릿과 비슷한 모양의 컨트롤러 인터페이스를 도입한다.
- 각 컨트롤러들은 이 인터페이스를 구현하면 된다.
- 프론트 컨트롤러는 이 인터페이스를 호출해 다형성으로써 각각의 구현 컨트롤러와의 의존관계를 끊을 수 있다.
회원 등록, 저장, 목록 컨트롤러
이제 컨트롤러 인터페이스를 구현하는 3개(회원 등록, 회원 가입, 회원 목록)의 컨트롤러를 만들어 준다.
이때 v1 단계에서는 기존 로직을 최대한 유지하는게 핵심이다.
👉🏻 회원 등록 폼 컨트롤러
📁 web/frontcontroller/v1/controller/MemberFormControllerV1
public class MemberFormControllerV1 implements ControllerV1 {
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 뷰로 이동
String viewPath = "/WEB-INF/views/new-form.jsp"; // 뷰(JSP) 경로 지정
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);// 컨트롤러에서 뷰로 이동할 때 사용
dispatcher.forward(request, response); // 뷰(JSP)를 찾아서 이동
}
}
👉🏻 회원 저장 컨트롤러
📁 web/frontcontroller/v1/controller/MemberSaveControllerV1
public class MemberSaveControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// HTTP 요청 받기
String username = request.getParameter("username"); // HTTP 요청 데이터를 받아 이름 조회
int age = Integer.parseInt(request.getParameter("age")); // HTTP 요청 데이터를 받아 나이 조회
// 회원 생성 및 저장
Member member = new Member(username, age);
memberRepository.save(member); // 회원 저장
// 모델에 데이터 보관
request.setAttribute("member", member);
// 뷰로 이동
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
👉🏻 회원 목록 컨트롤러
📁 web/frontcontroller/v1/controller/MemberListControllerV1
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);
}
}
프론트 컨트롤러
프론트 컨트롤러를 만들어 위에서 만들어준 3개의 컨트롤러들을 호출할 수 있도록 한다.
📁 web/frontcontroller/v1/FrontControllerServletV1
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
private Map<String, ControllerV1> controllerMap = new HashMap<>();
public FrontControllerServletV1() {
// 매핑 정보를 담음 (해당 url이 오면 매핑 정보를 통해 각 컨트롤러를 호출)
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(); // 클라이언트가 요청한 URI를 그대로 받음
ControllerV1 controller = controllerMap.get(requestURI); // 받은 URI 정보를 가지고 매핑되는 컨트롤러를 찾음
// 매핑되는 컨트롤러가 없으면
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404 오류
return;
}
// 매핑되는 컨트롤러가 있으면
controller.process(request, response); // 호출
}
}
- urlPatterns = "/front-controller/v1/*"
- 기존에 작성하던 url과는 조금 다르게 명확한 경로가 아닌 *을 넣어줬다.
- 이는 상위 경로(/front-controller/v1)를 포함하는 하위 경로의 요청을 모두 이 서블릿에서 받겠다는 의미이다.
- 예) /front-controller/v1, /front-controller/v1/a, /front-controller/v1/a/b
- Map<String, ControllerV1> controllerMap
- key : 매핑 URL
- value : 호출될 컨트롤러
- controllerMap.put()을 사용해 매핑 정보를 저장한다.
- service()
- getRequestURI() 메소드로 요청 URI를 얻은 뒤, 실제 호출할 컨트롤러를 controllerMap에서 찾는다.
- 만약 컨트롤러가 없다면 404(SC_NOT_FOUND) 상태 코드를 반환한다.
- 만약 컨트롤러가 있다면 controller.process(request, response)를 호출해 해당 컨트롤러를 실행한다.
- 뷰(JSP)는 이전 MVC 패턴에서 사용했던 것을 그대로 사용한다. (상대경로 지정)
✅ 동작 확인
- 회원 등록 폼 : http://localhost:8080/front-controller/v1/members/new-form
- 회원 저장 : http://localhost:8080/front-controller/v1/members/save
- 회원 목록 : http://localhost:8080/front-controller/v1/members
v2 - View 분리
프론트 컨트롤러 패턴을 도입해 공통 처리를 모을 수 있었다.
하지만 아직 컨트롤러에서 뷰로 이동하는 코드(forward)가 중복되고 있다.
String viewPath = "URL";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
이 부분을 분리하기 위해 별도로 뷰를 처리하는 객체를 만들어 프론트 컨트롤러에서 사용할 수 있도록 만들어 보자.
이전 v1에서는 컨트롤러에서 JSP로 직접 forward해줬는데, v2에서는 MyView라는 객체를 만들어 이 객체를 통해 JSP를 forward하도록 구조를 변경한다.
- 클라이언트가 HTTP 요청을 보내면 프론트 컨트롤러가 받아 URL 매핑 정보에서 호출할 컨트롤러를 조회해서 호출한다.
- 호출된 컨트롤러는 MyView 객체를 생성하고 반환해준다.
- 프론트 컨트롤러에서 반환된 MyView의 render()를 호출한다.
- 호출된 MyView에서 JSP forward를 해서 클라이언트에 응답을 보낸다.
MyView 만들기
📁 web/frontcontroller/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);
}
}
- MyView는 전달된 뷰 경로(JSP, viewPath)를 받아 생성된다.
- render() 메소드 호출시 request, response 인자로 JSP forward를 한다.
컨트롤러 인터페이스
📁 web/frontcontroller/v2/ControllerV2
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
- MyView를 반환한다.
회원 등록, 저장, 목록 컨트롤러
이제 각 컨트롤러는 복잡한 dispatcher.forward()를 직접 호출하지 않아도 된다.
단순히 뷰 경로를 전달하며 MyView 객체를 생성해 반환하면 된다.
👉🏻 회원 등록 폼 컨트롤러
📁 web/frontcontroller/v2/controller/MemberFormControllerV2
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");
}
}
👉🏻 회원 저장 컨트롤러
📁 web/frontcontroller/v2/controller/MemberSaveControllerV2
public class MemberSaveControllerV2 implements ControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// HTTP 요청 받기
String username = request.getParameter("username"); // HTTP 요청 데이터를 받아 이름 조회
int age = Integer.parseInt(request.getParameter("age")); // HTTP 요청 데이터를 받아 나이 조회
// 회원 생성 및 저장
Member member = new Member(username, age);
memberRepository.save(member); // 회원 저장
// 모델에 데이터 보관
request.setAttribute("member", member);
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
👉🏻 회원 목록 컨트롤러
📁 web/frontcontroller/v2/controller/MemberSaveControllerV2
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");
}
}
프론트 컨트롤러
📁 web/frontcontroller/v2/FrontControllerServletV2
@WebServlet(name = "frontControllerServletV2", urlPatterns = "/front-controller/v2/*")
public class FrontControllerServletV2 extends HttpServlet {
private Map<String, ControllerV2> controllerMap = new HashMap<>();
public FrontControllerServletV2() {
// 매핑 정보를 담음 (해당 url이 오면 매핑 정보를 통해 각 컨트롤러를 호출해줌)
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(); // 클라이언트가 요청한 URI를 그대로 받음
ControllerV2 controller = controllerMap.get(requestURI); // 받은 URI 정보를 가지고 매핑되는 컨트롤러를 찾음
// 매핑되는 컨트롤러가 없으면
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404 오류
return;
}
// 매핑되는 컨트롤러가 있으면 Myview 객체의 render() 호출
MyView view = controller.process(request, response);
view.render(request, response);
}
}
- /front-controller/v2/members/new-form 요청이 오면 FrontControllerServletV2 서블릿이 호출된다.
- 요청 URI를 받아 controllerMap에서 실제 호출할 컨트롤러를 찾고,
- 호출할 컨트롤러가 없으면
- 404 반환
- 호출할 컨트롤러가 있으면
- 해당 컨트롤러의 process()를 호출하는데, 호출 결과 MyView를 생성해서 반환해준다.
- 반환 받은 MyView 객체를 사용해 render()를 호출하면 forward 로직이 실행되고,
- 실제 뷰(JSP)로 이동해 웹 브라우저에게 응답을 넘겨주게 된다.
- 호출할 컨트롤러가 없으면
✅ 동작 확인
- 회원 등록 : http://localhost:8080/front-controller/v2/members/new-form
- 회원 저장 : http://localhost:8080/front-controller/v2/members/save
- 회원 목록 : http://localhost:8080/front-controller/v2/members
프론트 컨트롤러의 도입으로 MyView 객체의 render()를 호출하는 부분을 모두 일관되게 처리할 수 있게 되었다.
이제 각각의 컨트롤러는 forward 로직이 제대로 수행되고 있는지 신경 쓸 필요 없이 MyView 객체를 생성해 반환만 해주면 된다.
v3 - Model 추가
MyView를 사용해 뷰 이동 로직을 분리해줬다. 하지만 아직 다음과 같은 문제점이 남아있다.
- 서블릿 종속성 제거
지금까지는 모든 컨트롤러에서 HttpServletRequest, HttpServletRespons를 인자 값으로 전달해줬지만, 이러한 인자 값들 항상 사용되지는 않았다. 매번 사용되지도 않는 인자값을 전달하는 것은 비효율적이다.
이는 request 객체를 모델로 사용하기 때문에 생기는 일이다.
이 문제를 해결하기 위해 별도의 모델 객체를 만들어 사용할 수 있다.
별도의 모델 객체를 만들어 구현 컨트롤러가 서블릿 기술을 전혀 사용하지 않도록 변경해보자.
- 뷰 이름 중복 제거
컨트롤러에서 viewPath를 지정해줄 때 뷰의 물리이름이 사용되었기 때문에 계속해서 중복이 있는 것을 확인 할 수있다.
- /WEB-INF/views/new-form.jsp → new-form (논리이름)
- /WEB-INF/views/save-result.jsp → save-result (논리이름)
- /WEB-INF/views/members.jsp → members (논리 이름)
이러한 중복을 제거하기 위해 컨트롤러가 뷰의 논리이름 반환하고, 실제 물리이름은 프론트 컨트롤러에서 처리하도록 변경해보자.
이전 v2에서는 프론트 컨트롤러에서 바로 MyView 객체의 render()를 호출해 JSP forward 해줬다면, v3에서는 그 전에 뷰 리졸버를 호출해 MyView를 반환하도록 변경해준다.
이때 컨트롤러는 MyView가 아닌 ModelView 객체를 반환하도록 변경해준다.
- HTTP 요청이 오면 프론트 컨트롤러는 매핑 정보에서 실제 호출할 컨트롤러를 조회해 호출한다.
- 호출된 컨트롤러는 뷰의 논리이름을 가지고 ModelView 객체를 생성하고 반환해준다.
- 프론트 컨트롤러는 받환 받은 ModelView의 뷰 논리이름을 전달하며 뷰 리졸버를 호출한다.
- 호출된 뷰 리졸버는 전달받은 뷰의 논리이름을 실제 물리이름으로 변경해 MyView 객체를 생성한 뒤 반환해준다.
- 프론트 컨트롤러는 반환 받은 MyView 객체를 사용해 render()를 호출해 JSP를 실행하고 응답을 보낸다.
ModelView 만들기
지금까지 컨트롤러에서는 모델로 서블릿의 HttpServletRequest를 사용했다. setAttribute()를 통해 모델에 데이터를 저장하고 getAttribute()를 통해 뷰에 전달하였다.
이런 서블릿의 종속성을 제거하기 위해 모델을 직접 만들고, 뷰 이름까지 전달하는 ModelView 객체를 만들어보자.
📁 web/frontcontroller/ModelView
@Getter
@Setter
public class ModelView {
private String viewName; // 뷰 이름(논리 이름)
private Map<String, Object> model = new HashMap<>(); // 모델
public ModelView(String viewName) {
this.viewName = viewName;
}
}
- viewName
- 뷰의 논리 이름 저장
- Map<String, Object> model
- 모델을 Map으로 구현
- 컨트롤러에서 뷰에 필요한 데이터를 key, value로 저장해주면 된다.
컨트롤러 인터페이스
📁 web/frontcontroller/v3/ControllerV3
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap); // ModelView 반환
}
- 서블릿 기술을 전혀 사용하지 않는다. (HttpServletRequest, HttpServletResponse을 인자값으로 받지 않음)
- 따라서 구현이 매우 단순해지며, 테스트 코드 작성이 용이해진다.
- HttpServletRequest에서 필요한 정보(파라미터)는 프론트 컨트롤러에서 paramMap에 담아 process()를 호출하면 된다.
- 뷰 이름(논리 이름), Model 데이터를 포함하는 ModelView 객체를 반환해준다.
회원 등록, 저장, 목록 컨트롤러
👉🏻 회원 등록 폼 컨트롤러
📁 web/frontcontroller/v3/controller/MemberFormControllerV3
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form"); // 뷰의 논리 이름을 사용해 모델 뷰 생성 및 반환
}
}
- modelView를 생성할 때 뷰의 논리이름을 지정해준다.
- 실제 물리이름은 프론트 컨트롤러에서 뷰 리졸버에 의해 처리된다.
👉🏻 회원 저장 컨트롤러
📁 web/frontcontroller/v3/controller/MemberSaveControllerV3
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);
// 뷰의 논리 이름을 사용해 모델 뷰 생성 및 반환
ModelView mv = new ModelView("save-result");
mv.getModel().put("member", member);
return mv;
}
}
- paramMap.get("username") : 전달 받은 paramMap에서 필요한 요청 파라미터를 조회해서 사용한다.
- mv.getModel().put("member", member) : 뷰에 필요한 member 객체를 ModelView 객체의 model 영역에 담는다.
👉🏻 회원 목록 조회 컨트롤러
📁 web/frontcontroller/v3/controller/MemberListControllerV3
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;
}
}
프론트 컨트롤러
📁 web/frontcontroller/v3/FrontControllerServletV3
@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
private Map<String, ControllerV3> controllerMap = new HashMap<>();
public FrontControllerServletV3() {
// 매핑 정보를 담음 (해당 url이 오면 매핑 정보를 통해 각 컨트롤러를 호출해줌)
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(); // 클라이언트가 요청한 URI를 그대로 받음
ControllerV3 controller = controllerMap.get(requestURI); // 받은 URI 정보를 가지고 매핑되는 컨트롤러를 찾음
// 매핑되는 컨트롤러가 없으면
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404 오류
return;
}
// 매핑되는 컨트롤러가 있으면
Map<String, String> paramMap = createParamMap(request); // paramMap 생성
ModelView mv = controller.process(paramMap); // 컨트롤러 호출 -> 뷰 논리이름을 가진 모델 뷰가 반환됨
String viewName = mv.getViewName(); // 뷰 논리이름을 꺼냄(new-form)
MyView view = viewResolver(viewName); // 뷰 리졸버를 호출 -> MyView 객체가 반환됨
view.render(mv.getModel(), request, response); // 모델 정보를 넘기며 render 호출
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp"); // 뷰 논리이름을 가지고 실제 물리 이름을 만들어 MyView 객체를 반환
}
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames() // request에서 모든 파라미터의 이름을 가져옴
.asIterator() // 하나씩 꺼내와서
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName))); // paramMap에 저장
return paramMap;
}
}
- createParamMap()
- HttpServletRequest에서 모든 파라미터 정보를 꺼내 paramMap에 저장한다.
- controller.process(paramMap)
- paramMap을 전달하며 컨트롤러를 호출하면 뷰 논리이름을 가진 ModelView를 반환한다.
- viewResolver(viewName)
- 뷰 논리이름을 전달하며 뷰리졸버를 호출하면 실제 뷰 물리이름으로 변경해준다.
- 뷰의 실제 물리이름을 가진 MyView 객체를 반환한다.
- 뷰 논리이름 : members
- 뷰 물리이름 : /WEB-INF/views/members.jsp
- view.render(mv.getModel(), request, response)
- 뷰 객체를 통해 HTML 화면을 렌더링 한다.
- 뷰 객체의 render()는 ModelView에 저장된 모델 정보도 함께 전달 된다.
뷰 리졸버를 분리해주었기 때문에 만약 물리적인 경로 이름이 변경되었을 때 컨트롤러 코드는 전혀 건드리지 않고 뷰 리졸버만 변경해주면 된다는 장점이 있다.
MyView 만들기
📁 web/frontcontroller/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); // 모델에 있는 데이터를 모두 꺼내 request에 담음
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response); // JSP 실행
}
private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
model.forEach((key, value) -> request.setAttribute(key, value));
}
}
- JSP는 request.getAttribute()로 데이터를 조회하기 때문에 모델 정보에서 데이터를 꺼내 reqeust에 담아둬야 한다.
- JSP로 forward해 JSP를 렌더링 한다.
✅ 동작 확인
- 회원 등록 : http://localhost:8080/front-controller/v3/members/new-form
- 회원 저장 : http://localhost:8080/front-controller/v3/members/save
- 회원 목록 : http://localhost:8080/front-controller/v3/members
v4 - 단순, 실용적인 컨트롤러
지금까지 만든 v3 컨트롤러는 대부분의 한계를 극복하고 중복도 제거되어 잘 설계된 컨트롤러라 할 수 있다.
하지만 개발자 입장에서 보면, 매번 ModelView 객체를 생성하고 반환하는 과정이 조금은 번거롭다.
좋은 프레임워크는 아키텍처도 중요하지만, 그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다.
따라서 v4에서는 이런 번거로운 과정을 줄여 개발자가 더 편리하게 개발할 수 있도록 리팩토링 해보도록 하자.
프론트 컨트롤러에서 컨트롤러를 호출할 때 model이 추가로 전달되고 , ModelView가 아닌 ViewName를 반환한다.
컨트롤러 인터페이스
📁 web/frontcontroller/v4/ControllerV4
public interface ControllerV4 {
/**
* @param paramMap
* @param model
* @return viewName
*/
String process(Map<String, String> paramMap, Map<String, Object> model);
}
- model 객체가 파라미터로 전달된다.
- 반환타입이 String으로 viewName만 그대로 반환해주면 된다.
- 따로 ModelView 객체를 생성할 필요 없이 뷰 논리이름만 반환해주면 된다.
회원 등록, 저장, 목록 컨트롤러
ControllerV4 인터페이스를 구현하는 3개의 컨트롤러는 이제 모델 객체 생성 없이 뷰의 논리 이름만 반환하면 된다.
👉🏻회원 등록 폼 컨트롤러
📁 web/frontcontroller/v4/controller/ MemberFormControllerV4
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
return "new-form"; // 뷰 논리이름을 반환
}
}
👉🏻 회원 가입 컨트롤러
📁 web/frontcontroller/v4/controller/MemberSaveControllerV4
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);
// 모델에 저장
model.put("member", member);
return "save-result"; // 뷰 논리이름 반환
}
}
- model.put("member", member) : 모델이 파라미터로 전달되기 때문에, 모델을 직접 생성하지 않아도 된다.
👉🏻 회원 목록 컨트롤러
📁 web/frontcontroller/v4/controller/MemberListControllerV4
public class MemberListControllerV4 implements ControllerV4 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
List<Member> members = memberRepository.findAll(); // 회원 목록 조회
model.put("members", members); // 모델에 저장
return "members"; // 뷰 논리이름 반환
}
}
프론트 컨트롤러
📁 web/frontcontroller/v4/FrontController
@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {
private Map<String, ControllerV4> controllerMap = new HashMap<>();
public FrontControllerServletV4() {
// 매핑 정보를 담음 (해당 url이 오면 매핑 정보를 통해 각 컨트롤러를 호출해줌)
controllerMap.put("/front-controller/v4/members/new-form", new MemberFormControllerV4());
controllerMap.put("/front-controller/v4/members/save", new MemberSaveControllerV4());
controllerMap.put("/front-controller/v4/members", new MemberListControllerV4());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("FrontControllerServletV4.service"); // 정상 동작 확인
String requestURI = request.getRequestURI(); // 클라이언트가 요청한 URI를 그대로 받음
ControllerV4 controller = controllerMap.get(requestURI); // 받은 URI 정보를 가지고 매핑되는 컨트롤러를 찾음
// 매핑되는 컨트롤러가 없으면
if (controller == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404 오류
return;
}
// 매핑되는 컨트롤러가 있으면
Map<String, String> paramMap = createParamMap(request); // paramMap 생성
Map<String, Object> model = new HashMap<>(); // 모델 생성
String viewName = controller.process(paramMap, model); // 컨트롤러 호출 -> 뷰 논리이름 반환
MyView view = viewResolver(viewName); // 뷰 리졸버를 호출 -> MyView 객체가 반환됨
view.render(model, request, response); // 모델 정보를 넘기며 render 호출
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp"); // 뷰 논리이름을 가지고 실제 물리 이름을 만들어 MyView 객체를 반환
}
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames() // request에서 모든 파라미터의 이름을 가져옴
.asIterator() // 하나씩 꺼내와서
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName))); // paramMap에 저장
return paramMap;
}
}
- Map<String, Object> model = new HashMap<>()
- 모델 객체를 프론트 컨트롤러에서 생성해 호출 할 컨트롤러에게 넘겨 준다.
- 컨트롤러에서 모델 객체에 값을 담으면 model에 저장된다.
- viewResolver(viewName)
- 컨트롤러가 직접 뷰의 논리이름을 반환하기 때문에 이 값을 사용해 뷰 리졸버를 호출하여 실제 물리이름을 얻을 수 있다.
✅ 동작 확인
- 회원 등록 : http://localhost:8080/front-controller/v4/members/new-form
- 회원 저장 : http://localhost:8080/front-controller/v4/members/save
- 회원 목록 : http://localhost:8080/front-controller/v4/members
v5 - 유연한 컨트롤러
어댑터 패턴
지금까지 컨트롤러를 v1 ~ v4까지 다양한 방법으로 만들어 회원관리 웹 어플리케이션을 구현해봤다.
지금까지 개발한 방식으로는 프론트 컨트롤러가 한가지 방식의 컨트롤러 인터페이스만 사용할 수 있다.
private Map<String, ControllerV3> controllerMap = new HashMap<>(); // ControllerV3이라고 지정됨
또한 ControllerV3과 ControllerV4는 완전히 다른 인터페이스이기 때문에 호환이 불가능하다.
마치 v3는 110v이고, v4는 220v 콘센트 같은 것이다.
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap);
}
public interface ControllerV4 {
String process(Map<String, String> paramMap, Map<String, Object> model);
}
이럴때 사용하는 것이 바로 어댑터이다 !
어댑터 패턴을 사용해 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경해보자.
- 클라이언트가 HTTP 요청을 보내면 프론트 컨트롤러는 핸들러 매핑 정보에서 핸들러를 조회한다.
- 핸들러 어댑터 목록에서 핸들러를 처리할 수 있는 핸들러 어댑터를 조회한다.
- 프론트 컨트롤러가 핸들러를 직접 호출할 수 없으며, 어댑터를 통해서 호출한다.
- 핸들러 어댑터가 핸들러를 호출하면 핸들러는 내부적으로 실제 컨트롤러를 호출해 ModelView를 반환한다.
- 반환받은 ModelView를 사용해 뷰리졸버를 호출해 실제 물리 뷰를 얻은 다음 render()를 호출한다.
💡 핸들러 어댑터
- 중간에 핸들러를 처리해주는 어댑터 역할을 한다.
- 핸들러 어댑터를 사용해 다양한 종류의 핸들러를 호출할 수 있다.
💡 핸들러
- 컨트롤러이며, 컨트롤러의 이름을 더 넓은 범위인 핸들러로 변경했다.
- 변경한 이유는 어댑터가 있기 때문에 컨트롤러의 개념 뿐만 아니라 해당 종류의 어댑터만 있다면 어떤 핸들러든 모두 처리할 수 있기 때문이다.
어댑터 만들기
📁 web/frontconrtroller/v5/MyHandlerAdapter
public interface MyHandlerAdapter {
boolean supports(Object handler);
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}
- supports(Object handler)
- 핸들러는 컨트롤러를 의미한다.
- 어댑터가 해당 핸들러(컨트롤러)를 처리할 수 있는지 확인하는 메소드이다.
- handle(HttpServletRequest request, HttpServletResponse response, Object handler)
- 어댑터는 실제 컨트롤러를 호출하고, 그 결과 ModelView를 반환해야 한다.
- 실제 컨트롤러가 ModelView를 반환하지 못하면, 어댑터가 직접 생성해서라도 반환해야 한다.
- 이전에는 프론트 컨트롤러가 직접 실제 컨트롤러를 호출했지만 이제는 어댑터를 통해 호출할 수 있다.
👉🏻 ControllerV3 타입의 컨트롤러를 지원하는 어댑터
📁 web/frontcontroller/v5/adapter/ControllerV3HandlerAdapter
public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof ControllerV3); // 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; // ModelView를 반환해줌
}
private Map<String, String> createParamMap(HttpServletRequest request) {
Map<String, String> paramMap = new HashMap<>();
request.getParameterNames() // request에서 모든 파라미터의 이름을 가져옴
.asIterator() // 하나씩 꺼내와서
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName))); // paramMap에 저장
return paramMap;
}
}
- return (handler instanceof ControllerV3)
- 핸들러(컨트롤러)가 ControllerV3의 구현체인지 판단해 true/false를 반환한다.
- true일 경우 ControllerV3를 지원할 수 있는 어댑터임을 뜻한다.
- ControllerV3 controller = (ControllerV3) handler
- 인자값으로 받은 핸들러는 Object 타입이기 때문에 ControllerV3 타입으로 형변환을 해줘야 한다.
(support 메소들를 통해 ControllerV3의 구현체인 것을 검증했기 때문에 형변화 시 문제가 없다.) - ControllerV3은 ModelView를 반환하므로 로직 수행 후 ModelView를 반환하면 된다.
- 인자값으로 받은 핸들러는 Object 타입이기 때문에 ControllerV3 타입으로 형변환을 해줘야 한다.
프론트 컨트롤러
📁 web/frontcontroller/v5/FrontControllerServletV5
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private Map<String, Object> handlerMappingMap = new HashMap<>(); // 매핑 정보가 담긴 맵 생성(여러 컨트롤러 호환을 위해 Object 사용)
private 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());
}
private void initHandlerAdapters() {
handlerAdapters.add(new ControllerV3HandlerAdapter());
}
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Object handler = getHandler(request); // 요청 정보를 가지고 핸들러를 찾음
// 매핑되는 핸들러가 없으면
if (handler == null) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND); // 404 오류
return;
}
// 매핑되는 핸들러가 있으면
MyHandlerAdapter adapter = getHandlerAdapter(handler); // 해당 핸들러를 지원하는 어댑터를 찾음
ModelView mv = adapter.handle(request, response, handler); // 어댑터를 통해 핸들러를 호출
String viewName = mv.getViewName(); // 뷰 논리이름을 꺼냄(new-form)
MyView view = viewResolver(viewName); // 뷰 리졸버를 호출 -> MyView 객체가 반환됨
view.render(mv.getModel(), request, response); // 모델 정보를 넘기며 render 호출
}
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(); // 클라이언트가 요청한 URI를 그대로 받음
return handlerMappingMap.get(requestURI); // 요청 URI에 매핑되는 핸들러를 찾아서 반환
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp"); // 뷰 논리이름을 가지고 실제 물리 이름을 만들어 MyView 객체를 반환
}
}
- Map<String, Object> handlerMappingMap = new HashMap<>()
- urlPattern에 매핑되는 핸들러(컨트롤러)를 저장하는 Map
- value 타입이 Object인 이유는 저장될 핸들러(컨트롤러)가 어떤 타입(V1~V4)인지 모르기 때문이다.
- List<MyHandlerAdapter> handlerAdapters = new ArrayList<>()
- 사용할 어댑터를 저장하는 List
- Object handler = getHandler(request)
- handlerMappingMap에서 요청 URI에 매핑되는 핸들러를 찾아 반환해준다.
- MyHandlerAdapter adapter = getHandlerAdapter(handler)
- handlerAdapters에서 해당 핸들러를 처리할 수 있는 어댑터를 찾아 반환해준다.
- 만약 지원하는 어댑터가 존재하지 않다면 예외가 발생한다.
- ModelView mv = adapter.handle(request, response, handler)
- 어댑터의 handle() 메소드를 호출해 실제 어댑터(ControllerV3HandlerAdapter)를 호출한다.
- 호출된 실제 어댑터는 핸들러(컨트롤러)를 호출한다.
- 내부 비즈니스 로직을 실행한 뒤 ModelView를 반환한다.
✅ 동작 확인
- 회원 등록 : http://localhost:8080/front-controller/v5/v3/members/new-form
- 회원 저장 : http://localhost:8080/front-controller/v5/v3/members/new-form
- 회원 목록 : http://localhost:8080/front-controller/v5/v3/members/new-form
ControllerV4 추가
ControllerV4도 호환되도록 추가해보자.
프론트 컨트롤러에 핸들러 매핑 정보, 어댑터 추가
👉🏻 FrontControllerServletV5
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()); // V4 추가
}
- handlerMappingMap에 ControllerV4를 구현하는 컨트롤러를 추가한다.
- handlerAdapters에 해당 컨트롤러를 처리할 수 있는 어댑터인 ControllerV4HandlerAdapter를 추가한다.
핸들러 어댑터 만들기 - ControllerV4 타입의 지원
📁 web/frontcontroller/v5/adapter/ControllerV4HandlerAdapter
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() // request에서 모든 파라미터의 이름을 가져옴
.asIterator() // 하나씩 꺼내와서
.forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName))); // paramMap에 저장
return paramMap;
}
}
- return (handler instanceof ControllerV4)
- 핸들러가 ControllerV4의 구현체일 경우 처리하는 어댑터임을 뜻한다.
- String viewName = controller.process(paramMap, model)
ModelView mv = new ModelView(viewName)
mv.setModel(model)- 실제 컨트롤러를 호출하면 ControllerV4 타입이므로 viewName(뷰 논리이름)을 반환해준다.
- 어댑터는 이것을 ModelView로 만들어 형식에 맞춰 반환해준다. (마치 110v 콘센트를 220v 콘센트로 변경하듯이 어댑터 역할을 해줌)
✅ 동작 확인
- 회원 등록 : http://localhost:8080/front-controller/v5/v4/members/new-form
- 회원 저장 : http://localhost:8080/front-controller/v5/v4/members/save
- 회원 목록 : http://localhost:8080/front-controller/v5/v4/members
총 정리
지금까지 v1 ~ v5로 점진적으로 프레임워크를 발전시켜왔다.
- v1 : 프론트 컨트롤러 도입
- 기존 구조를 최대한 유지하면서 프론트 컨트롤러 도입
- v2 : View 분리
- 단순 반복되는 뷰 로직을 MyView로 분리
- v3 : Model 추가
- 서블릿 종속성 제거 - 모델로 HttpServletRequest가 아닌 ModelView를 만들어사용
- 뷰 이름 중복 제거 - viewResolver를 사용해 뷰 논리이름을 실제 물리이름으로 조합
- v4 : 단순하고 실용적인 컨트롤러
- v3와 거의 비슷
- 구현 입장에서 ModelView를 직접 생성해서 반환하지 않도록 편리한 인터페이스 제공
- v5 : 유연한 컨트롤러
- 어댑터 패턴 도입
- 어댑터를 추가해 프레임워크를 유연하고 확장성 있게 설계
'🌱 Spring > Web MVC' 카테고리의 다른 글
스프링 MVC 웹 페이지 만들기 (0) | 2022.03.22 |
---|---|
로깅 (0) | 2022.03.18 |
스프링 MVC 기본 기능 (0) | 2022.03.18 |
스프링 MVC 구조 이해하기 (0) | 2022.03.17 |
서블릿, JSP, MVC 패턴으로 웹 애플리케이션 개발하기 (0) | 2022.03.15 |
서블릿 (0) | 2022.03.14 |
자바 웹 기술의 역사 (2) | 2022.03.11 |
웹 애플리케이션의 이해 (0) | 2022.03.11 |