스프링 MVC 1편 백엔드 웹 개발 핵심 기술의 복습을 위한 글이며,
이 글에 나오는 모든 사진과 코드의 저작권은 김영한 강사님께 있습니다.
1. 프론트 컨트롤러
프론트 컨트롤러 도입 전
프론트 컨트롤러가 도입 되기 전에는 한 개의 URL에 한 개의 서블릿이 매핑되는 구조였다. 이러한 구조는 매 서블릿을 구현할 때 마다 아래와 같은 중복되는 소스가 발생하였다.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
프론트 컨트롤러 도입 후
프론트 컨트롤러 도입 전에는 공통으로 발생되는 중복 소스들이 각 컨트롤러에 포함되었다. 프론트 컨트롤러 패턴이란 이러한 중복되는 소스들을 프론트 컨트롤러에서 처리하고 서로 각기 다른 코드만 각 컨트롤러에서 처리하자는 개념이다.
이렇게 프론트 컨트롤러 패턴을 도입함으로써, 프론트 컨트롤러 하나로 클라이언트의 요청을 받고, 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출할 수 있게 된다. 즉, 모든 요청의 입구를 하나로 통일시키면서 공통 처리 기능이 가능해지고, 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다.
2. 프론트 컨트롤러 - V1
단계적으로 프론트 컨트롤러를 도입해보겠다. 프론트 컨트롤러의 동작 구조는 아래와 같다.
- 요청받은 URI 주소를 토대로 프론트 컨트롤러에서 매핑된 정보(컨트롤러)를 반환.
- 해당 컨트롤러 로직 실행
- 컨트롤러 수행 후 JSP로 forwading
이제, 위 순서에 맞게 프론트 컨트롤러를 포함한 컨트롤러를 작성해보자. 우선, 서블릿 모양의 컨트롤러 인터페이스를 도입한다. 이 인터페이스는 컨트롤러들의 공통 항목을 메소드로 뽑아낼 수 있는 기능을 가지고 있다. 그리고 각 컨트롤러가 ControllerV1을 상속받게 함으로써 다형성을 지켜 컨트롤러를 작성하게 된다.
ControllerV1
process라는 메소드를 만들고, request, response를 받아오도록 한다.
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
예외 처리도 동일하게 처리한다. 위 인터페이스는 다음과 로직의 일관성을 유지하기 위해 작성한 것이다.
- 일단 클라이언트들의 요청을 받아서 request, response로 담고, 이 process 메소드로 오게 만든다.
- 다형성을 이용해서 ControllerV1을 상속받는 각 컨트롤러에서 process의 메소드를 작성한다.
- 공통으로 request, response를 받아오고 예외처리하는 것은 동일하기 때문에 인터페이스의 메소드 형태로 만들어 놓는 것이다.
- 그러나, request, response를 받아와서 처리하는 로직 코드나, view(jsp)로 넘겨주는 주소는 각 컨트롤러마다 다르다.
- 이것들을 각 컨트롤러에서 오버라이드하여 다형성을 구현하게 된다.
MemberFormControllerV1 - 회원 등록 컨트롤러
코드의 내용은 이전에 진행한 MVC 패턴과 동일한 방식으로 작성하여 로직을 실행하고, View(Jsp)쪽으로 이동될 수 있게 만든다. 앞서 말한 것과 같이 process 메소드를 오버라이드해서 각 컨트롤러에서 원하는 방식으로 로직을 진행할 수 있는 것이다.
// 회원 등록 컨트롤러
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 - 회원 저장 컨트롤러
// 회원 저장 컨트롤러
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);
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
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);
}
}
FrontControllerServletV1 - 프론트 컨트롤러
이 프론트 컨트롤러는 Http 요청을 받아들이고, 분석하여 각 컨트롤러(Form, Save, List)로 보내는 역할을 한다. urlPatterns부분에서 /*로 지정한 부분을 볼 수 있는데, 이렇게 지정하면 /front-controller/v1/ 을 포함한 그 하위 주소에 대한 모든 요청을 일단 이 프론트 컨트롤러에서 처리하도록 해준다. 그래서 Http 요청 URI에 따라 각 컨트롤러로 요청 정보를 전달해 줄 수 있게 되는 것이다.
@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);
}
}
3. View 분리 - V2
V1에서 컨트롤러 -> 뷰로 이동하는 부분에 아래와 같은 중복 코드들이 있다.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
위 부분을 공통화하여 구조적으로 변경해줄 수 있다. 그리고 공통화된 객체를 통해서 View쪽으로 접근하게 하여, Controller에서 직접 View로 이동하는 것이 아니라 프론트 컨트롤러에서 View로 이동하게 되어 View를 분리할 수 있게 한다.
ControllerV2
process 메서드의 반환 타입을 MyView로 설정하여 각 컨트롤러가 MyView 객체를 프론트 컨트롤러에게 전달해줄 수 있도록 만든다.
public interface ControllerV2 {
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
MyView
반환될 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);
}
}
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");
}
}
MemberSaveControllerV2
// 회원 저장
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);
request.setAttribute("member", member);
return new MyView("/WEB-INF/views/save-result.jsp");
}
}
MemberListControllerV2
// 회원 조회
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");
}
}
FrontControllerServletV2
받아온 MyView 객체를 통해서 render()
메소드를 실행하여 각 컨트롤러에 알맞는 View로 이동하게 된다.
@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);
}
}
4. Model 추가 - V3
HttpServletRequest와 HttpServletResponse는 필요가 없는 파라미터인데도 불구하고, 서블릿 스펙상 항상 포함되었다. 그리고, Model도 request.setAttribute()
를 통해 데이터를 저장하고 뷰에 전달했다. 이제 서블릿 기술에 대한 종속성을 일부 제거하고, Model 이라는 객체를 통해 request의 속성들을 받아온다.
그리고, viewPath에서 공통으로 작성된 부분들도 viewResolver라는 객체로 공통화하여 코드 변경이 용이해질 수 있도록 한다.
구조는 V2와 동일하나, ModelView가 추가되었다. Servlet 대신 이 ModelView를 통해서 프론트 컨트롤러와 각 컨트롤러가 소통한다는 것을 이해해야 한다.
ModelView
MoelView에서는 viewName이라는 문자열 객체와 model이라는 Map 객체를 정의한다. 각 컨트롤러에서 ModelView를 생성할 때, 이동하고자 하는(forward) URI 경로를 넣기 위해 viewName(=viewPath)을 생성자의 파라미터로 넣어준다. 그리고, 각 컨트롤러에서 로직을 수행한 객체를 담을 수 있도록, Map 타입으로 model 객체를 만들어 놓는다.
@Getter
@Setter
public class ModelView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelView(String viewName) {
this.viewName = viewName;
}
}
ControllerV3
각 컨트롤러가 ModelView에 지정된 요청 정보인 paramMap을 이용할 수 있도록 파라미터로 지정하고, ModelView 타입을 반환하도록 해준다. V2와 비교해보면, 서블릿에 대한 의존성이 제거되었다.
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap);
}
MemberFormControllerV3
// 회원 등록 폼
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form");
}
}
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;
}
}
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;
}
}
FrontControllerV3
각 컨트롤러에서 받아온 요청 파라미터 정보를 createParamMap 메서드에서 하나씩 모두 꺼내어 myView에 전달한다. 그리고 컨트롤러에서 받아온 viewName 정보를 기반으로 실제 View(.jsp)로 이동할 절대 경로를 생성하고, 경로를 MyView객체에 전달한다. 마지막으로 이 객체의 render()
메서드를 실행하여 dispatcher.forward()
처리를 해주게 된다.
@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");
}
}
viewResolver()
는 컨트롤러가 반환한 논리 뷰 이름을 실제 물리 뷰 경로로 변경한다.
- 논리 뷰 이름 : members
- 물리 뷰 경로 : /WEB-INF/views/members.jsp
MyView 리팩토링
model 객체에서 요청 파라미터 및 viewName 정보를 실제 View쪽으로 넘겨주는 작업을 한다. model 객체의 파라미터를 하나씩 requestAttribute로 지정해준다. 그리고 viewName 정보를 viewPath로 지정하여 dispatcher.forward로 어떤 View로 이동할지 지정해준다. 이렇게 되면 마지막 View에서는 viewPath가 적용된 .jsp 파일로 이동하고, 사용자의 요청 정보를 request에 들어있는 attribute 정보에서 뽑아내서 렌더링할 수 있다.
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));
}
}
4.1 V3 정리
- 클라이언트의 요청이 FrontController로 온다(urlPatterns를 지정해놨기 때문)
- 요청 URI 정보를 추출하여 알맞는 Controller로 매핑한다.
- 요청의 파라미터 정보를 추출하여 각 Controller로 보내고, Controller에서 save등의 필요한 로직을 처리한다.
- Controller에서 처리된 정보와 viewName(viewPath, 즉, 이동하고자 하는 URI)을 ModelView객체 형태로 받아온다.
- ModelView.viewName을 기반으로 viewResolver로 View의 경로값을 만든다. 그리고 컨트롤러에서 로직이 처리된 후의 파라미터 정보인 ModelView.model를 Myview.render() 메서드에 전달한다.
- Myview에서는 FrontController에서 받아온 model 정보를 추출하여 request의 attribute로 담고, ViewPath 정보를 통해 알맞는 View(.jsp) 경로로 정보들을 보낸다.
감사합니다 :)
'Backend > Spring - MVC' 카테고리의 다른 글
Spring MVC #5. 스프링 MVC 구조 이해 (0) | 2023.02.09 |
---|---|
Spring MVC #4. 프레임워크 만들기 (2) (0) | 2023.02.07 |
Spring MVC #3. 서블릿, JSP, MVC 패턴 (0) | 2023.02.05 |
Spring MVC #2. 서블릿 (0) | 2023.02.04 |
Spring MVC #1. 웹 애플리케이션의 이해 (0) | 2023.02.04 |