Notice
Recent Posts
Recent Comments
Link
관리 메뉴

look-forest

MVC 프레임워크 만들기 본문

Spring/Spring MVC - 웹 개발 핵심 기술

MVC 프레임워크 만들기

studyHub 2023. 4. 28. 22:39

지난 시간에 MVC 패턴을 도입해야 하는 이유와 한계를 살펴보았다.

 

컨트롤러에서 중복되는 공통 기능을 처리하기 위해선, 공통적으로 거쳐가는 입구 즉, 프론트 컨트롤러를 만들어야 한다.

MVC 프레임워크들은 이러한 프론트 컨트롤러 패턴을 구현한 것이다.

 

이번 시간에는, 직접 MVC 프레임워크를 만드는 과정을 통해 MVC 프레임워크의 구조와 원리에 대해 이해해보겠다.

 


프론트 컨트롤러 패턴이란?

프론트 컨트롤러 도입 전

요청마다 각각의 서블릿이 연결된다. 그래서 서블릿마다 공통 기능이 중복된다.
프론트 컨트롤러가 모든 요청을 받는 입구가 되어, 공통 로직을 처리하고 각각의 컨트롤러를 호출한다.

 

Front Controller 패턴의 특징

1. 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받는다.

2. 프론트 컨트롤러에서 공통 처리가 가능해진다.

3. 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출한다.
   프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 된다.

요청이 오면 WAS에서 처음 요청이 들어가는 부분이 서블릿이다.
프론트 컨트롤러가 WAS로부터 직접 호출되고, 이하 컨트롤러는 서비스, 리포지토리처럼 호출된다.

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

스프링 웹 MVC의 핵심도 바로 FrontController이다.

스프링 웹 MVC의 DispatcherServletFrontController 패턴으로 구현되어 있다.

 

 


MVC 프레임워크 만들기

구조를 바꿀 때는 단계별로 개선하는 것이 좋다. 단계별로 MVC 프레임워크를 만들어보자.

version 1. 프론트 컨트롤러 도입

프론트 컨트롤러를 도입한 후 구조

 

서비스 호출하듯 컨트롤러를 호출한다.

 

1. 먼저 다형성을 활용해서 로직의 일관성을 취하도록 컨트롤러 인터페이스를 도입한다.

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

 

2. 프론트 컨트롤러를 만든다.

프론트 컨트롤러(서블릿)는 모든 요청을 받아들이고 각 url에 따른 컨트롤러를 호출한다.

이를 위해 controllerMap을 만든다.

@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) { //url에 해당하는 컨트롤러가 없을 경우 404 에러
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(request, response);
    }
}

 

모든 요청이 프론트 컨트롤러로 오도록 구조를 바꾸었다.

 

3. 컨트롤러 인터페이스를 구현한 컨트롤러를 만든다. //기존의 컨트롤러(서블릿)의 로직과 같다.

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

        //Model에 저장
        request.setAttribute("members", members);

        //View로 forwarding
        String viewPath = "/WEB-INF/views/members.jsp";
        request.getRequestDispatcher(viewPath).forward(request, response);
    }
}

 


Version 2. View 분리

모든 컨트롤러에서 뷰로 이동하는 부분에 중복이 있다.

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

 

프론트 컨트롤러에서 중복을 처리해보자.

 

 

1. 프론트 컨트롤러에서 중복되는 뷰로의 포워딩 로직을 처리한다.

@Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        /* 이전 버전과 같이 URI에 따른 컨트롤러 매핑 */

        MyView view = controller.process(request, response);
        //공통 로직 처리 - 뷰로 forward
        view.render(request, response);
    }

 

2. 컨트롤러에서 뷰를 반환하도록 인터페이스를 수정한다.

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

 

3. 별도로 뷰를 처리하는 객체를 만든다.

public class MyView {
    private String viewPath;

    public MyView(String viewPath) {
        this.viewPath = viewPath;
    }

    //View로 forwarding
    public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}

 

 


Version 3. Model 추가

컨트롤러에서 HttpServletRequest, HttpServletResponse가 꼭 필요할까?

- request 객체의 요청 파라미터 정보를 Map으로 넘기면 컨트롤러가 서블릿 기술을 몰라도 동작할 수 있다.

- request 객체를 Model로 사용하는 대신에 별도의 Model 객체를 만들어서 반환하면 된다.

 

우리가 구현하는 컨트롤러가 서블릿 기술을 전혀 사용하지 않도록 변경해보자.

이렇게 하면 구현 코드도 매우 단순해지고, 테스트 코드 작성이 쉽다.

 

추가로, 뷰 풀네임 내의 중복도 제거해보자.

변경의 지점을 하나로 모으자.

컨트롤러는 뷰의 이름을 반환하고, 실제 물리 위치의 이름은 프론트 컨트롤러에서 처리하도록 하면,

향후 뷰의 물리 위치가 변해도 프론트 컨트롤러만 고치면 된다.

 

컨트롤러가 서블릿 기술을 전혀 사용하지 않도록 Model을, 뷰 경로의 변경 지점을 하나로 모으도록 viewResolver를 추가.

 

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

@Getter
@Setter
public class ModelView {
    private String viewName;
    private Map<String, Object> model = new HashMap<>();

    public ModelView(String viewName) {
        this.viewName = viewName;
    }
}

 

2. 컨트롤러는 서블릿 기술을 사용하지 않고, 전달 인자를 Map으로 전달 받는다. 그리고 Model과 뷰 이름을 반환한다.

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

 

3. Controller 인터페이스에 변화에 맞게 FrontController를 수정한다.

@WebServlet(name = "frontControllerServletV3", urlPatterns = "/front-controller/v3/*")
public class FrontControllerServletV3 extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        /* 이전과 같다 */
        
        Map<String, String> paramMap = createParamMap(request);
        ModelView modelView = controller.process(paramMap);

        MyView view = viewResolver(modelView);
        view.render(modelView.getModel(), request, response);
    }
}

 


Version 4. 단순하고 실용적인 컨트롤러

version 3의 컨트롤러는 서블릿 종속성과 뷰 경로의 중복을 제거한, 잘 설계된 컨트롤러이다.

컨트톨러 인터페이스를 구현할 때, 항상 ModelView 객체를 생성하고 반환해야 하는 부분이 조금 번거롭다.

 

좋은 프레임워크는 아키텍처도 중요하지만,

그와 더불어 실제 개발하는 개발자가 단순하고 편리하게 사용할 수 있어야 한다.

즉, 실용성이 있어야 한다.

 

이번 버전에서는 개발자가 구현하기 쉽도록 컨트롤러를

[비즈니스 로직 → 모델에 데이터 추가 → 뷰 이름 반환]의 구조로 단순화해보자.

 

기본 구조는 version 3와 같지만, 컨트롤러가 ModelView 객체를 생성해 반환하지 않고, ViewName만 반환한다.

 

1. 컨트롤러가 ModelView 객체가 아닌 뷰 이름만 반환한다. 그리고 Model 객체는 파라미터로 전달된다.

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

 

2. 모델 객체를 프론트 컨트롤러에서 생성해서 넘겨준다. 컨트롤러에서 모델 객체에 값을 담는다.

@WebServlet(name = "frontControllerServletV4", urlPatterns = "/front-controller/v4/*")
public class FrontControllerServletV4 extends HttpServlet {
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        /* 이전 버전과 동일 */
        
        Map<String, Object> model = new HashMap<>(); //모델 객체를 프론트 컨트롤러에서 생성
        String viewName = controller.process(paramMap, model); //컨트롤러에게 모델 전달, 뷰 이름 반환

        MyView view = viewResolver(viewName);
        view.render(model, request, response);
    }
}

 

기존 구조에서 모델을 파라미터로 넘기고, 뷰의 이름을 반환하도록 했을 뿐인데,
컨트롤러를 구현하는 개발자 입장에서 보면 이제 군더더기 없는 코드를 작성할 수 있게 됐다!

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

 


Version 5. 유연한 컨트롤러

만약 어떤 개발자는 ControllerV3 방식으로 개발하고 싶고, 어떤 개발자는 ControllerV4 방식으로 개발하고 싶다면 어떻게 해야할까? 두 컨트롤러는 인터페이스가 다르다.

 

어댑터 패턴

ControllerV3 , ControllerV4 는 완전히 다른 인터페이스이다. 따라서 호환이 불가능하다.

마치 ControllerV3 는 110v이고, ControllerV4 는 220v 전기 콘센트 같은 것이다.

이럴 때 사용하는 것이 바로 어댑터이다.

어댑터 패턴을 사용해서 프론트 컨트롤러가 다양한 방식의 컨트롤러를 처리할 수 있도록 변경해보자!

 

여러 컨트롤러 인터페이스를 호환해줄 '어댑터'를 사용한다!

핸들러: 컨트롤러(Controller) → 핸들러(Handler) 로 명칭 변경

이전에는 컨트롤러를 직접 매핑해서 사용했다. 그런데 이제는 어댑터를 사용하기 때문에,
컨트롤러 뿐만 아니라 어댑터가 지원하기만 하면, 어떤 것이라도 URL에 매핑해서 사용할 수 있다.
그래서 이름을 컨트롤러에서 더 넒은 의미인 핸들러로 변경했다

 

핸들러 어댑터: 중간에서 어댑터 역할을 해주는 덕분에 다양한 종류의 컨트롤러를 호출할 수 있다.

어댑터는 handler(컨트롤러)를 호출하고 그 결과값을 일관성 있게 맞추어 반환한다.

즉, 어탭터를 통해 '호환'된다

 

1. 각 핸들러마다 어댑터를 만들 것이라, '어댑터는 이렇게 구현해야 한다' 어댑터 인터페이스를 만든다.

public interface MyHandlerAdapter {
    //어댑터가 해당 컨트롤러를 처리할 수 있는지 판단
    boolean supports(Object handler);

    //어댑터는 컨트롤러를 호출하고, 그 결과로 ModelView를 반환해야 한다.
    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;
}

 

2. 각 핸들러를 지원하는 어댑터를 각각 구현

여기서 어댑터가 꼭 필요한 이유가 나온다.

ControllerV4 는 뷰의 이름을 반환했지만, 어댑터는 이것을 ModelView로 형식을 맞추어 반환한다.

마치 110v 전기 콘센트를 220v 전기 콘센트로 변경하듯이!

이로써 뼈대에 해당하는 프론트 컨트롤러에서 일관성 있게 흐름을 가져갈 수 있게 된다.

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) throws ServletException, IOException {
        ControllerV4 controller = (ControllerV4) handler;
        Map<String, String> paramMap = createParamMap(request);
        Map<String, Object> model = new HashMap<>();

        String viewName = controller.process(paramMap, model);

        ModelView modelView = new ModelView(viewName); //어댑터가 필요한 이유!
        modelView.setModel(model);
        return modelView;
    }
}

 

3. 프론트 컨트롤러는 핸들러를 처리할 수 있는 어댑터를 조회하여, 어댑터를 통해 핸들러를 실행한다.

@WebServlet(name = "frontControllerServletV5", urlPatterns = "/front-controller/v5/*")
public class FrontControllerServletV5 extends HttpServlet {
    //private Map<String, ControllerV4> controllerMap = new HashMap<>(); 이전 버전
    private final Map<String, Object> handlerMappingMap = new HashMap<>(); //V3든 V4든 수용할 수 있도록 Object로 받는다.
    private final List<MyHandlerAdapter> handlerAdapters = new ArrayList<>();

    //생성자는 핸들러 매핑과 어댑터를 초기화(등록)한다.
    public FrontControllerServletV5() {
        initHandlerMappingMap();
        initHandlerAdapters();
    }

    @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 modelView = adapter.handle(request, response, handler);

        MyView view = viewResolver(modelView.getViewName());
        view.render(modelView.getModel(), request, response);
    }
	
    /* 새로운 컨트롤러와 그에 맞는 어댑터가 추가되더라도 이 부분만 수정하면 된다! 뼈대를 흔들지 않는다! */
    //url에 매핍되는 핸들러를 반환
    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());
        
        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());
    }
}

 

다형성과 어댑터 덕분에 기존 구조를 유지하면서, 프레임워크의 기능을 확장할 수 있다.

이것이 역할과 구현을 분리한 것이다.
인터페이스로 뼈대 로직을 끌고 가기 때문에, 구현체를 갈아 끼워도 뼈대 코드를 바꿀 필요가 없다! (OCP)
만약 애노테이션을 활용한 컨트롤러를 만들고 싶다면, 관련 컨트롤러(핸들러)와 어댑터만 구현하면 된다!

정리

version 1. 프론트 컨트롤러 도입

수문장이 되는 서블릿에서 공통 처리가 가능해지고, url에 따라 각 컨트롤러를 호출한다.

프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받는다

 

version 2. View 중복 로직 처리

각 컨트롤러에서 중복되는 view 포워딩 로직을 제거하고 프론트 컨트롤러에서 처리한다.

프론트 컨트롤러에서 공통 기능 처리

 

version 3. Model 추가

컨트롤러에서 불필요한 서블릿 종속성 제거
+ 뷰 풀네임 내의 중복 → 변경의 지점을 하나로 모은다 (프론트 컨트롤러)

서블릿 종속성 제거(불필요한 req, res), 뷰 풀네임 내의 중복 제거(이름만 반환)

 

version 4. 단순하고 실용적인 컨트롤러

개발자가 구현하기 쉽도록 컨트롤러를 [비즈니스 로직 → 모델에 데이터 추가 → 뷰 이름 반환]의 구조로 단순화

항상 ModelView 객체를 생성하고 반환해야 하는 번거로움 제거

 

version 5. 유연한 컨트롤러

다양한 컨트롤러 인터페이스를 프론트 컨트롤러에서 일관되게 처리할 수 있도록 '어댑터 패턴' 도입

어댑터를 끼워 핸들러(컨트롤러) 실행 → 같은 형식의 결과값 반환

 

 

결론적으로, 프론트 컨트롤러어댑터를 통해 컨트롤러를 단순하고 유연하게 구현할 수 있게 되었다!

 

 

이것이 역할과 구현을 분리한 것이다.
인터페이스로 뼈대 로직을 끌고 가기 때문에, 구현체를 갈아 끼워도 뼈대 코드를 바꿀 필요가 없다! (OCP)

모든 것을 인터페이스 기반으로하고, 바꾸고 싶은 부분만 중간중간 구현체를 꽂아넣도록 설계해라!

 

 

이것이 스프링 MVC의 구조이다.
지금까지 작성한 코드는 스프링 MVC 프레임워크의 핵심 코드의 축약 버전이고, 구조도 거의 같다.
@RequestMapping("/hello") ▷ RequestMappingHandlerAdapter

 


 

참고 자료 & 이미지 출처
스프링 MVC 1편(김영한 님)