- 톰캣 코드 저장소 : https://github.com/dongho108/jwp-dashboard-http/tree/step234
- 어노테이션기반 MVC 프레임워크 코드 저장소 : https://github.com/dongho108/jwp-dashboard-mvc/tree/step1
지난 시간에는 서블릿 컨테이너인 톰캣을 구현해보았습니다.
구현했던 톰캣 기능
- 요청당 쓰레드 생성 (쓰레드관리)
- 커넥션관리
- 서버소켓 생성
- HttpRequest, HttpResponse 변환
- 컨트롤러 찾기
- 컨트롤러 실행
그래서 4~6번은 아래와 같은 구조가 만들어졌습니다.
위와 같은 구조에는 문제점이 있었습니다.
Request에 맞는 Controller를 찾아주는 메서드와 클래스가 전혀 다른 곳에 있는 패키지를 의존하고 있습니다.
사실 비즈니스 로직에 컨트롤러가 추가된다고 스프링 코드가 변화하지는 않죠.
프레임워크는 비즈니스 로직과 완전 분리되어야 한다는 뜻입니다.
그렇다면 어떻게 스프링은 계속 변화하는 비즈니스 코드를 알고 Controller를 매핑시켜주는 것일까요?
어노테이션 기반 MVC 프레임워크
아래와 같은 코드의 컨트롤러를 비즈니스로직에서 작성하고, HandlerMapping에서 자동으로 읽어올 수 있게 만들어 보겠습니다.
@Controller
public class TestController {
private static final Logger log = LoggerFactory.getLogger(TestController.class);
@RequestMapping(value = "/get-test", method = RequestMethod.GET)
public ModelAndView findUserId(final HttpServletRequest request, final HttpServletResponse response) {
log.info("test controller get method");
final var modelAndView = new ModelAndView(new JspView(""));
modelAndView.addObject("id", request.getAttribute("id"));
return modelAndView;
}
@RequestMapping(value = "/post-test", method = RequestMethod.POST)
public ModelAndView save(final HttpServletRequest request, final HttpServletResponse response) {
log.info("test controller post method");
final var modelAndView = new ModelAndView(new JspView(""));
modelAndView.addObject("id", request.getAttribute("id"));
return modelAndView;
}
}
interface HandlerMapping
public interface HandlerMapping {
void initialize();
Object getHandler(HttpServletRequest request);
}
class AnnotationHandlerMapping
initialize()에서는 리플렉션으로 @Controller 어노테이션이 붙어있는 클래스들을 가져옵니다.
클래스의 각 메서드들을 HandlerExcecution 클래스로 만들어 Map에 넣어줍니다.
getHandler()에서는 request에서 url, method를 key로 작업을 수행할 HandlerExcecution을 가져올 수 있습니다.
public class AnnotationHandlerMapping implements HandlerMapping {
private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerMapping.class);
private static final Class<RequestMapping> REQUEST_MAPPING_ANNOTATION_CLASS = RequestMapping.class;
private static final Class<Controller> CONTROLLER_ANNOTATION_CLASS = Controller.class;
private final Object[] basePackage;
private final Map<HandlerKey, HandlerExecution> handlerExecutions;
public AnnotationHandlerMapping(final Object... basePackage) {
this.basePackage = basePackage;
this.handlerExecutions = new HashMap<>();
}
public void initialize() {
final Reflections reflections = new Reflections(basePackage);
final Set<Class<?>> classes = reflections.getTypesAnnotatedWith(CONTROLLER_ANNOTATION_CLASS);
for (Class<?> clazz : classes) {
Object instance = getInstance(clazz);
final List<Method> methods = getRequestMappingMethods(clazz);
methods.forEach(method -> putHandlerExecutionByRequestMapping(instance, method));
}
log.info("Initialized AnnotationHandlerMapping!");
}
public Object getHandler(final HttpServletRequest request) {
final String requestURI = request.getRequestURI();
final String method = request.getMethod();
final RequestMethod requestMethod = RequestMethod.valueOf(method);
final HandlerKey handlerKey = new HandlerKey(requestURI, requestMethod);
if (handlerExecutions.containsKey(handlerKey)) {
return handlerExecutions.get(handlerKey);
}
throw new NotFoundHandlerException(requestURI, method);
}
...
}
HandlerKey
HandlerExecution을 가져오기위해 url, method를 묶은 VO입니다.
public class HandlerKey {
private final String url;
private final RequestMethod requestMethod;
public HandlerKey(final String url, final RequestMethod requestMethod) {
this.url = url;
this.requestMethod = requestMethod;
}
@Override
public String toString() {
return "HandlerKey{" +
"url='" + url + '\'' +
", requestMethod=" + requestMethod +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof HandlerKey)) return false;
HandlerKey that = (HandlerKey) o;
return Objects.equals(url, that.url) && requestMethod == that.requestMethod;
}
@Override
public int hashCode() {
return Objects.hash(url, requestMethod);
}
}
HandlerExecution
컨트롤러의 메서드를 대신 실행시켜주는 녀석입니다.
현재 제가 만들고 있는 어노테이션 기반의 컨트롤러는 HandlerExecution을 이용해야만 실행시킬 수 있습니다.
(이 말을 잘 기억해주세요.)
public class HandlerExecution {
private final Object object;
private final Method method;
public HandlerExecution(final Object object, final Method method) {
this.object = object;
this.method = method;
}
public ModelAndView handle(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
return (ModelAndView) method.invoke(object, request, response);
}
}
Test
제가 만든 어노테이션기반의 핸들러매핑이 잘 동작하는지 확인해보겠습니다.
위에서 정의한 TestController
@Controller
public class TestController {
private static final Logger log = LoggerFactory.getLogger(TestController.class);
@RequestMapping(value = "/get-test", method = RequestMethod.GET)
public ModelAndView findUserId(final HttpServletRequest request, final HttpServletResponse response) {
log.info("test controller get method");
final var modelAndView = new ModelAndView(new JspView(""));
modelAndView.addObject("id", request.getAttribute("id"));
return modelAndView;
}
@RequestMapping(value = "/post-test", method = RequestMethod.POST)
public ModelAndView save(final HttpServletRequest request, final HttpServletResponse response) {
log.info("test controller post method");
final var modelAndView = new ModelAndView(new JspView(""));
modelAndView.addObject("id", request.getAttribute("id"));
return modelAndView;
}
}
TestController가 속한 pakage 이름을 AnnotationHandlerMapping 생성자의 매개변수로 넣어줍니다.
그리고 초기화를 시켜줍니다.
class AnnotationHandlerMappingTest {
private AnnotationHandlerMapping handlerMapping;
@BeforeEach
void setUp() {
handlerMapping = new AnnotationHandlerMapping("samples");
handlerMapping.initialize();
}
@Test
void get() throws Exception {
final var request = mock(HttpServletRequest.class);
final var response = mock(HttpServletResponse.class);
when(request.getAttribute("id")).thenReturn("gugu");
when(request.getRequestURI()).thenReturn("/get-test");
when(request.getMethod()).thenReturn("GET");
final var handlerExecution = (HandlerExecution) handlerMapping.getHandler(request);
final var modelAndView = handlerExecution.handle(request, response);
assertThat(modelAndView.getObject("id")).isEqualTo("gugu");
}
@Test
void post() throws Exception {
final var request = mock(HttpServletRequest.class);
final var response = mock(HttpServletResponse.class);
when(request.getAttribute("id")).thenReturn("gugu");
when(request.getRequestURI()).thenReturn("/post-test");
when(request.getMethod()).thenReturn("POST");
final var handlerExecution = (HandlerExecution) handlerMapping.getHandler(request);
final var modelAndView = handlerExecution.handle(request, response);
assertThat(modelAndView.getObject("id")).isEqualTo("gugu");
}
}
이제 @Controller 어노테이션과 @RequestMapping 어노테이션을 달아주는 것 만으로도 프레임워크의 코드를 수정하지 않고 컨트롤러를 추가할 수 있게 되었습니다.
그런데 한가지 문제점이 더 발생했습니다.
기존의 상속기반 컨트롤러는 어떻게 되는 것일까요?
기존 컨트롤러 ForwardController (톰캣 구현하기의 컨트롤러와 조금 달라졌습니다.)
기존 컨트롤러는 메서드명도 execute입니다.
public class ForwardController implements Controller {
private final String path;
public ForwardController(final String path) {
this.path = Objects.requireNonNull(path);
}
@Override
public String execute(final HttpServletRequest request, final HttpServletResponse response) {
return path;
}
}
기존 핸들러 매핑 방식도 다릅니다.
public class ManualHandlerMapping implements HandlerMapping {
private static final Logger log = LoggerFactory.getLogger(ManualHandlerMapping.class);
private static final Map<String, Controller> controllers = new HashMap<>();
@Override
public void initialize() {
controllers.put("/", new ForwardController("/index.jsp"));
controllers.put("/login", new LoginController());
controllers.put("/login/view", new LoginViewController());
controllers.put("/logout", new LogoutController());
controllers.put("/register/view", new RegisterViewController());
controllers.put("/register", new RegisterController());
log.info("Initialized Handler Mapping!");
controllers.keySet()
.forEach(path -> log.info("Path : {}, Controller : {}", path, controllers.get(path).getClass()));
}
@Override
public Controller getHandler(HttpServletRequest request) {
final String requestURI = request.getRequestURI();
log.debug("Request Mapping Uri : {}", requestURI);
return controllers.get(requestURI);
}
}
기존 컨트롤러와 어노테이션 기반 컨트롤러의 실행방법에는 차이가 존재합니다.
- 기존의 컨트롤러는 상속한 클래스의 excecute를 실행하는 방식이고
- 어노테이션 기반의 컨트롤러는 HandlerExecution의 handle을 실행시키는 방식입니다.
Handler Adapter (핸들러 어댑터)
기존의 컨트롤러를 새로운 어노테이션 기반의 컨트롤러로 코드를 수정하는 방법도 있습니다만,
만약 기존의 컨트롤러가 수백개 수천개라면 어떻게 될까요?
기존 컨트롤러를 어노테이션 기반으로 변경할때까지 서비스를 중단해야할까요?
가장 좋은 방법은 기존 컨트롤러와 어노테이션 기반의 컨트롤러를 둘 다 지원하게 만드는 방법입니다.
차근차근 전환을 하면 되죠.
만약 실제 프레임워크를 만든다고 생각해도 갑자기 기존 코드를 지원중단할 수도 없겠죠.
그래서 필요한 것이 핸들러 어댑터입니다.
실제 Spring MVC 에서 핸들러 어댑터를 사용해 여러가지 방식의 컨트롤러를 지원하도록 합니다.
지금부터 핸들러 어댑터를 자세히 알아보고 기존의 컨트롤러와 새로운 어노테이션 기반의 컨트롤러를 둘 다 지원하도록 구현해보겠습니다.
Dispatch Servlet
여기서 잠시,
그림에 갑자기 등장한 DispatcherServlet에 대해 잠깐 언급하고 넘어가겠습니다.
현재 구조는 아래와 같습니다.
톰캣이 서블릿을 직접 생성해서 넘겨주는 상황입니다.
하지만 저희는 서블릿에게 HttpRequest를 넘겨주어도 서블릿에서는 이 요청이 어떤 컨트롤러에 의해 처리되어야 하는지 찾아야합니다.
(핸들러매핑)
또한, 이 핸들러가 어떻게 구현했는지에 상관없이 실행시켜야 하기 때문에 컨트롤러에 맞는 핸들러어댑터도 찾아야 합니다.
(핸들러어댑터 조회)
그리고 작업이 수행되고나서 ModelAndView를 렌더링해야 합니다.
정리하면 요청마다 아래의 과정이 반복됩니다.
- 핸들러매핑
- 핸들러어댑터조회
- 핸들러실행
- 렌더링
사실 개발자가 짜야하는 비즈니스 로직은 핸들러 실행 부분입니다.
1, 2, 4 부분은 항상 같은 로직이 반복됩니다.
그래서 등장한 것이 DispatchServlet (front controller 패턴) 입니다.
비즈니스로직마다 달라지는 코드 빼고 중복되는 처리는 front controller 인 DispatcherServlet에서 담당합니다.
그래서 Spring MVC에서는 아래와 같은 그림이 나오게되는 것이죠.
interface HandlerAdapter
핸들러 어댑터는 아래와 같은 인터페이스를 구현합니다.
supports() 는 자신이 핸들링 할 수 있는 핸들러인지 판단하는 메서드입니다. 외부에서 supports를 이용해 핸들러 어댑터를 찾을 수 있게하는 메서드입니다.
handle() 은 매개변수로 받는 Object handler를 자신이 핸들링할 수 있는 객체로 캐스팅해 메서드를 실행합니다.
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
}
AnnotationHandlerAdapter
supports() 에서는 매개변수로 들어온 handler가 HandlerExecution의 인스턴스인지 확인합니다.
handle() 에서는 handlerExcecution의 handle을 실행합니다.
public class AnnotationHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(final Object handler) {
return handler instanceof HandlerExecution;
}
@Override
public ModelAndView handle(final HttpServletRequest request,
final HttpServletResponse response,
final Object handler) throws Exception {
final HandlerExecution handlerExecution = (HandlerExecution) handler;
return handlerExecution.handle(request, response);
}
}
ManualHandlerAdapter
support()에서는 매개변수로 들어온 handler가 Controller를 상속받는 클래스의 인스턴스인지 확인합니다.
handle()은 해당 controller의 메서드를 실행시킵니다.
하지만 controller는 String viewName을 반환하기 때문에 인터페이스의 반환타입을 맞춰주려면 ModelAndView로 변환해야합니다.
여기서는 일단 View 템플릿은 JSP만 있다고 가정하겠습니다.
public class ManualHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(final Object handler) {
return handler instanceof Controller;
}
@Override
public ModelAndView handle(final HttpServletRequest request,
final HttpServletResponse response,
final Object handler) throws Exception {
final Controller controller = (Controller) handler;
final String viewName = controller.execute(request, response);
return new ModelAndView(new JspView(viewName));
}
}
HandlerAdapter 덕분에 과거에 지원하던 핸들러도 실행시킬 수 있게 되었습니다.
@Override
protected void service(final HttpServletRequest request, final HttpServletResponse response) throws ServletException {
log.debug("Method : {}, Request URI : {}", request.getMethod(), request.getRequestURI());
try {
final var handler = getHandler(request); // 핸들러 매핑
final HandlerAdapter handlerAdapter = getHandlerAdapter(handler); // 핸들러어댑터 조회
final ModelAndView modelAndView = handlerAdapter.handle(request, response, handler); // handle
modelAndView.render(request, response); // 렌더링
} catch (Throwable e) {
log.error("Exception : {}", e.getMessage(), e);
throw new ServletException(e.getMessage());
}
}
private Object getHandler(final HttpServletRequest request) {
return handlerMappings.stream()
.map(handlerMapping -> handlerMapping.getHandler(request))
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new NoSuchElementException("핸들러를 찾을 수 없습니다."));
}
private HandlerAdapter getHandlerAdapter(final Object handler) {
return handlerAdapters.stream()
.filter(it -> it.supports(handler))
.findFirst()
.orElseThrow(() -> new NotFoundHandlerAdapterException("핸들러 어댑터를 찾을 수 없습니다."));
}
실제로 스프링에는 SimpleControllerHandlerAdapter 가 존재합니다.
Controller 인터페이스를 구현하는 방식의 컨트롤러를 지원하기 위한 것이죠.
지금까지 간단한 DispatcherServlet과 핸들러매핑, 핸들러 어댑터를 구현해보았습니다.
조금 더 개선할 점이 보이는데요.
- 컨트롤러 어노테이션 스캔 역할 분리
- JSON view 지원
다음시간에서 차근차근 개선해봅시다!
'Web > MVC' 카테고리의 다른 글
[Servlet 구현하기] Controller Scanner와 JSON View (3) | 2022.09.29 |
---|---|
EP9. PRG (Post/Redirect/Get) 패턴 (0) | 2021.04.01 |
EP8. Thymeleaf 타임리프 (0) | 2021.04.01 |
EP7. HTTP 응답 데이터 처리 (0) | 2021.03.30 |
EP6. HTTP 요청 데이터 처리 (0) | 2021.03.29 |