티스토리 뷰
이전 글에서는 가장 기본적인 프론트 컨트롤러의 구조에 대해서 알아보고 이를 구현해보았다. 사실 글을 쓰면서도 이렇게 까지 기본적인? 내용에 대해 글을 쓰고 코드로 구현을 해봐야 할까 싶었지만, 사실 스프링에서 구현된 프론트 컨트롤러 패턴은 매우매우 복잡해서 공부한 뒤 일주일만 지나도 이에 대해 확실하게 기억하기가 쉽지 않다. 그러나 아주 기본적이면서도 간단한 4개의 객체로만 구성된 구조를 파악하고 기억하는 것은 매우 쉬운데 이를 통해 오랫동안 스프링의 구조를 잘 기억할 수 있기를 바라며 글을 작성해 보았다. 또한 프레임워크를 한번도 공부해본 적이 없는 분들에게도 도움이 되지 않을까 한다.
각설하고 이번 글에서는 지난번에 구현했던 프론트 컨트롤러 패턴을 조금더 향상된 구조로 리팩토링하려고 한다. 지난 구조의 문제는 FrontController
, Dispatcher
객체의 책임이 너무? 많았다는 것이다. 즉 요청에 따른 적절한 Command
, View
객체를 선택하는 책임이 추가로 존재하였는데 이번에는 해당 책임을 가진 클래스를 별도로 분리하고자 한다. 또한 이번에 구현할 클래스와 메소드들의 이름은 최대한 기존 스프링의 것들과 비슷하게 지어 스프링의 프론트 컨트롤러 구조에 조금 더 친숙해지도록 할 것이다. 이번 구현에서 사용되는 주요 클래스와 각 기능은 다음과 같다.
FrontController | 모든 요청을 처리하는 핸들러로, 보안, 국제화, 로깅 등의 공통 처리를 수행하며 요청에 따라 적절한 Handler에게 처리를 위임한다. 또한 ViewResolver에게 Handler의 처리 결과를 전달하여 적절한 View를 반환받아 렌더링한다. |
HandlerMapping | 요청에 따른 적절한 Handler를 반환하는 역할 |
ViewResolver | 요청 처리 결과에 따른 적절한 View를 반환하는 역할 |
Handler | 현재 요청을 처리하고 ModelAndView 객체를 반환 |
ModelAndView | Handler의 요청 처리 결과인 데이터와 뷰 이름을 저장 |
View | 요청 처리 결과인 데이터를 이용하여 뷰를 렌더링함으로써 클라이언트에게 전달할 응답을 생성 |
이전 글에서 소개한 (FrontController
, Dispatcher
), Command
, View
클래스는 각각 위의 FrontController
, Handler
, View
클래스에 대응된다. HandlerMapping
과 ViewResolver
는 하나의 클래스에 많은 책임이 있어 이를 리팩토링하는 과정에서 추가된 클래스이다. 또한 이전 글에서는 요청에 대한 응답으로 정적인 뷰, 즉 요청 처리 결과 데이터가 뷰에 포함되지 않았는데 이번 글에서는 요청에 대한 응답으로 동적인 뷰, 즉 요청 처리 결과 데이터를 뷰에 포함시키기 위해 Handler
의 반환 타입으로 ModelAndView
라는 새로운 클래스를 추가하였다. 즉 뷰 이름뿐만 아니라 뷰에 포함시킬 데이터도 함께 반환하기 위해서이다. 클래스 간 관계는 다음과 같다. 아직은 뭔말인지 싶겠지만 직접 코드를 보면서 천천히 이해해보자.
저번 글에서는 프론트 컨트롤러 패턴을 순수한 자바 코드로만 구현해보았다. 이번에는 웹 요청을 처리하는 프론트 컨트롤러 패턴을 구현하기 위해 웹 어플리케이션 서버인 톰캣을 내장하고 있는 스프링 부트를 이용할 것이다. 즉 스프링 프레임워크를 기반으로 아주 간단한 웹 프레임워크를 직접 설계해보는 것이다.
그럼 우리가 만들 웹 프레임워크에서 프론트 컨트롤러 역할을 하는 클래스는 @RequestMapping("/**")
어노테이션이 붙어 모든 요청 path와 메소드에 매핑되는 Controller
로 정의하면 되는 걸까? 이 경우 스프링의 프론트 컨트롤러인 DispatcherServlet
이 먼저 동작해 결국 웹 요청을 우리의 프레임워크가 아닌 스프링이 처리하게 된다. 따라서 DispatcherServlet
보다 먼저 요청을 채갈 수 있도록 별도의 서블릿을 등록해주어야 한다. 스프링에서는 직접 정의한 서블릿이 기존 서블릿보다 실행의 우선순위가 높기 때문에 DispatcherServlet
이 동작하지 않게 된다.
구현하기에 앞서 서블릿에 대해 아주 간단히만 짚고 넘어가자. 웹 애플리케이션은 HTTP 프로토콜로 요청이 들어오면 해당 요청을 파악하고 요청에 따라 적절한 처리 후 HTTP 프로토콜로 응답을 반환한다. 서블릿은 이러한 일련의 과정을 수행하는 자바 클래스로 요청에 따라 각기 다른 서블릿이 동작하도록 설정할 수 있다. 스프링 부트는 서블릿을 직접 등록해서 사용할 수 있도록 @ServletComponentScan
어노테이션을 지원하며 이 어노테이션이 위치한 패키지 내 (하위 패키지 포함) 모든 서블릿을 인식하여 등록해준다. 따라서 보통 main 메소드가 포함된 클래스에 해당 어노테이션을 붙여준다.
(아직 웹 어플리케이션 서버, 서블릿 등에 대한 개념이 헷갈린다면 링크 참고)
@ServletComponentScan //서블릿 등록
@SpringBootApplication
public class FrontControllerApplication {
public static void main(String[] args) {
SpringApplication.run(FrontControllerApplication.class, args);
}
}
FrontController
//FrontController + Dispatcher 역할
@Slf4j
@WebServlet(name = "frontController", urlPatterns = "/posts")
public class FrontController extends HttpServlet {
private final HandlerMapping handlerMapping = new HandlerMapping();
private final ViewResolver viewResolver = new ViewResolver();
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
logRequest(request);
doDispatch(request, response);
}
private void logRequest(HttpServletRequest request) {
log.info("Request Log: " + request.getMethod() + " " + request.getRequestURI());
}
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
Handler handler = handlerMapping.getHandler(request);
ModelAndView mav = handler.handle(request, response);
processDispatchResult(request, response, mav);
}
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, ModelAndView mav) throws ServletException, IOException {
String viewName = mav.getViewName();
View view = viewResolver.resolveViewName(viewName);
view.render(mav.getModel(), request, response);
}
}
직접 서블릿을 정의하기 위해서는 HTTP 프로토콜 관련 기능을 포함한 HttpServlet
인터페이스를 구현하고 @WebServlet
어노테이션을 통해 매핑될 요청 URL 패턴을 지정하면 된다. 서블릿에서 실제 요청을 처리하는 부분은 service
메소드로, 해당 메소드를 오버라이딩해야 한다.
모든 요청에 대한 처리를 수행한다는 프론트 컨트롤러의 정의에 따르면 @WebServlet
어노테이션의 urlPatterns
속성을 "/*"으로 설정해야 하지만, JSP에 대한 내부 요청을 처리하는 기존 JspServlet
을 그대로 사용하기 위해 urlPatterns
속성을 "/posts"로 하였다. 만약 이를 "/*" 로 설정하게 되면 JSP를 처리하는 로직 또한 우리가 직접 구현해야 하는데 매우 복잡하므로 그냥 기존의 것이 동작하도록 하였다. (실제로 스프링에서 JSP에 대한 요청은 외부 즉 클라이언트로부터가 아닌 서버 내부로부터만 발생하는데, JSP는 DispatcherServlet
이 아닌 JspServlet
이 처리한다. 더 자세한 설명은 뒤에서...)
클라이언트로부터 "/posts" URL 패턴을 가진 요청이 들어오면 위의 FrontController
서블릿의 service
메소드가 호출된다. service
메소드의 파라미터로 전달되는 HttpServletRequest
객체는 현재 요청 정보를 전달해주며, HttpServletResponse
는 해당 요청에 대한 응답을 전달하기 위한 객체로 요청 처리 결과를 저장한다.
service
메소드 내에서는 logRequest
메소드를 통해 로그를 남기는 공통 처리를 수행하며 doDispatch
에서 본격적인 요청 처리를 수행한다. doDispatch
메소드 내에서는 HandlerMapping
객체를 통해 현재 요청을 처리할 수 있는 Handler
를 조회하고 해당 Handler
에게 처리를 위임한다. 이후 핸들러가 처리한 결과(ModelAndView
)를 반환받아 processDispatchResult
메소드 내에서 결과에 대한 후처리를 진행한다. processDispatchResult
메소드에서는 ViewResolver
객체를 통해 요청 처리 결과에 따른 View
객체를 반환받고 해당 뷰를 렌더링함으로써 클라이언트에게 응답을 전달한다. 정리하자면 FrontController
에서의 요청 처리 흐름은 다음과 같다.
공통 처리 → 핸들러 조회 → 핸들러에게 처리 위임 → 요청 처리 결과(ModelAndView)에 따른 뷰 조회 → 뷰를 렌더링하여 응답 전달
HandlerMapping
public class HandlerMapping {
public Handler getHandler(HttpServletRequest request) {
String method = request.getMethod();
String requestURI = request.getRequestURI();
if (Objects.equals(method, "GET") && Objects.equals(requestURI, "/posts")) return new PostReadHandler();
else if (Objects.equals(method, "POST") && Objects.equals(requestURI, "/posts")) return new PostCreateHandler();
return null;
}
}
HandlerMapping
클래스에서는 요청을 처리할 수 있는 Handler
를 조회하여 반환한다. 위의 HandlerMapping
은 현재 요청의 메소드와 URI에 따라 if문을 통해 적합한 핸들러를 생성하여 반환하지만, 스프링에서는 다양한 방법으로 핸들러를 조회할 수 있다. 예를 들어 우리가 흔히 Controller
에서 사용하는 @RequestMapping
(@GetMapping
, @PostMapping
등)의 value 속성으로 지정한 값과 요청 URI가 일치하는 핸들러(컨트롤러)를 조회하거나 또는 요청 URI와 Bean 이름이 일치하는 핸들러(컨트롤러)를 조회할 수 있다. 이렇듯 다양한 조건과 방식으로 핸들러를 조회할 수 있도록 스프링에서는 HandlerMapping
인터페이스를 제공하며 RequestMappingHandlerMapping
, BeanNameUrlHandlerMapping
등 다양한 핸들러 매핑을 구현하여 사용하고 있다.
실제로 스프링의 DispatcherServlet
에는 여러 HandlerMapping
을 초기화해놓은 handlerMappings
인스턴스 변수를 두어 for 문으로 핸들러 매핑 리스트를 순회하며 적합한 핸들러를 조회한다. 참고로 여러 핸들러 매핑이 사용되기 때문에 각 핸들러 매핑에는 우선순위가 부여되는데 RequestMappingHandlerMapping
이 0순위로 우선순위가 가장 높다. 즉 @RequestMapping
어노테이션이 붙은 컨트롤러를 우선적으로 조회한다. 아래 코드는 실제 DispatcherServlet
내 코드로 이해를 위해 아주 단순화하였다.
//기본 핸들러 매핑 리스트 초기화
private void initHandlerMappings(ApplicationContext context) {
if (this.detectAllHandlerMappings) {
Map<String, HandlerMapping> matchingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(context, HandlerMapping.class, true, false);
if (!matchingBeans.isEmpty()) {
this.handlerMappings = new ArrayList<>(matchingBeans.values());
}
}
else {
HandlerMapping hm = context.getBean(HANDLER_MAPPING_BEAN_NAME, HandlerMapping.class);
this.handlerMappings = Collections.singletonList(hm);
}
if (this.handlerMappings == null) {
this.handlerMappings = getDefaultStrategies(context, HandlerMapping.class);
}
}
//핸들러 매핑을 통해 적절한 핸들러 조회
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
for (HandlerMapping mapping : this.handlerMappings) {
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) return handler; //제일 처음으로 조회되는 핸들러 반환
}
return null;
}
Handler
//스프링의 controller 역할
public interface Handler {
ModelAndView handle(HttpServletRequest request, HttpServletResponse response) throws IOException;
}
스프링의 Controller
와 대응되는 클래스로 (정확히 말하면 일반적으로는 Controller
내 특정 메소드와 대응, @RequestMapping
어노테이션은 클래스 뿐만 아니라 메소드에 대해서도 선언 가능하기 때문) 위의 Handler
는 파라미터로 HttpServletRequest
, HttpServletResponse
가, 반환값으로는 ModelAndView
타입이 고정되어 있지만, 실제 스프링의 Controller
내 메소드의 시그니처는 고정되어 있지 않다. 즉 ModelAndView
뿐만 아니라 String
, ResponseEntity
또는 다양한 객체도 반환 가능하며 파라미터로 HttpServletRequest
, HttpServletResponse
뿐만 아니라 String
, RequestEntity
또는 다양한 객체도 전달받을 수 있다.
구현의 용이성을 위해 위와 같이 단순화하였으나 실제 스프링에서는 메소드 시그니처의 유연성을 위해 Handler
외의 추가적인 클래스가 존재하는데 바로 HandlerAdapter
이다. 핸들러의 메소드가 어떻게 정의되어 있든지 해당 메소드에 알맞은 파라미터를 전달해주고 메소드의 반환값을 처리하여 이를 클라이언트에게 응답으로 전달해준다. 당연히 이러한 모든 책임을 HandlerAdapter
가 지니고 있다면 이 또한 SRP 원칙에 부합하지 않는다. 따라서 스프링에는 HandlerMethodArgumentResolver
, HandlerMethodReturnValueHandler
라는 클래스가 추가로 존재하는데 각각은 핸들러 메소드의 파라미터로 적절한 객체를 생성하여 전달해주는 역할을, 핸들러 메소드의 반환 객체를 처리하여 응답으로 사용할 수 있도록 하는 역할을 한다. 보다 자세한 내용은 밑에서 다시 정리해보기로 하자.
PostCreateHandler
public class PostCreateHandler implements Handler {
private PostRepository postRepository = PostRepository.getInstance();
@Override
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response) throws IOException {
ServletInputStream inputStream = request.getInputStream();
String content = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); //요청 바디 추출
postRepository.addPost(content);
return new ModelAndView("postCreate");
}
}
요청 데이터로부터 Post
객체를 생성하고 Repository
에 저장한 뒤 완료 페이지 관련 ModelAndVIew
객체를 반환하는 Handler
로, 파라미터로 HttpServletRequest
객체가 전달되기 때문에 어쩔 수 없이 직접 요청 바디를 추출하는 추가 작업이 필요하다. 이때 응답으로 나갈 완료 페이지는 정적 페이지로 요청 처리 결과 데이터를 포함하지 않기 때문에 ModelAndView
객체에는 뷰의 논리 이름만 저장해준다.
PostReadHandler
public class PostReadHandler implements Handler {
private PostRepository postRepository = PostRepository.getInstance();
@Override
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response) {
Long postId = Long.parseLong(request.getParameter("id"));
Post post = postRepository.getPost(postId);
ModelAndView mv = new ModelAndView("postRead");
mv.addModel("post", post);
return mv;
}
}
요청 파라미터로 전달된 id
의 Post
객체를 조회하고 조회 결과 페이지 관련 ModelAndView
를 반환하는 Handler
로, ModelAndView
객체에는 뷰의 논리 이름과 조회된 post
객체를 저장해준다.
Post
@Getter
public class Post {
private Long id;
private String content;
public Post(Long id, String content) {
this.id = id;
this.content = content;
}
}
데이터 클래스로 id
, content
속성만을 가진다.
PostRepository
public class PostRepository {
private static final Map<Long, Post> posts = new HashMap<>();
private static Long seq = 1L;
private static final PostRepository instance = new PostRepository();
public static PostRepository getInstance() {
return instance;
}
private PostRepository() {}
public Post getPost(Long postId) {
return posts.get(postId);
}
public void addPost(String content) {
Post post = new Post(seq++, content);
posts.put(post.getId(), post);
}
}
Post
객체의 저장소 역할을 하는 클래스로 Post
객체를 생성 후 저장하거나 postId
값으로 저장된 Post
객체를 조회할 수 있다.
ModelAndView
@Getter
public class ModelAndView {
private String viewName;
private Map<String, Object> model = new HashMap<>();
public ModelAndView(String viewName) {
this.viewName = viewName;
}
public void addModel(String name, Object value) {
model.put(name, value);
}
}
Handler
의 요청 처리 결과를 반환하기 위한 클래스로 viewName
과 처리 결과 데이터를 key-value 형태로 저장하는 model
필드를 가진다. 이때 viewName
은 뷰 파일의 실제 물리적 위치를 나타내는 이름이 아닌 상대적 위치를 나타내는 논리 이름이다.
ViewResolver
public class ViewResolver {
public View resolveViewName(String viewName) {
return new View( "/WEB-INF/views/" + viewName + ".jsp");
}
}
ModelAndView
의 viewName
속성은 뷰의 논리 이름이기 때문에 실제 뷰 파일의 물리적 위치를 알 수 없다. 따라서 ViewResolver
에서 뷰의 논리 이름을 물리 이름으로 변환하고 이에 대한 View
객체를 생성하여 반환한다. 이번 구현에서 뷰로 사용할 JSP 파일의 실제 디렉토리 상 위치(/WEB-INF/views/)와 파일 확장자(.jsp)를 뷰의 논리 이름 앞뒤에 추가해주었다.
실제 스프링에서는 해당 viewName
과 동일한 Bean 이름을 가진 View
객체를 반환하거나 위와 같이 뷰의 논리 이름에 미리 설정된 prefix, suffix를 추가하여 물리 이름으로 변환하고 이에 대한 View
객체를 반환하는 등 다양한 방식으로 적합한 View
를 조회한다. 따라서 이 또한 뷰를 조회하는 방식의 유연성을 위해 스프링에서는 ViewResolver
라는 인터페이스를 제공하며 BeanNameViewResolver
, UrlBasedViewResolver
등의 클래스를 구현하여 사용한다.
HandlerMapping
과 마찬가지로 ViewResolver
도 DispatcherServlet
에 여러 ViewResolver
를 초기화해놓은 viewResolvers
를 인스턴스 변수로 가지며 for문으로 viewResolvers
리스트를 순회하면서 제일 먼저 조회된 View
를 반환한다. 다음은 실제 DispatcherServlet
코드의 일부로 이해를 위해 단순화하였다.
//기본 뷰 리졸버 리스트 초기화
private void initViewResolvers(ApplicationContext context) {
if (this.detectAllViewResolvers) {
Map<String, ViewResolver> matchingBeans = BeanFactoryUtils.beansOfTypeIncludingAncestors(context, ViewResolver.class, true, false);
if (!matchingBeans.isEmpty()) {
this.viewResolvers = new ArrayList<>(matchingBeans.values());
}
}
else {
ViewResolver vr = context.getBean(VIEW_RESOLVER_BEAN_NAME, ViewResolver.class);
this.viewResolvers = Collections.singletonList(vr);
}
if (this.viewResolvers == null) {
this.viewResolvers = getDefaultStrategies(context, ViewResolver.class);
}
}
//뷰 리졸버를 통해 뷰 조회
protected View resolveViewName(String viewName, @Nullable Map<String, Object> model, Locale locale, HttpServletRequest request) throws Exception {
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) return view;
}
return null;
}
View
public class View {
private final String viewPath; //뷰의 물리적 위치
public View(String viewPath) {
this.viewPath = viewPath;
}
public void render(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
dispatcher.forward(request, response);
}
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
exposeModelAsRequestAttributes(model, request);
render(request, response);
}
protected void exposeModelAsRequestAttributes(Map<String, Object> model, HttpServletRequest request) {
model.forEach((name, value) -> request.setAttribute(name, value));
}
}
먼저 뷰에 요청 처리 결과 데이터를 바인딩하여 동적인 뷰를 렌더링하는 경우에는 exposeModelAsRequestAttributes
메소드를 호출하여 HttpServletRequest
객체의 setAttribute
메소드로 데이터를 저장해두어야 한다. 즉 HttpServletRequest
객체가 일종의 저장소 역할을 한다고 보면 된다. 이후 JSP에서는 해당 request 객체에 접근할 수 있는데 이를 통해 필요한 데이터를 조회하고 화면에 출력할 수 있다.
request 객체에 데이터를 저장한 후 해당 viewPath
에 위치한 JSP 뷰를 렌더링하기 위해서는 RequestDispatcher
객체를 생성하고 JSP를 처리할 수 있는 서블릿에게 재요청을 보내야 한다. 이때 호출되는 RequestDispatcher
의 forward
메소드는 다른 서블릿이나 JSP로 요청을 넘겨주는 메소드로 이 요청을 넘겨받은 서블릿은 클라이언트에게 최종적으로 전달한 응답을 생성한다. 즉 클라이언트로부터 온 첫번째 요청이 forward
메소드를 호출하여 서버 내부적으로 두번째 요청을 보내고 이 두번째 요청을 처리하는 서블릿이 최종적으로 클라이언트가 받을 응답을 생성한다.
위와 같이 RequestDispatcher
객체를 생성하여 다른 서블릿에게 재요청하는 식으로 뷰를 렌더링할 수도 있지만, 스프링에서는 다양한 방식으로 뷰를 렌더링할 수 있도록 View
인터페이스를 제공하며 InternalResourceView
, FreeMarkerView
등의 클래스를 구현하여 사용한다.
PostCreate.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title>Title</title>
</head>
<body>
<h3>글 작성이 완료되었습니다.</h3>
</body>
</html>
스프링에서 모든 JSP 파일은 src/main/webapp 아래에서 관리되며 스프링의 Jar 패키징은 해당 디렉토리를 인식하지 못하기 때문에 War로 패키징을 해야 한다. 또한 JSP는 DispatcherServlet
이 아닌 JspServlet
이 처리하는데 이를 위해 외부 라이브러리를 추가해야한다. 따라서 build.gradle 파일에 다음 코드를 추가해준다.
plugins {
id 'war' //War 패키징
}
dependencies {
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper' //JSP 관련 라이브러리
}
JSP 파일은 그냥 src/main/webapp 디렉토리 내에서 관리해도 되지만 이 경우 브라우저를 통해 JSP 코드에 접근할 수 있어 보안상 좋지 않다. /WEB-INF 경로안에 JSP 파일이 있으면 외부에서 접근이 불가능하기 때문에 JSP 파일은 모두 src/main/webapp/WEB-INF 디렉토리 내에서 관리되도록 한다. 이 경우 해당 JSP 파일에는 서버의 내부 요청(forward
)을 통해서만 접근할 수 있다.
PostRead.jsp
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page import="com.example.demo.v2.data.Post" %> <!-- Post 클래스 import -->
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<table border="1" style="border-collapse: collapse">
<tr>
<th>id</th> <th>content</th>
</tr>
<tr>
<!-- Object 타입의 객체를 조회하기 때문에 타입 캐스팅 필요 -->
<td><%= ((Post)request.getAttribute("post")).getId() %></td>
<td><%= ((Post)request.getAttribute("post")).getContent() %></td>
</tr>
</table>
</body>
</html>
<%= request.getAttribute("post")%>
로 HttpServletRequest
객체에 저장해 두었던 Post
객체를 조회할 수 있지만 이보다 더 간단한 방법으로도 해당 데이터에 접근할 수 있다. JSP가 제공하는 문법인 ${}
으로 request 객체의 attribute에 저장된 데이터를 보다 편리하게 조회할 수 있다. 참고로 <%= %>
문법은 JSP에서 자바 코드를 출력할 수 있게 해준다.
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<table border="1" style="border-collapse: collapse">
<tr>
<th>id</th> <th>content</th>
</tr>
<tr>
<!-- request 저장소의 데이터를 편리하게 조회 -->
<td>${post.id}</td> <td>${post.content}</td>
</tr>
</table>
</body>
</html>
스프링의 DispatcherServlet
스프링의 MVC 패턴과 엄청난 추상화 덕분에 개발자는 복잡한 요청 처리 흐름을 알지 못해도 단순히 비즈니스 로직에만 집중하면서 개발을 할 수 있다. 스프링의 DispatcherServlet
을 완전히 이해했다고 이게 바로 개발 실력 향상으로 이어지진 않겠지만 그래도 스프링을 오랫동안 써봤다면 근간이 되는 흐름과 구조는 어느 정도 알고 있어야 하지 않을까?
사실 앞서 직접 프레임워크를 만들어본 실습은 이해를 돕기 위한 과정일 뿐 이 DispatcherServlet
의 실제 코드를 살펴보는 것이 더 중요하다고 생각한다. 그럼 해당 코드를 하나씩 분석해보자. (코드를 단순화하기 위해 널 처리에 대한 부분은 제외하였는데 실제로는 객체가 NULL인 경우 보통 메소드 호출을 생략하거나 예외를 던진다.)
public class DispatcherServlet extends FrameworkServlet {
@Nullable
private List<HandlerMapping> handlerMappings; //기본 핸들러 매핑 리스트
@Nullable
private List<HandlerAdapter> handlerAdapters; //기본 핸들러 어댑터 리스트
@Nullable
private List<ViewResolver> viewResolvers; //기본 뷰 리졸버 리스트
//초기화 메소드
protected void initStrategies(ApplicationContext context) {
initHandlerMappings(context);
initHandlerAdapters(context);
initViewResolvers(context);
}
//서블릿의 생명주기 메소드 (요청 처리)
@Override
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
logRequest(request);
doDispatch(request, response); //본격적인 요청 처리
}
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HandlerExecutionChain mappedHandler = getHandler(request); //현재 요청을 처리할 수 있는 핸들러 조회
if (mappedHandler == null) { //조회된 핸들러가 없다면 예외 던지고 종료
noHandlerFound(request, response);
return;
}
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); //조회된 핸들러를 처리할 수 있는 핸들러 어댑터 조회
ModelAndView mv = ha.handle(request, response, mappedHandler.getHandler()); //요청 처리 결과인 ModelAndView 객체 반환
processDispatchResult(request, response, mappedHandler, mv, dispatchException); //요청 처리 결과에 대한 후처리
}
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
for (HandlerMapping mapping : this.handlerMappings) {
HandlerExecutionChain handler = mapping.getHandler(request);
if (handler != null) {
return handler;
}
}
return null;
}
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
for (HandlerAdapter adapter : this.handlerAdapters) {
if (adapter.supports(handler)) {
return adapter;
}
}
throw new ServletException("");
}
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv, @Nullable Exception exception) throws Exception {
if (mv != null && !mv.wasCleared()) {
render(mv, request, response); //뷰 렌더링
}
}
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);
View view;
String viewName = mv.getViewName();
if (viewName != null) {
view = resolveViewName(viewName, mv.getModelInternal(), locale, request); //뷰 리졸버로 뷰 조회
}
else {
view = mv.getView();
}
view.render(mv.getModelInternal(), request, response);
}
protected View resolveViewName(String viewName, @Nullable Map<String, Object> model, Locale locale, HttpServletRequest request) throws Exception {
for (ViewResolver viewResolver : this.viewResolvers) {
View view = viewResolver.resolveViewName(viewName, locale);
if (view != null) {
return view;
}
}
return null;
}
}
일단 이전에는 못봤던 HandlerAdapter
라는 낯선 클래스 하나가 추가되었는데 왜 해당 클래스가 굳이 필요할까? 이유를 생각해보자. 우리가 보통 구현하는 Controller
에서는 요청당 하나의 Controller
클래스가 아닌 하나의 Controller
내 메소드 하나가 매핑된다. (@RequestMapping
어노테이션을 메소드 위에 선언하기 때문에 하나의 요청에 대해 하나의 Controller
내 하나의 메소드가 호출된다.) 이를 핸들러 메소드라고 하는데 이때 해당 메소드들은 제각각으로 생겼다. 즉 메소드의 시그니처가 고정되어 있지 않다는 것이다. 따라서 메소드 시그니처의 유연성을 위해 HandlerAdapter
라는 클래스가 추가되었고 이 핸들러 어댑터는 실제 핸들러 메소드가 어떻게 생겨먹었든지 (다른 객체들의 도움을 받으면서) 해당 핸들러 메소드를 안정적으로 호출해주고 반환 결과에 대한 뒷처리도 해준다. 정리하자면 핸들러가 어떻게 생기든지, 새로운 핸들러가 추가되든지 doDispatch
내 코드는 변경할 필요 없이 해당 핸들러를 처리하는 HandlerAdapter
만 변경하도록 하여 스프링 아키텍처를 보다 유연하고 안정적으로 가져가기 위해서라고 할 수 있다.
아무튼 DispatcherServlet
도 서블릿이기 때문에 서블릿의 생명주기를 따라 초기화될 때 initStrategies
메소드가 호출된다. 해당 메소드에서는 요청 처리에서 사용될 handlerMappings
, handlerAdapters
, viewResolvers
등의 인스턴스 변수가 스프링이 제공하는 기본값들로 초기화된다. 이후 클라이언트로부터 요청이 들어오면 서블릿의 생명주기 메소드인 doService
내에서 본격적인 처리를 위한 doDispatch
메소드가 호출된다.
doDispatch
메소드에서는 현재 요청을 처리할 수 있는 핸들러를 조회하는데 getHandler
메소드에서는 handlerMappings
리스트를 순회하며 제일 먼저 조회되는 Handler
를 반환한다. 이후에는 해당 핸들러를 처리할 수 있는 HandlerAdapter
를 getHandlerAdapter
메소드에서 마찬가지의 방법으로 조회한다. 그리고 조회된 핸들러 어댑터를 통해 핸들러에게 요청 처리를 위임하며 처리 결과로 ModelAndView
객체를 반환한다. 여기서 핸들러의 메소드 시그니처는 가변적인 반면 핸들러 어댑터의 handle
메소드는 다음과 같은 형태로 모두 고정되어 있다.
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
이후 processDispatchResult
메소드에서 요청 처리 결과에 대한 후처리를 진행한다. 일단 viewResolvers
리스트를 순회하면서 ModelAndView
객체의 viewName
값으로 제일 먼저 조회되는 View
를 반환받는다. 이후 반환된 뷰를 렌더링하여 최종적으로 클라이언트에게 응답한다. 참고로 API 통신으로 JSON 데이터를 응답으로 전달하는 경우(예를 들어 @ResponseBody
어노테이션이 붙은 경우)에는 ModelAndView
객체가 NULL 값으로 반환되어 뷰를 렌더링하는 로직을 타지 않는다.
앞서 핸들러 어댑터가 핸들러를 어댑팅하는 과정에서 다른 객체들의 도움을 받는다고 하였는데, 해당 객체들은 무엇인지, 또한 어떤 역할을 하고 있는지 알아보기 위해 가장 많이 사용되는 어댑터인 RequestMappingHandlerAdapter
의 코드를 살펴보자. (이해를 위해 매우 단순화하고 일부를 변경하였다.)
일단 모든 핸들러 어댑터의 최상위 인터페이스인 HandlerAdapter
에는 크게 supports
, handle
두 개의 메소드가 선언되어 있다. 여기서 supports
는 현재 핸들러 어댑터가 파라미터로 전달된 핸들러를 처리할 수 있는지 여부를 판단하는 메소드이며, handle
은 파라미터로 전달된 핸들러를 실제로 호출하고 그 결과 ModelAndView
객체를 반환하는 메소드이다. 아래 코드의 supportsInternal
, handleInternal
메소드는 각각 상위 클래스(AbstractHandlerMethodAdapter
)의 supports
, handle
메소드 내에서 호출된다.
public class RequestMappingHandlerAdapter extends AbstractHandlerMethodAdapter implements BeanFactoryAware, InitializingBean {
@Nullable
private HandlerMethodArgumentResolverComposite argumentResolvers;
@Nullable
private HandlerMethodReturnValueHandlerComposite returnValueHandlers;
private final List<HttpMessageConverter<?>> messageConverters = new ArrayList<>();
private void initMessageConverters() {
//기본 메시지 컨버터 리스트 초기화
}
private List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() {
//기본 파라미터 리졸버 리스트 생성 후 반환 (일부 파라미터 리졸버 생성 시 메시지 컨버터 전달)
}
private List<HandlerMethodReturnValueHandler> getDefaultReturnValueHandlers() {
//기본 반환값 핸들러 리스트 생성 후 반환 (일부 반환값 핸들러 생성 시 메시지 컨버터 전달)
}
@Override
protected boolean supportsInternal(HandlerMethod handlerMethod) {
return true; //HandlerMethod를 처리할 수 있는 어댑터
}
@Override
protected ModelAndView handleInternal(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
return invokeHandlerMethod(request, response, handlerMethod); //핸들러 메소드 호출
}
@Nullable
protected ModelAndView invokeHandlerMethod(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
ServletInvocableHandlerMethod invocableMethod = createInvocableHandlerMethod(handlerMethod);
ModelAndView mav = new ModelAndView();
//핸들러 메소드에서 사용할 수 있도록 파라미터 리졸버, 반환값 핸들러 전달
if (this.argumentResolvers != null) {
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
if (this.returnValueHandlers != null) {
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
invocableMethod.invokeAndHandle(request, response, mav);
return mav;
}
}
RequestMappingHandlerAdapter
는 인스턴스 변수를 통해 많은 클래스와 연관관계를 맺고 있는데 그 중 중요한 클래스는 바로 HandlerMethodArgumentResolverComposite
, HandlerMethodReturnValueHandlerComposite
, HttpMessageConverter
이다. (이때 HandlerMethodArgumentResolverComposite
, HandlerMethodReturnValueHandlerComposite
는 컴포지트 디자인 패턴으로 구현된 클래스로 각각 List<HandlerMethodArgumentResolver>
, List<HandlerMethodReturnValueHandler>
로 봐도 무방하다.)
핸들러 어댑터와 협력하는 각 객체들의 역할을 설명해보자면, 먼저 HandlerMethodArgumentResolver
는 HttpServletRequest
객체의 요청 내용을 해석해 핸들러 메소드의 파라미터 타입에 맞는 객체로 생성하여 전달하는 역할을, HandlerMethodReturnValueHandler
는 핸들러 메소드의 반환값을 해석해 응답에 사용할 수 있도록 HttpServletResponse
객체 또는 ModelAndView
객체에 저장하는 역할을 한다.
이 과정에서 HTTP 관련 객체 → 일반 객체 또는 일반 객체 → HTTP 관련 객체로 변환하기 위해 HTTP 프로토콜을 알고 있는 별도의 객체가 필요한데, 이것이 바로 HttpMessageConverter
이다. 즉 HandlerMethodArguemtnResolver
나 HandlerMethodReturnValueHandler
가 HTTP 프로토콜을 기반으로 요청을 읽어오거나 응답으로 써야 할 경우 이 HttpMessageConverter
를 호출하는 것이다. 반면 컨트롤러의 메소드 파라미터로 스프링의 ModelAndView
객체를 전달받을 수 있는데 해당 객체는 HTTP 요청과는 관련이 없어 이 경우에는 HttpMessageConverter
가 동작하지 않는다.
아무튼 RequestMappingHandlerAdapter
에서도 DispatcherServlet
과 비슷하게 각 인스턴스 변수는 스프링이 제공하는 기본값으로 초기화되며, 이후 invokeHandlerMethod
메소드 내에서 현재 요청에 매핑된 핸들러 메소드를 호출하여 처리를 위임한다. invokeAndHandle
메소드를 호출하기 전 핸들러 메소드에서 HandlerMethodArgumentResolver
와 HandlerMethodReturnValueHandler
를 사용할 수 있도록 setter 메소드를 호출하는 것도 확인할 수 있다.
진짜 마지막으로, 요청이 들어왔을 때 스프링 MVC의 전체적인 요청 처리 흐름을 다음 그림과 함께 정리해보자.
1. 클라이언트로부터 요청이 발생하여 DispatcherServlet
의 doDispatch
메소드 호출
2. 현재 요청을 처리할 수 있는 핸들러를 조회하기 위해 HandlerMapping
에게 요청
3. HandlerMapping
리스트를 순회하며 제일 먼저 조회되는 Handler
를 반환
4. 반환된 핸들러를 처리할 수 있는 핸들러 어댑터를 조회하기 위해 HandlerAdapter
에게 요청
5. HandlerAdapter
리스트를 순회하며 supports
메소드의 반환값이 true인 HandlerAdapter
를 반환
6. 반환된 핸들러 어댑터의 handle
메소드 호출
7. HandlerMethod
에게 현재 요청에 대한 처리를 위임
8. HandlerMethod
의 파라미터로 전달할 객체 생성을 위해 HandlerMethodArguemtnResovler
에게 요청
8-1. HTTP 요청을 읽어 객체를 생성해야 하는 경우 HttpMessageConverter
에게 요청
8-2. HttpMessageConverter
는 처리 결과 Object 반환
9. HandlerMethod
에게 생성된 파라미터 객체를 전달
10. 전달받은 파라미터와 함께 HandlerMethod
호출 (실제로는 특정 Controller
내 특정 메소드를 호출)
11. HandlerMethod
의 반환값을 처리하기 위해 HandlerMethodReturnValueHandler
에게 요청
11-1. 반환값을 HTTP 응답으로 작성해야 하는 경우 HttpMessageConverter
에게 요청
11-2. HttpMessageConverter
는 반환값을 HTTP 응답에 write
12. HandlerAdapter
는 요청 처리 결과인 ModelAndView
객체 반환
13. 요청 처리 결과로부터 응답 View
를 조회하기 위해 ViewResolver
에게 요청
14. ViewResolver
는 viewName
으로 조회된 View
반환
15. 반환된 뷰를 렌더링하여 클라이언트에게 응답
시간이 넉넉하다면 한번 스프링의 DispatcherServlet
클래스의 doDispatch
메소드 내에서 브레이크 포인트를 찍어가며 디버깅해보는 것도 추천한다. 실제 흐름을 따라가다 보면 코드의 유연성을 위해 엄청난 계층화와 추상화가 이루어져 있고 많은 디자인 패턴이 적용되어 있어 스프링에 압도당하는 경험을 할 수 있다... 뭐 이것이 지금 당장 개발에 도움은 안되겠지만 스프링이 어떤 식으로 돌아가는지 기초 지식이 있으면 최소한 문제가 발생했을 때 어떤 부분이 문제인지 빠르게 진단하는데 분명 도움이 될 것이라 생각한다. 이러한 이유 외에도 굉장한 개발자들이 작성하고 오랜 기간동안 리팩토링되어 온 코드이기 때문에 코드의 구조나 네이밍 등에서도 배울 점이 매우 많다.
사실 DispatcherServlet
클래스에 대해 더 자세히 다루고 싶었으나 이건 좀 여유가 생기면 그때 글로 정리해볼까 한다.
끝.
참고
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1
'Spring > Spring' 카테고리의 다른 글
[Spring Boot] 날짜 데이터를 요청 및 응답으로 주고 받는 여러 가지 방법 (0) | 2023.11.23 |
---|---|
[Querydsl] Spring Boot 3.x with Gradle 설정 (1) | 2023.10.20 |
Web Server vs WAS vs Web Container(Servlet Container) (0) | 2023.09.02 |
[개념] @Transactional(readOnly = true)임에도 insert 쿼리가 발생하는 경우 (0) | 2023.01.29 |
[개념] Service 계층 메소드에서 @Transactional, @Transactional(readOnly = true), 트랜잭션 어노테이션 없음 간 동작방식의 차이 (0) | 2023.01.17 |
- Total
- Today
- Yesterday
- vscode
- rest api
- spring
- spring aop
- Linux
- C++
- 단위 테스트
- Gitflow
- SSE
- 전략 패턴
- Assertions
- mockito
- JPA
- junit5
- github
- Transaction
- 서블릿 컨테이너
- Git
- 모두의 리눅스
- 템플릿 콜백 패턴
- 디자인 패턴
- Spring Security
- Java
- servlet filter
- ParameterizedTest
- QueryDSL
- spring boot
- FrontController
- facade 패턴
- Front Controller
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 30 | 31 |