@MVC 구현하기

디우·2022년 10월 15일
0

1단계 - @MVC 프레임워크 구현하기

@MVC 프레임워크 구현하기 1단계 저장소 링크

PR 링크


체크 리스트

  • AnnotationHandlerMappingTest가 정상 동작한다.
    • 어노테이션 기반으로 HTTP 메서드와 URL에 따라 컨트롤러를 매핑해줄 수 있다.
  • DispatcherServlet에서 HandlerMapping 인터페이스를 활용하여 AnnotationHandlerMapping과 ManualHandlerMapping 둘다 처리할 수 있다.
    • DispatcherServlet에서 instanceof 를 이용하여 두 가지 HandlerMapping 을 처리해준다.
  • JspView 를 이용해서 redirect 혹은 forward 를 해줄 수 있다.

구현한 내용

AnnotationHandlerMapping

리플렉션을 사용하여 basePackage 하위에 존재하는 Controller 어노테이션 클래스들을 찾아오도록 구현하였습니다.
이후 해당 클래스들의 메소드들 중 RequestMapping 어노테이션이 붙은 메소드들로 HandlerExecution 을 생성해 등록함으로써 어노테이션 개반의 핸들러를 지원해주도록 구현하였습니다.

AnnotationHandlerMapping & ManualHandlerMapping 지원

DispatcherServlet 에서 등록된 handlerMapping 에 대해서 instanceof 키워드로 Controller 인터페이스를 구현하고 있는지, HandlerExecution 인지에 따라 분기해줌으로써 기존 ManualHandlerMapping 이외에도 samples basePackage 하위에 있는 어노테이션 기반의 핸들러를 매핑해줄 수 있도록 구현하였습니다. 별도의 컨트롤러를 프로덕션 코드에 추가하지는 않고, 테스트 코드를 통해서 DispatcherServlet 이 어노테이션 기반의 핸들러를 지원해주는지 확인해주었습니다.

JspView

JspView는 어떻게 구현 할 수 있을까? 와 같은 내용이 있어서 JspView도 함께 고민하고 구현하여 테스트를 진행해주었습니다. JspView 로 redirect와 forward에 대한 책임이 분리되면서 DispatcherServlet 에서는 기존의 move() 메소드를 제거해주었습니다.


핵심 코드 및 설명

AnnotatioHandlerMapping

public class AnnotationHandlerMapping implements HandlerMapping {

    private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerMapping.class);

    private final String[] basePackage;
    private final Map<HandlerKey, HandlerExecution> handlerExecutions;

    public AnnotationHandlerMapping(final String... basePackage) {
        this.basePackage = basePackage;
        this.handlerExecutions = new HashMap<>();
    }

    public void initialize() {
        log.info("Initialized AnnotationHandlerMapping!");
        initHandlerExecution(basePackage);
    }

    public Object getHandler(final HttpServletRequest request) {
        return handlerExecutions.get(
                new HandlerKey(request.getRequestURI(), RequestMethod.valueOf(request.getMethod())));
    }

    private void initHandlerExecution(final Object[] basePackage) {
        final Reflections reflections = new Reflections(basePackage);
        final Set<Class<?>> controllers = getAllControllers(reflections);

        for (Class<?> controllerClass : controllers) {
            final Method[] methods = controllerClass.getDeclaredMethods();
            putSupportedMethodInHandlerExecution(methods);
        }
    }

    private static Set<Class<?>> getAllControllers(final Reflections reflections) {
        return reflections.getTypesAnnotatedWith(Controller.class);
    }

    private void putSupportedMethodInHandlerExecution(final Method[] methods) {
        for (Method method : methods) {
            final RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
            final Object handler = getHandler(method);
            putHandlerExecution(method, requestMapping, handler);
        }
    }

    private static Object getHandler(final Method method) {
        try {
            return method.getDeclaringClass().getConstructor().newInstance();
        } catch (InstantiationException | IllegalAccessException | InvocationTargetException |
                 NoSuchMethodException e) {
            throw new NotSupportHandler();
        }
    }

    private void putHandlerExecution(final Method method, final RequestMapping requestMapping, final Object handler) {
        final List<HandlerKey> handlerKeys = getHandlerKeys(requestMapping);
        for (HandlerKey handlerKey : handlerKeys) {
            log.info("HandlerKey : {}", handlerKey);
            handlerExecutions.put(handlerKey, new HandlerExecution(handler, method));
        }
    }

    private static List<HandlerKey> getHandlerKeys(final RequestMapping requestMapping) {
        if (requestMapping != null) {
            return Arrays.stream(requestMapping.method())
                    .map(method -> new HandlerKey(requestMapping.value(), method))
                    .collect(toList());
        }
        return new ArrayList<>();
    }
}

여기서 가장 핵심이 되는 코드는 바로 getHandler() 메소드이다. 여기서 말하는 getHandler() 는 private 메소드가 아닌 public 메소드를 말한다.

해당 메소드는 HandlerKey 를 키로 그리고 HandlerExecution 을 값으로 가지는 Map 구조의 handlerExecutions 에서 적절한 Key를 만들어 반환해주는 메소드이다. 이 때 파라미터로는 HttpServletRequest 를 받는다.

즉, 요청이 들어온 URI와 HTTP method 에 따라 적절한 HandlerKey를 만들고, 이에
따른 적절한 HandlerExecution 을 반환해주는 메소드이다. HandlerKey 는 다음과 같다.

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);
    }
}

즉 정리하면 AnnotationHandlerMapping 클래스는 요청으로부터 적절한 HandlerExecution 을 반환해주는 getHandler() 메소드를 제공하는 클래스이다.

그런데 여기서 그럼 해당 클래스는 어떻게 요청에 대한 적절한 처리기인 HandlerExecution 을 찾아 반환해줄까? 그리고 앞서 언급한 handlerExecutions 를 초기화해주고 있는 것일까? 즉, 핸들러 키와 핸들러 exeuction을 어떻게 미리 준비해두고 있을까?

우선 먼저 getHandler() 메소드가 호출되기 이전에 AnnotationHandlerMapping 클래스는 initialize() 메소드가 호출된다. 즉, 어노테이션 기반의 핸들러매핑을 초기화해주어야한다.
그리고 여기서 initHandlerExecution() 메소드를 호출하며 basePackages 이름을 전달해준다.

그러면 앞서 구현한 내용 정리에서 드러난 것과 같이 리플렉션을 활용하여 특정 패키지 하위에 존재하는 Controller 어노테이션이 붙은 클래스들을 모두 찾고, 해당 클래스에 존재하는 메소드 중 RequestMapping 어노테이션이 붙은 메소드들을 HandlerExecution으로 등록해준다. 여기서 특정 패키지는 initHandlerExecution() 을 호출하며 넘긴 basePackages 들이 될 것이다.

AnnotationHandlerMapping 클래스에 존재하는 initHandlerExecution() 메소드를 보면basePackage 들을 Object 배열을 통해 파라미터로 넘기면서 Reflections 객체를 생성해준다. 이는 특정 패키지 아래에 존재하는 클래스들을 탐색하기 위한 용도로 생성해주었다. 이 경우, Controller 어노테이션이 붙은 클래스들을 getTypesAnnotatedWith() 메소드를 통해서 찾아낸다. 그리고 그렇게 찾은 클래스들의 모든 메소드들에 대해서 HandlerExecution 으로 등록이 가능한지, 즉 RequestMapping 어노테이션이 붙은 메소드인지를 찾고, 인스턴스를 생성해서 핸들러로 등록해준다. 여기서 핸들러는 앞서 생성한 인스턴스와 그 메소드를 통해서 생성해 등록해준다.

그럼 HandlerExecution 을 어떻게 미리 준비해두는지에 대한 답은 어느정도 구해진 것 같다. 그럼 그에 매핑되는 키는 어떻게 미리 준비해두는 것일까?

이 부분은 getHandlerKeys() 메소드를 보면 찾을 수 있는데, RequestMapping 어노테이션을 붙일 때 사용한 method 부분의 값을 스트림으로 돌면서 value와 함께 HandlerKey로 생성하는 것이다.

잘 이해가 안될 수 있으니 아래 예제를 통해서 봐보자.

@Controller
public class TestController {

    private static final Logger log = LoggerFactory.getLogger(TestController.class);

    @RequestMapping(value = "/get-test", method = 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 어노테이션은 아래와 같이 구현되어 있다.

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
    String value() default "";

    RequestMethod[] method() default {};
}

즉, 컨트롤러에서 RequestMapping을 붙이면서 value 와 methd에 값을 주는데, value의 경우에는 요청과 매핑되는 URI에 해당하게 될 것이고, method는 요청이 온 HTTP Method가 될 것이다.

여기서 method에 대해서 RequestMethod[] 배열을 사용해준 이유는 같은 URL에 대해서 여러개의 메소드가 존재할 수 있기 때문이다.

    @RequestMapping(value = "/multiple-method-test", method = {GET, POST})
    public ModelAndView supportMultipleMethod(final HttpServletRequest request, final HttpServletResponse response) {
        log.info("test support multiple method");
        final var modelAndView = new ModelAndView(new JspView(""));
        modelAndView.addObject("id", request.getAttribute("id"));
        return modelAndView;
    }

정리하면, 초기화 메소드를 통해 AnnotationHandlerMapping 클래스는 초기화를 진행하는데, 이 때 요청 URI와 HTTP Method에 따른 HandlerKey, 그리고 그와 매핑되는 HandlerExecution 을 가지게 되는데, 이 때 어노테이션 기반으로 HandlerKey 와 HandlerExecution을 등록하게 되며 getHandler() 메소드를 통해서 HttpServletRequest 의 URI와 HTTP Method로 부터 적절한 HandlerExecution 을 찾아 반환해주는 클래스라고 이해해볼 수 있다.

HandlerExecution

public class HandlerExecution {

    private final Object handler;
    private final Method method;

    public HandlerExecution(final Object handler, final Method method) {
        this.handler = handler;
        this.method = method;
    }

    public ModelAndView handle(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        return (ModelAndView) method.invoke(handler, request, response);
    }
}

HandlerExecution 클래스이다. 앞서 언급한 대로 Controller 클래스를 찾고, 그 클래스에 존재하는 RequestMapping 어노테이션이 붙은 메소드를 통해서 생성되는 객체라고 보면 된다. RequestMapping이 선언되어 있는 클래스를 통해서 객체를 만들어 생성자의 handler 로 등록이 되고, RequestMapping이 붙은 메소드를 method 인자로 넘겨서 생성하게 된다. 해당 클래스의 중요한 기능은 바로 handle() 메소ㄷ라고 볼 수 있는데, invoke() 를 활용하여 앞서 생성자를 통해서 초기화해준 필드의 handler 의 함께 등록해준 method를 호출해주는 기능이다. 그리고 여기에 인자(argument)로써 request, response) 를 함께 invoke 메소드로 넘겨준다.

DispatcherServlet

public class DispatcherServlet extends HttpServlet {

    private static final long serialVersionUID = 1L;
    private static final Logger log = LoggerFactory.getLogger(DispatcherServlet.class);

    private final List<HandlerMapping> handlerMappings;

    public DispatcherServlet() {
        this.handlerMappings = new ArrayList<>();
    }

    @Override
    public void init() {
        handlerMappings.forEach(HandlerMapping::initialize);
    }

    public void addHandlerMapping(final HandlerMapping handlerMapping) {
        handlerMappings.add(handlerMapping);
    }

    @Override
    protected void service(final HttpServletRequest request, final HttpServletResponse response)
            throws ServletException {
        log.debug("Method : {}, Request URI : {}", request.getMethod(), request.getRequestURI());
        final var handler = getHandler(request);

        if (handler instanceof Controller) {
            handleManualHandler(request, response, (Controller) handler);
        }
        if (handler instanceof HandlerExecution) {
            handleAnnotationHandler(request, response, (HandlerExecution) handler);
        }
    }

    private Object getHandler(final HttpServletRequest request) {
        return handlerMappings.stream()
                .map(handlerMapping -> handlerMapping.getHandler(request))
                .filter(Objects::nonNull)
                .findAny()
                .orElseThrow(NotSupportHandler::new);
    }

    private void handleManualHandler(final HttpServletRequest request, final HttpServletResponse response,
                                     final Controller handler) throws ServletException {
        try {
            String viewName = handler.execute(request, response);
            final ModelAndView modelAndView = new ModelAndView(new JspView(viewName));
            renderView(modelAndView, request, response);
        } catch (Throwable e) {
            log.error("Exception : {}", e.getMessage(), e);
            throw new ServletException(e.getMessage());
        }
    }

    private static void handleAnnotationHandler(final HttpServletRequest request, final HttpServletResponse response,
                                                final HandlerExecution handler) {
        try {
            ModelAndView modelAndView = handler.handle(request, response);
            renderView(modelAndView, request, response);
        } catch (Exception e) {
            log.error("Exception : {}", e.getMessage(), e);
            throw new RuntimeException(e.getMessage());
        }
    }

    private static void renderView(final ModelAndView modelAndView, final HttpServletRequest request,
                                   final HttpServletResponse response) throws Exception {
        final Map<String, Object> model = modelAndView.getModel();
        final View view = modelAndView.getView();
        view.render(model, request, response);
    }
}

DispatcherServlet 클래스는 Front Controller 역할을 하는 클래스로, 앞서 우리가 열심히 만든 어노테이션 기반의 핸들러 매핑등을 이용해 사용자의 요청을 핸들러 매핑을 이용해 적절한 핸들러로 보내는 역할을 수행한다.

HttpServlet 을 상속해서 구현하고 있는 DispatcherServlet 은 또한 생명주기 메소드를 가지는데, init() 메소드는 서블릿을 웹 컨테이너(톰캣 등)에 의해서 처음으로 실행될 때 호출되는 메소드로 한 번만(once) 실행되게 된다. 여기서는 DispatcherServlet이 필드로 가지는 HandlerMapping 들에 대해서 initialize 메소드를 호출해주고 있다. init() 메소드 호출 이후에는 service() 메소드를 통해서 클라이언트의 요청을 핸들러 매핑에게 넘겨주고, 핸들러 매핑은 적절한 핸들러를 찾아 반환해주고, 요청을 처리한다.
추가적으로 서블릿에는 destroy() 라는 생명주기 메소드 또한 존재하는데 이는 서블릿을 종료할 때 작업해주어야할 일이 있으면 해당 메소드가 호출되게 된다.

DispatcherServlet의 역할은 정리하였으므로 코드를 살펴보자.
우선 addHandlerMapping() 메소드는 DispatcherServlet에 핸들러 매핑을 추가해줄 수 있게 해주는 메소드이다.
다음으로 오버라이딩하여 구현하고 있는 service() 메소드 에서는 getHandler() 메소드를 통해서 핸들러 매핑으로 부터 요청에 대한 처리가 가능한 핸들러를 찾아서 반환해주고, 이 핸들러가 Controller 인터페이스 기반의 핸들러인지 혹은 새롭게 추가해준 어노테이션 기반의 핸들러인지를 instanceof 키워드 기반으로 확인하여 메소드를 호출해주고 있는데, 여기서 두 방식의 차이에는 큰 차이점이 없다. 둘 모두 핸들러를 통한 처리를 해주고, 처리를 통해서 얻은 ModelAndView 를 통해서 적절한 뷰를 렌더링해주면 사용자 요청 처리가 종료되게 된다.

(물론, Controller 인터페이스 기반의 핸들러는 현재 뷰의 이름(viewName) 을 반환해주고 있어 이를 통해서 직접 ModelAndView 를 생성해주어야 하는 차이점이 존재한다.)

AppWebApplicationInitializer

public class AppWebApplicationInitializer implements WebApplicationInitializer {

    private static final Logger log = LoggerFactory.getLogger(AppWebApplicationInitializer.class);

    @Override
    public void onStartup(final ServletContext servletContext) {
        final var dispatcherServlet = new DispatcherServlet();
        dispatcherServlet.addHandlerMapping(new ManualHandlerMapping());
        dispatcherServlet.addHandlerMapping(new AnnotationHandlerMapping("smaples"));

        final var dispatcher = servletContext.addServlet("dispatcher", dispatcherServlet);
        dispatcher.setLoadOnStartup(1);
        dispatcher.addMapping("/");

        log.info("Start AppWebApplication Initializer");
    }
}
@HandlesTypes(WebApplicationInitializer.class)
public class NextstepServletContainerInitializer implements ServletContainerInitializer {

    @Override
    public void onStartup(Set<Class<?>> webAppInitializerClasses, ServletContext servletContext)
            throws ServletException {
        final List<WebApplicationInitializer> initializers = new LinkedList<>();

        if (webAppInitializerClasses != null) {
            for (Class<?> waiClass : webAppInitializerClasses) {
                try {
                    initializers.add((WebApplicationInitializer) waiClass.getDeclaredConstructor().newInstance());
                } catch (Throwable e) {
                    throw new ServletException("Failed to instantiate WebApplicationInitializer class", e);
                }
            }
        }

        if (initializers.isEmpty()) {
            servletContext.log("No Spring WebApplicationInitializer types detected on classpath");
            return;
        }

        for (WebApplicationInitializer initializer : initializers) {
            initializer.onStartup(servletContext);
        }
    }
}

AppWebApplicationInitializer 는 WebApplicationInitializer 인터페이스를 구현하고 있는 클래스이며, onStartUp() 메소드는 웹 애플리케이션이 시작할 때 자동으로 호출되는 메소드이다. 따라서 초기화 작업을 수행할 때 해당 메소드를 이용할 수 있다.

AppWebApplicationInitializer 에서 진행해주는 초기화 작업은 DispatcherServlet 을 생성하고, addHandlerMapping() 메소드를 통해서 DispatcherServlet에 핸들러 매핑을 추가해준다. 그리고 나서 ServletContext에 해당 서블릿을 추가해주는 작업을 수행한다.

JspView

public class JspView implements View {

    private static final Logger log = LoggerFactory.getLogger(JspView.class);
    private static final String REDIRECT_PREFIX = "redirect:";

    private final String viewName;

    public JspView(final String viewName) {
        this.viewName = viewName;
    }

    @Override
    public void render(final Map<String, ?> model, final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        if (viewName.startsWith(REDIRECT_PREFIX)) {
            response.sendRedirect(viewName.substring(REDIRECT_PREFIX.length()));
            return;
        }

        model.keySet().forEach(key -> {
            log.debug("attribute name : {}, value : {}", key, model.get(key));
            request.setAttribute(key, model.get(key));
        });

        final var requestDispatcher = request.getRequestDispatcher(viewName);
        requestDispatcher.forward(request, response);
    }
}

View 인터페이스를 implements(구현)하는 클래스이다. 해당 클래스는 요청을 Redirect 하는 책임과 파라미터를 통해 넘어온 model을 request의 attribute에 추가해주는 책임, 그리고 적절한 뷰로 forward해주는 책임을 가진다. 즉, 뷰에 대한 처리를 하는 책임을 가지고 있다.

구체적으로 뷰의 이름이 redirect: 로 시작하면 리다이렉트를 시켜주고, 그게 아니라면 모델을 request의 attribute에 추가해주고 forward해준다.

DispatcherServlet의 역할은 받은 요청을 적절한 컨트롤러로 매핑하는 것, 그리고 컨트롤러(핸들러)의 작업 결과에 따라서(응답에 따라서) 적절한 뷰를 호출해주는 것이라고 생각하는데, 기존에 주어진 코드에서는 어떻게 뷰를 처리해야 할지에 대해서도 DispatcherServlet이 알고 있었기 때문에 이를 분리해내었다.


2 단계 - 점진적인 리팩터링

@MVC 프레임워크 구현하기 2단계 저장소 링크

PR 링크

체크 리스트

  • ControllerScanner 클래스에서 @Controller가 붙은 클래스를 찾을 수 있다.
    • 기존에 DispaterServlet에 있던 해당 로직을 ControllerScanner로 위임한다.
    • ControllerScanner는 Controller 어노테이션이 붙은 클래스를 찾고, 해당 인스턴스를 생성해 반환한다.
  • Adapter 를 구현하여 Adapter의 지원(support) 여부에 따라 요청을 처리한다.
    • DispaterServlet에서는 Adapter를 이용하여 처리하도록 수정한다.
    • 기존에 DispaterServlet에 있던 renderView() 메소드를 ModelAndView 쪽으로 이동시킨다.
  • HandlerMappingRegistry 클래스에서 HandlerMapping을 처리하도록 구현했다.
    • HandlerMappingRegistry 에 HandlerMapping 인스턴스를 추가할 수 있다.
    • HandlerMappingRegistry 를 통해서 핸들러를 찾을 수 있다.
  • HandlerAdapterRegistry 클래스에서 HandlerAdapter를 처리하도록 구현했다.
    • HandlerAdapterRegistry 에 HandlerAdapter 를 추가할 수 있다.
    • HandlerAdapterRegistry 를 통해서 요청을 처리 가능한 핸들러를 찾을 수 있다.

구현한 내용

2단계 점진적인 리팩터링을 진행하면서 앞서 1단계에서 instranceof 를 사용하던 부분을 제거하고, Adapter 를 추가하였다. 이를 통해 기존의 DispatcherServlet이 가지고 있던 handlerManualHandler() 메소드와 handlerAnnotationHandler()와 같은 메소드를 제거할 수 있게 되었다. (<- Adapter를 이용하여 support 여부에 따라 요청을 Adapter가 직접 처리해주게 되었으므로)

또한 이번 미션이 점진적인 리팩터링이므로 한 번에 변경하기 보다는 기능을 하나씩 추가하고, 새롭게 추가된 것으로 대체해나가는 방식으로 리팩터링을 진행해보았다.

ControllerScanner

@Controller 어노테이션이 붙은 클래스를 찾고, 인스턴스를 생성해 함께 반환해주는 책임을 가지는 ControllerScanner 를 구현해주었다.
이후 기존에 AnnotationHandlerMapping 에서 수행하던 위 작업을 ControllerScanner 를 이용해줄 수 있도록 리팩터링하는 과정을 진행해보았다.

Adapter

어떤 형식의 핸들러를 지원하는지(supports) 파악 가능하고 지원 가능한 핸들러를 통해서 요청을 처리해주는 Adapter를 어노테이션 기반 핸들러에 대해서(AnnotationHandlerMapping), 그리고 Controller 인터페이스를 구현한 핸들러에 대해서(ControllerHandlerMapping)을 구현해주었다.

DispatcherServlet 에서는 이를 이용하여 핸들러에 대한 적절한 어댑터를 찾고, 그 어댑터를 이용해 요청을 처리해줄 수 있도록 리팩터링하는 과정을 진행하였다. 또한 기존의 DispatcherServlet 에서 가지고 있던 뷰 랜더링에 대한 책임을 ModelAndView 쪽으로 위임해줌으로써 DispatcherServlet 은 요청에 다른 핸들러를 찾고 이를 핸들러 어댑터에 넘겨 주고 요청을 처리해서 얻은 ModelAndView에서 적절한 응답을 해줄 수 있도록 책임이 간소화되었다.

Registry

어댑터와 핸들러 매핑에 대해서 각각 Registry 클래스를 구현해주었다.
기존에 DispatcherServlet 에서 요청에 대한 핸들러를 찾고, 그 핸들러에 맞는 핸들러 어댑터를 가져오는 과정과 핸들러 매핑 및 어댑터 추가에 대한 책임을 위 두 Registry에 분리해주었다.

HandlerMappingRegistry 는 핸들러 매핑을 추가하고 요청에 대해서 적절한 핸들러를 반환해주는 책임을 가지고, HandlerAdapterRegistry는 핸들러 어댑터를 추가할 수 있고, 핸들러에 대해 적절한 핸들러 어댑터를 반환해주는 책임을 가지도록 구현하였습니다. 그리고 DispatcherServlet 에서는 이 둘을 이용하여 요청을 처리하도록 리팩터링을 진행하였습니다.


핵심 코드 및 설명

ControllerScanner

public class ControllerScanner {

    private final Reflections reflections;

    public ControllerScanner(final Reflections reflections) {
        this.reflections = reflections;
    }

    public Map<Class<?>, Object> getControllers() {
        final Set<Class<?>> controllerClasses = reflections.getTypesAnnotatedWith(Controller.class);
        final Map<Class<?>, Object> controllers = new HashMap<>();

        for (Class<?> controllerClass : controllerClasses) {
            controllers.put(controllerClass, createController(controllerClass));
        }

        return controllers;
    }

    private Object createController(Class<?> controllerClass) {
        try {
            return controllerClass.getConstructor().newInstance();
        } catch(NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
            throw new InvalidReflectionException();
        }
    }
}

basePacakage 들 기반으로 생성된 Reflections를 받아 생성되는 ControllerScanner 클래스이다.
기존에 AnnotationHandlerMapping 에 존재하던 "어노테이션으로 부터 컨트롤러 클래스를 찾고, 그 컨트롤러 클래스의 인스턴스를 생성하던 부분"을 해당 클래스로 옮겨주었다고 이해해도 충분하다.
로직을 보면 Controller 어노테이션이 붙은 클래스들을 찾고, 해당 클래스로 부터 인스턴스를 생성해 Map 자료구조에 저장하고 반환해주는 역할을 수행한다.

AnnotationHandlerMapping에서는 getController() 메소드를 통해서 Map을 받고, Map의 키를 통해서 Controller 어노테이션이 붙은 클래스 중 RequestMapping 어노테이션이 붙은 메소드들을 찾아서 핸들러와 키를 등록해준다. (생성했던 Controller 어노테이션이 붙은 클래스의 인스턴스는 handler로 등록된다.)

ControllerScanner 를 사용함으로써 변경된 최종 AnnotationHandlerMapping 클래스의 모습은 다음과 같다.

public class AnnotationHandlerMapping implements HandlerMapping {

    private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerMapping.class);

    private final String[] basePackage;
    private final Map<HandlerKey, HandlerExecution> handlerExecutions;

    public AnnotationHandlerMapping(final String... basePackage) {
        this.basePackage = basePackage;
        this.handlerExecutions = new HashMap<>();
    }

    public void initialize() {
        log.info("Initialized AnnotationHandlerMapping!");
        initHandlerExecution(basePackage);
    }

    public Object getHandler(final HttpServletRequest request) {
        final HandlerKey handlerKey = new HandlerKey(request.getRequestURI(), RequestMethod.valueOf(request.getMethod()));
        return handlerExecutions.get(handlerKey);
    }

    private void initHandlerExecution(final Object[] basePackage) {
        final ControllerScanner controllerScanner = new ControllerScanner(new Reflections(basePackage));
        final Map<Class<?>, Object> controllers = controllerScanner.getControllers();

        for (Class<?> controller : controllers.keySet()) {
            final Set<Method> methods = getAllMethods(controller);
            putSupportedMethodInHandlerExecution(methods, controllers.get(controller));
        }
    }

    private static Set<Method> getAllMethods(final Class<?> aClass) {
        return ReflectionUtils.getAllMethods(aClass, ReflectionUtils.withAnnotation(RequestMapping.class));
    }

    private void putSupportedMethodInHandlerExecution(final Set<Method> methods, final Object handler) {
        for (Method method : methods) {
            putHandlerExecution(method, handler);
        }
    }

    private void putHandlerExecution(final Method method, final Object handler) {
        final RequestMapping requestMapping = method.getAnnotation(RequestMapping.class);
        final List<HandlerKey> handlerKeys = getHandlerKeys(requestMapping);

        for (HandlerKey handlerKey : handlerKeys) {
            log.info("HandlerKey : {}", handlerKey);
            handlerExecutions.put(handlerKey, new HandlerExecution(handler, method));
        }
    }

    private static List<HandlerKey> getHandlerKeys(final RequestMapping requestMapping) {
        if (requestMapping != null) {
            return Arrays.stream(requestMapping.method())
                    .map(method -> new HandlerKey(requestMapping.value(), method))
                    .collect(toList());
        }
        return new ArrayList<>();
    }
}

HandlerExecutionHandlerAdapter

public interface HandlerAdapter {
    boolean supports(Object handler);

    ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
}

위와 같은 HandlerAdapter 라는 인터페이스를 implements(구현)하는 클래스로, 어떤 형식의 핸들러를 지원하는지 그리고 요청과 응답, 핸들러를 받아서 요청을 처리하는 역할을 수행하는 클래스이다.

public class HandlerExecutionHandlerAdapter 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);
    }
}

어노테이션 기반의 핸들러의 경우 HandlerExecution 이므로, supports() 메소드를 위와 같이 구현한다. 그리고 handler를 HandlerExecution 으로 형변환 후 handle() 메소드를 호출한다.

이를 통해서 기존의 DispatcherServlet 에서 if문과 함께 instanceof 키워드를 활용하여 검사하던 핸들러를 이제는 supports() 여부에 따라서 적절한 핸들러 어댑터를 찾고, 그 어댑터를 활용해 요청을 처리하면 된다.

    @Override
    protected void service(final HttpServletRequest request, final HttpServletResponse response)
            throws ServletException {
        log.debug("Method : {}, Request URI : {}", request.getMethod(), request.getRequestURI());

        final Object handler = getHandler(request);
        final HandlerAdapter adapter = getAdapter(handler);

        try {
            final ModelAndView modelAndView = adapter.handle(request, response, handler);
            modelAndView.renderView(request, response);
        } catch (Exception e) {
            log.error("Exception : {}", e.getMessage(), e);
            throw new ServletException(e.getMessage());
        }
    }
    
    ...
    
        private HandlerAdapter getAdapter(final Object handler) {
        return handlerAdapters.stream()
                .filter(handlerAdapter -> handlerAdapter.supports(handler))
                .findAny()
                .orElseThrow(NotSupportHandler::new);
    }

ControllerHandlerAdapter

앞서 기존의 dispatcherServlet 의 handlerAnnotationHandler() 부분을 어댑터를 통해 제거한 것과 마찬가지로 Controller 인터페이스 기반의 핸들러, 즉 handlerManualHandler() 부분 또한 어댑터를 통해서 제거해줄 필요가 있다.

public class ControllerHandlerAdapter 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;
        String viewName = controller.execute(request, response);
        return new ModelAndView(new JspView(viewName));
    }
}

어떤 핸들러를 지원하는지 여부인 supports() 와 요청과 응답, 그리고 핸들러를 받아 그 핸들러를 통해서 요청을 처리하는 handler() 부분 모두 앞선 AnnotationhadnlerMapping과 큰 차이가 없다.
여기서는 핸들러를 Controller 로 형변환 후 요청을 처리(execute())해 viewName을 얻고, 이를 통해서 ModelAndView 를 만들어 반환한다.

HandlerAdapterRegistry

public class HandlerMappingRegistry {

    private final List<HandlerMapping> handlerMappings;

    public HandlerMappingRegistry() {
        this.handlerMappings = new ArrayList<>();
    }

    public void addHandlerMapping(final HandlerMapping handlerMapping) {
        handlerMappings.add(handlerMapping);
    }

    public void initialize() {
        handlerMappings.forEach(HandlerMapping::initialize);
    }

    public Optional<Object> getHandler(HttpServletRequest request) {
        return handlerMappings.stream()
                .map(handlerMapping -> handlerMapping.getHandler(request))
                .filter(Objects::nonNull)
                .findAny();
    }
}

앞서 HandlerExecutionHandlerAdapter 에 대해서 설명하면서 잠깐 보인 DispatcherServlet을 보면 getAdapter() 라고 하는 private 메소드가 존재한다. 그리고 해당 메소드는 핸들러를 통해서 어떤 핸들러 어댑터를 반환해주어야할지를 결정하는 책임으로 볼 수 있다.

이 책임을 HandlerAdapterRegistry 가 수행하도록 책임을 옮겨주었다고 보면 된다.

HandlerAdapterRegistry 는 우선 핸들러 어댑터를 추가하는 메소드인 addHandlerAdapter() 를 가지며 추가적으로 앞서 보인 DispatcherServlet 의 getAdapter() 메소드에 해당하는 getHandlerAdapter() 메소드를 가진다.

HandlerMappingRegistry

public class HandlerMappingRegistry {

    private final List<HandlerMapping> handlerMappings;

    public HandlerMappingRegistry() {
        this.handlerMappings = new ArrayList<>();
    }

    public void addHandlerMapping(final HandlerMapping handlerMapping) {
        handlerMappings.add(handlerMapping);
    }

    public void initialize() {
        handlerMappings.forEach(HandlerMapping::initialize);
    }

    public Optional<Object> getHandler(HttpServletRequest request) {
        return handlerMappings.stream()
                .map(handlerMapping -> handlerMapping.getHandler(request))
                .filter(Objects::nonNull)
                .findAny();
    }
}

HandlerMappingRegistry 도 마찬가지로 DispatcherServlet에 존재하던 다음의 getHandler() 메소드를 별도로 분리해내었다고 보면 된다.

    private Object getHandler(final HttpServletRequest request) {
        return handlerMappings.stream()
                .map(handlerMapping -> handlerMapping.getHandler(request))
                .filter(Objects::nonNull)
                .findAny()
                .orElseThrow(NotSupportHandler::new);
    }

요청에 대한 핸들러를 찾는 메소드이다. 여기서 앞선 HandlerMappingRegistry와의 차이점은 handlerMapping 들의 초기화 작업을 여기서 함께 진행해준다는 점이다.

ModelAndView

public class ModelAndView {

    private final View view;
    private final Map<String, Object> model;

    public ModelAndView(final View view) {
        this.view = view;
        this.model = new HashMap<>();
    }

    public ModelAndView addObject(final String attributeName, final Object attributeValue) {
        model.put(attributeName, attributeValue);
        return this;
    }

    public void renderView(final HttpServletRequest request, final HttpServletResponse response) throws Exception {
        view.render(model, request, response);
    }

    public Object getObject(final String attributeName) {
        return model.get(attributeName);
    }

    public Map<String, Object> getModel() {
        return Collections.unmodifiableMap(model);
    }

    public View getView() {
        return view;
    }
}

renderView() 메소드를 추가하여 뷰 렌더링에 대한 책임을 위임해주었다.

DispatcherServlet

public class DispatcherServlet extends HttpServlet {

    private static final long serialVersionUID = 1L;
    private static final Logger log = LoggerFactory.getLogger(DispatcherServlet.class);

    private final HandlerMappingRegistry handlerMappingRegistry;
    private final HandlerAdapterRegistry handlerAdapters;

    public DispatcherServlet() {
        this.handlerMappingRegistry = new HandlerMappingRegistry();
        this.handlerAdapters = new HandlerAdapterRegistry();
    }

    @Override
    public void init() {
        handlerMappingRegistry.initialize();
    }

    public void addHandlerMapping(final HandlerMapping handlerMapping) {
        handlerMappingRegistry.addHandlerMapping(handlerMapping);
    }

    public void addHandlerAdapter(final HandlerAdapter handlerAdapter) {
        handlerAdapters.addHandlerAdapter(handlerAdapter);
    }

    @Override
    protected void service(final HttpServletRequest request, final HttpServletResponse response) throws ServletException {
        log.debug("Method : {}, Request URI : {}", request.getMethod(), request.getRequestURI());

        final Optional<Object> handler = handlerMappingRegistry.getHandler(request);
        if (handler.isEmpty()) {
            response.setStatus(SC_NOT_FOUND);
            return;
        }
        final HandlerAdapter handlerAdapter = handlerAdapters.getHandlerAdapter(handler.get());
        handleRequest(request, response, handler.get(), handlerAdapter);
    }

    private static void handleRequest(final HttpServletRequest request, final HttpServletResponse response,
                                  final Object handler, final HandlerAdapter handlerAdapter) throws ServletException {
        try {
            final ModelAndView modelAndView = handlerAdapter.handle(request, response, handler);
            modelAndView.renderView(request, response);
        } catch (Exception e) {
            log.error("Exception : {}", e.getMessage(), e);
            throw new ServletException(e.getMessage());
        }
    }
}

DispatcherServlet 이 하던 많은 일을 분산시켜 책임을 최소화할 수 있었다. 기존에 DispatcherServlet 이 하던 일은 다음과 같다고 볼 수 있다.

  • 핸들러 매핑 초기화하기
  • 요청으로부터 핸들러 찾기
  • 핸들러로 처리가 가능한지에 따라 요청 처리하기
  • 결과로 나온 뷰에 대한 처리하기

현재는 요청으로 핸들러 찾기handlerMappingRegistry 에 위임하고 있으며, 찾은 handler로 adapter 찾기, 즉 핸들러로 처리가 가능한지에 따라 요청 처리하기는 handlerAdapter 로 위임해주고 있다.
마지막으로 수행한 결과 ModelAndView 에 따른 뷰 렌더링과 같은 처리는 ModelAndView 쪽으로 위임해주고 있다.

따라서 handlerMappingRegistry 초기화, registry 이용해 요청에 따른 핸들러 찾기, 어댑터로 작업 수행하기 정도로 DispatcherServlet의 책임이 간소화되었다.


3 단계 - JSON View 구현하기

@MVC 프레임워크 구현하기 3단계 저장소 링크

PR 링크

체크 리스트

  • JspView 클래스를 구현한다.
    • DispatcherServlet 클래스의 service 메서드에서 어떤 부분이 뷰에 대한 처리를 하고 있는지 파악해서 JspView 클래스로 옮겨보자.
  • JsonView 클래스를 구현한다.
    • HTTP Request Body로 JSON 타입의 데이터를 받았을 때 어떻게 자바에서 처리할지 고민해보고 JsonView 클래스를 구현해보자.
    • model에 데이터가 1개면 값을 그대로 반환하고 2개 이상이면 Map 형태 그대로 JSON 으로 변환해서 반환한다.
  • Legacy MVC 제거하기
    • app 모듈에 있는 모든 컨트롤러를 어노테이션 기반 MVC로 변경한다.
    • asis 패키지에 있는 레거시 코드를 삭제해도 서비스가 정상 동작하도록 리팩터링하자.
  • 힌트에서 제공한 UserController 컨트롤러가 json 형태로 응답을 반환한다.
  • 레거시 코드를 삭제하고 서버를 띄워도 정상 동작한다.

구현한 내용

JspView 클래스를 구현한다

이 부분은 2단계 미션 진행하면서 DispatcherServlet 에서 ModelAndView 를 이용해 JspView 를 렌더링하도록 책임을 분리하였기 때문에 추가적인 작업은 진행하지 않았다.

JsonView 클래스를 구현한다.

ModelAndView 에서 객체를 받았을 때 ObjectMapper 를 이용해서 JSON 형태로 반환해줄 수 있도록 구현하였습니다. 추가적으로 model 에 담긴 데이터가 한 개인 경우에는 그대로 반환해줄 수 있게 분기처리를 해주었습니다.
UserController 가 제대로 동작하는지 확인하기 위해서 User 도메인에 대해서 Getter 를 열어주었습니다..!!
그런데 JsonView 를 단위 테스트하는데에는 stringWriter 의 toString() 을 이용해 원하는 형식으로 출력되는지 확인하는 방식으로 테스트를 진행해주었습니다.
(참고한 글 에서 How to test a method using a PrintWriter 를 참고해서 테스트 작성)

    @DisplayName("데이터가 2개 이상이면 Map 형태 그대로 JSON 으로 변환해서 반환한다.")
    @Test
    void returnMapGreaterThenOwnData() throws Exception {
        final ObjectMapper objectMapper = new ObjectMapper();
        final StringWriter stringWriter = new StringWriter();
        final PrintWriter printWriter = new PrintWriter(stringWriter);

        HttpServletRequest request = mock(HttpServletRequest.class);
        HttpServletResponse response = mock(HttpServletResponse.class);

        when(response.getWriter()).thenReturn(printWriter);

        final Map<String, Object> model = new HashMap<>();
        final Map<String, String> account = Map.of("account", "dwoo");
        final Map<String, String> password = Map.of("password", "password");
        model.put("account", account);
        model.put("password", password);

        final JsonView jsonView = new JsonView();
        jsonView.render(model, request, response);

        final String writtenValue = stringWriter.toString();
        final String expected = objectMapper.writeValueAsString(model);
        assertThat(writtenValue).isEqualTo(expected);
    }

Legacy MVC 제거하기

컨트롤러 하나씩 리팩터링을 진행하며 테스트 코드를 추가해 기존의 동작을 리팩터링 했을 때도 잘 동작하는지 확인할 수 있도록 해주었습니다. 또한 asis 패키지의 클래스들도 한 번에 제거하는 것이 아니라 하나씩 제거해 나가는 방식으로 리팩터링을 진행하였습니다.

    @DisplayName("로그인 요청시 로그인을 처리하고 index.jsp 로 리다이렉트한다.")
    @Test
    void login() throws Exception {
        final var request = mock(HttpServletRequest.class);
        final var response = mock(HttpServletResponse.class);
        final var session = mock(HttpSession.class);
        final LoginController loginController = new LoginController();

        when(request.getParameter("account")).thenReturn("gugu");
        when(request.getParameter("password")).thenReturn("password");
        when(request.getRequestURI()).thenReturn("/login");
        when(request.getMethod()).thenReturn("POST");
        when(request.getSession()).thenReturn(session);
        when(request.getSession().getAttribute(SESSION_KEY)).thenReturn(null);

        final ModelAndView modelAndView = loginController.login(request, response);
        final View view = modelAndView.getView();

        modelAndView.renderView(request, response);

        assertThat(view).isInstanceOf(JspView.class);
        verify(response).sendRedirect("/index.jsp");
    }

핵심 코드 및 설명

핵심 코드 및 설명에서 새롭게 추가한 controller 나 그에 대한 테스트 코드는 너무 많아 제외하였습니다.

JsonView

public class JsonView implements View {

    private final ObjectMapper objectMapper;

    public JsonView() {
        this.objectMapper = new ObjectMapper();
    }

    @Override
    public void render(final Map<String, ?> model, final HttpServletRequest request, HttpServletResponse response) throws Exception {
        response.setContentType(APPLICATION_JSON_UTF8_VALUE);
        final String body = getBody(model);
        response.getWriter().write(body);
    }

    private String getBody(final Map<String, ?> model) throws JsonProcessingException {
        return objectMapper.writeValueAsString(getAttribute(model));
    }

    private Object getAttribute(final Map<String, ?> model) {
        if (model.size() == 1) {
            final String key = (String) model.keySet().toArray()[0];
            return model.get(key);
        }
        return model;
    }
}

JsonView를 위와 같이 구현하여 model에 데이터가 1개면 값을 그대로 반환하고 2개 이상이면 Map 형태 그대로 JSON 으로 변환해서 반환할 수 있도록 구현해주었다. 그리고 아래의 UserController 를 통해서 JSON 형식의 값이 제대로 응답되는지를 확인해주었다.

@Controller
public class UserController {

    private static final Logger log = LoggerFactory.getLogger(UserController.class);

    @RequestMapping(value = "/api/user", method = RequestMethod.GET)
    public ModelAndView show(HttpServletRequest request, HttpServletResponse response) {
        final String account = request.getParameter("account");
        log.debug("user id : {}", account);

        final ModelAndView modelAndView = new ModelAndView(new JsonView());
        final User user = InMemoryUserRepository.findByAccount(account)
                .orElseThrow();

        modelAndView.addObject("user", user);
        return modelAndView;
    }
}

profile
꾸준함에서 의미를 찾자!

0개의 댓글