RE-Heat 개발자 일지

스프링 MVC 1편 - [4] MVC 프레임워크 만들기 본문

백엔드/스프링

스프링 MVC 1편 - [4] MVC 프레임워크 만들기

RE-Heat 2023. 6. 23. 19:28

출처 : https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

 

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

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., -

www.inflearn.com

인프런 김영한님의 스프링 MVC 1편 강의를 듣고 정리한 내용입니다.

 

[1] 프론트 컨트롤러 패턴 소개

  • 프론트 컨트롤러 하나로 클라이언트 요청받음
  • 프론트 컨트롤러가 요청에 맞는 컨트롤러 호출
  • 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿 사용하지 않아도 됨.(프론트 컨트롤러가 서블릿 요청을 다 받음)
  • 공통 처리 가능

[2] 프론트 컨트롤러 도입 -v1

 

FrontControllerServletV1

@WebServlet(name="frontControllerServletV1", urlPatterns = "/front-controller/v1/*") //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();//브라우저에서 온 URI 받아옴

        //ControllerV1 controller = new MemberListControllerV1();

        //가져오면
        ControllerV1 controller = controllerMap.get(requestURI);
        if (controller == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        controller.process(request, response);
    }
}

urlPatterns ="/front-controller/v1/*  은 v1을 포함한 모든 요청을 서블릿에서 받아들인다는 뜻

ControllerMap -> key: 매핑 URL, value:호출될 컨트롤러

service() => requestURI 조회에서 controllerMap 찾음. 없으면 404(SC_NOT_FOUND) 상태코드 반환

 

ControllerV1

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

=> 다형성을 위해 만든 인터페이스. 

각 컨트롤러에서 상속받은 뒤 비즈니스 로직에 맞게 오버라이딩

 

순서

1. 클라이언트가 HTTP 요청을 하면 FrontController가 요청한 URI에 알맞은 컨트롤러(MemeberForm, MemberSave, MemberList)를 호출.

2. 컨트롤러에서 JSP forward로 JSP로 이동

3. 클라이언트에게 HTML 응답

 

[3] View 분리 - v2

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

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

 

해결책 : 전담 View 객체 생성

컨트롤러가 직접 view(JSP)로 forward하는 대신 MyView 객체를 생성해서 호출하기만 함. forward는 MyView가 대신해 줌

 

FrontControllerServletV2

@WebServlet(name="frontControllerServletV2", urlPatterns = "/front-controller/v2/*") //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 {
        System.out.println("FrontControllerServletV2.service");

        String requestURI = request.getRequestURI();//브라우저에서 온 URI 받아옴


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

클라이언트 요청에 맞는 Controller 호출. 이후 컨트롤러에서 MyView값을 받아와 render()

 

ConrollerV2

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

MyView값을 return한다는 것이 V1과 차이점

 

MyView

public class MyView {
    private String viewPath;

    public MyView(String viewPath) { // /WEB-INF/views/new-form.jsp
        this.viewPath = viewPath;
    }

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

FrontController에서 받은 viewPath 값을 바탕으로 MyView에서 JSP로 forward

 

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

 

결과 : 컨트롤러에서 중복되던 dispatcher.forward 제거

 

[4] Model 추가 - v3

개선점

 - 서블릿 종속성 제거 : 프론트 컨트롤러가 아닌 컨트롤러는 HttpServletRequest·HttpServletResponse가 필요 없음. request의 임시저장소를 쓰던 객체를 Model로 사용하는 대신 별도의 Model객체를 만들어 관리하고, 프론트를 제외한 컨트롤러에선 서블릿 제거

 - 뷰 이름 중복 제거 : /WEB-INF/views/new-form.jsp 중 논리 이름인 new-form만 쓰고 중복되는 prefix : /WEB-INF/views와 suffix : .jsp는 viewResolver가 처리하도록 변경

 

v3 구조

① 컨트롤러 조회 : FrontController의 controllerMap에서 매핑 정보 확인

② 컨트롤러 호출 : HttpServletRequest 정보 토대로 paramMap({age=123, username=123})을 추출해서 보냄

③ 컨트롤러 ModelView 반환 : view 경로의 논리이름 + 로직으로 생긴 데이터(ex) memberRepository.findAll())를 ModelView 객체 담아 반환.

  1] String viewName은 new로 생성할 때 생성자 파라미터에 넣어 보냄

  2] Model 객체는 mv.getmodel()로 모델객체를 불러온 후 put으로 key값과 value값을 넣어줌.

④ viewResolver 호출 : 논리이름("save-result")에 suffix("/WEB-INF/views)와 prefix(".jsp) 추가해 주는 뷰리졸버 호출.

⑤ 뷰리졸버가 viewName에 suffix와 prefix 붙여준 후 MyView에 반환.

⑥ render(model) 호출 : MyView로 view 경로로 foward(model, request, respose). model이 필요한 이유는 메인 로직으로 생성된 데이터를 view를 그릴 때 사용할 수 있도록 하기 위해.

⑦ HTML 응답 : 서블릿이 jsp와 view를 토대로 Http 응답 메시지 생성.

 

ModelView

 

ControllerV2 vs Controller V3

Servlet 통째로 보내는 대신 paramMap을 통해 매개변수(HttpServletRequest에서 뽑아온 파라미터)만 전달.

일반적인 컨트롤러는 HttpServlet 몰라도 동작 가능 -> 서블릿 종속성 제거

 

MemberSaveControllerV2 vs MemberSaveControllerV3

메인 로직은 일치하나 v3에선 viewPath를 넣은 Model객체를 만든 후 view에 전달할 member의 정보를 넣는다.

 

FrontControllerServletV3

@WebServlet(name="frontControllerServletV3", urlPatterns = "/front-controller/v3/*") //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();//브라우저에서 온 URI 받아옴

        ControllerV3 controller = controllerMap.get(requestURI);
        if (controller == null){
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        //paramMap 참고 : 메소드 만드는 단축키 ctrl+alt+m
        Map<String, String> paramMap = createParamMap(request);
        ModelView mv = controller.process(paramMap);

        String viewName = mv.getViewName();//논리이름 ex)new-form
        MyView view = viewResolver(viewName);

        view.render(mv.getModel(), request, response);
    }

    private MyView viewResolver(String viewName) {
        return new MyView("/WEB-INF/views/" + viewName + ".jsp");
    }

    private Map<String, String> createParamMap(HttpServletRequest request) {
        Map<String, String> paramMap = new HashMap<>();
        request.getParameterNames().asIterator()
                .forEachRemaining(paramName -> paramMap.put(paramName, request.getParameter(paramName)));
        System.out.println("FrontControllerServletV3.createParamMap");
        System.out.println("paramMap = " + paramMap);
        return paramMap;
    }
}

① createParamMap메소드로 request에 담긴 파라미터를 담음, 이 값을 paramMap 객체에 담음

② Controller에 paraMap을 전송, 컨트롤러는 ModelView 객체(논리 이름 + 파라미터 값) 리턴

③ viewResolver가 model에 담긴 논리 이름을 실제 viewPath로 변경해 줌.

④ 렌더링 전 JSP가 읽을 수 있도록 request에 담는 작업 필요

modelToRequestAttribute() 메소드로 model객체를 request.setAttriubet(key, value)에 담음. 이후 viewPath를 담음 dispatcher에 request, response를 담아 전송.

 

[5] 단순하고 실용적인 컨트롤러 - v4

v3와 다른 점 : ModelView 객체를 일일이 생성하기 번거로우니 FrontController에서 model객체도 관리하도록 수정

 

v4의 구조

V3와 다른 점은 ModelView 대신 ViewName을 반환하는 것뿐이다.

 

ControllerV3 vs ControllerV4

FrontController에서 paramMap외에 model 객체도 생성해 보내 줌. 그래서 컨트롤러에선 model을 만들지 않고 jsp의 논리이름만 보내주면 됨

 

MemberSaveControllerV3 vs MemberSaveControllerV4

V3에선 컨트롤러마다 ModelView 객체를 만들어줘야 했으나 V4에선 파라미터로 받아온 model에 담기만 하면 됨. 그리고 모델객체 대신 논리이름만 리턴해 한결 코드가 간결해짐

 

FrontControllerServletV3 vs FrontControllerServletV4

① V3에 만들었던 ModelView 객체를 쓰지 않고, Map model을 생성해 보냄. 

② process에 Map model을 보내고 반환값으로 jsp 이름만 반환. 

 

[6] 유연한 컨트롤러1 - v5

v4와 다른 점

'다른 Controller 버전을 사용하고 싶을 땐 어떻게 할 것인가?' 에서 착안. 실제로 전기 코드를 꽂을 때 110V를 220V로 전환할 수 있게 '어댑터'처럼 컨트롤러도 호환될 수 있게 어댑터 추가.

 

v5의 구조

특이점 : 컨트롤러가 더 넓은 범위인 핸들러라는 명칭으로 바뀜  

핸들러 어댑터 : 중간에 어댑터 역할을 하는 핸들러 어댑터가 추가. 그래서 이제는 FrontController가 직접 핸들러를 호출하지 못하고 핸들러 어댑터를 거쳐 호출해야 함.

 

MyHandlerAdapter

public interface MyHandlerAdapter {
    boolean supports(Object handler);

    ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException;

}

① boolean supports(Object handler) : 어댑터가 해당 컨트롤러를 처리할 수 있는지 판단

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

  1] 어뎁터가 Controller 호출 결과를 ModelView로 반환

  2] 컨트롤러가 ModelView를 반환하지 못하면 어댑터가 ModelView 직접 생성해서 반환해야 함.

 

ControllerV3HandlerAdapter

public class ControllerV3HandlerAdapter implements MyHandlerAdapter {

    @Override
    public boolean supports(Object handler) {
        //5. MemberFormControllerV3 <ControllerV3 인스턴스 맞음
        return (handler instanceof ControllerV3); //v3의 인스턴스가 맞는 가?
    }

    @Override
    public ModelView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
        //MemberFormControllerV3
        ControllerV3 controller = (ControllerV3) handler; //supports에서 한 번 거른 후 쓰기 때문에 괜찮음

        Map<String, String> paramMap = createParamMap(request);
        ModelView mv = controller.process(paramMap); //v3는 modelview로 반환 v4는 String임

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

 - supports() : 전달된 handler가 ControllerV3의 인스턴스인지 확인

 - handle()

  handler를 ControllerV3으로 형변환. supports()에서 한 번 거른 후 형변환하기 때문에 문제 없음

  ModelView mv : ControllerV3는 ModelView 값을 반환하므로 ModelView로 받음 이 값을 FrontController에 전달

 

[7] 유연한 컨트롤러2 - v5

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) throws ServletException, IOException {
        ControllerV4 controller = (ControllerV4) handler;

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

기본적으로 ControllerV3HandlerAdapter와 유사

대신 v4 어댑터는 viewName을 ModelVie로 만들어서 형식에 맞추어 반환한다는 게 특징.

=> 기능을 확장할 땐 컨트롤러에 맞는 HandlerAdapter만 추가하면 됨.

 

FrontController와 연결된 모든 걸(핸들러 매핑, 핸들러 어댑터, 뷰 리졸버, MyView 등) interface로 바꾸고 외부 설정만 바꾸면 주입되게 하면 최상 => 스프링 MVC가 거쳐온 길 + 애노테이션까지 추가

 

정리

v1 : 프론트 컨트롤러를 도입

기존 구조를 최대한 유지한 상태에서 프론트 컨트롤러를 도입

 

v2 : View 분류

단순 반복되는 뷰 로직 분리

 

v3 : Model 추가

서블릿 종속성 제거 (HttpServletRequset, HttpServletResponse 보내지 않게 ModelView 객체 사용)

뷰 이름 중복 제거 (뷰 리졸버)

 

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

구현할 때 ModelView 직접 생성하지 않도록 함. FrontController에서 처리

 

v5: 유연한 컨트롤러

어댑터 도입

어댑터 추가해 프레임워크를 유연하고 확정성 있게 설계함.

 

여기서 애노테이션 지원하는 어댑터까지 추가하면 최상. 이게 스프링 MVC의 기본 구조