
프론트 컨트롤러를 도입하기 전에는 먼저 앞에 공통 로직을 깔고, 그 다음에 별도의 컨트롤러 로직을 추가해야 했다. 쉽게 말하면, 이 상황은 입구가 없는 거다. 그냥 다 들어올 수 있기 때문에 공통 로직을 일일이 다 만들어줘야 하는 것이다.
이제 프론트 컨트롤러를 도입한 모습을 살펴보자.

프론트 컨트롤러는 말 그대로 서블릿 하나를 도입한 것에 불과하다. 이 서블릿이 모든 요청을 받아 공통 로직을 처리하고, 이후 각 요청에 맞는 컨트롤러를 찾아 호출한다. 즉, 공통의 관심사를 한 곳에 모아두고, 수문장 역할을 하는 컨트롤러가 앞단에 서 있는 것이다. 따라서 모든 요청은 반드시 프론트 컨트롤러를 거쳐야 하며, 프론트 컨트롤러가 해당 요청에 맞는 컨트롤러를 호출한다. 이때, 개별 컨트롤러들은 더 이상 서블릿을 직접 상속할 필요가 없다.
이제 프론트 컨트롤러 패턴을 도입하면서 구조를 점진적으로 개선시켜보자. 프론트 컨트롤러 패턴을 도입하게 되면 프론트 컨트롤러가 뒤에 있는 컨트롤러들을 매핑해야 한다고 했다.

클라이언트가 HTTP 요청을 보내면, 가장 먼저 프론트 컨트롤러가 요청을 받는다. 프론트 컨트롤러는 미리 등록된 URL 컨트롤러 매핑 정보를 바탕으로, 해당 요청을 처리할 컨트롤러를 찾는다. 이후 해당 컨트롤러를 호출하여 비즈니스 로직을 수행하고, 최종적으로 forward()를 통해 JSP 뷰를 호출한다. JSP는 화면을 렌더링하여 클라이언트에게 응답을 반환하는 구조다.
일단 먼저 Controller를 인터페이스로 뽑자.
package hello.servlet.web.frontcontroller.v1;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public interface ControllerV1 {
void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
서블릿과 모양이 아주 유사하다. 이제 각 컨트롤러들은 이 인터페이스를 구현하면 된다. 프론트 컨트롤러는 이 인터페이스를 호출하기만 함으로써 구현에 전혀 신경쓰지 않고 로직의 일관성을 유지할 수 있다.
이제 이 인터페이스를 구현해보자.
// 회원 등록 컨트롤러 V1
package hello.servlet.web.frontcontroller.v1.controller;
import hello.servlet.web.frontcontroller.v1.ControllerV1;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.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 dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
}
// 회원 저장 컨트롤러 V1
package hello.servlet.web.frontcontroller.v1.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.v1.ControllerV1;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
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);
}
}
// 회원 목록 컨트롤러 V1
package hello.servlet.web.frontcontroller.v1.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.v1.ControllerV1;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
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);
}
}
보다시피 기존 서블릿과 거의 유사하다. 이제 대망의 프론트 컨트롤러를 설계해보도록 하자.
package hello.servlet.web.frontcontroller.v1;
import hello.servlet.web.frontcontroller.v1.controller.MemberFormControllerV1;
import hello.servlet.web.frontcontroller.v1.controller.MemberListControllerV1;
import hello.servlet.web.frontcontroller.v1.controller.MemberSaveControllerV1;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@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);
}
}
여기서 urlPatterns이 중요하다. “/front-controller/v1/*” 로 해야 하는데, "/front-controller/v1/" 로 시작하는 모든 요청이 이 프론트 컨트롤러 서블릿으로 들어온다. 그리고 controllerMap은 Key는 정확한 매핑 URL, Value는 호출할 컨트롤러 인스턴스로 구성되어 있다.
그럼 service()는
현재 요청의 URI를 구한다. → String requestURI = request.getRequestURI()
매핑 테이블에서 해당 URI에 대응하는 컨트롤러를 조회
없으면 404 반환, 있으면 controller.process(request, response) 호출
각 컨트롤러는 내부에서 모델 데이터를 request.setAttribute()로 담고, dispatcher.forward()로 JSP 뷰에 제어를 넘겨 화면을 렌더링한다.
구조에 대한 전체 흐름을 상기해보자.
클라이언트 → 프론트 컨트롤러(공통 진입점)
프론트 컨트롤러 → 매핑 조회 → 해당 컨트롤러 호출
컨트롤러 → (서비스/리포지토리 호출 등) 로직 처리 → request에 모델 담기 → JSP로 forward()
JSP 렌더링 → HTML 응답 반환
이제 뷰를 분리하도록 하자. 말했다시피 컨트롤러에 뷰로 이동하는 부분이 중복되고 있다.
String viewPath = "/WEB-INF/views/new-form.jsp";
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
위 중복되는 부분을 해결하기 위해 별도로 뷰를 처리하는 객체를 만들어야 한다. 아래 V2의 구조를 살펴보자.

클라이언트가 프론트 컨트롤러로 HTTP 요청을 보내면, 프론트 컨트롤러는 매핑 정보를 통해 알맞은 컨트롤러를 찾아 호출한다. 기존에는 각 컨트롤러가 직접 JSP로 forward()했지만, 이제 그 책임을 MyView라는 전용 뷰 객체에 위임할 것이다. 컨트롤러는 뷰 경로(그리고 필요하면 모델 데이터)로 MyView를 생성해 반환하고, 프론트 컨트롤러는 해당 MyView에게 실제 forward()를 수행하도록 맡긴다.
핵심은 하위 컨트롤러가 JSP 포워딩에 관여하지 않아도 된다는 점이다. 컨트롤러는 오직 어떤 뷰를 보여줄지(MyView)와 어떤 데이터(Model)를 줄지만 결정하면 되고, 뷰 렌더링(포워드)은 MyView가 일관되게 처리하도록 하는 것이다.
package hello.servlet.web.frontcontroller.myview;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
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 경로로 내부 forward()를 수행하고, request에 담긴 파라미터(모델 데이터)를 JSP에서 EL(${...})로 사용할 수 있게 한다.
package hello.servlet.web.frontcontroller.v2;
import hello.servlet.web.frontcontroller.myview.MyView;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public interface ControllerV2 {
// MyView를 반환
MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}
기존 ControllerV1 인터페이스의 process() 메서드는 반환 타입이 void 이었기 때문에 컨트롤러에서 알아서 뷰를 띄웠지만, 이제는 그냥 컨트롤러들이 MyView를 생성해서 반환하도록 인터페이스를 설계했다.
이 흐름 그대로 구현체를 구현하도록 하자.
// 회원 등록 컨트롤러 V2
package hello.servlet.web.frontcontroller.v2.controller;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v2.ControllerV2;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
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");
}
}
보다시피 이제 컨트롤러는 dispatcher.forward()를 직접 생성해서 호출하지 않아도 된다. 그냥 MyView 객체를 생성하고 거기에 뷰 이름만 넣고 반환하면 된다. 다른 구현체들도 마찬가지다.
// 회원 저장 컨트롤러 V2
package hello.servlet.web.frontcontroller.v2.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v2.ControllerV2;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
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");
}
}
// 회원 목록 컨트롤러 V2
package hello.servlet.web.frontcontroller.v2.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v2.ControllerV2;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
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");
}
}
이제 프론트 컨트롤러를 설계해보자.
package hello.servlet.web.frontcontroller.v2;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v2.controller.MemberFormControllerV2;
import hello.servlet.web.frontcontroller.v2.controller.MemberListControllerV2;
import hello.servlet.web.frontcontroller.v2.controller.MemberSaveControllerV2;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@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);
}
}
예를 들어, 클라이언트가 회원가입 POST 요청을 보내면, 요청 URL은 "/front-controller/v2/members/save" 가 된다. @WebServlet(urlPatterns = "/front-controller/v2/*") 덕분에 요청은 먼저 FrontControllerServletV2로 들어오고, 프론트 컨트롤러는 URL 컨트롤러 매핑에서 MemberSaveControllerV2를 찾아 process()를 호출한다. MemberSaveControllerV2는 비즈니스 로직(파라미터 조회, 저장 등)을 수행한 뒤, JSP 경로가 담긴 MyView를 반환하고, 프론트 컨트롤러가 view.render()를 호출해 JSP로 forward()하여 응답을 생성하는 것이다.
현재 개별 컨트롤러 구현체들은 request나 response 객체를 직접 사용할 일이 거의 없다. 대부분 뷰를 선택해 반환하기만 한다. 따라서 서블릿 의존성을 걷어내기 위해, 컨트롤러가 필요로 하는 요청 파라미터만 Map 형태로 전달받도록 바꿀 것이다. 이렇게 하면 컨트롤러는 서블릿 API를 몰라도 동작할 수 있다.
또한 뷰 경로를 매번 "/WEB-INF/views/..." 처럼 물리 경로로 직접 적는 중복을 제거해야 한다. 컨트롤러는 new-form, save-result, members 같은 논리 뷰 이름만 반환하고, 물리 경로로의 변환은 프론트 컨트롤러가 맡게 할 것이다. 이렇게 해두면 뷰 폴더 구조가 바뀌어도 프론트 컨트롤러만 수정하면 된다. 전체 구조를 그림으로 살펴보자.

기존에는 프론트 컨트롤러가 매핑 정보를 가지고 해당 컨트롤러를 호출하고 뷰를 반환하도록 했다. 하지만, 컨트롤러는 이제 모델과 뷰 이름을 함께 담은 ModelView를 반환한다.
프론트 컨트롤러는 ModelView에서 논리 뷰 이름을 꺼내 viewResolver에 넘겨 물리 경로를 가진 MyView로 해석한다. 이후 MyView가 모델을 request 파라미터로 채워 JSP로 forward() 하여 렌더링하게 하는 것이다.
흐름을 정리하자면...
프론트 컨트롤러가 요청을 받는다.
매핑 정보로 해당 컨트롤러를 찾는다.
요청 파라미터를 Map으로 만들어 컨트롤러에 전달한다.
컨트롤러는 ModelView(논리 뷰 이름 + 모델)를 반환한다.
프론트 컨트롤러가 viewResolver로 논리 이름 → 물리 경로를 해석해 MyView를 얻는다.
MyView가 모델을 세팅하고 JSP로 forward() 하여 응답을 생성한다.
그럼 서블릿의 종속성을 제거하기 위해 Model을 직접 만들고, View의 이름까지 전달하는 객체를 만들어보자.
package hello.servlet.web.frontcontroller;
import java.util.HashMap;
import java.util.Map;
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;
}
}
보다시피 뷰의 이름과 뷰를 렌더링할 때 필요한 model 객체를 가지고 있다. model은 단순히 Map으로 되어 있기 때문에 그냥 컨트롤러에서 뷰에 필요한 데이터를 키-값으로 넣어주기만 하면 된다.
이제 ModelView를 반환하는 컨트롤러 인터페이스를 설계하자.
package hello.servlet.web.frontcontroller.v3;
import hello.servlet.web.frontcontroller.ModelView;
import java.util.Map;
public interface ControllerV3 {
ModelView process(Map<String, String> paramMap);
}
보다시피 이 인터페이스는 서블릿 관련 코드가 전혀 없다. 따라서 구현이 아주 단순해지고, 테스트하기 편해진다.
이제 구현체들을 구현해보자.
// 회원 등록 컨트롤러 V3
package hello.servlet.web.frontcontroller.v3.controller;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.v3.ControllerV3;
import java.util.Map;
public class MemberFormControllerV3 implements ControllerV3 {
@Override
public ModelView process(Map<String, String> paramMap) {
return new ModelView("new-form");
}
}
이렇게 ModelView를 반환할 때 논리 이름만 담아서 반환해주면 된다. 실제 물리 이름은 프론트 컨트롤러에서 처리할 것이다.
// 회원 저장 컨트롤러 V3
package hello.servlet.web.frontcontroller.v3.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.v3.ControllerV3;
import java.util.Map;
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;
}
}
// 회원 목록 컨트롤러 V3
package hello.servlet.web.frontcontroller.v3.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.v3.ControllerV3;
import java.util.List;
import java.util.Map;
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;
}
}
이제 프론트 컨트롤러를 설계해보자.
package hello.servlet.web.frontcontroller.v3;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v2.ControllerV2;
import hello.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import hello.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import hello.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@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);
}
// 진짜 파일 위치를 담고 있는 MyView를 생성해서 반환
private static MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
// HttpServletRequest에서 파라미터 정보를 꺼내서 Map으로 변환
private static 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;
}
}
이제 MyView를 설계해보자.
package hello.servlet.web.frontcontroller.myview;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
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((key, value) -> request.setAttribute(key, value));
}
}
V3 컨트롤러 정도만 해도 아주 잘 설계된 컨트롤러지만, 실제 인터페이스를 구현하는 입장에서 보면 항상 ModelView 객체를 생성하고 반환해줘야 하는 부분이 살짝 걸린다. 조금의 리팩토링을 통해 코드를 개선시켜보자.

그림을 살펴보면 기본적인 구조는 V3와 같지만, 컨트롤러가 ModelView를 반환하지 않고 ViewName만 반환하도록 할 거다.
package hello.servlet.web.frontcontroller.v4;
import java.util.Map;
public interface ControllerV4 {
/**
*
* @param paramMap
* @param model
* @return viewName
*/
String process(Map<String, String> paramMap, Map<String, Object> model);
}
ControllerV4는 서블릿 의존성을 제거하고, 뷰 논리 이름만 반환하도록 인터페이스를 단순화했다. 컨트롤러는 프론트 컨트롤러가 넘겨주는 두 개의 인자만 다루면 된다.
paramMap: 요청 파라미터를 담은 Map<String, String>
model: 뷰에 전달할 데이터를 담을 Map<String, Object> (이미 생성되어 파라미터로 주어지므로 model.put(...)만 하면 됨)
컨트롤러는 필요한 값을 paramMap에서 읽어 로직을 수행하고, 결과를 model에 채운 뒤 논리 뷰 이름만 반환한다. 물리 경로 해석과 렌더링은 프론트 컨트롤러/뷰 리졸버가 책임지도록 하는 것이다.
이제 인터페이스를 구현해보자.
// 회원 등록 컨트롤러 V4
package hello.servlet.web.frontcontroller.v4.controller;
import hello.servlet.web.frontcontroller.v4.ControllerV4;
import java.util.Map;
public class MemberFormControllerV4 implements ControllerV4 {
@Override
public String process(Map<String, String> paramMap, Map<String, Object> model) {
return "new-form";
}
}
// 회원 저장 컨트롤러 V4
package hello.servlet.web.frontcontroller.v4.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.v4.ControllerV4;
import java.util.Map;
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";
}
}
// 회원 목록 컨트롤러 V4
package hello.servlet.web.frontcontroller.v4.controller;
import hello.servlet.domain.member.Member;
import hello.servlet.domain.member.MemberRepository;
import hello.servlet.web.frontcontroller.v4.ControllerV4;
import java.util.List;
import java.util.Map;
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";
}
}
프론트 컨트롤러의 로직은 거의 유사하다.
package hello.servlet.web.frontcontroller.v4;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import hello.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import hello.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {
private Map<String, ControllerV4> controllerMap = new HashMap<>();
public FrontControllerServletV4() {
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 {
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);
}
// 진짜 파일 위치를 담고 있는 MyView를 생성해서 반환
private static MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
private static 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;
}
}
어떤 개발자는 ControllerV3 방식으로, 어떤 개발자는 ControllerV4 방식으로 개발하고 싶다고 한다면 어떻게 해야 할까? 지금은 타입이 정해져 있어서 다른 타입을 집어 넣으려고 하면 컴파일 오류가 발생한다.
해결책은 바로 어댑터 패턴이다. 어댑터 패턴을 사용해서 프론트 컨트롤러가 다양한 타입의 컨트롤러를 다룰 수 있도록 처리해주자.

어댑터 패턴을 적용하면, 프론트 컨트롤러는 여러 타입의 컨트롤러(핸들러)를 직접 알 필요 없이 핸들러 어댑터 목록에서 해당 컨트롤러를 처리할 수 있는 적합한 어댑터를 찾아 위임한다. 기존에는 프론트 컨트롤러가 컨트롤러를 직접 호출했지만, 이제는 프론트 컨트롤러 → 핸들러 어댑터 → 핸들러 순서로 호출이 이뤄진다. 핸들러 어댑터는 핸들러를 실행해 결과를 ModelView로 변환하여 프론트 컨트롤러에 반환하고, 이후 뷰 리졸버, 렌더링 과정은 기존과 동일하게 처리된다.
먼저 핸들러 어댑터를 만들어보자. 아래의 MyHandlerAdapter는 프론트 컨트롤러가 다양한 형태의 컨트롤러(핸들러)를 일관된 방식으로 실행할 수 있도록 하는 어댑터 계약이다.
package hello.servlet.web.frontcontroller.v5;
import hello.servlet.web.frontcontroller.ModelView;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
public interface MyHandlerAdapter {
// 특정 handler(컨트롤러) 타입을 이 어댑터가 처리할 수 있는지 판별
boolean supports(Object handler);
// handler를 실제로 호출하고, 실행 결과를 ModelView로 표준화해 반환
ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}
프론트 컨트롤러는 컨트롤러의 구체 타입(ControllerV3, ControllerV4, …)을 알 필요 없이, 이 인터페이스를 구현한 어댑터에 호출을 위임한다. 어댑터는 개별 컨트롤러 규약을 실행한 뒤 표준 결과 타입(ModelView)으로 변환해 반환하는 것이다.
이제 어댑터를 구현해보자. 먼저 ControllerV3를 지원하는 어댑터를 만들어보자.
package hello.servlet.web.frontcontroller.v5.adapter;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.v3.ControllerV3;
import hello.servlet.web.frontcontroller.v5.MyHandlerAdapter;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class ControllerV3HandlerAdapter implements MyHandlerAdapter {
@Override
public boolean supports(Object handler) {
// ControllerV3 컨트롤러일 때 true 반환
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 static 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;
}
}
이제 프론트 컨트롤러를 설계해보자.
package hello.servlet.web.frontcontroller.v5;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import hello.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import hello.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import hello.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
private final Map<String, Object> handlerMappingMap = new HashMap<>(); // 아무 값이나 받을 수 있도록 Object로 타입 변경
private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();
public FrontControllerServletV5() {
initHandlerMappingMap();
initHandlerAdapters();
}
// 어댑터 초기화
private boolean initHandlerAdapters() {
return 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 {
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);
}
// URL에 매핑된 컨트롤러 객체를 찾아서 반환
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
// 핸들러를 처리할 수 있는 어댑터 조회
private MyHandlerAdapter getHandlerAdapter(Object handler) {
MyHandlerAdapter a;
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) { // 해당 어댑터가 컨트롤러 처리 가능?
return adapter;
}
}
throw new IllegalArgumentException("핸들러 어댑터를 찾을 수 없습니다! handler = " + handler);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
지금까지의 동작 흐름을 간단히 요약하자면...
프론트 컨트롤러가 핸들러 매핑으로 컨트롤러(핸들러) 객체를 찾는다.
등록된 어댑터 목록을 순회하며 supports(handler)가 true인 어댑터를 고른다.
선택된 어댑터의 handle(...)을 호출해 컨트롤러 실행 → ModelView 획득
프론트 컨트롤러는 ModelView의 논리 뷰 이름을 뷰 리졸버로 해석해 MyView를 얻고, 렌더링한다.
이제 프론트 컨트롤러에 ControllerV4 기능도 추가해보도록 하자.
package hello.servlet.web.frontcontroller.v5;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.myview.MyView;
import hello.servlet.web.frontcontroller.v3.controller.MemberFormControllerV3;
import hello.servlet.web.frontcontroller.v3.controller.MemberListControllerV3;
import hello.servlet.web.frontcontroller.v3.controller.MemberSaveControllerV3;
import hello.servlet.web.frontcontroller.v4.controller.MemberFormControllerV4;
import hello.servlet.web.frontcontroller.v4.controller.MemberListControllerV4;
import hello.servlet.web.frontcontroller.v4.controller.MemberSaveControllerV4;
import hello.servlet.web.frontcontroller.v5.adapter.ControllerV3HandlerAdapter;
import hello.servlet.web.frontcontroller.v5.adapter.ControllerV4HandlerAdapter;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@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());
handlerAdapters.add(new ControllerV4HandlerAdapter());
}
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());
}
@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);
String viewName = mv.getViewName();
MyView view = viewResolver(viewName);
view.render(mv.getModel(), request, response);
}
private Object getHandler(HttpServletRequest request) {
String requestURI = request.getRequestURI();
return handlerMappingMap.get(requestURI);
}
private MyHandlerAdapter getHandlerAdapter(Object handler) {
MyHandlerAdapter a;
for (MyHandlerAdapter adapter : handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new IllegalArgumentException("핸들러 어댑터를 찾을 수 없습니다! handler = " + handler);
}
private MyView viewResolver(String viewName) {
return new MyView("/WEB-INF/views/" + viewName + ".jsp");
}
}
핸들러 매핑에 ControllerV4를 사용하는 컨트롤러를 추가해줬다. 이제 해당 컨트롤러를 처리할 수 있는 어댑터를 빠르게 설계해보도록 하자.
package hello.servlet.web.frontcontroller.v5.adapter;
import hello.servlet.web.frontcontroller.ModelView;
import hello.servlet.web.frontcontroller.v4.ControllerV4;
import hello.servlet.web.frontcontroller.v5.MyHandlerAdapter;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
public class ControllerV4HandlerAdapter implements MyHandlerAdapter {
@Override
public boolean supports(Object handler) {
// ControllerV4일 경우에만 true 반환
return handler instanceof ControllerV4;
}
@Override
public ModelView handle(HttpServletRequest request, HttpServletResponse resp, Object handler) throws ServletException, IOException {
ControllerV4 controller = (ControllerV4) handler; // 핸들러를 ControllerV4 타입으로 캐스팅
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 static 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;
}
}
위 코드에서...
ModelView mv = new ModelView(viewName);
mv.setModel(model);
return mv;
이 부분에 집중해야 하는데, 어댑터가 호출하는 ControllerV4는 뷰의 이름을 반환하고 있다. 하지만 어댑터는 뷰의 이름이 아닌, ModelView를 만들어서 반환해줘야 한다. 여기서 어댑터가 꼭 필요한 이유가 나오는 것이다. 지금도 분명 ControllerV4는 뷰의 이름을 반환했지만, 어댑터는 이걸 ModelView로 만들어서 형식을 맞춰 반환한다.