이전까지 우리는 JSP와 Servlet을 이용하여 간단한 기능을 가진 직접적인 MVC 프레임워크를 만들어보았다. 해당 커스텀 구조는 현재는 작지만, 확장성을 염두에 두고 설계하였기에 큰 구조적인 면에서 Spring MVC와 크게 차이가 없다. 이전 포스팅의 미니 프로젝트는 의도적으로 Spring MVC의 설계 철학을 참고하여 설계된 것이었다.


갑자기 컨트롤러를 핸들러로 지칭하면서 생긴 헷갈림과 어댑터의 존재 필요성에 대한 의문을 다시 확인해보려고 한다. 이 확인 작업은 커스텀 MVC를 다시 잘 이해하기 위한 것이며, Spring MVC를 대상으로 한 코드 분석은 아니다.
어댑터 클래스에서 중요한 메서드인 handle()은 프론트 컨트롤러에서 주입받은 구현 컨트롤러 객체를 실행시키고, 실행 결과로 ModelView를 만들어 리턴한다.
우리는 프론트 컨트롤러에서 handlerMappingMap에 URI에 매핑되는 구현 컨트롤러들을 Map에 구성해놓았고 컨트롤러를 검사하고 실행시키는 목적으로 어댑터가 존재한다. 단순히 Map에 적재된 컨트롤러를 디스패처 서블릿이 실행해도 되지만 굳이 하나의 레벨을 더 추가(어댑터를 거치는)하는 이유는 아마 검증이라는 새로운 추가로직 때문일 것이다.
"Map에 컨트롤러를 구성해놓은 것 자체가 하나의 검증이 아닌가? 왜 검증이 필요하지?"라는 질문에 답해야한다.
Spring MVC에는 다양한 스타일의 Controller 구현체들이 존재한다. 이러한 구현체들은 결국 최상단 부모 인터페이스인 Controller를 기반으로 동작할 것이다. 하지만 핸들러 검증 과정이 없다면 누군가가 아무 핸들러나 매핑 맵에 URI와 매핑시킨 경우에 대해 대처할 수 없게 된다. 매핑된 핸들러가 우리가 의도한Controller 인터페이스를 구현하지 않았다면 예상된 작업을 수행하지 않을 수 있다. 확장성은 적절한 제한 아래에서 성공적으로 이루어질 수 있다.
컨트롤러는 핸들러의 한 종류이다. 우리가 먼저 배운 것이 컨트롤러였기 때문에 더 헷갈렸던 것 같다.
@Controller 및 @RestControllerHttpRequestHandlerHttpRequestHandler 인터페이스를 구현하여 직접 HTTP 요청과 응답을 처리하는 핸들러.WebSocketHandlerController 인터페이스Spring MVC에서 컨트롤러는 웹 요청을 처리하는 핸들러로, 핸들러의 한 종류라고 볼 수 있다. (즉, "요청을 처리하면 핸들러이다.") 그러나 모든 핸들러가 컨트롤러인 것은 아니다. 예를 들어, WebSocket 요청을 처리하는 WebSocketHandler는 핸들러이지만 컨트롤러는 아니다.
컨트롤러는 핸들러의 세부적인 구현으로 볼 수 있다. 단순히 더 구체적인 역할을 가진 핸들러일 뿐이다. 쓰면서도 참 헷갈리게 만들어놨다는 생각이 든다. 아마도 Controller라는 단어 하나로 끝나서 사용하기 편리하기 때문에 이런 구조가 된 것 같다. 하지만 만약 모든 핸들러 이름을 XxxHandler 또는 XxxController로 통일했다면 학습 시 더 명확하지 않았을까 싶다.
컨트롤러는 웹 요청 처리기로써의 역할을 하고, 핸들러는 좀 더 광범위한 요청 처리기를 뜻한다. 예를 들어, 메시지를 다루는 MessageHandler는 메시지 요청을 처리하는 핸들러일 뿐 컨트롤러는 아니다.
결론적으로, Spring MVC에서 컨트롤러는 웹 관련 요청을 처리하는 핸들러이며, 익숙해지는 것이 답인 것 같다. 컨트롤러는 핸들러의 세부적인 구현이라는 사실을 받아들이고, "Controller는 Handler이다"라는 점을 기억해야 한다. 단어가 다른 탓에 둘이 다른 것 같다는 무의식적인 혼란을 떨쳐내자!
우리가 앞서 직접 만든 FrontController는 Spring MVC의 DispatcherServlet과 대응된다. "흩뿌린다"는 느낌이 잘 전달되는 이름으로, 이름을 잘 지었다고 생각한다.
당연히 DispatcherServlet은 HttpServlet을 상속받는다. (비록 상속 계층이 꽤 깊지만 말이다.)
스프링 부트는 DispatcherServlet을 서블릿으로 자동 등록하며, 모든 경로("/")에 대해서 이 서블릿으로 요청이 오도록 매핑한다. 서블릿이 호출되면 service() 메서드가 호출되고, 이 메서드는 여러 내부 메서드를 호출하지만 가장 중요한 것은 DispatcherServlet.doDispatch() 메서드가 실행된다는 점이다.
doDispatch() 메서드의 주요 절차핸들러 조회
mappedHandler = getHandler(processedRequest)
요청을 처리할 핸들러를 조회한다.
핸들러 어댑터 조회
ha = getHandlerAdapter(mappedHandler)
핸들러를 실행할 수 있는 적절한 어댑터를 찾는다.
핸들러 어댑터 실행
mv = ha.handle(...)
핸들러 어댑터를 실행하고, 처리 결과로 ModelView를 반환받는다.
뷰 관련 메서드 실행
processDispatchResult(req, resp, mappedHandler, mv, ...)
핸들러 처리 결과를 바탕으로 뷰 처리를 시작한다.
processDispatchResult 내부에서 렌더링 준비
render(mv, req)
렌더링을 위해 모델과 뷰를 준비한다.
뷰 리졸버를 통해 뷰 찾기
렌더링 과정에서 뷰 리졸버를 통해 적절한 뷰를 찾는다.
렌더링
view.render(mv.getModelInternal(), req, resp)
최종적으로 뷰를 렌더링하여 클라이언트에 응답을 전송한다.
직접 FrontController를 구현해본 경험 덕분에, 이 절차가 매우 익숙하게 느껴진다. 우리가 만든 구조가 스프링 MVC의 핵심 구조와 비슷한 방식으로 동작하기 때문에, DispatcherServlet의 동작 과정을 학습하는 데 큰 도움이 되었다고 생각한다.
스프링 부트에서는 핸들러 매핑(HandlerMapping)과 핸들러 어댑터(HandlerAdapter)가 자동으로 등록된다. 이 등록 후, 요청 처리 과정에서 각각의 핸들러와 어댑터를 우선순위에 따라 검색하게 된다. 아래는 주요 핸들러 매핑과 어댑터의 예이다.
RequestMappingHandlerMapping
@RequestMapping 또는 @GetMapping, @PostMapping 등을 처리하기 위한 핸들러 매핑.BeanNameUrlHandlerMapping
@Component("myHandler")로 등록된 Bean이 /myHandler와 같은 URL에 매핑된다.SimpleUrlHandlerMapping
RequestMappingHandlerAdapter RequestMappingHandlerMapping에 의해 매핑된 핸들러를 처리하는 어댑터. @Controller
@RequestMapping("/example")
public class ExampleController {
@GetMapping("/hello")
@ResponseBody
public String sayHello() {
return "Hello, RequestMappingHandlerAdapter!";
}
}
HttpRequestHandlerAdapter HttpRequestHandler 인터페이스를 구현한 핸들러를 처리하는 어댑터. public class CustomHttpRequestHandler implements HttpRequestHandler {
@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws IOException {
response.getWriter().write("Hello, HttpRequestHandlerAdapter!");
}
}
SimpleControllerHandlerAdapter -> 안씀Controller 인터페이스를 처리하는 어댑터.스프링 MVC는 핸들러와 어댑터를 처리할 때 등록된 우선순위에 따라 가장 적합한 매핑과 어댑터를 선택한다. 예를 들어, RequestMappingHandlerMapping이 가장 먼저 검색되며, 매핑된 핸들러가 없을 경우 이후의 핸들러 매핑으로 넘어간다. 어댑터 역시 해당 핸들러에 맞는 어댑터를 순차적으로 검색하여, 실행 가능한 어댑터를 선택하여 동작한다.
스프링 부트의 자동 등록된 핸들러 매핑과 어댑터는 다양한 요청 처리 방식을 지원한다. 주요 매핑과 어댑터는 각각 @RequestMapping 기반의 컨트롤러, HTTP 직접 처리 핸들러, 그리고 레거시 컨트롤러를 다룰 수 있다. 우선순위에 따라 적합한 매핑과 어댑터가 선택되어 동작한다.
application.properties에 다음을 추가하면 논리이름을 사용할 수 있다.
spring.mvc.view.prefix=/WEB-INF/views/
spring.mvc.view.suffix=.jsp
위 처럼 설정해놓으면 우리는
return new ModelAndView("new-form");를 사용할 수 있다. (우리가 했던 방식이랑 매우 비슷하네)
BeanNameViewResolver : 빈 이름으로 뷰를 찾아서 반환한다. (예: 엑셀 파일 생성 기능 에 사용)InternalResourceViewResolver : JSP를 처리할 수 있는 뷰를 반환한다.위의 경우는 2.에 해당한다.
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {
private MemberRepository memberRepository = MemberRepository.getInstance();
@RequestMapping("/new-form")
public ModelAndView newForm() {
return new ModelAndView("new-form");
}
@RequestMapping("")
public ModelAndView members() {
List<Member> members = memberRepository.findAll();
ModelAndView mv = new ModelAndView("members");
mv.addObject("members", members);
return mv;
}
@RequestMapping("/save")
public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
String username = request.getParameter("username");
int age = Integer.parseInt(request.getParameter("age"));
Member member = new Member(username, age);
memberRepository.save(member);
ModelAndView mv = new ModelAndView("save-result");
mv.addObject("member", member);
return mv;
}
}
@Controller를 사용하면 컨트롤러 빈으로 등록되어 스프링 컨테이너에서 관리된다. DispatcherServlet은 이를 활용해 어댑터 매핑을 진행하며, 컨트롤러 클래스 내부의 메서드에 선언된 @RequestMapping 애노테이션을 기반으로 매핑 맵을 자동으로 구성한다. 이를 통해 요청과 컨트롤러 간의 연결이 간단해지며, Spring MVC를 활용한 V2 구현은 프레임워크의 지원 기능을 통해 더욱 개선할 수 있다.
@Controller
@RequestMapping("/springmvc/v3/members")
public class SpringMemberControllerV3 {
private MemberRepository memberRepository = MemberRepository.getInstance();
// @RequestMapping(value = "/new-form", method = RequestMethod.GET)
@GetMapping("/new-form")
public String newForm() {
return "new-form";
}
// @RequestMapping(value = "", method = RequestMethod.GET)
@GetMapping("")
public String members(Model model) {
List<Member> members = memberRepository.findAll();
model.addAttribute("members", members);
return "members";
}
// @RequestMapping(value = "/save", method = RequestMethod.POST)
@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";
}
}
기존에는 반환을 ModelAndView로 처리하기 위해 각 매핑된 메서드에서 모델을 직접 생성했으나, 이제는 그럴 필요 없이 반환 타입을 String으로 지정하고, 모델이 필요한 경우 메서드의 파라미터로 org.springframework.ui.Model을 받으면 된다. 스프링은 자동으로 모델 객체를 주입해주기 때문에 개발자는 간편하게 사용할 수 있다.
또한, 기존의 save 메서드에서 HttpServletRequest를 파라미터로 받아 처리하던 방식도 불필요하다. 스프링은 요청 파라미터를 메서드의 파라미터로 직접 바인딩하거나 객체로 매핑할 수 있는 기능을 제공하므로, 보다 간결하게 구현할 수 있다.
public String save(
@RequestParam("username") String username,
@RequestParam("age") int age,
Model model)
@RequestParam 애노테이션을 통해 스프링에서 알아서 Request에서 쿼리파라미터라는 알맹이만 쏙 빼서 주입해준다.
핸들러-어댑터의 개념을 잘 숙지하자.
컨트롤러는 핸들러이며, 웹 MVC 프레임워크에서 사용되는 핸들러는 모두 웹 관련 요청 처리기이기 때문에 컨트롤러와 핸들러를 같은 의미로 이해하는 것이 편리하다. 어댑터는 핸들러가 적절한 핸들러인지 검증하고, 해당 핸들러를 실행시키는 역할을 수행하는 통제자 또는 명령자 같은 존재이다.
높은 확장성을 가진 Spring MVC 구조
Spring MVC는 높은 확장성을 제공하지만, 우리가 이 시스템을 확장할 일은 거의 없다. 이미 수년간 수많은 사람들이 우리의 상상을 뛰어넘는 대부분의 기능을 구현해 놓았기 때문이다. 실제로 우리는 컨트롤러 부분만 코딩하면 어댑터가 이를 실행하고, 프론트 컨트롤러인 DispatcherServlet이 렌더링 로직으로 넘기는 작업, 핸들러를 찾는 과정 등은 모두 스프링에서 처리한다. 이로 인해 개발자는 비즈니스 로직에 집중할 수 있다.