[MVC-2] 직접 만드는 MVC 프레임워크

2025. 12. 30. 18:22·Spring/MVC

1. 프론트 컨트롤러(Front Controller) 패턴 소개

 과거의 방식은 모든 서블릿이 각자의 로직을 수행하며 공통 처리를 중복으로 수행해야 했다. 프론트 컨트롤러 패턴은 '입구를 하나로' 모아 공통 로직을 통합 관리하는 것이 핵심이다.

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

  • 특징: 서블릿 하나로 모든 클라이언트 요청을 받고, 요청에 맞는 컨트롤러를 찾아 호출한다.
  • 스프링과의 관계: 스프링 웹 MVC의 DispatcherServlet이 바로 이 프론트 컨트롤러 패턴으로 구현되어 있다.

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

FrontControllerServletV1

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

v1의 문제점: 각 컨트롤러마다 RequestDispatcher.forward() 로직이 중복됨


3. v2: View 분리 (중복 제거)

모든 컨트롤러에서 반복되는 RequestDispatcher.forward() 로직을 별도의 객체로 분리한다.

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

컨트롤러는 이제 직접 화면을 이동시키지 않고 MyView 객체를 반환하기만 한다.

MemberFormControllerV2

public class MemberFormControllerV2 implements ControllerV2 {
    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) {
        return new MyView("/WEB-INF/views/new-form.jsp");
    }
}

FrontControllerServletV2

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

v2의 개선점: 뷰 렌더링 로직이 MyView로 추상화되어 중복 제거


4. v3: Model 추가 (서블릿 종속성 제거)

컨트롤러가 서블릿 기술(Request, Response)을 몰라도 동작할 수 있도록 설계한다. 이를 통해 테스트 코드를 작성하기가 매우 쉬워진다.

ModelView 객체

public class ModelView {
    private String viewName;  // 논리적 뷰 이름 (예: "new-form", "save-result")
    private Map<String, Object> model = new HashMap<>();
    
    public ModelView(String viewName) {
        this.viewName = viewName;
    }
    
    // Getter, Setter
    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 인터페이스

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

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

FrontControllerServletV3

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    // 1. 요청 파라미터를 Map으로 변환 (paramMap)
    Map<String, String> paramMap = createParamMap(request);
    
    // 2. 컨트롤러 호출 (서블릿 종속성 없음)
    ModelView mv = controller.process(paramMap);
    
    // 3. ViewResolver 호출: 논리 이름을 물리 경로로 변환
    String viewName = mv.getViewName();
    MyView view = viewResolver(viewName);
    
    // 4. 렌더링 (모델 데이터 포함)
    view.render(mv.getModel(), request, response);
}

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

v3의 개선점:
1) 서블릿 종속성 완전 제거 (테스트 용이)
2) 논리적 뷰 이름 사용 (물리적 경로 변경 시 프론트 컨트롤러만 수정)
3) Model 객체 도입으로 데이터 전달 명확화


5. v4: 단순하고 실용적인 컨트롤러 (개발 편의성 증대)

v3는 구조적으로 완벽하지만, 개발자가 매번 ModelView 객체를 생성해서 반환해야 하는 번거로움이 있다. 이를 개선하여 뷰의 이름(String)만 반환하도록 변경한다.

ControllerV4 인터페이스

public interface ControllerV4 {
    /**
     * @param paramMap 요청 파라미터 맵
     * @param model 뷰에 전달할 모델 객체
     * @return viewName (논리적 뷰 이름)
     */
    String process(Map<String, String> paramMap, Map<String, Object> model);
}

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";         // 뷰 이름만 반환
    }
}

FrontControllerServletV4

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

v4의 개선점: 개발자 편의성 증대 - ModelView 객체 생성 없이 뷰 이름만 반환


6. v5: 유연한 컨트롤러 (어댑터 패턴 도입)

문제 상황

1) v3 방식 선호 개발자와 v4 방식 선호 개발자가 함께 작업해야 함
2) 서로 다른 인터페이스를 하나의 프레임워크에서 지원해야 함

MyHandlerAdapter 인터페이스

public interface MyHandlerAdapter {
    // 해당 핸들러를 지원하는가?
    boolean supports(Object handler);
    
    // 어댑터는 실제 컨트롤러를 호출하고, 결과를 ModelView로 반환
    ModelView handle(HttpServletRequest request, HttpServletResponse response, 
                    Object handler) throws ServletException, IOException;
}

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

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);
        
        // V4는 String을 반환하지만, 어댑터는 ModelView로 변환
        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;
    }
}

FrontControllerServletV5

@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 initHandlerMappingMap() {
        // V3 매핑
        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());
    }
    
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) 
            throws ServletException, IOException {
        
        // 1. 핸들러(컨트롤러) 조회
        Object handler = getHandler(request);
        if (handler == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        
        // 2. 핸들러를 처리할 수 있는 어댑터 조회
        MyHandlerAdapter adapter = getHandlerAdapter(handler);
        
        // 3. 어댑터 실행
        ModelView mv = adapter.handle(request, response, handler);
        
        // 4. 뷰 렌더링
        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");
    }
}

v5의 개선점: 어댑터 패턴으로 다양한 컨트롤러 인터페이스 지원


정리: MVC 프레임워크 진화 과정

버전 주요 개선점 핵심  아이디어
v1 프론트 컨트롤러 도입 요청의 입구를 하나로 통합하고, 공통 처리를 중앙에서 관리
v2 View 객체 분리 뷰 렌더링 로직을 캡슐화하여 중복을 제거
v3 Model 도입, 서블릿 종속성 제거 테스트가 쉬운 구조로 개선하고, 논리적 뷰 이름 사용
v4 인터페이스 단순화 컨트롤러 구현을 단순화하여 개발자 편의성 증대
v5 어댑터 패턴 도입 다양한 컨트롤러를 수용할 수 있도록 유연성과 확장성 극대화

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

[MVC-6] CSR 환경에서 @RequestBody만 쓸까? (feat. 파일 업로드와 @ModelAttribute)  (0) 2026.02.18
[MVC-5] 스프링 MVC 기본 기능  (0) 2025.12.30
[MVC-4] 스프링 MVC 시작하기: 애노테이션 기반 컨트롤러  (0) 2025.12.30
[MVC-3] Spring MVC 구조 이해  (0) 2025.12.30
[MVC-1] 서블릿에서 JSP까지: 자바 백엔드 웹 기술의 발전과 한계  (0) 2025.12.30
'Spring/MVC' 카테고리의 다른 글
  • [MVC-5] 스프링 MVC 기본 기능
  • [MVC-4] 스프링 MVC 시작하기: 애노테이션 기반 컨트롤러
  • [MVC-3] Spring MVC 구조 이해
  • [MVC-1] 서블릿에서 JSP까지: 자바 백엔드 웹 기술의 발전과 한계
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[MVC-2] 직접 만드는 MVC 프레임워크
상단으로

티스토리툴바