본문으로 바로가기

스프링 MVC 1 정리

category Coding/Java Spring 2023. 9. 12. 23:12
반응형

서블릿

@ServletComponentScan

@SpringBootApplication
@ServletComponentScan
public class ServletApplication {
	public static void main(String[] args) {
		SpringApplication.run(ServletApplication.class, args);
	}
}

스프링 부트는 서블릿을 직접 등록해서 사용할 수 있는 어노테이션을 제공한다. 예를 들어 위처럼 @ServletComponentScan 어노테이션을 사용하면 스프링이 자동으로 현재 패키지 기준 하위 패키지를 스캔하여 모든 서블릿을 찾고 등록시켜준다.

@WebServlet

@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        super.service(req, resp);
    }
}

서블릿은 @WebServlet 어노테이션을 통해 생성할 수 있다. WAS는 urlPatterns에 명시한 URL과 일치하는 서블릿이 있는 경우 Request/Response 객체를 만들고 해당 서블릿으로 요청을 전달한다. 그리고 오버라이드한 service() 메소드를 호출하게 되는데, 이러한 이유로 인해 service() 메소드의 인자가 Request/Response인것이다.

HttpServletRequest

서블릿은 개발자가 HTTP Request를 편리하게 사용할 수 있도록 개발자 대신 요청 메시지를 파싱한다. 그리고 그 결과를 HttpServletRequest 객체에 담아 제공한다.

MVC

프론트 컨트롤러

실제로 Dispatcher Servlet은 프론트 컨트롤러 패턴으로 구현되어있다. 클라이언트가 요청을 보내면 각 URL에 맞는 컨트롤러로 직접 요청이 들어가는게 아닌 프론트 컨트롤러(서블릿)으로 요청이 오게 된다. 그리고 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출한다. 이를 통해 공통 처리를 한군데로 집중시킬 수 있고 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 되게끔 한다. 간단하게 단계별로 나누자면 다음과 같다.

  1. 클라이언트가 프론트 컨트롤러로 HTTP요청을 보낸다.
  2. 프론트 컨트롤러는 받은 요청을 통해 URL매핑 정보를 조회하여 해당하는 컨트롤러를 찾는다.
  3. 프론트 컨트롤러는 2번에서 찾은 컨트롤러를 호출한다.
  4. 컨트롤러는 로직을 수행하고 응답을 리턴한다.

버전 1

버전 1에서는 위와 같은 간단한 구조를 만들어본다.

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

먼저 HttpServlet과 거의 동일한 메소드가 정의되어있는 인터페이스를 하나 생성한다. 이제부터 점진적으로 코드를 개선해나갈텐데 각 구현체는 위 인터페이스를 구현한다고 생각하면 된다.

public class MemberFormControllerV1 implements ControllerV1 {
    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.getWriter().write("MemberFormControllerV1");
        response.setStatus(200);
    }
}


public class MemberSaveControllerV1 implements ControllerV1 {
    @Override
    public void process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.getWriter().write("MemberSaveControllerV1");
        response.setStatus(200);
    }
}

이제 위처럼 Form/Save 컨트롤러 2개를 구현한다. 강의에서는 화면을 랜더링하는데 크게 중요한 부분은 아니라 나는 단순히 Response를 빼는 형태로만 구현해줬다. 실제 강의에서는 뷰 처리하는 로직이 들어있다.

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

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();
        ControllerV1 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        controller.process(req, resp);
    }
}

다음으로 프론트 컨트롤러를 만든다. 본문 상단 서블릿을 등록하는 과정과 거의 유사하다.

  • urlPatterns를 통해 특정 URL로 들어오면 모두 매칭되도록 하였다.
  • controllerMap에 URL매핑 정보를 담아준다. 참고로 위에서는 생성자에서 URL에 맞게 컨트롤러를 인스턴스화하여 넣어줬다. 추후 매핑 정보를 찾을 때 이렇게 생성한 Map을 사용한다.
  • WAS는 urlPatterns에 명시한 URL과 일치하는 서블릿이 있는 경우 Request/Response 객체를 만들고 해당 서블릿으로 요청을 전달한다고 하였다. 그리고 전달하는 데이터는 service() 메소드를 호출한다.
  • 해당 메소드에서는 위에서 생성한 매핑 정보(controllerMap)에서 매칭되는 컨트롤러를 찾은다음 없다면 404를, 있다면 해당 컨트롤러의 process() 메소드를 호출한다.

버전 2

버전 1에서는 모든 컨트롤러에서 뷰 처리 로직이 중복되어있다. 따라서 버전 2에서는 뷰를 별도로 처리하는 객체를 만들어본다. 그림을 보면 Controller에서 View처리를 직접하였지만, 이젠 MyView라는 객체를 반환하고 프론트 컨트롤러에서 MyView의 render() 메소드를 호출하는 형태로 수정한다. 버전 2부터는 실제 JSP페이지로 랜더링하는 로직을 포함해야하므로 버전 1처럼 단순 Response를 빼는게 아닌 본 강의와 동일한 형태로 작업한다.

public class MyView {
    private String viewPath;

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

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

req.getRqeustDispatcher(viewPath) 를 통해 처리할 RequestDispatcher를 가져오고 forward()를 호출하여 Request/Response를 전달한다. 버전 1에서 각 컨트롤러에 있던 뷰 처리 로직을 MyView를 만들어서 넣는다고 생각하면 된다.

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

이제 컨트롤러 인터페이스를 새롭게 생성한다. 이전에는 리턴타입이 void였지만 이번에는 위에서 만든 MyView를 리턴하도록 한다.

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


public class MemberSaveControllerV2 implements ControllerV2 {
    @Override
    public MyView process(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        return new MyView("/WEB-INF/views/save-result.jsp");
    }
}

그리고 이전과는 다르게 MyView를 리턴하도록 컨트롤러를 작성한다.

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

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String requestURI = req.getRequestURI();
        ControllerV2 controller = controllerMap.get(requestURI);
        if (controller == null) {
            resp.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }
        MyView view = controller.process(req, resp);
        view.render(req, resp);
    }
}

마지막으로 프론트 컨트롤러를 작성한다. 버전 1과 다른점은 가장 마지막 아래 2줄인데, 리턴타입이 void -> MyView로 변경되었기 때문에 컨트롤러의 실행 결과(MyView)를 받고, 해당 뷰에 있는 render() 메소드를 호출하도록 한다.

버전 3/4

Model에 관한 내용이므로 스킵한다.

버전 5

현재까지 개발한 컨트롤러는 한가지 방식의 인터페이스만 사용할 수 있다. 버전 1/2를 보면 ControllerV1/V2를 상속받아 구현했는데 이 2가지 인터페이스는 전혀다른 인터페이스이다. 따라서 호환이 불가능한데, 이럴 때 어댑터 패턴을 사용하면 다양한 방식의 컨트롤러를 처리할 수 있도록 변경할 수 있다.

기존과 다르게 중간에 핸들러 어댑터와 핸들러가 추가되었다. 핸들러 어댑터는 프론트 컨트롤러와 컨트롤러의 중간다리 역할을 해줌으로써 다양한 형태의 컨트롤러를 사용할 수 있도록 해준다. 핸들러는 컨트롤러와 동일한데 좀 더 넓은 범위를 가리키기 위해 핸들러라고 단순히 네이밍만 변경해준 것이다.

public interface MyHandlerAdapter {
    boolean supports(Object handler);

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

먼저 어댑터 인터페이스를 정의한다. supports() 메소드는 핸들러(컨트롤러)를 인자로 받아서 현재 처리할 수 있는 핸들러인지 판단한다. handle() 메소드는 실제 핸들러를 호출하고 그 결과로 MyView를 리턴한다. 실제 컨트롤러가 MyView를 반환하지 못하면 어댑터가 직접 생성해서라도 반환해야한다.

이전에는 프론트 컨트롤러가 실제 컨트롤러를 호출했지만 이제는 어댑터를 통해 컨트롤러가 호출된다는 점이 기존과 가장 큰 차이점이라고 생각하면 된다.

public class ControllerV2HandlerAdapter implements MyHandlerAdapter {
    @Override
    public boolean supports(Object handler) {
        return (handler instanceof ControllerV2);
    }

    @Override
    public MyView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws ServletException, IOException {
        ControllerV2 controller = (ControllerV2) handler;
        return controller.process(request, response);
    }
}

이제 실제 어댑터 구현체를 만들어준다. supports()에서는 ControllerV2의 타입이라면 지원이 가능하기에 위처럼 작성한다. 그리고 handler()에 실제 컨트롤러를 호출한 결과를 리턴하도록 한다.

@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() {
        handlerMappingMap.put("/front-controller/v2/members/new-form", new MemberFormControllerV2());
        handlerMappingMap.put("/front-controller/v2/members/save", new MemberSaveControllerV2());

        handlerAdapters.add(new ControllerV2HandlerAdapter());
    }

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String requestURI = request.getRequestURI();
        Object handler = handlerMappingMap.get(requestURI);
        if (handler == null) {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            return;
        }

        MyHandlerAdapter adapter = getHandlerAdapter(handler);
        MyView view = adapter.handle(request, response, handler);
        view.render(request, response);
    }

    private MyHandlerAdapter getHandlerAdapter(Object handler) {
        for (MyHandlerAdapter adapter : handlerAdapters) {
            if (adapter.supports(handler)) {
                return adapter;
            }
        }
        throw new IllegalArgumentException("handler adapter not found");
    }
}

이제 프론트 컨트롤러를 작성한다. 기존과는 다른점은 handlerMapping Map에서 밸류의 타입이 Object로 바뀐점인데 이는 여러가지 타입의 컨트롤러를 저장해야하기 때문이다. 또한 핸들러 어댑터를 저장하는 리스트인 handlerAdapters 또한 추가적으로 정의해줬다.

service() 쪽은 기존과 거의 동일하다. handlerMapping에서 핸들러를 가져온 후 해당 핸들러를 처리할 수 있는 핸들러 어댑터에게 핸들러를 위임하여 처리한다. 본문에서는 버전 2 컨트롤러 하나만을 추가하였지만 동일하게 버전 3 컨트롤러도 추가해주면 인터페이스가 다른 여러가지의 컨트롤러를 지원할 수 있는 유연성을 가져갈 수 있다.

스프링 MVC 구조

실제 스프링 MVC의 Dispatcher Servlet도 프론트 컨트롤러 패턴을 통해 구현되어있다. 그리고 DispatcherServlet은 우리가 위에서 만들었던 서블릿과 동일하게 HttpServlet을 상속받아서 사용하고 서블릿으로 동작한다. 스프링 부트는 DispatcherServlet을 자동으로 서블릿으로 등록하면서 인자로 준 urlPatterns에 적힌 모든 경로를 매핑한다. 간단하게 흐름을 정리하면 다음과 같다.

  • 서블릿이 호출되면 HttpServlet이 제공하는 service()가 호출된다.
  • 스프링 MVC는 DispatcherServlet의 부모인 FrameworkServlet에서 service()를 오버라이드 해두었다.
  • FrameworkServlet.service()를 시작으로 여러 메소드가 호출되면서 DispatcherServlet.doDispatch()가 호출된다.

doDispatch()

간단하게 핵심 로직만 살펴보자.

  • 핸들러를 조회한다.
    • 핸들러가 없다면 404를 리턴한다.
  • 핸들러를 처리할 수 있는 핸들러 어댑터를 찾고 위에서 꺼낸 핸들러를 위임하여 실제로 호출한다.
    • 핸들러 어댑터의 supports() 메소드를 통해 루프를 돌며 현재 지원 가능한 핸들러인지 판단한다.
  • 리턴값을 받고 ModelAndView를 반환한다.
  • ViewResolver 통해 뷰를 찾고 View를 반환한다.
  • View를 랜더링한다.

MVC 동작 원리

그림을 참고하면서 본다.

  1. 핸들러 조회: 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러를 조회한다.
  2. 핸들러 어댑터 조회: 핸들러를 실행할 수 있는 어댑터를 조회한다.
  3. 핸들러 어댑터 실행: 핸들러 어댑터에 핸들러를 위임하여 실행한다.
  4. ModelAndView 반환: 핸들러 어댑터는 핸들러가 반환하는 정보를 ModelAndView로 변환하여 반환한다.
  5. ViewResolver 호출: 뷰 리졸버를 찾고 실행한다.
  6. View 반환: 뷰 리졸버는 뷰의 논리 이름을 물리 이름으로 바꾸고 랜더링 역할을 담당하는 뷰 객체를 반환한다.
  7. 뷰 랜더링: 뷰를 통해 뷰를 랜더링한다.

인터페이스

  • 핸들러 매핑: `org.springframework.web.servlet.HandlerMapping`
  • 핸들러 어댑터: `org.springframework.web.servlet.HandlerAdapter`
  • 뷰 리졸버: `org.springframework.web.servlet.ViewResolver`
  • 뷰: `org.springframework.web.servlet.View`

스프링 MVC는 위와 같은 인터페이스를 제공하는데, 이 인터페이스들의 구현체를 사용자가 만들어서 커스텀한 컨트롤러를 사용할 수 있게 해준다.

스프링 자동 등록

  • RequestMappingHandlerMapping: 어노테이션 기반 컨트롤러인 @RequestMapping에서 사용
  • BeanNameUrlHandlerMapping: 스프링 빈의 이름으로 핸들러를 찾음

핸들러 매핑의 경우 위 순서대로 스프링이 자동으로 핸들러를 등록한다.

  • RequestMappingHandlerAdapter: 어노테이션 기반 컨트롤러인 @RequestMapping에서 사용
  • HttpRequestHandlerAdapter: HttpRequestHandler 처리
  • SimpleControllerHandlerAdapter: Controller 인터페이스(어노테이션X, 과거에 사용) 처리

핸들러 어댑터는 위 순서대로 등록한다.

Reference

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

반응형