ABOUT ME

Today
Yesterday
Total
  • Spring MVC - Understanding Structure
    Java/Spring 2025. 3. 17. 17:00

    Spring MVC 패턴의 전체 구조를 이해합니다.

     

     


    < 스프링 MVC 전체 구조 >

    직접 만든 MVC 프레임워크와 스프링 MVC를 비교해 보겠습니다.

     

    직접 만든 MVC 프레임워크

     

    Spring MVC

     

    직접 만든 MVC 프레임워크 → 스프링 MVC 비교

    • FrontController → DispatcherServlet
    • handlerMappingMap → HandlerMapping
    • MyHandlerAdapter → HandlerAdapter
    • ModelView → ModelAndView
    • viewResolver → ViewResolver
    • MyView → View

     

     


    DispatcherServlet 구조 살펴보기

    스프링 MVC는 프론트 컨트롤러 패턴으로 구현되어 있으며, 그 핵심이 바로 디스패처 서블릿(DispatcherServlet)입니다.

    Dispatcher Servlet 상속 다이어그램

    • DispatcherServlet은 부모 클래스로부터 HttpServlet을 상속받아 서블릿으로 동작합니다.
    • 클래스 계층 구조는 DispatcherServletFrameworkServletHttpServletBeanHttpServlet 입니다.
    • 스프링 부트는 DispatcherServlet을 서블릿으로 자동 등록하며, 모든 경로(urlPatterns="/")에 매핑합니다.

     

     


    DispatcherServlet 요청 흐름

    • 서블릿이 호출되면 HttpServlet이 제공하는 service()가 실행됩니다.
    • 스프링 MVC에서는 DispatcherServlet의 부모인 FrameworkServlet에서 service()를 오버라이드하여 구현하였습니다.
    • 이 과정에서 여러 메서드가 호출되며 최종적으로 DispatcherServlet의 doDispatch()가 실행됩니다.


    이제 DispatcherServlet의 핵심인 doDispatch() 코드를 분석해보겠습니다.
    (단, 설명을 간단히 하기 위해 예외 처리와 인터셉터 기능은 제외하였습니다)

     

    protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        
        HttpServletRequest processedRequest = request;
        HandlerExecutionChain mappedHandler = null;
        ModelAndView mv = null;
        
        // 1. 핸들러 조회
        mappedHandler = getHandler(processedRequest);
        if (mappedHandler == null) {
    		noHandlerFound(processedRequest, response);
            return;
        }
        
        // 2. 핸들러 어댑터 조회 - 핸들러를 처리할 수 있는 어댑터
        HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
        
        // 3. 핸들러 어댑터 실행 -> 4. 핸들러 어댑터를 통해 핸들러 실행 -> 5. ModelAndView 반환
        mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
        
        processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
        
    }
    
    private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, 
                                       HandlerExecutionChain mappedHandler, ModelAndView mv, Exception exception) throws Exception {
        
        // 뷰 렌더링 호출
        render(mv, request, response);
    }
    
    protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
        
        View view;
        String viewName = mv.getViewName();
        
        // 6. 뷰 리졸버를 통해서 뷰 찾기, 7. View 반환
        view = resolveViewName(viewName, mv.getModelInternal(), locale, request);
        
        // 8. 뷰 렌더링
        view.render(mv.getModelInternal(), request, response);
    }

     

    코드를 살펴보면, 스프링 MVC가 우리가 직접 구현한 MVC 프레임워크와 유사한 과정을 거친다는 것을 확인할 수 있습니다.
    즉, 스프링 MVC의 전체적인 동작 순서는 다음과 같습니다.

     

    Spring MVC

     

    1. 핸들러 조회: 핸들러 매핑을 통해 요청 URL에 매핑된 핸들러(컨트롤러)를 조회합니다.
    2. 핸들러 어댑터 조회: 해당 핸들러를 실행할 수 있는 핸들러 어댑터를 조회합니다.
    3. 핸들러 어댑터 실행: 조회된 핸들러 어댑터를 실행합니다.
    4. 핸들러 실행: 핸들러 어댑터가 실제 핸들러(컨트롤러)를 실행합니다.
    5. ModelAndView 반환: 핸들러 실행 결과를 ModelAndView로 변환하여 반환합니다.
    6. 뷰 리졸버 호출: ViewResolver를 찾아 실행합니다.
    7. 뷰 반환: 뷰 리졸버가 논리 뷰 이름을 물리 뷰 이름으로 변환한 후, 렌더링을 담당하는 뷰 객체를 반환합니다.
    8. 뷰 렌더링: 최종적으로 뷰를 통해 화면을 렌더링합니다.

     

     


    스프링 MVC - 인터페이스

    스프링 MVC의 큰 강점은 DispatcherServlet 코드를 변경하지 않고도 원하는 기능을 변경하거나 확장할 수 있다는 점입니다.
    스프링은 대부분의 기능을 확장 가능하도록 인터페이스로 제공하며, 이를 구현하여 DispatcherServlet에 등록하면 원하는 방식으로 동작을 커스터마이징할 수 있습니다.

     

    주요 인터페이스 목록 :

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

    이러한 인터페이스를 구현하면 자신만의 컨트롤러와 뷰 처리를 구성할 수도 있습니다.

     

     

    스프링 MVC는 코드가 방대하고 복잡하여 내부 구조를 완전히 파악하기 쉽지 않습니다. 하지만 직접 확장하거나 나만의 컨트롤러를 만들 일은 거의 없으므로 걱정할 필요는 없습니다. 이미 스프링 MVC는 다양한 개발자들의 요구사항을 반영해 필요한 기능을 대부분 제공하고 있기 때문입니다.
    그럼에도 핵심 동작 방식을 이해하면, 문제 발생 시 원인을 빠르게 파악하고 해결할 수 있으며, 확장이 필요할 때 어떤 부분을 수정해야 하는지 감을 잡을 수 있습니다.

     

     


    < 핸들러 매핑과 핸들러 어댑터 >

    핸들러 매핑과 핸들러 어댑터의 종류와 사용 방법을 살펴보겠습니다.
    과거에는 스프링이 제공하는 간단한 컨트롤러를 주로 사용했으며, 이를 통해 핸들러 매핑과 어댑터의 동작을 이해해 보겠습니다.

     

     


    Controller 인터페이스

    스프링도 처음에는 아래와 같은 딱딱한 형식의 컨트롤러를 제공하였습니다.

    public interface Controller {
        ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
    }

    Controller 인터페이스는 @Controller 애노테이션과는 전혀 다릅니다.

     

    이를 구현하면 다음과 같습니다.

    @Component("/springmvc/old-controller")
    public class OldController implements Controller {
    
        @Override
        public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
            System.out.println("OldController.handleRequest");
            return null;
        }
    }
    • @Component를 사용하면 이 컨트롤러는 /springmvc/old-controller라는 이름의 스프링 빈으로 등록되며, 해당 빈의 이름을 URL에 매핑합니다.
    • 실행 후 콘솔에 OldController.handleRequest가 출력되면 성공입니다.

     

    그렇다면 이 컨트롤러는 어떻게 호출될 수 있는 걸까요?

     
    Spring MVC

    이 컨트롤러가 호출되려면 다음 두 가지가 필요합니다.

    1. HandlerMapping(핸들러 매핑)
      • 핸들러 매핑을 통해 이 컨트롤러를 찾을 수 있어야 합니다.
      • 예: 스프링 빈의 이름으로 핸들러를 찾을 수 있는 핸들러 매핑이 필요합니다.
    2. HandlerAdapter(핸들러 어댑터)
      • 핸들러 매핑을 통해 찾은 핸들러를 실행할 수 있는 핸들러 어댑터가 필요합니다.
      • 예: Controller 인터페이스를 실행할 수 있는 핸들러 어댑터가 필요합니다.

    스프링은 이미 대부분의 핸들러 매핑과 핸들러 어댑터를 구현해두었기 때문에, 개발자가 직접 이를 만들 필요는 거의 없습니다.

     

     


    스프링 부트가 자동 등록하는 핸들러 매핑과 핸들러 어댑터

    스프링 부트는 여러 개의 핸들러 매핑과 핸들러 어댑터를 우선순위에 따라 사용합니다.

     

     

    핸들러 매핑(HandlerMapping)

    스프링 부트는 여러 핸들러 매핑을 등록하며, 우선순위(숫자가 작을수록 우선순위가 높음)에 따라 컨트롤러를 찾습니다.

     

    1. RequestMappingHandlerMapping (우선순위 0)

    • @RequestMapping 애노테이션이 붙은 컨트롤러를 찾아 실행합니다.
    • 현재 스프링 MVC에서 가장 많이 사용하는 방식입니다.

    Ex)

    @RestController
    public class MyController {
        @RequestMapping("/hello")
        public String hello() {
            return "Hello, Spring!";
        }
    }

    : RequestMappingHandlerMapping이 /hello 요청을 이 컨트롤러로 매핑합니다.

     

    2. BeanNameUrlHandlerMapping (우선순위 1)

    • 스프링 빈의 이름을 URL로 사용하여 컨트롤러를 찾습니다.
    • 과거에 사용되던 방식이며, 최근에는 거의 사용하지 않습니다.

    Ex)

    @Component("/old-controller")
    public class OldController implements Controller {
        @Override
        public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) {
            return new ModelAndView("oldView");
        }
    }

    : BeanNameUrlHandlerMapping이 /old-controller 요청을 OldController로 매핑합니다.

     

     

    핸들러 어댑터 (HandlerAdapter)

    스프링 부트는 여러 핸들러 어댑터를 제공하며, 우선순위(숫자가 작을수록 우선순위가 높음)에 따라 적절한 어댑터를 사용합니다.

     

    1. RequestMappingHandlerAdapter (우선순위 0)

    • @RequestMapping 애노테이션이 붙은 컨트롤러를 실행합니다.
    • 현재 가장 많이 사용하는 방식입니다.

    Ex)

    @RestController
    public class MyController {
        @RequestMapping("/hello")
        public String hello() {
            return "Hello, Spring!";
        }
    }

    : RequestMappingHandlerAdapter가 이 컨트롤러를 실행합니다.


    2. HttpRequestHandlerAdapter (우선순위 1)

    • HttpRequestHandler 인터페이스를 구현한 핸들러를 실행합니다.

    Ex)

    @Component("/myRequestHandler")
    public class MyHttpRequestHandler implements HttpRequestHandler {
        @Override
        public void handleRequest(HttpServletRequest request, HttpServletResponse response) {
            response.getWriter().write("Hello from HttpRequestHandler");
        }
    }

    : HttpRequestHandlerAdapter가 이 핸들러를 실행합니다.

     

    3. SimpleControllerHandlerAdapter (우선순위 2)

    • 옛날 방식의 Controller 인터페이스를 구현한 핸들러를 실행합니다.
    • 과거에 사용되던 방식이며, 최근에는 거의 사용하지 않습니다.

    Ex)

    @Component("/old-controller")
    public class OldController implements Controller {
        @Override
        public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) {
            return new ModelAndView("oldView");
        }
    }

    : SimpleControllerHandlerAdapter가 이 컨트롤러를 실행합니다.

     

     


    Controller 호출 과정

     

    위에서 구현한 Controller가 호출되는 과정은 다음과 같습니다.

     

    1. 핸들러 매핑으로 핸들러 조회

    • HandlerMapping을 순서대로 실행하여 요청을 처리할 핸들러를 찾습니다.
    • 이 경우 빈 이름으로 핸들러를 찾아야 하므로, BeanNameUrlHandlerMapping이 실행되어 OldController를 반환합니다.

    2. 핸들러 어댑터 조회

    • HandlerAdapter의 supports() 메서드를 순서대로 호출하여 해당 핸들러를 실행할 수 있는 어댑터를 찾습니다.
    • SimpleControllerHandlerAdapter가 Controller 인터페이스를 지원하므로 선택됩니다.

    3. 핸들러 어댑터 실행

    • DispatcherServlet이 조회한 SimpleControllerHandlerAdapter를 실행하면서 핸들러 정보를 함께 전달합니다.
    • SimpleControllerHandlerAdapter는 OldController를 실행하고, 그 결과를 반환합니다.

     

     


    < 뷰 리졸버 >

    이번에는 뷰 리졸버에 대해서 자세히 알아보겠습니다.
     
    먼저, View를 사용할 수 있도록 Controller 코드를 수정하겠습니다.
    @Component("/springmvc/old-controller")
    public class OldController implements Controller {
    
        @Override
        public ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception {
            System.out.println("OldController.handleRequest");
            return new ModelAndView("new-form");
        }
    }

     

    위 코드를 실행해보면 컨트롤러는 정상 호출되지만, Whitelabel Error Page 오류가 발생합니다.

    이를 해결하기 위해선 application.properties 에 다음 코드를 추가해 주어야 합니다.

    spring.mvc.view.prefix=/WEB-INF/views/
    spring.mvc.view.suffix=.jsp

     

     

    Spring Boot는 InternalResourceViewResolver를 자동으로 등록하며, 이때 application.properties에 설정된 spring.mvc.view.prefix와 spring.mvc.view.suffix 값을 사용합니다. 따라서 해당 설정이 반드시 필요합니다.

     

    권장하지는 않지만 설정 없이 다음과 같이 전체 경로를 주어도 동작하기는 합니다.
    return new ModelAndView("/WEB-INF/views/new-form.jsp");

     

     


    뷰 리졸버 동작 방식

    Spring Boot는 여러 개의 뷰 리졸버(ViewResolver)를 자동으로 등록합니다. (실제로는 더 많지만, 주요 뷰 리졸버만 설명합니다.)
    아래 숫자는 우선순위를 나타냅니다.

    1. BeanNameViewResolver – 빈 이름을 기반으로 뷰를 찾아 반환
    2. InternalResourceViewResolver – JSP를 처리할 수 있는 뷰를 반환

     

    위 컨트롤러를 예로 들어, 뷰 리졸버가 동작하는 방식은 다음과 같습니다.

     

    1. 핸들러 어댑터 호출

    • 핸들러 어댑터를 통해 "new-form"이라는 논리적 뷰 이름을 획득합니다.

    2. ViewResolver 호출

    • "new-form"이라는 뷰 이름을 기반으로 ViewResolver들을 순서대로 호출합니다.
    • BeanNameViewResolver는 new-form이라는 이름의 스프링 빈으로 등록된 뷰를 찾아야 하는데 없기 때문에 다음 순서인 InternalResourceViewResolver가 호출됩니다.

    3. InternalResourceViewResolver 호출

    • 이 뷰 리졸버는 InternalResourceView를 반환합니다.

    4. 뷰 - InternalResourceView

    • InternalResourceView는 JSP처럼 포워드 forward()를 호출해서 처리할 수 있는 경우에 사용하는 뷰입니다.

    5. view.render()

    • view.render()가 호출되고 InternalResourceView는 forward()를 사용해서 JSP를 실행합니다.

     

    [ JSTL 라이브러리와 JstlView ]
    InternalResourceViewResolver는 JSTL 라이브러리가 있으면 JstlView를 반환하며, JSTL 태그 사용 시 추가 기능을 제공합니다.

    [ JSP와 다른 뷰 템플릿의 렌더링 차이 ]
    JSP는 forward()를 통해 실행되지만, Thymeleaf 등 다른 뷰 템플릿은 별도의 이동 없이 바로 렌더링됩니다.

    [ Thymeleaf 뷰 리졸버 자동 등록 ]
    Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 합니다. 최근에는 라이브러리만 추가하면 스프링 부트가 ThymeleafViewResolver를 자동으로 등록 및 설정해줍니다.

     

     


    < 스프링 MVC - 시작하기 >

    이제 스프링 MVC를 본격적으로 시작해보겠습니다.


    스프링에서 제공하는 컨트롤러는 애노테이션 기반으로 동작하기 때문에 매우 유연하고 실용적입니다. 과거에는 자바 언어에 애노테이션이 없었으며, 스프링 역시 처음부터 이러한 유연한 컨트롤러를 제공하지 않았습니다.

     

     

    @RequestMapping

    스프링 MVC에서 가장 높은 우선순위를 가지는 핸들러 매핑과 핸들러 어댑터는 RequestMappingHandlerMappingRequestMappingHandlerAdapter입니다.
    이들은 @RequestMapping의 앞 글자를 따서 이름 지어졌으며, 현재 스프링에서 주로 사용하는 애노테이션 기반 컨트롤러를 지원하는 매핑과 어댑터입니다. 실무에서도 99.9% 이 방식의 컨트롤러를 사용합니다.

     

    이제 애노테이션 기반 컨트롤러를 직접 사용해보겠습니다. 기존에 만들었던 프레임워크 스타일의 컨트롤러를 @RequestMapping 기반의 스프링 MVC 컨트롤러로 변경해보겠습니다.

    @Controller
    public class SpringMemberFormControllerV1 {
    
        @RequestMapping("/springmvc/v1/members/new-form")
        public ModelAndView process() {
            return new ModelAndView("new-form");
        }
    }

    @Controller

    • 스프링이 해당 클래스를 자동으로 스프링 빈으로 등록합니다.
    • 내부적으로 @Component 애노테이션을 포함하고 있어 컴포넌트 스캔 대상이 됩니다.
    • 스프링 MVC에서 해당 클래스를 애노테이션 기반 컨트롤러로 인식합니다.

     

    @RequestMapping

    • 요청 정보를 매핑합니다.
    • 지정된 URL이 호출되면 해당 메서드가 실행됩니다.
    • 애노테이션 기반이므로 메서드 이름은 자유롭게 정할 수 있습니다.

     

    ModelAndView

    • 모델과 뷰 정보를 함께 담아 반환하는 객체입니다.

    이처럼 @RequestMapping 애노테이션을 사용하면 복잡한 설정 없이도 요청을 처리하는 컨트롤러를 간단히 구현할 수 있습니다.

     

     


    < 스프링 MVC - 컨트롤러 통합 >

    @RequestMapping 애너테이션을 자세히 살펴보면 클래스 단위가 아니라 메서드 단위에 적용되는 것을 확인할 수 있습니다.
    이를 활용하면 컨트롤러 클래스를 보다 유연하게 하나로 통합할 수 있습니다.

     

    @Controller
    @RequestMapping("/springmvc/v2/members")
    public class SpringMemberControllerV2 {
    
        private final MemberRepository memberRepository = MemberRepository.getInstance();
    
        // 신규 회원 등록 폼 요청 처리
        @RequestMapping("/new-form")
        public ModelAndView newForm() {
            return new ModelAndView("new-form");
        }
    
        // 회원 저장 요청 처리
        @RequestMapping("/save")
        public ModelAndView save(HttpServletRequest request) {
            String username = request.getParameter("username");
            int age = Integer.parseInt(request.getParameter("age"));
    
            Member member = new Member(username, age);
            memberRepository.save(member);
    
            ModelAndView mav = new ModelAndView("save-result");
            mav.addObject("member", member);
            return mav;
        }
    
        // 전체 회원 목록 조회 요청 처리
        @RequestMapping
        public ModelAndView members() {
            List<Member> members = memberRepository.findAll();
    
            ModelAndView mav = new ModelAndView("members");
            mav.addObject("members", members);
            return mav;
        }
    }

    위 예제에서는 클래스 레벨과 메서드 레벨의 @RequestMapping을 조합하여 중복을 제거하였습니다.

    • 클래스 레벨에서 @RequestMapping("/springmvc/v2/members")을 선언하여 기본 경로를 지정합니다.
    • 각 메서드에서 추가적인 경로를 지정하여 요청을 처리합니다.

     

    결과적으로 다음과 같은 URL 매핑이 자동으로 적용됩니다.

    • 메서드 레벨: @RequestMapping("/new-form") → /springmvc/v2/members/new-form
    • 메서드 레벨: @RequestMapping("/save") → /springmvc/v2/members/save
    • 메서드 레벨: @RequestMapping → /springmvc/v2/members

     

     


    < 스프링 MVC - 실용적인 방식 >

    이전에 개발한 MVC 프레임워크에서 v3는 ModelView를 개발자가 직접 생성해서 반환해야 했기 때문에 다소 불편했습니다. 그러나 v4를 만들면서 이를 실용적으로 개선했던 기억이 있을 것입니다.

     

    스프링 MVC는 다양한 편의 기능을 제공하며, 실무에서는 아래와 같은 방식을 주로 사용합니다.

     

    아래 코드는 이전에 작성한 컨트롤러를 스프링이 제공하는 편의 기능을 활용하여 개선한 버전입니다.

    @Controller
    @RequestMapping("/springmvc/v3/members")
    public class SpringMemberControllerV3 {
    
        private final MemberRepository memberRepository = MemberRepository.getInstance();
    
        @GetMapping("/new-form")
        public String newForm() {
            return "new-form"; 
        }
    
        @PostMapping("/save")
        public String save(@RequestParam("username") String username, 
                           @RequestParam("age") int age, Model model) {
            Member member = new Member(username, age);
            memberRepository.save(member);
            model.addAttribute("member", member);
            return "save-result";
        }
    
        @GetMapping
        public String members(Model model) {
            model.addAttribute("members", memberRepository.findAll());
            return "members";
        }
    }

     

    주요 개선 사항

    • Model 파라미터 사용 : save(), members()를 보면 Model을 파라미터로 받는 것을 확인할 수 있습니다. 스프링 MVC도 이런 편의 기능을 제공합니다
    • ViewName 직접 반환 : 뷰의 논리 이름을 직접 반환할 수 있습니다.
    • @RequestParam 사용 : 스프링은 HTTP 요청 파라미터를 @RequestParam으로 받을 수 있습니다.
    • @RequestMapping→ @GetMapping, @PostMapping, ... : @RequestMapping은 URL만 매칭하는 것이 아니라, HTTP Method도 함께 구분할 수 있습니다.

     

     

     

     

     

     

     

     

     

     

    해당 글에 포함된 코드나 그림은 김영한님이 제공해주신 자료를 바탕으로 작성되었습니다.

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

     

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

    김영한 | , 원리를 알아야 핵심이 보인다!김영한의 스프링 MVC 기본편 👨‍💻 📌 수강 전 확인해주세요! 본 강의는 자바 스프링 완전 정복 시리즈의 네 번째 강의입니다. 우아한형제들 최연소

    www.inflearn.com

     

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

    Creating an MVC framework  (2) 2025.02.13
    Servlet / JSP / MVC  (2) 2025.02.12
    Understanding Web Applications  (5) 2025.01.14
    Bean Scope  (1) 2025.01.12
    Bean Lifecycle Callback  (3) 2025.01.03

    댓글

Designed by Tistory.