ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Creating an MVC framework
    Java/Spring 2025. 2. 13. 23:22

    MVC프레임워크를 만들어보며 프론트 컨트롤러 패턴을 학습합니다.

     

     


    < 프론트 컨트롤러(Front Controller) 패턴 소개 >

    프론트 컨트롤러 도입 전
    프론트 컨트롤러 도입 후

     

    Front Controller 패턴의 특징

    • 단일 프론트 컨트롤러: 하나의 프론트 컨트롤러 서블릿으로 클라이언트의 모든 요청을 받습니다.
    • 컨트롤러 매핑: 프론트 컨트롤러가 요청에 맞는 개별 컨트롤러를 찾아 호출합니다.
    • 공통 처리 용이: 공통 기능(인증, 로깅 등)을 프론트 컨트롤러에서 한 곳에서 처리할 수 있습니다.
    • 서블릿 의존성 최소화: 프론트 컨트롤러를 제외한 다른 컨트롤러는 서블릿을 직접 사용하지 않아도 됩니다.

     

    스프링 웹 MVC와 프론트 컨트롤러

    • 스프링 MVC의 핵심 구조: 스프링 웹 MVC는 프론트 컨트롤러 패턴이 핵심입니다.
    • DispatcherServlet: 스프링 웹 MVC의 `DispatcherServlet`역시 프론트 컨트롤러 패턴으로 구현되어 있습니다.

     

     


    < V1 - 프론트 컨트롤러 도입 >

    V1 구조

     

    ControllerV1 :

    public interface ControllerV1 {
        void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
    }

    : 각 컨트롤러는 이 인터페이스를 구현하고, 프론트 컨트롤러는 이 인터페이스를 호출함으로써 구현과 관계없이 로직의 일관성을 가져갈 수 있습니다.

     

     

    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";
            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 - 프론트 컨트롤러 :

    @WebServlet(name = "frontControllerServletV1", urlPatterns = "/frontcontroller/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);
        }
    }
    • urlPatterns
      urlPatterns = "/front-controller/v1/*": /front-controller/v1을 포함한 하위 모든 요청은 이 서블릿에서 받아들입니다.
    • controllerMap
      key: 매핑 URL
      value: 호출될 컨트롤러
    • service()
      `requestURI`를 조회하여 `controllerMap`에서 컨트롤러를 찾습니다. 컨트롤러가 없으면 `404(SC_NOT_FOUND)` 상태 코드를 반환합니다.
      컨트롤러가 있으면 `controller.process(request, response)`를 호출하여 실행합니다.

     

     


    < V2 - View 분리 >

     

    V1의 문제점은 모든 컨트롤러에서 뷰로 이동하는 코드가 중복되어 깔끔하지 않다는 점입니다.

    String viewPath = "/WEB-INF/views/new-form.jsp";
    RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
    dispatcher.forward(request, response);

    이 문제를 해결하기 위해 별도로 뷰를 처리하는 객체를 만듭니다.

     

     

    V2 구조

     

     

    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);
        }
    }

     

     

    ControllerV2 :

    public interface ControllerV2 {
        MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException; // 컨트롤러가 뷰를 반환한다.
    }

    : 각 컨트롤러는 이제 복잡한 dispatcher.forward()를 직접 생성하여 호출할 필요가 없습니다. 대신에 MyView 객체를 생성하고, 그 안에 필요한 뷰 이름을 설정하여 반환하면 됩니다.

     

     

     

    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");
        }
    }

    : ControllerV1을 구현한 클래스와 ControllerV2를 구현한 클래스를 비교해보면, 이러한 중복 코드가 확실히 제거된 것을 확인할 수 있습니다.

     

     

    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 - 프론트 컨트롤러 :

    @WebServlet(name = "frontControllerServletV2", urlPatterns = "/frontcontroller/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);
        }
    }

    ControllerV2의 반환 타입이 MyView이므로, 프론트 컨트롤러는 컨트롤러의 실행 결과로 MyView 객체를 받게 됩니다. 그 후 view.render()를 호출하면 forward 로직이 수행되어 JSP가 실행됩니다.

     

    프론트 컨트롤러를 도입함으로써 MyView 객체의 render() 메서드를 호출하는 부분을 모두 일관되게 처리할 수 있습니다. 각 컨트롤러는 단순히 MyView 객체를 생성하여 반환하기만 하면 됩니다.

     

     


    < V3 - Model 추가 >

    주요 사항

    #1. 서블릿 종속성 제거

     

    컨트롤러 입장에서 HttpServletRequest 객체가 꼭 필요할까요? (HttpServletResponse는 당장 사용하지 않으니 제외하겠습니다.)

    사실 컨트롤러에 필요한 것은 HttpServletRequest 객체 자체가 아니라, 그 안에 담긴 요청 파라미터 정보입니다. 예를 들어:

    String username = request.getParameter("username");
    int age = Integer.parseInt(request.getParameter("age"));

     

    위와 같이 요청 파라미터를 사용하고 있습니다.

     

    따라서 프론트 컨트롤러에서 요청 파라미터 정보를 자바의  Map 형태로 전달해준다면, 현재 구조에서 다른 컨트롤러들은 서블릿 기술을 몰라도 동작할 수 있습니다. 또한, request 객체를 모델로 사용하는 대신 별도의 Model 객체를 만들어 반환하면 됩니다.

    우리의 컨트롤러가 서블릿 기술에 전혀 의존하지 않도록 변경해봅시다. 이렇게 하면 구현 코드도 매우 단순해지고, 테스트 코드 작성도 쉬워집니다.

     

     

    #2. 뷰 이름 중복 제거

    컨트롤러에서 지정하는 뷰 이름에 중복되는 부분이 있는 것을 확인할 수 있습니다. 예를 들어 /WEB-INF/views/와 .jsp가 반복적으로 사용되고 있습니다.

    return new MyView("/WEB-INF/views/members.jsp");

    컨트롤러에서는 뷰의 논리 이름만 반환하고, 실제 물리적인 위치는 프론트 컨트롤러에서 처리하도록 구조를 단순화해 보겠습니다.

    예를 들어:

    • /WEB-INF/views/save-result.jsp ⇒ save-result
    • /WEB-INF/views/members.jsp ⇒ members

    이렇게 해두면, 나중에 뷰의 폴더 위치가 변경되더라도 프론트 컨트롤러만 수정하면 되므로 유지보수가 편리해집니다.

     

     

    ModelView :

    지금까지 컨트롤러에서 서블릿에 종속적인 HttpServletRequest를 사용했습니다. 그리고 Model도 request.setAttribute() 를 통해 데이터를 저장하고 뷰에 전달했습니다.

    서블릿의 종속성을 제거하기 위해 Model을 직접 만들고, 추가로 View 이름까지 전달하는 객체를 만들어 보겠습니다.

    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으로 되어 있으므로 컨트롤러에서 뷰에 필요한 데이터를 key, value로 넣어주면 됩니다.

     

     

    ControllerV3 :

    public interface ControllerV3 {
    	ModelView process(Map<String, String> paramMap);
    }
    • 이 컨트롤러는 서블릿 기술을 전혀 사용하지 않습니다. 따라서 구현이 매우 단순해지고, 테스트 코드 작성시 테스트 하기 쉽습니다.
    • 각 컨트롤러는 응답 결과로 뷰 이름과 뷰에 전달할 Model 데이터를 포함하는 ModelView 객체를 반환하면 됩니다.

     

     

    MemberFormControllerV3 - 회원 등록 폼 :

    public class MemberFormControllerV3 implements ControllerV3 {
        @Override
        public ModelView process(Map<String, String> paramMap) {
    	    return new ModelView("new-form");
        }
    }

    ModelView 를 생성할 때 new-form 이라는 view의 논리적인 이름을 지정합니다. 실제 물리적인 이름은 프론트 컨트롤러에서 처리합니다.

     

     

    MemberSaveControllerV3 - 회원 저장 :

    public class MemberSaveControllerV3 implements ControllerV3 {
        private MemberRepository memberRepository = MemberRepository.getInstance();
        
        @Override
        public ModelView process(Map<String, String> paramMap) {
            String username = paramMap.get("username"); // paramMap에서 필요한 요청 파라미터 조회
            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;
        }
    }

     

     

    FrontControllerServletV3 :

    @WebServlet(name = "frontControllerServletV3", urlPatterns = "/frontcontroller/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);
        }
        
        // HttpServletRequest에서 파라미터 정보를 꺼내에 Map으로 변환한다.
        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");
        }
    }

    MyView view = viewResolver(viewName): 컨트롤러가 반환한 논리 뷰 이름을 실제 물리 뷰 경로로 변경합니다. 실제 물리 경로가 있는 MyView 객체를 반환한다.

    • 논리 뷰 이름: members
    • 물리 뷰 경로: /WEB-INF/views/members.jsp

    view.render(mv.getModel(), request, response): 뷰 객체를 통해서 HTML 화면을 렌더링합니다.

    • 뷰 객체의 render()는 모델 정보도 함께 받습니다.
    • JSP는 request.getAttribute()로 데이터를 조회하기 때문에, 모델의 데이터를 꺼내서 request.setAttribute()로 담아둡니다.

     

     

    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);
        }
        // 모델 정보를 함께 받는 새로운 render()를 정의
        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);
        }
        
        // 모델의 데이터를 꺼내어 request에 담아둔다.
        private void modelToRequestAttribute(Map<String, Object> model, HttpServletRequest request) {
    	    model.forEach((key, value) -> request.setAttribute(key, value));
        }
    }

     

     


    < V4 - 단순하고 실용적인 컨트롤러 >

    앞서 만든 V3 컨트롤러는 서블릿 종속성을 제거하고 뷰 경로의 중복을 제거하는 등 잘 설계된 컨트롤러입니다. 그러나 실제로 컨트롤러 인터페이스를 구현하는 개발자의 입장에서 보면, 매번 ModelView 객체를 생성하고 반환해야 하는 부분이 약간 번거로울 수 있습니다.

     

    좋은 프레임워크는 아키텍처도 중요하지만, 개발자들이 단순하고 편리하게 사용할 수 있어야 합니다. 즉, 실용성이 있어야 합니다.

     

    V4

    이번에는 V3를 약간 변경하여, 실제 구현하는 개발자들이 더욱 편리하게 개발할 수 있는 V4 버전을 만들어보겠습니다. 기본적인 구조는 V3와 동일하지만, 컨트롤러가 ModelView를 반환하지 않고 뷰의 이름(ViewName)만 반환하도록 할 것입니다.

     

    ControllerV4 :

    public interface ControllerV4 {
        
        /**
        * @param paramMap
        * @param model
        * @return viewName
        */
        String process(Map<String, String> paramMap, Map<String, Object> model);
    }

    V4 버전은 인터페이스에 ModelView가 없습니다. model 객체는 파라미터로 전달되기 때문에 그냥 사용하면 되고, 결과로 뷰의 이름만 반환해주면 됩니다.

     

     

    MemberFormControllerV4 :

    public class MemberFormControllerV4 implements ControllerV4 {
        
        @Override
        public String process(Map<String, String> paramMap, Map<String, Object> model) {
    	    return "new-form"; // 뷰의 논리 이름만 반환하면 된다.
        }
    }

     

     

    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";
        }
    }

     

     

    MemberListControllerV4 :

    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";
        }
    }
    

     

     

    FrontControllerServletV4 :

    package hello.servlet.web.frontcontroller.v4;
    
    import hello.servlet.web.frontcontroller.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 javax.servlet.ServletException;
    import javax.servlet.annotation.WebServlet;
    import javax.servlet.http.HttpServlet;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;
    import java.util.HashMap;
    import java.util.Map;
    
    @WebServlet(name = "frontControllerServletV4", urlPatterns = "/frontcontroller/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);
        }
        
        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");
        }
    }

     

     

    이번 버전의 컨트롤러는 매우 단순하고 실용적입니다. 기존 구조에서 모델을 파라미터로 넘기고, 뷰의 논리 이름을 반환한다는 작은 아이디어만 적용했을 뿐인데도, 컨트롤러를 구현하는 개발자 입장에서는 이제 불필요한 코드를 작성하지 않아도 됩니다.

    또한 중요한 사실은 여기까지 한 번에 도달한 것이 아니라는 점입니다. 프레임워크가 점진적으로 발전하는 과정에서 이러한 방법을 찾아낼 수 있었습니다.

     

    프레임워크나 공통 기능이 수고로워야 사용하는 개발자가 편리해집니다.

     

     


    < V5 - 유연한 컨트롤러 >

    만약 어떤 개발자는 ControllerV3 방식으로 개발하고 싶고, 어떤 개발자는 ControllerV4 방식으로 개발하고 싶다면 어떻게 해야할까요?

    public interface ControllerV3 {
        ModelView process(Map<String, String> paramMap);
    }
    public interface ControllerV4 {
        String process(Map<String, String> paramMap, Map<String, Object> model);
    }

     

    어댑터 패턴

    지금까지 우리가 개발한 프론트 컨트롤러는 한 가지 방식의 컨트롤러 인터페이스만 사용할 수 있었습니다.

    ControllerV3와 ControllerV4는 완전히 다른 인터페이스를 가지고 있기 때문에 서로 호환이 불가능합니다. 이는 마치 V3는 110V 전기 콘센트를 사용하고, V4는 220V 전기 콘센트를 사용하는 것과 같습니다.

     

    이럴 때 사용하는 것이 바로 어댑터 패턴(Adapter Pattern)입니다.

    어댑터 패턴을 활용하여 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경해봅시다.

     

    중간에 핸들러 어댑터라는 어댑터가 추가되었습니다. 이 어댑터가 중간에서 역할을 해줌으로써 다양한 종류의 컨트롤러를 호출할 수 있게 되었습니다.

    또한, 컨트롤러의 이름을 더 넓은 범위인 "핸들러"로 변경하였습니다. 그 이유는 이제 어댑터가 있으므로 꼭 컨트롤러의 개념뿐만 아니라, 어떠한 것이든 해당 종류의 어댑터만 있으면 모두 처리할 수 있기 때문입니다.

     

     

    MyHandlerAdapter :

    public interface MyHandlerAdapter {
        boolean supports(Object handler);
        ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
    }

     

    boolean supports(Object handler)

    • 이 메서드는 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단합니다.
    • 여기서 handler는 컨트롤러를 의미합니다.

    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler)

    • 어댑터는 실제 컨트롤러를 호출하고, 그 결과로 ModelView를 반환해야 합니다.
    • 만약 실제 컨트롤러가 ModelView를 반환하지 못하면, 어댑터가 ModelView를 직접 생성해서라도 반환해야 합니다.
    • 이전에는 프론트 컨트롤러가 실제 컨트롤러를 호출했지만 이제는 이 어댑터를 통해서 실제 컨트롤러가 호출됩니다.

     

     

    #1. V5 - Controller V3

    ControllerV3HandlerAdapter :

    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) {
            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;
        }
    }

     

    public boolean supports(Object handler) {
        return (handler instanceof ControllerV3);
    }
    
    
    • 이 메서드는 해당 어댑터가 ControllerV3를 처리할 수 있는지 판단합니다.
    ControllerV3 controller = (ControllerV3) handler;
    Map<String, String> paramMap = createParamMap(request);
    ModelView mv = controller.process(paramMap);
    return mv;
    • handler를 ControllerV3로 캐스팅한 다음, V3 방식에 맞게 호출합니다.
    • supports() 메서드를 통해 ControllerV3만 지원함을 보장하기 때문에, 타입 변환은 안전하게 진행할 수 있습니다.
    • ControllerV3는 ModelView를 반환하므로, 결과를 그대로 반환하면 됩니다.

     

     

    FrontControllerServletV5 :

    @WebServlet(name = "frontControllerServletV5", urlPatterns = "/frontcontroller/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 initHandlerMappingMap() {
            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);
                return;
            }
    
            MyHandlerAdapter adapter = getHandlerAdapter(handler);
            ModelView mv = adapter.handle(request, response, handler);
            MyView view = viewResolver(mv.getViewName());
            view.render(mv.getModel(), request, response);
        }
    
        private Object getHandler(HttpServletRequest request) {
            String requestURI = request.getRequestURI();
            return handlerMappingMap.get(requestURI);
        }
    
        private MyHandlerAdapter getHandlerAdapter(Object handler) {
            for (MyHandlerAdapter adapter : handlerAdapters) {
                if (adapter.supports(handler)) {
                    return adapter;
                }
            } throw new IllegalArgumentException("handler adapter를 찾을 수 없습니다. handler=" + handler);
        }
    
        private MyView viewResolver(String viewName) {
            return new MyView("/WEB-INF/views/" + viewName + ".jsp");
        }
    }

     

    public FrontControllerServletV5() {
        initHandlerMappingMap();   // 핸들러 매핑 초기화
        initHandlerAdapters();     // 어댑터 초기화
    }
    • 생성자에서는 핸들러 매핑과 어댑터를 초기화하는 메서드를 호출합니다.

     

    private final Map<String, Object> handlerMappingMap = new HashMap<>();
    
    
    • 매핑 정보의 값 타입이 ControllerV3, ControllerV4 같은 인터페이스에서 Object로 변경되었습니다.
    • 이를 통해 다양한 종류의 컨트롤러 객체를 매핑에 저장할 수 있습니다.

     

    Object handler = getHandler(request);
    
    private Object getHandler(HttpServletRequest request) {
        String requestURI = request.getRequestURI();
        return handlerMappingMap.get(requestURI);
    }
    
    
    • handlerMappingMap에서 URL에 매핑된 핸들러(컨트롤러) 객체를 찾아서 반환합니다.

     

    MyHandlerAdapter adapter = getHandlerAdapter(handler);
    
    for (MyHandlerAdapter adapter : handlerAdapters) {
        if (adapter.supports(handler)) {
            return adapter;
        }
    }
    
    
    • adapter.supports(handler)를 통해 핸들러를 처리할 수 있는 어댑터를 찾습니다.
    • 예를 들어, handler가 ControllerV3 인터페이스를 구현했다면, ControllerV3HandlerAdapter 객체가 반환됩니다.

     

    ModelView mv = adapter.handle(request, response, handler);
    
    
    • 어댑터의 handle(request, response, handler) 메서드를 통해 실제 어댑터가 호출됩니다.
    • 어댑터는 핸들러(컨트롤러)를 호출하고, 그 결과를 어댑터에 맞추어 반환합니다.
    • ControllerV3HandlerAdapter의 경우, 어댑터의 구조와 컨트롤러의 구조가 유사하여 변환 로직이 단순합니다.

     

    지금은 V3 컨트롤러를 처리할 수 있는 어댑터와 ControllerV3 핸들러만 등록되어 있어서 큰 변화가 없어 보일 수 있습니다.

    이제 ControllerV4를 지원하는 기능을 추가하여, 다양한 컨트롤러를 처리할 수 있도록 개선해봅시다.

     

    #2. V5 - Controller V4

    FrontControllerServletV5에 ControllerV4 기능도 추가해 보겠습니다.

    private void initHandlerMappingMap() {
        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/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를 사용하는 컨트롤러를 추가하고, 해당 컨트롤러를 처리할 수 있는 어댑터인 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) {
            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;
        }
    
        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;
        }
    }

     

    public boolean supports(Object handler) {
        return (handler instanceof ControllerV4);
    }
    
    
    • 이 메서드는 해당 어댑터가 ControllerV4를 처리할 수 있는지 판단합니다.

     

    ControllerV4 controller = (ControllerV4) handler;
    
    Map<String, String> paramMap = createParamMap(request);
    Map<String, Object> model = new HashMap<>();
    
    String viewName = controller.process(paramMap, model);
    
    
    • handler를 ControllerV4로 캐스팅한 후, paramMap과 model을 생성하여 해당 컨트롤러를 호출합니다.
    • 컨트롤러의 process 메서드를 호출하면 viewName을 반환받습니다.

     

    ModelView mv = new ModelView(viewName);
    mv.setModel(model);
    return mv;
    
    
    • 어댑터에서 이 부분은 단순하지만 중요한 부분입니다.
    • 어댑터가 호출하는 ControllerV4는 뷰의 이름(viewName)을 반환합니다. 그러나 어댑터는 ModelView 객체를 생성하여 반환해야 합니다.
    • 여기서 어댑터의 필요성이 드러납니다.
      • ControllerV4는 뷰의 이름만 반환하지만, 프론트 컨트롤러는 ModelView 객체를 필요로 합니다.
      • 따라서 어댑터는 컨트롤러가 반환한 뷰의 이름과 모델 정보를 사용하여 ModelView 객체를 생성하고 반환해야 합니다.
      • 이는 마치 110V 전기 콘센트를 220V 콘센트로 변경하는 어댑터와 같은 역할을 수행하는 것입니다.

     

     


    < 정리 >

    지금까지 우리는 V1부터 V5까지 프레임워크를 단계적으로 발전시켜 왔습니다. 이제까지의 작업을 정리해보면 다음과 같습니다.

     

    V1: 프론트 컨트롤러 도입

    • 기존 구조를 최대한 유지하면서 프론트 컨트롤러를 도입하였습니다.

     

    V2: 뷰 분리

    • 반복되는 뷰 로직을 별도의 뷰로 분류하여 코드의 중복을 제거했습니다.

     

    V3: 모델 추가 및 서블릿 종속성 제거

    • 서블릿 종속성을 제거하여 컨트롤러가 서블릿 기술에 의존하지 않게 만들었습니다.
    • 뷰 이름의 중복을 제거하여 관리하기 쉽게 개선했습니다.

     

    V4: 단순하고 실용적인 컨트롤러

    • V3와 거의 비슷하지만, 구현하는 개발자 입장에서 ModelView를 직접 생성하고 반환하지 않아도 되도록 편리한 인터페이스를 제공했습니다.

     

    V5: 유연한 컨트롤러 (어댑터 도입)

    • 어댑터 패턴을 도입하여 프레임워크를 유연하고 확장 가능하게 설계했습니다.
    • 어댑터를 통해 다양한 종류의 컨트롤러를 처리할 수 있게 되었습니다.
    • 향후 애노테이션을 사용하여 컨트롤러를 더 편리하게 발전시킬 수도 있습니다.
    • 다형성과 어댑터 덕분에 기존 구조를 유지하면서도 프레임워크의 기능을 확장할 수 있었습니다.

     

    초기 버전부터 작은 아이디어들을 적용해 가며, 개발자 입장에서 사용하기 편리한 프레임워크가 되도록 핵심 코드를 구현해 보았습니다.

     

    사실 지금까지 작성한 코드는 스프링 MVC 프레임워크 핵심 코드의 축약 버전이며, 구조도 거의 동일합니다. 따라서 스프링 MVC는 우리가 학습한 내용과 거의 같은 구조를 가지고 있다고 생각해도 무방합니다.

    중요한 점은 여기까지 한 번에 도달한 것이 아니라는 것입니다. 프레임워크가 점진적으로 발전하는 과정에서 이러한 방법들을 찾아낼 수 있었습니다.

     

    명심합시다. 프레임워크나 공통 기능이 수고로워야 사용하는 개발자가 편리해지고, 개발자가 수고로워야 사용하는 고객이 편리해집니다.

     

     

     

     

     

     

     

     

     

     

     

    해당 글에 포함된 코드나 그림은 김영한님이 제공해주신 자료를 바탕으로 작성되었습니다.

    스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런

     

    스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 강의 | 김영한 - 인프런

    김영한 | , 원리를 알아야 핵심이 보인다!김영한의 스프링 MVC 기본편 👨‍💻 📌 수강 전 확인해주세요! 본 강의는 자바 스프링 완전 정복 시리즈의 네 번째 강의입니다. 우아한형제들 최연소

    www.inflearn.com

     

     

     

     

     

    'Java > Spring' 카테고리의 다른 글

    Spring MVC - Understanding Structure  (2) 2025.03.17
    Servlet / JSP / MVC  (2) 2025.02.12
    Understanding Web Applications  (5) 2025.01.14
    Bean Scope  (1) 2025.01.12
    Bean Lifecycle Callback  (3) 2025.01.03

    댓글

Designed by Tistory.