Web/MVC

[Servlet 구현하기] Controller Scanner와 JSON View

지난 시간에 어노테이션 기반의 MVC 프레임워크를 만들어 보았습니다.

 

아직은 개선할 점이 있었습니다.

  1. 컨트롤러 어노테이션 스캔 역할 분리
  2. JSON view 지원

 

 

 

프레임워크에서 기본으로 어노테이션 핸들러 지원하기

 

위의 개선점들을 해결하기 전에, 생각해봐야할 점이 있었습니다.

 

MVC 프레임워크 패키지가 아닌 APP (어플리케이션) 패키지에서 지원하는 핸들러와 어댑터를 추가해주고 있었습니다.

 

저희가 스프링 MVC를 사용할때 어노테이션 매핑을 따로 추가해주지 않아도 기본으로 지원하는 것 처럼, 개발자는 어노테이션 매핑에 대한 추가설정 없이 기본적으로 제공받도록 구현하고 싶었습니다.

 

그래서 MVC 프레임워크 패키지에 DefaultApplicationInitializer를 만들어주었습니다.

package nextstep.mvc.config;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import nextstep.mvc.AnnotationHandlerAdapter;
import nextstep.mvc.DispatcherServlet;
import nextstep.mvc.controller.tobe.AnnotationHandlerMapping;
import nextstep.web.WebApplicationInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DefaultAppWebApplicationInitializer implements WebApplicationInitializer {

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

    @Override
    public void onStartup(final ServletContext servletContext) throws ServletException {
        final var dispatcherServlet = DispatcherServlet.getInstance();
        dispatcherServlet.addHandlerMapping(new AnnotationHandlerMapping());
        dispatcherServlet.addHandlerAdapter(new AnnotationHandlerAdapter());

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

        log.info("Start DefaultAppWebApplication Initializer");
    }
}

 

 

개발자 영역에서는 기본으로 제공되는 어노테이션 뿐만 아니라 추가로 사용하고 싶은 핸들러와 어댑터를 DispatcherServlet에 넣어주면 됩니다.

 

package com.techcourse.config;

import jakarta.servlet.ServletContext;
import nextstep.mvc.DispatcherServlet;
import nextstep.web.WebApplicationInitializer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class AppWebApplicationInitializer implements WebApplicationInitializer {

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

    @Override
    public void onStartup(final ServletContext servletContext) {
        final var dispatcherServlet = DispatcherServlet.getInstance();
        dispatcherServlet.addHandlerMapping(new ManualHandlerMapping());
        dispatcherServlet.addHandlerAdapter(new ManualHandlerAdapter());
        log.info("Start AppWebApplication Initializer");
    }
}

 

여기서 바뀐점이 있는데요.

DispatcherServlet을 싱글톤으로 받고있는 부분입니다.

App 패키지에서 추가하는 핸들러와 어댑터들도 같은 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;
    private final List<HandlerAdapter> handlerAdapters;

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

    public static class DispatcherServletGenerator {
        private static final DispatcherServlet INSTANCE = new DispatcherServlet();
    }
    public static DispatcherServlet getInstance() {
        return DispatcherServletGenerator.INSTANCE;
    }
    ...
}

 

 

하지만 여기서주의해야 할 점은 실제로 서블릿 컨테이너가 서블릿에 대해 싱글톤을 보장하지는 않는다는 점입니다.

실제 기본 서블릿의 경우 서블릿 컨테이너가 서블릿 선언당 인스턴스를 1개만 사용하기는 하지만, SingleThreadModel 인터페이스를 구현하는 서블릿의 경우 많은 요청을 처리하기 위해 여러 인스턴스를 만들어 사용합니다.

 

정리하면, 기본적으로 동일한 웹 앱에서 서블릿 선언당 인스턴스를 1개만 사용하기는 하지만, 여러 개의 인스턴스화를 막고있진 않기 때문에 서블릿은 싱글톤이 아닙니다.

 

저는 구현 편의상 싱글톤으로 구현했습니다.

 

 

 

 

 

 

Controller Scanner

아래와 같이 리플렉션으로 컨트롤러를 읽어오는 코드가 AnnotationHandlerMapping에 존재하고

이 과정에서 적지 않은 코드가 발생하는 것을 볼 수 있습니다.

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

    private Object getInstance(final Class<?> clazz) {
        try {
            final Constructor<?> constructor = clazz.getConstructor();
            return constructor.newInstance();
        } catch (NoSuchMethodException exception) {
            log.error(exception.getMessage());
            throw new IllegalArgumentException("생성자를 가져올 수 없습니다. " + clazz.getName());
        } catch (InstantiationException
                | IllegalAccessException
                | InvocationTargetException exception){
            log.error(exception.getMessage());
            throw new IllegalArgumentException("인스턴스화할 수 없습니다. " + clazz.getName());
        }
    }
    ...
}

 

 

ControllerScanner에게

  1. 리플렉션으로 컨트롤러가 붙은 클래스를 읽고
  2. 인스턴스화해서 Map으로 반환하는

역할을 부여하였습니다.

package nextstep.mvc.controller.annotation;

import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import nextstep.web.annotation.Controller;
import org.reflections.Reflections;

public class ControllerScanner {

    private static final Class<Controller> CONTROLLER_ANNOTATION_CLASS = Controller.class;

    private final Reflections reflections;

    public ControllerScanner(final Object[] basePackage) {
        this.reflections = new Reflections(basePackage);
    }

    public Map<Class<?>, Object> getControllers() {
        final Set<Class<?>> classes = reflections.getTypesAnnotatedWith(CONTROLLER_ANNOTATION_CLASS);
        return instantiateControllers(classes);
    }

    private Map<Class<?>, Object> instantiateControllers(final Set<Class<?>> classes) {
        final Map<Class<?>, Object> controllers = new HashMap<>();
        for (Class<?> controller : classes) {
            controllers.put(controller, getInstance(controller));
        }
        return controllers;
    }

    private Object getInstance(final Class<?> clazz) {
        try {
            final Constructor<?> constructor = clazz.getConstructor();
            return constructor.newInstance();
        } catch (NoSuchMethodException exception) {
            throw new IllegalArgumentException("생성자를 가져올 수 없습니다. " + clazz.getName());
        } catch (InstantiationException
                | IllegalAccessException
                | InvocationTargetException exception){
            throw new IllegalArgumentException("인스턴스화할 수 없습니다. " + clazz.getName());
        }
    }
}

 

AnnotationHandlerMapping은 ControllerScanner로 부터 컨트롤러 인스턴스를 받고 각 인스턴스로부터 메서드를 추출해 HandlerExecutions에 추가해줍니다.

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 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 ControllerScanner controllerScanner = new ControllerScanner(basePackage);
        final Map<Class<?>, Object> controllers = controllerScanner.getControllers();
        for (Class<?> aClass : controllers.keySet()) {
            final List<Method> methods = getRequestMappingMethods(aClass);
            methods.forEach(method -> putHandlerExecutionByRequestMapping(controllers.get(aClass), method));
        }
        log.info("Initialized AnnotationHandlerMapping!");
    }

    private List<Method> getRequestMappingMethods(final Class<?> clazz) {
        return Arrays.stream(clazz.getDeclaredMethods())
                .filter(method -> method.isAnnotationPresent(REQUEST_MAPPING_ANNOTATION_CLASS))
                .collect(Collectors.toList());
    }
    ...
}

 

 

 

JSON View

Jsp view만 지원하는 것이 아닌 Json view로도 반환하고 싶었습니다.

 

View 인터페이스를 구현하여 render에서 response body에 model 데이터를 json 형식으로 변환해 넣어주도록 구현했습니다.

public class JsonView implements View {

    @Override
    public void render(final Map<String, ?> model, final HttpServletRequest request, HttpServletResponse response)
            throws Exception {

        final ObjectMapper objectMapper = new ObjectMapper();
        final String body = objectMapper.writeValueAsString(model);

        response.setContentType(APPLICATION_JSON_UTF8_VALUE);
        response.getWriter().write(body);
    }
}

 

사용할 때는

  1. ModelAndView를 생성할때 어떤 view를 사용할 것인지 넣어주고
  2. ModelAndView의 Object에 user 데이터를 넣어주면
  3. DispatcherServlet이 render를 호출합니다.
@Controller
public class UserController {

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

    @RequestMapping(value = "/api/user", method = GET)
    public ModelAndView show(final HttpServletRequest request, final HttpServletResponse response) {
        log.info("test controller api");
        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;
    }
}

 

DispatcherServlet

   ...
   @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 = handlerMappingRegistry.getHandler(request);
            final HandlerAdapter handlerAdapter = handlerAdapterRegistry.getHandlerAdapter(handler);
            final ModelAndView modelAndView = handlerAdapter.handle(request, response, handler);
            render(modelAndView, request, response);
        } catch (Throwable e) {
            log.error("Exception : {}", e.getMessage(), e);
            throw new ServletException(e.getMessage());
        }
    }

    private void render(final ModelAndView modelAndView, final HttpServletRequest request, final HttpServletResponse response)
            throws Exception {
        modelAndView.render(request, response);
    }
    ...

 

요청 처리 확인

URL : /api/user

Method : GET

 

 

 

 

 

정리

자바의 서블릿을 구현해보았고, 실제로 스프링 MVC에서는 자바의 서블릿을 DispatcherServlet으로 사용하고 있었습니다.

 

 

 

 

지금까지 어노테이션 기반의 MVC 프레임워크를 만들어 컨트롤러를 스캔해보고 jsp가 아닌 다른형식의 view도 지원하도록 만들어 보았습니다.

 

MVC

 

Model, View, Controller 를 나누는 이유에 대해서 더 명확하게 이해를 할 수 있었습니다.

그리고 이들이 해주는 행위에 대해서도 직접 구현해보면서 확실히 알게되었습니다.

 

 

 

 

 

다음목표는..

스프링의 핵심원리 중 하나인 DI(Dependency Injection)를 직접 구현해보도록 하겠습니다.

 

 

 

참고자료