Post

Spring MVC 동작 원리

DispatcherServlet 중심 Spring MVC 구현해보기

Spring MVC 동작 원리

Spring MVC

https://docs.spring.io/spring-framework/reference/web/webmvc.html

Spring 프레임워크의 Spring MVCServlet API를 기반으로 웹 요청을 처리하는 핵심 모듈이다. Spring Boot 환경에서는 implementation 'org.springframework.boot:spring-boot-starter-web' 의존성을 추가하여 Spring MVC 기반의 웹 애플리케이션을 쉽게 개발할 수 있다. Spring Boot에 내장된 Tomcat은 Servlet Container로, Servlet API 기반의 Spring MVC가 동작하는 데 필수적인 실행 환경을 제공한다.

MVC 패턴

MVCModel, View, Controller의 약자로, 애플리케이션의 구성 요소를 세 가지 역할로 분리하는 디자인 패턴이다. 관심사 분리(Separation of Concerns, SoC)를 목표로 각 컴포넌트는 서로 독립적으로 작동하며, 이를 통해 코드의 결합도를 낮추고 각자의 역할에만 집중할 수 있게 한다.

  • Model (모델): 애플리케이션의 데이터와 비즈니스 로직을 담당한다. 데이터베이스와 상호작용하여 데이터를 처리하고, 컨트롤러의 요청에 따라 결과 데이터를 반환하는 역할을 수행한다.
  • View (뷰): 사용자에게 보여지는 UI(사용자 인터페이스)를 담당한다. 모델로부터 받은 데이터를 화면에 렌더링하여 사용자가 볼 수 있는 형태로 만드는 데 집중한다.
  • Controller (컨트롤러): 모델과 뷰 사이의 중재자 역할을 한다. 사용자의 입력을 받아 모델의 상태를 변경하고, 변경된 모델 데이터를 뷰에 전달하여 화면을 업데이트하도록 지시한다.

Spring MVC 패턴

Spring MVC는 MVC 패턴의 핵심 개념을 DispatcherServlet이라는 단일 진입점(Front Controller)을 통해 구현한다. 모든 클라이언트의 요청은 가장 먼저 DispatcherServlet으로 전달되며, DispatcherServlet은 요청 처리의 전 과정을 조율하고 각 컴포넌트에 작업을 위임한다.

Front Controller

프론트 컨트롤러 패턴은 모든 클라이언트 요청을 단일 진입점(Front Controller)에서 수신하고, 공통 로직 처리 후 요청을 개별 컨트롤러나 핸들러로 위임하는 구조다.

프론트 컨트롤러의 동작 단계는 다음과 같다.

  1. 클라이언트가 URL 요청을 단일 프론트 컨트롤러 객체에게 보낸다.
  2. 프론트 컨트롤러가 요청을 먼저 수신해 공통 전처리 로직을 수행한다.
  3. 실제 비즈니스 로직을 수행할 핸들러(Controller, Handler) 객체를 찾아 제어를 넘긴다.
  4. 핸들러가 요청 처리를 완료하면 프론트 컨트롤러에게 다시 제어권이 돌아온다.
  5. 프론트 컨트롤러는 처리된 결과를 바탕으로 렌더링할 뷰(View)를 결정하고, 최종 응답을 클라이언트에게 반환한다.

Servlet

https://jakarta.ee/specifications/servlet/4.0/apidocs/?overview-summary.html

ServletJava 기반의 웹 요청을 처리하는 서버 측 컴포넌트로, 클라이언트(브라우저)의 HTTP 요청을 받아 로직을 처리하고 응답을 반환하는 표준 인터페이스이다. 클라이언트의 요청을 처리하고, HTML/JSON 등의 응답을 생성하며 Servlet Container(Tomcat)에 의해 생명주기가 관리된다.

Servlet의 기본 메서드는 다음과 같다.

  • init(): 초기화 (최초 한 번)
  • service(): 요청 처리 메인 메서드 (GET, POST 등)
  • doGet(), doPost(): HTTP 메서드별 처리
  • destroy(): 소멸 처리

DispatcherServlet

https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-servlet.html

DispatcherServlet은 Spring MVC의 프론트 컨트롤러 역할을 수행하는 핵심 요소다. Spring의 웹 애플리케이션 진입점으로 모든 HTTP 요청을 가로채고, 해당 요청을 적절한 컨트롤러(핸들러)로 위임한 후 결과를 응답으로 전달한다. HttpServlet을 상속하며, Servlet의 표준 생명주기 메서드를 오버라이드하여 Spring MVC의 핵심 흐름을 구현한다.

DispatcherServlet의 다음과 같이 등록된다.

  • Spring Boot: 자동 등록 (@SpringBootApplication)

  • 전통 Servlet 환경: web.xml 또는 Java Config로 등록

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    // Java Config를 통한 DispatcherServlet 등록 예시
    public class MyWebAppInitializer implements WebApplicationInitializer {
      @Override
      public void onStartup(ServletContext servletContext) {
          AnnotationConfigWebApplicationContext appContext = new AnnotationConfigWebApplicationContext();
          appContext.register(AppConfig.class);
    
          DispatcherServlet dispatcherServlet = new DispatcherServlet(appContext);
          ServletRegistration.Dynamic registration = servletContext.addServlet("dispatcher", dispatcherServlet);
          registration.setLoadOnStartup(1);
          registration.addMapping("/");
      }
    }
    

DispatcherServlet의 핵심 역할은 다음과 같다.

  • 요청 매핑: HandlerMapping을 통해 적절한 Controller 찾기
  • 실행 위임: HandlerAdapter로 실제 컨트롤러 실행
  • 응답 처리: ViewResolver를 통해 View 결정 및 렌더링
  • 예외 처리: HandlerExceptionResolver를 통해 에러 핸들링

DispatcherServlet의 주요 메서드는 다음과 같다.

  • init()
    • Servlet 초기화 시 호출됨
    • Spring ApplicationContext 생성 및 내부 구성 요소 초기화 수행
  • doService(HttpServletRequest, HttpServletResponse)
    • 실제 요청을 처리하는 핵심 로직의 진입점
    • 내부적으로 아래 메서드 호출 흐름 포함
  • doDispatch(HttpServletRequest, HttpServletResponse)
    • 요청 라우팅 및 처리의 중심 메서드
    • 동작 흐름
      1. HandlerMapping을 통해 핸들러(Controller) 탐색
      2. HandlerAdapter로 핸들러 실행
      3. ModelAndView 반환
      4. ViewResolver로 View 탐색 및 렌더링
  • processDispatchResult()
    • 컨트롤러 처리 결과를 기반으로 View를 렌더링
  • processHandlerException()
    • 요청 처리 중 예외가 발생했을 때 예외 핸들러를 통해 응답 구성

Spring MVC 동작 원리

Spring MVC 동작 단계

DispatcherServlet을 중심으로 한 Spring MVC의 요청 처리 흐름은 다음과 같은 단계를 거친다.

  1. 클라이언트 HTTP 요청
    • 사용자가 브라우저에서 URL을 입력한다.
  2. DispatcherServlet 요청 수신
    • 모든 요청은 web.xml 또는 Spring Boot 설정에 의해 DispatcherServlet으로 전달된다.
  3. HandlerMapping을 통해 컨트롤러 찾기
    • DispatcherServletHandlerMapping에게 요청을 처리할 컨트롤러(핸들러)가 무엇인지 질의한다.
    • HandlerMapping은 요청 URL, HTTP 메서드 등의 정보를 바탕으로 가장 적절한 컨트롤러 메서드를 찾아 DispatcherServlet에 반환한다.
  4. HandlerAdapter를 통해 컨트롤러 실행
    • DispatcherServlet은 찾은 핸들러를 실행할 수 있는 HandlerAdapter를 찾아 컨트롤러를 실행한다.
  5. 핸들러 실행
    • 비즈니스 로직 수행 후 결과를 담은 ModelAndView 또는 ResponseEntity, @ResponseBodyDispatcherServlet에 반환한다.
  6. ViewResolver 호출
    • DispatcherServletModelAndView에서 뷰의 논리적 이름을 얻어 ViewResolver에게 전달한다.
    • homehome.html, home.jsp 등으로 매핑한다.
  7. View 렌더링 및 응답 반환
    • 최종 결과물을 HTML/JSON 등으로 렌더링하여 클라이언트에게 응답을 전달한다.

Spring MVC 구현하기

Spring MVC는 DispatcherServlet을 중심으로 요청을 처리하는 점이 핵심이다. 이 흐름은 크게 Servlet 등록, 컴포넌트 초기화, 요청 처리, 뷰 렌더링의 네 단계로 나눌 수 있다. 사용자의 요청이 컨트롤러에 전달되고, 그 결과가 뷰(View)로 렌더링되어 다시 사용자에게 돌아가기까지, 실제 동작 방식과 유사하게 Spring MVC를 구현해봤다.

Spring MVC 구현 GitHub Repo


1단계: 서버 시작과 DispatcherServlet 등록

애플리케이션이 시작되면, Servlet 3.0 스펙에 따라 web.xml 없이도 Servlet을 동적으로 등록하는 과정이 진행된다.

  1. ServletContainerInitializer 실행

    Servlet Container(Tomcat)는 SpringServletContainerInitializer를 실행한다. 이 클래스는 리플렉션으로 @HandlesTypes(WebApplicationInitializer.class) 어노테이션이 붙은 모든 WebApplicationInitializer 구현체를 찾아낸다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    
    @HandlesTypes(WebApplicationInitializer.class)
    public class SpringServletContainerInitializer implements ServletContainerInitializer {
    
     @Override
     public void onStartup(Set<Class<?>> webAppInitializerClasses, ServletContext servletContext)
             throws ServletException {
         final List<WebApplicationInitializer> initializers = new ArrayList<>();
    
         if (webAppInitializerClasses != null) {
             for (Class<?> waiClass : webAppInitializerClasses) {
                 try {
                     initializers.add((WebApplicationInitializer)
                             ReflectionUtils.accessibleConstructor(waiClass).newInstance());
                 } catch (Throwable ex) {
                     throw new ServletException("Failed to instantiate WebApplicationInitializer class", ex);
                 }
             }
         }
    
         if (initializers.isEmpty()) {
             servletContext.log("No Spring WebApplicationInitializer types detected on classpath");
             return;
         }
    
         for (WebApplicationInitializer initializer : initializers) {
             initializer.onStartup(servletContext);
         }
     }
    }
    
  2. WebApplicationInitializer 호출

    SpringServletContainerInitializer는 찾아낸 WebApplicationInitializer 구현체(DispatcherServletInitializer)의 인스턴스를 생성하고 onStartup 메서드를 호출한다.

    1
    2
    3
    
    public interface WebApplicationInitializer {
     void onStartup(ServletContext servletContext) throws ServletException;
    }
    
  3. DispatcherServlet 등록

    DispatcherServletInitializeronStartup 메서드 내에서 DispatcherServlet 인스턴스가 생성되고, Servlet 컨텍스트에 등록된다. addMapping("/") 설정을 통해 모든 요청을 처리하는 프론트 컨트롤러로 지정된다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    
    public class DispatcherServletInitializer implements WebApplicationInitializer {
    
     private static final Logger log = LoggerFactory.getLogger(DispatcherServletInitializer.class);
    
     private static final String DEFAULT_SERVLET_NAME = "dispatcher";
    
     @Override
     public void onStartup(final ServletContext servletContext) {
         final var dispatcherServlet = new DispatcherServlet();
    
         final var registration = servletContext.addServlet(DEFAULT_SERVLET_NAME, dispatcherServlet);
         if (registration == null) {
             throw new IllegalStateException("Failed to register servlet with name '" + DEFAULT_SERVLET_NAME + "'. " +
                     "Check if there is another servlet registered under the same name.");
         }
    
         registration.setLoadOnStartup(1);
         registration.addMapping("/");
    
         log.info("Start AppWebApplication Initializer");
     }
    }
    

2단계: DispatcherServlet 핵심 컴포넌트 초기화

  1. 핵심 컴포넌트 초기화

    DispatcherServlet은 생성된 후 init() 메서드를 통해 요청 처리에 필요한 핵심 컴포넌트들을 스스로 초기화한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    // DispatcherServlet.java
    @Override
    public void init() {
     // 1. 핸들러 매핑 전략들을 등록한다.
     this.handlerMappings = List.of(new AnnotationHandlerMapping("com.techcourse"), new ManualHandlerMapping());
     this.handlerMappings.forEach(HandlerMapping::initialize);
    
     // 2. 핸들러 어댑터 전략들을 등록한다.
     this.handlerAdapters = List.of(new AnnotationHandlerAdapter(), new ControllerAdapter());
    }
    
    • handlerMappings: 요청 URL을 어떤 컨트롤러가 처리할지 결정하는 핸들러 매핑 전략들의 리스트다. AnnotationHandlerMapping@Controller를, ManualHandlerMapping은 수동 등록된 컨트롤러를 찾는다.
    • handlerAdapters: 찾아낸 핸들러를 어떻게 실행할지 결정하는 핸들러 어댑터 전략들의 리스트다. AnnotationHandlerAdapter@RequestMapping 메서드를, ControllerAdapterController 인터페이스 구현체를 실행한다.
  2. AnnotationHandlerMapping 컴포넌트 초기화

    AnnotationHandlerMappingControllerScanner@Controller 어노테이션이 붙은 컨트롤러 클래스를 찾아 매핑한다. ControllerScanner는 리플렉션을 통해 런타임에 동적으로 컨트롤러를 자동으로 찾는다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    public class ControllerScanner {
    
     public Map<Class<?>, Object> scan(final Object[] basePackage) {
         try {
             return doScan(basePackage);
         } catch (final NoSuchMethodException | InvocationTargetException | InstantiationException |
                        IllegalAccessException e) {
             throw new IllegalStateException("Failed to scan controllers", e);
         }
     }
    
     private Map<Class<?>, Object> doScan(final Object[] basePackage)
             throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
         final Reflections reflections = new Reflections(basePackage);
         final Set<Class<?>> controllerClazzs = reflections.getTypesAnnotatedWith(Controller.class);
    
         final Map<Class<?>, Object> controllers = new HashMap<>();
         for (final Class<?> controllerClazz : controllerClazzs) {
             controllers.put(controllerClazz, controllerClazz.getDeclaredConstructor().newInstance());
         }
         return controllers;
     }
    }
    

3단계: 요청 처리 사이클

HTTP 요청이 들어오면 DispatcherServletservice 메서드가 호출되며 다음과 같은 처리 사이클이 시작된다.

  1. 핸들러 매핑: 요청 처리기 찾기

    가장 먼저 getHandler() 메서드가 호출되어, init()에서 등록된 handlerMappings 리스트를 순회하며 요청을 처리할 핸들러를 찾는다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
     // DispatcherServlet.java
     private Object getHandler(final HttpServletRequest request) {
         for (final HandlerMapping handlerMapping : handlerMappings) {
             final Object handler = handlerMapping.getHandler(request);
             if (handler != null) {
                 // 핸들러를 찾으면 즉시 반환한다.
                 return handler;
             }
         }
         return null;
     }
    

2. 핸들러 어댑터 조회: 처리기 실행자 찾기

다음으로 getHandlerAdapter() 메서드가 호출되어, 찾아낸 핸들러를 실행할 수 있는 HandlerAdapterhandlerAdapters 리스트에서 찾는다. 각 어댑터의 supports(handler) 메서드를 호출하여 가장 먼저 true를 반환하는 어댑터를 선택한다.

1
2
3
4
5
6
7
8
9
10
// DispatcherServlet.java
private HandlerAdapter getHandlerAdapter(final Object handler) {
    for (final HandlerAdapter handlerAdapter : handlerAdapters) {
        if (handlerAdapter.supports(handler)) {
            // 이 핸들러를 실행할 수 있는 어댑터를 찾으면 즉시 반환한다.
            return handlerAdapter;
        }
    }
    return null;
}
  1. 핸들러 실행 및 ModelAndView 반환

    선택된 HandlerAdapterhandle() 메서드가 호출되어 실제 컨트롤러 로직이 실행되고, 그 결과는 ModelAndView 객체에 담겨 반환된다. ModelAndView는 뷰에 전달할 데이터(Model)와 렌더링할 View 객체를 함께 가지고 있는 컨테이너이다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    
    // DispatcherServlet.java
    @Override
    protected void service(final HttpServletRequest request, final HttpServletResponse response)
         throws ServletException {
         // ...
    
     try {
         // ...
         final HandlerAdapter handlerAdapter = getHandlerAdapter(handler);
         if (handlerAdapter == null) {
             response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
             return;
         }
         final ModelAndView mav = handlerAdapter.handle(request, response, handler);
         renderView(mav, request, response);
     } catch (final Throwable e) {
         // ...
     }
    }
    

4단계: 뷰 렌더링과 최종 응답

마지막으로 renderView() 메서드가 호출되어 ModelAndView에 담긴 정보를 바탕으로 최종 응답 화면을 생성한다.

1
2
3
4
5
6
// DispatcherServlet.java
private void renderView(final ModelAndView mav, final HttpServletRequest request,
                        final HttpServletResponse response) throws Exception {
    final View view = mav.getView();
    view.render(mav.getModel(), request, response);
}

만약 ViewJspView라면 render 메서드는 모델 데이터를 request의 속성으로 옮겨 담은 뒤, RequestDispatcher를 통해 해당 JSP 파일로 제어권을 위임(forward)한다. JSP는 이 데이터를 사용하여 동적으로 HTML을 생성하고 최종적으로 완성된 HTML이 클라이언트에게 응답으로 전달된다.


Spring MVC 흐름 요약

단계별 요약

  1. URL 입력 및 요청 발생

    사용자가 브라우저에 URL을 입력하거나 클릭하면 웹 페이지 요청이 시작된다(www.google.com).

  2. DNS 조회

    브라우저는 DNS 서버에 도메인 이름을 질의하여 해당 도메인의 IP 주소를 얻는다(172.217.31.174).

  3. HTTP 요청 전송

    브라우저는 확보한 IP 주소를 이용해 웹 서버에 HTTP 요청을 보낸다. 요청 방식으로는 주로 GET, POST, PUT, DELETE 등이 사용된다.

  4. 서버 처리 및 응답 생성

    웹 서버는 요청을 분석하고 처리하며 필요에 따라 데이터베이스를 조회한다. 동적 콘텐츠는 HTML로 생성되며 이와 함께 CSS, JavaScript 등의 리소스를 포함한 HTTP 응답을 브라우저에 전송한다.

  5. 브라우저 렌더링(Rendering)

    브라우저는 서버 응답으로 받은 리소스를 처리하여 화면에 표시한다. HTML로 DOM을, CSS로 CSSOM을 생성한 후, 둘을 합쳐 렌더 트리를 구축한다. 이 렌더 트리를 바탕으로 레이아웃을 계산하고 화면에 그리는(Paint) 과정을 거쳐 사용자에게 웹 페이지를 보여준다.

  6. 최종 표시

    완성된 웹 페이지가 사용자에게 표시되며 JavaScript가 실행되어 동적 기능이 활성화된다.

최종 요약

사용자가 URL을 입력하면, 브라우저는 DNS 조회로 IP 주소를 얻어 웹 서버에 HTTP 요청을 보낸다. 서버는 요청을 분석하고 처리한 뒤, HTML, CSS, JavaScript 등의 리소스를 포함한 HTTP 응답을 다시 보낸다. 브라우저는 이 응답을 파싱하여 DOM과 CSSOM을 만들고, 이를 결합해 렌더링을 거쳐 페이지를 화면에 표시하며, JavaScript로 동적인 기능을 활성화한다.

This post is licensed under CC BY 4.0 by the author.