지난 시간, 스프링 웹 MVC 이전의 개발을 1줄 요약하면 다음과 같다.
동적 html 처리 -> 서블릿 등장 -> 서블릿은 Tcp 연결, 멀티쓰레드 등 문제해결 but 자바로 html 생성이 어려움 -> JSP -> 편하지만(공감 못함) 너무 많은 로직이 담겨있어 유지보수 에바 -> 둘 합친 MVC 패턴 -> 훌륭한데 중복이 너무 많음(공통처리가 어려움)
그래서! FrontController라는 것이 나왔다.
이제 FrontController가 어떻게 도입되고 편리를 제공했는지 잘 보자.
FrontController는 말그대로 앞단에 컨트롤러를 두는 것이다.
나중에 등장하겠지만 스프링 웹 MVC의 DispatcherServlet과 역할이 같다.
스프링 복습 포스팅 컨셉상, 그림을 최대한 빼려고 했는데 이번 파트는 그림이 없으면 이해하기가 어려워서 이번에만 그림을 넣겠다.
코드도 많긴한데, 다 넣지는 않고 버전이 바뀔때 중요한 변경사항이 생길때만 넣는걸로!
V1

public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
//회원 폼 컨트롤러
//회원 저장 컨트롤러
//회원 리스트 컨트롤러..
//프론트 컨트롤러는 여기 의존하며 편하게 호출
}
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 requestDispatcher = request.getRequestDispatcher(viewPath);
requestDispatcher.forward(request,response);
}
}
public class MemberListControllerV1 implements ControllerV1 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@Override
public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
System.out.println("MvcMemberListServlet.service");
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);
}
}
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);
System.out.println("member = " + member);
memberRepository.save(member);
request.setAttribute("member", member);
String viewPath = "/WEB-INF/views/save-result.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
@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 req, HttpServletResponse resp) throws ServletException, IOException {
System.out.println("FrontControllerServletV1.service");
String requestURI = req.getRequestURI();
//인터페이스로 꺼내면 일관성 있는 처리가 가능
ControllerV1 controller = controllerMap.get(requestURI);
if(controller==null){
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
//호출
//다형성 이해 필수
controller.process(req, resp);
}
}
전체적으로 일단 인터페이스인 ControllerV1에 의존하고 process를 구현한다.
- 요청 URL을 확인하여 적절한 컨트롤러를 controllerMap에서 찾음
- 찾은 컨트롤러의 process() 메서드를 호출
근데 문제점이 보이는가?
모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있고 깔끔하지 못하다.
그래서 V2패턴이 등장했다.
V2의 컨셉: 별도로 뷰를 처리하는 객체를 만들자!

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 static void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
model.forEach(request::setAttribute);
}
}
@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 req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
ControllerV2 controller = controllerMap.get(requestURI);
if(controller==null){
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
MyView view = controller.process(req, resp);
view.render(req, resp);
}
}
이렇게 뷰를 분리하므로써, 기존의 RequestDispatcher와 forward를 쓰지않고 아래처럼 간단하게 쓸 수 있다.
return new MyView("/WEB-INF/views/new-form.jsp");
오호.... 뷰는 분리하는게 좀 좋아보이네!!
그런데 또, 이 귀차니즘 MAX 개발자들이 불편...한점 을 발견했나보다.
컨트롤러에서 아무 필요없는 Request, Response를 받아야 하나?
아니 안쓰는데, 그러면 우리가 구현하는 컨트롤러가 서블릿 기술을 사용하지 않게 할 수 있을거 같은데?
요청 파라미터 정보는, 자바의 MAP으로 대신 넘기도록 하면
그리고 request객체를 모델로 사용하는 대신에 별도의 모델 객체를 만들어 반환하면
지금 구조에서는 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다.
+ 뷰 이름 중복도 제거
컨트롤러는 뷰의 논리 이름을 반환하고
실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 단순화
이러면 뷰의 폴더 위치가 바뀌어도, 프론트 컨트롤러만 고치면 된다!

여기서는 좀 많은 게 바뀌었다
(3번 주목: 기존 뷰 반환 대신 모델과 뷰를 합친 모델뷰 반환, 4,5번 과정 추가)
다시 정리하면, 컨트롤러를 서블릿에 종속적이지 않게 하고 싶다
지금까지는 request.setAttribute를 통해 데이터를 저장하고 뷰에 전달 했다.
-> 모델을 직접 만들고 뷰 이름까지 전달하는 객체를 만들자!
코드를 봅시다.
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String requestURI = req.getRequestURI();
ControllerV3 controller = controllerMap.get(requestURI);
if(controller==null){
resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
//paramMap
Map<String, String> paramMap = createParamMap(req);
ModelView mv = controller.process(paramMap);
String viewName= mv.getViewName();
//물리 뷰 반환
MyView view = viewResolver(viewName);
view.render(mv.getModel(),req,resp);
}
컨트롤러가 반환한 논리 뷰 이름을 실제 물리 뷰 경로로 변경한다. 그리고 실제 물리 경로가 있는 MyView 객체를 반환
한다.
논리 뷰 이름: `members`
물리 뷰 경로: `/WEB-INF/views/members.jsp`
private static Map<String, String> createParamMap(HttpServletRequest req) {
Map<String,String> paramMap= new HashMap<>();
req.getParameterNames().asIterator()
.forEachRemaining(paramName-> paramMap.put(paramName, req.getParameter(paramName)));
return paramMap;
}
HttpServletRequest에서 파라미터 정보를 꺼내서 Map으로 변환한다. 그리고 해당 Map(`paramMap` )을 컨트롤러
에 전달하면서 호출한다.
public class ModelView {
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는?
ModelView process(Map<String,String> paramMap);
이렇게 바뀐다 (MyView에서 ModelView)
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form");
}
이제 점점 코드가 단순화 된다.
@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;
}
이제 진짜 괜찮은거 같은데, 아직도 인터페이스를 구현하는 개발자의 입장에서 보면, 항상 모델 뷰 객체를 생성하고 반환하는 부분이 조금 번거롭다.
좋은 프레임워크는 아키텍처도 중요하지만, 그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다. 소위 실용성이 있어야 한다. (EJB는 그래서 사라졌다)
V4버전은 뭐가 달라졌을까?
컨트롤러가 ViewName만 반환(이전에는 ModelView)
이번 버전은 인터페이스에 ModelView가 없다. model 객체는 파라미터로 전달되기 때문에 그냥 사용하면 되고, 결과
로 뷰의 이름만 반환해주면 된다.
@Override
public String process(Map<String, String> paramMap, Map<String,Object> model) {
return "new-form";
}
이거다 이거...!
private static MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
어댑터 패턴
우리가 지금 개발한 프론트 컨트롤러는 한가지 방식의 인터페이스만 사용할 수 있다.
지금 보면 Controller V3, V4는 완전히 다른 인터페이스이다.

public interface MyHandlerAdapter {
boolean supports(Object handler);
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException,
IOException;
}
- 컨트롤러(Controller) 핸들러(Handler)**
이전에는 컨트롤러를 직접 매핑해서 사용했다. 그런데 이제는 어댑터를 사용하기 때문에, 컨트롤러 뿐만 아니라 어댑터가 지원하기만 하면, 어떤 것이라도 URL에 매핑해서 사용할 수 있다. 그래서 이름을 컨트롤러에서 더 넒은 범위의 핸들러로 변경했다.
- v1: 프론트 컨트롤러를 도입
- 기존 구조를 최대한 유지하면서 프론트 컨트롤러를 도입
- v2: View 분류
- 단순 반복 되는 뷰 로직 분리
- v3: Model 추가
- 서블릿 종속성 제거 뷰 이름 중복 제거
- v4: 단순하고 실용적인 컨트롤러
- v3와 거의 비슷 구현 입장에서 ModelView를 직접 생성해서 반환하지 않도록 편리한 인터페이스 제공
- v5: 유연한 컨트롤러어댑터를 추가해서 프레임워크를 유연하고 확장성 있게 설계
- 어댑터 도입
여기에 애노테이션을 사용해서 컨트롤러를 더 편리하게 발전시킬 수도 있다. 만약 애노테이션을 사용해서 컨트롤러를 편리하게 사용할 수 있게 하려면 어떻게 해야할까? 바로 애노테이션을 지원하는 어댑터를 추가하면 된다!
라는데....
정리할겸 글을 쓰는데 아직도 이해가 안되는 부분이 있다. 이건 내가 경험해보지 못한 개발이라 더 그런것 같다. 특히 V4부터 어려운데 다시 강의를 들어보고 정리해야겠다.
강의 듣고 포스팅 고치고 나면 본격적으로 스프링 웹 MVC를 공부해봐야지
'Spring_Why?' 카테고리의 다른 글
| [Spring/MVC] 스프링 웹 MVC 마무리 (1) | 2025.05.02 |
|---|---|
| [Spring/MVC] 스프링 MVC 이전의 개발 (0) | 2025.04.16 |
| [Spring/기본] 스프링의 핵심 기능 (2) | 2025.04.11 |
| [Spring/기본] 스프링, 내가 생각한 핵심 개념 (0) | 2025.04.07 |