728x90
반응형
SMALL

참고자료:

 

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

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

www.inflearn.com

구조 이해하기 1편에서는 직접 MVC 구조와 거의 유사한 구조를 만들어서 이 구조가 어떻게 동작하는지 알아봤다.

이번엔 실제 스프링 MVC 구조를 직접 보고 1편에서 만든 구조와 어떻게 같고 어떤 부분은 다른지를 좀 더 자세히 알아보자. 

 

우선 1편에서 만든 구조와 스프링 MVC 구조의 전체적인 그림을 보자.

 

스프링 MVC 구조

 

어떤가? 스프링 MVC 구조와 직접 만든 구조가 거의 똑같다. 이름만 다른거 아닌가? 싶을 정도로 똑같다. 

즉, 이 구조를 이해하기 위해 이 구조를 직접 만들어보고 어떻게 동작하는지 직접 해 본 것이다.

 

비교를 해보자면, 

직접 만든 구조 <-> 스프링 MVC 구조

  • FrontController <-> DispatcherServlet
  • handlerMappingMap <-> HandlerMapping
  • MyHandlerAdapter <-> HandlerAdapter
  • ModelView <-> ModelAndView
  • viewResolver <-> ViewResolver
  • MyView <-> View

1편에서 말했던 내용인데 handlerMappingMap을 외부에서 주입받게 구현하면 FrontController는 아예 변경 사항이 없을 것이다라고 말했었다. 그게 스프링 MVC는 하고 있는 일이다. HandlerMapping으로 딱 봐도 타입(클래스, 인터페이스)형이다. 즉, 외부에서 주입받아 사용한다는 의미가 된다. ViewResolver도 마찬가지.

 

그건 그렇고, 우리가 만든 구조의 핵심은 FrontController였다. 결국 스프링 MVC 구조도 핵심은 DispatchServlet이다. 왜냐? 여기서 적절한 컨트롤러도 다 매핑해주고 외부로의 요청을 받는것도 다 이곳이 최초이기 때문에. 이 녀석을 살짝만 알아보자.

 

DispatcherServlet

우리가 만든 구조에서 FrontControllerServlet은 서블릿이었다. 스프링 MVC의 DispatcherServlet 역시 서블릿이고 결국 HttpServlet을 상속 받아서 사용한다. 스프링 부트는 DispatcherServlet을 서블릿으로 자동으로 등록하면서 모든 경로(urlPatterns = "/")에 대해서 매핑한다. 그래야 어떤 경로로 사용자가 요청하던, 이 DispatcherServlet을 먼저 통할테니까.

 

요청 흐름은 다음과 같다.

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

이 서블릿에서 가장 핵심 메서드는 doDispatch()이다. 이 코드를 보자.

 

DispatcherServlet.doDispatch()

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
        HttpServletRequest processedRequest = request;
        HandlerExecutionChain mappedHandler = null;
        boolean multipartRequestParsed = false;
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

        try {
            try {
                ModelAndView mv = null;
                Exception dispatchException = null;

                try {
                    processedRequest = this.checkMultipart(request);
                    multipartRequestParsed = processedRequest != request;
                    
                    // 1. 핸들러 조회
                    mappedHandler = this.getHandler(processedRequest);
                    if (mappedHandler == null) {
                        this.noHandlerFound(processedRequest, response);
                        return;
                    }
                    // 2. 핸들러 어댑터 조회
                    HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
                    String method = request.getMethod();
                    boolean isGet = HttpMethod.GET.matches(method);
                    if (isGet || HttpMethod.HEAD.matches(method)) {
                        long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                        if ((new ServletWebRequest(request, response)).checkNotModified(lastModified) && isGet) {
                            return;
                        }
                    }

                    if (!mappedHandler.applyPreHandle(processedRequest, response)) {
                        return;
                    }

                    // 3. 핸들러 어댑터 실행
                    mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
                    if (asyncManager.isConcurrentHandlingStarted()) {
                        return;
                    }

                    this.applyDefaultViewName(processedRequest, mv);
                    mappedHandler.applyPostHandle(processedRequest, response, mv);
                } catch (Exception var20) {
                    Exception ex = var20;
                    dispatchException = ex;
                } catch (Throwable var21) {
                    Throwable err = var21;
                    dispatchException = new ServletException("Handler dispatch failed: " + err, err);
                }

                this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
            } catch (Exception var22) {
                Exception ex = var22;
                triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
            } catch (Throwable var23) {
                Throwable err = var23;
                triggerAfterCompletion(processedRequest, response, mappedHandler, new ServletException("Handler processing failed: " + err, err));
            }

        } finally {
            if (asyncManager.isConcurrentHandlingStarted()) {
                if (mappedHandler != null) {
                    mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
                }
            } else if (multipartRequestParsed) {
                this.cleanupMultipart(processedRequest);
            }

        }
    }

 

코드가 꽤 길지만 내가 직접 주석 처리한 1번, 2번, 3번을 보자. 결국 직접 만든 FrontControllerServlet에서 가장 핵심인 부분과 유사하다. 1. 핸들러(컨트롤러)를 조회하고 2. 그 핸들러를 다룰 수 있는 어댑터를 찾아3. 어댑터의 handle() 메서드를 호출한다.

 

그리고 그 하단에는 processDispatchResult()를 호출한다. 여기에 핸들러 어댑터의 handle() 메서드를 호출해서 받은 ModelAndView 객체와 핸들러를 넘기는 것을 알 수 있다. 이 메서드는 뭘할까?

 

DispatchServlet.processDispatchResult()

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv, @Nullable Exception exception) throws Exception {
        boolean errorView = false;
        if (exception != null) {
            if (exception instanceof ModelAndViewDefiningException) {
                ModelAndViewDefiningException mavDefiningException = (ModelAndViewDefiningException)exception;
                this.logger.debug("ModelAndViewDefiningException encountered", exception);
                mv = mavDefiningException.getModelAndView();
            } else {
                Object handler = mappedHandler != null ? mappedHandler.getHandler() : null;
                mv = this.processHandlerException(request, response, handler, exception);
                errorView = mv != null;
            }
        }

        if (mv != null && !mv.wasCleared()) {
            // 1. 뷰 렌더링 호출
            this.render(mv, request, response);
            if (errorView) {
                WebUtils.clearErrorRequestAttributes(request);
            }
        } else if (this.logger.isTraceEnabled()) {
            this.logger.trace("No view rendering, null ModelAndView returned.");
        }

        if (!WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted()) {
            if (mappedHandler != null) {
                mappedHandler.triggerAfterCompletion(request, response, (Exception)null);
            }

        }
    }

몇 줄의 코드가 있지만 가장 중요한 뷰 렌더링을 하는 render()를 호출한다. 

이렇게 직접 만든 구조와 거의 동일하다. 물론 스프링 MVC가 훨씬 더 안정적이고 버그에 덜 취약하며 이것 저것 유효성 검사도 많고 잘 만들었지만 흐름이 유사하다는 것이다.

 

그럼 저 render()는 뭘할까?

DispatcherServlet.render()

protected void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
        Locale locale = this.localeResolver != null ? this.localeResolver.resolveLocale(request) : request.getLocale();
        response.setLocale(locale);
        String viewName = mv.getViewName();
        View view;
        if (viewName != null) {
            // 뷰 리졸버를 통해서 뷰 찾고 뷰를 반환받는다.
            view = this.resolveViewName(viewName, mv.getModelInternal(), locale, request);
            if (view == null) {
                String var10002 = mv.getViewName();
                throw new ServletException("Could not resolve view with name '" + var10002 + "' in servlet with name '" + this.getServletName() + "'");
            }
        } else {
            view = mv.getView();
            if (view == null) {
                throw new ServletException("ModelAndView [" + mv + "] neither contains a view name nor a View object in servlet with name '" + this.getServletName() + "'");
            }
        }

        if (this.logger.isTraceEnabled()) {
            this.logger.trace("Rendering view [" + view + "] ");
        }

        try {
            if (mv.getStatus() != null) {
                request.setAttribute(View.RESPONSE_STATUS_ATTRIBUTE, mv.getStatus());
                response.setStatus(mv.getStatus().value());
            }
            // 뷰 렌더링
            view.render(mv.getModelInternal(), request, response);
        } catch (Exception var8) {
            Exception ex = var8;
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Error rendering view [" + view + "]", ex);
            }

            throw ex;
        }
    }

마찬가지로 내가 직접 넣은 주석을 보면, 뷰 리졸버를 통해 뷰를 찾아 결국 마지막 즈음에 뷰를 렌더링한다. 결국은 같은 흐름으로 이어진다.

그러니까 직접 만든 구조와 흐름이 100% 동일하다.

 

그리고 스프링 MVC는 확장에 유연하고 변경에 닫혀있는 OCP원칙을 훨씬 더 잘 고수하며 만든 프레임워크라서 이 DispatcherServlet의 코드 변경 없이 원하는 기능을 변경하거나 확장할 수 있다. 지금까지 말했던 대부분의 것들을 확장 가능할 수 있게 인터페이스로 제공한다.

 

핵심 인터페이스 목록

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

이 인터페이스들만 구현해서, DispatchServlet에 등록하면 나만의 컨트롤러를 만들수도 있다. (만들라는 얘기는 절대 아니다.

 

이렇게 큰 맥락에서 스프링 MVC 구조와 직접 만든 구조를 비교해 보았다. 이미 직접 만들어봤기 때문에 이해하는데 어렵지 않았다. 이런 과정을 통해 스프링 MVC가 동작하는구나를 이해하면 된다. 그럼 DispatcherServlet을 알아봤는데 핸들러와 핸들러 어댑터는 어떻게 만들었을까? 요새 스프링으로 개발하는 거의 99%는 애노테이션 기반의 컨트롤러를 사용한다. 그래서 RequestMappingHandlerAdapter라는걸 스프링이 만들어서 사용하는데 그 전 세대 사람들은 어떻게 개발했을까?

 

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

지금은 전혀 사용되지 않지만, 과거에 주로 사용했던 스프링이 제공하는 간단한 컨트롤러로 핸들러 매핑과 어댑터를 이해해보자.

 

과거에는 Controller라는 인터페이스를 사용했다.

스프링이 만들어둔 Controller 인터페이스

public interface Controller {
	ModelAndView handleRequest(HttpServletRequest request, HttpServletResponse response) throws Exception;
}
참고로, 이 Controller 인터페이스와 @Controller는 아예 다른것이다.

 

그래서 저 예전 버전의 컨트롤러 인터페이스로 한번 구현해보자.

OldController

package org.example.servlet.web.springmvc.old;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.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 애노테이션을 활용해서 빈으로 자동 주입을 했다. 그리고 빈의 이름을 지정했는데 꼭 보면 빈의 이름이 URL같이 생겼다. 맞다. 빈의 이름을 URL로 매핑한다.

그래서 한번 URL에 다음 경로로 들어가보자. `http://localhost:8080/springmvc/old-controller`

저 시스템 로그가 찍히면 정상적으로 동작하는 것이다.

 

그럼 이 URL이 호출될 때 이 컨트롤러를 실행하는 과정이 어떻게 될까? 우선 이 컨트롤러가 실행되려면 두가지가 필요하다.

  • HandlerMapping
  • HandlerAdapter

가장 먼저, 핸들러 매핑을 통해서 이 컨트롤러를 찾을 수 있어야 한다. 스프링은 핸들러 매핑에 어떻게 등록할까?

 

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

스프링 부트가 자동 등록해주는 HandlerMapping

  • 0 = RequestMappingHandlerMapping
  • 1 = BeanNameUrlHandlerMapping

우선 0번부터 우선순위가 더 높은거라고 생각하면 된다. RequestMappingHandlerMapping은 애노테이션 기반으로 컨트롤러를 찾는 핸들러 매핑이다. 이건 아니고 두번째 거를 보자. BeanNameUrlHandlerMapping이다. 이름만 봐도 너무 이거일것같다. 맞다.

= 빈의 이름이 곧 URL이 되는 컨트롤러를 찾는 핸들러 매핑이다.

 

이 핸들러 매핑을 통해 적절한 컨트롤러 (위의 예시에선 내가 만든 OldController)를 찾아서 이 컨트롤러를 처리할 수 있는 어댑터를 찾는다. 스프링 부트가 역시 마찬가지로 자동으로 등록해주는 HandlerAdapter가 있다.

스프링 부트가 자동 등록해주는 HandlerAdapter

  • 0 = RequestMappingHandlerAdapter
  • 1 = HttpRequestHandlerAdapter
  • 2 = SimpleControllerHandlerAdapter

우선, 마찬가지로 0번이 제일 우선순위가 높은것이다. 그리고 RequestMappingHandlerAdapter는 애노테이션 기반의 핸들러를 처리할 수 있는 어댑터이고 HttpRequestHandlerAdapter는 이후에 살펴볼 핸들러 타입의 어댑터이다. 즉, 마지막 SimpleControllerHandlerAdapter가 바로 Controller라는 인터페이스를 구현한 컨트롤러를 처리할 수 있는 어댑터이다. 실제로 이 코드를 한번 봐보자.

SimpleControllerHandlerAdapter

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.servlet.mvc;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;

public class SimpleControllerHandlerAdapter implements HandlerAdapter {
    public SimpleControllerHandlerAdapter() {
    }

    public boolean supports(Object handler) {
        return handler instanceof Controller;
    }

    @Nullable
    public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        return ((Controller)handler).handleRequest(request, response);
    }

    public long getLastModified(HttpServletRequest request, Object handler) {
        if (handler instanceof LastModified lastModified) {
            return lastModified.getLastModified(request);
        } else {
            return -1L;
        }
    }
}

굉장히 익숙하게 생긴 supports()가 있고 보면 Controller 타입인지를 체크한다. 그 Controller는 위에서 다뤄본 Controller 인터페이스이다. 이게 바로 과거의 방식이었다. 하나 더 알아보자.

 

이번엔 Controller 인터페이스 말고 HttpRequestHandler를 알아보자.

스프링 부트가 자동으로 등록해주는 HttpRequestHandler

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;

@FunctionalInterface
public interface HttpRequestHandler {
    void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException;
}

이것도 역시 위 Controller 인터페이스와 유사한데 얘는 리턴 타입도 void이다. 즉 저 메서드 안에서 전부 다 처리해주는 방식이다.

이것을 구현한 컨트롤러가 있으면 된다.

MyHttpRequestHandler

package org.example.servlet.web.springmvc.old;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestHandler;

import java.io.IOException;

@Component("/springmvc/request-handler")
public class MyHttpRequestHandler implements HttpRequestHandler {
    @Override
    public void handleRequest(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        System.out.println("MyHttpRequestHandler.handleRequest");
    }
}

이 또한 역시 빈의 이름으로 URL을 매핑하는 핸들러를 만든다. 그래서 핸들러를 찾을때 빈의 이름과 URL이 똑같은 컨트롤러를 찾는다. (바로 이 MyHttpRequestHandler) 그럼 핸들러 매핑을 통해 핸들러를 찾았으면 이 핸들러가 어떤 어댑터에 적용될 수 있는지 핸들러 어댑터를 찾는다. 위에 말했던 1번 어댑터인 HttpRequestHandlerAdapter에 걸리는 것이다. 

 

보면 결국은 1편에서 직접 만들어본 구조랑 완전 똑같다. 스프링이 어떻게 MVC 구조를 만들었는지 아니까 이게 어렵지가 않다. 그리고 이 어댑터도 실제 코드를 보면 이렇게 생겼다.

HttpRequestHandlerAdapter

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.servlet.mvc;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.web.HttpRequestHandler;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ModelAndView;

public class HttpRequestHandlerAdapter implements HandlerAdapter {
    public HttpRequestHandlerAdapter() {
    }

    public boolean supports(Object handler) {
        return handler instanceof HttpRequestHandler;
    }

    @Nullable
    public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        ((HttpRequestHandler)handler).handleRequest(request, response);
        return null;
    }

    public long getLastModified(HttpServletRequest request, Object handler) {
        if (handler instanceof LastModified lastModified) {
            return lastModified.getLastModified(request);
        } else {
            return -1L;
        }
    }
}

supports()HttpRequestHandler 타입인지를 판단한다. 그리고 handle()은 결국 그 핸들러가 가지고 있는 (구현해야만 하는) handleRequest()를 호출한다. 너무너무 이해가 잘된다. 그리고 이젠 이런 방식을 사용하지 않는다는 것도 알고 있다.

 

지금은 거의 100%에 가깝게 애노테이션 기반의 컨트롤러(핸들러)를 사용하기 때문에 나도 이 방식으로 개발을 할거지만 이런 역사가 있었다는 사실을 알면 개발하는데 무조건 도움이 된다. 어떤게 불편해서 지금의 스프링이 있는지 이해를 할 수 있기 때문에.

 

그럼 핸들러와 핸들러 어댑터를 알아봤으니 뷰 리졸버도 한번 알아보자.

 

뷰 리졸버

우리가 직접 만든 구조에서 뷰 리졸버를 통해 논리 이름을 가지고 전체 이름을 가질 수 있게 만들었다.

스프링 부트를 사용하면 어떻게 해야 할까? 우선 위에서 만들어본 완전 과거 버전의 컨트롤러인 OldController를 이렇게 변경해보자.

 

OldController

package org.example.servlet.web.springmvc.old;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.mvc.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");
    }
}

반환값을 변경했다. 기존에는 null을 리턴했는데 이제 new ModelAndView("new-form")을 리턴한다. 그리고 이 컨트롤러를 호출하면 어떻게 될까? 결과는 다음과 같다. 아무것도 나타나지 않는다. 분명 우리 프로젝트에는 JSP 파일이 있다.

그러나 아무것도 보이진 않는다. 그치만 로그는 찍히고 있다. 컨트롤러 호출은 됐다는 이야기이다.

 

그럼 스프링은 뷰 리졸버를 어떻게 적용해야 할까?

스프링 부트의 설정 파일인 application.yml 파일로 가자.

application.yml

spring.application.name=servlet

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

파일의 아래 두 개의 라인을 추가해주면 된다. 그럼 정상적으로 동작할 것이다.

어떻게 동작하는 걸까? 스프링 부트는 InternalResourceViewResolver 라는 뷰 리졸버를 자동으로 등록하는데, 이때 application.yml 파일에 등록한 spring.mvc.view.prefix, spring.mvc.view.suffix 설정 정보를 사용해서 등록한다. 

 

그러니까 스프링은 뷰 리졸버도 인터페이스로 등록하고 그 인터페이스를 구현한 많은 구현체 중 하나로부터 이 뷰를 보여주는 방법을 사용하는 것이다. 그리고 스프링 부트가 자동으로 등록하는 뷰 리졸버는 여러개가 있고 그 중 일부는 다음과 같다.

  • 1 = BeanNameViewResolver: 빈 이름으로 뷰를 찾아서 반환 (예: 엑셀 파일 생성 기능에 사용)
  • 2 = InternalResourceViewResolver: JSP를 처리할 수 있는 뷰를 반환

저 중 BeanNameViewResolver는 현재 위 코드의 해당 사항이 아니다. 이는 빈 이름으로 뷰를 찾아서 반환하는데 우리가 호출하는 뷰 이름이 `new-form`인데 이런 빈이 없기 때문에 자기는 처리해줄 수 없다고 다음으로 넘긴다. 그리고 이 뷰 리졸버는 보통 엑셀 파일 생성 기능에 사용한다.

 

참고로, Thymeleaf 뷰 템플릿을 사용하면 ThymeleafViewResolver를 등록해야 하는데 최근 스프링 부트는 라이브러리만 추가하면 스프링 부트가 이런 작업도 모두 자동으로 해준다.

 

그래서 전체적인 구조를 다시 보자.

결국 핸들러 어댑터 목록을 통해 찾은 핸들러를 처리할 수 있는 핸들러 어댑터를 찾고 핸들러 어댑터가 가진 handle()을 호출하면 결과적으로 뷰의 논리 이름을 얻게 된다. 뷰의 논리 이름을 얻은 DispatchServlet은 뷰 리졸버를 통해 뷰를 찾아내는데 수많은 뷰 리졸버 중 우리는 InternalResourceViewResolver를 통해 뷰를 찾아서 렌더링 하게 된다.

 

결론

이게 바로 스프링 MVC의 전체 구조가 된다. 굉장히 복잡한 구조인데 직접 이 구조를 만들어보고 나니 그렇게 어렵게 느껴지지 않는다. 제대로 배운 느낌이 든다. 이제 구조도 다 이해했으니 진짜 Spring MVC를 사용해보자.

 

728x90
반응형
LIST

+ Recent posts