Level 4. MVC 프레임워크 구현하기 - 정리
서블릿이란
Java로 HTTP 요청 및 응답을 처리하기 위한 표준이다.
- Jakarta Servlet defines a server-side API for handling HTTP requests and responses.
서블릿 표준은 인터페이스로 제공되며 이 구현은 서블릿 컨테이너 구현체인 Tomcat, Jetty, Undertow가 제공한다.
Web Server vs WAS
짧게 말하자면, Web Server는 주로 정적인 컨텐츠를 처리하고,
WAS는 주로 요청에 따라 응갑이 달라지는 동적인 컨텐츠를 처리한다.
WAS는 일부 웹서버의 기능과 웹 컨테이너로 함께 구성된다.
앞 단의 웹 서버는 요청을 받아 웹 컨테이너로 넘겨주며, 웹 컨테이너는 이를 처리 후 다시 웹 서버로 전달한다.
자바에서는 이를 서블릿으로 처리하기 때문에, 이 웹 컨테이너를 서블릿 컨테이너라고도 부른다.
참고로 자바에서 서블릿은 스레드 기반으로 클라이언트 요청에 대해 동적으로 작동하는 구성요소이다.
요청에 따라서 각기 다른 서블릿이 실행되는데, 이 서블릿을 요청에 연결하고 수명 관리를 해주는 것이 서블릿 컨테이너이다.
(컨트롤러는 서블릿이었다.)
Tomcat
톰캣은 기본적으로는 서블릿 컨테이너(웹 컨테이너)이나 자체적으로는 웹 서버가 내장되어있다.
때문에 외부 요청을 받을 수 있어, WAS의 기능을 일부 가지고 있는 서블릿 컨테이너라고 할 수 있다.
➕ 추가로 보면 좋을 자료
서블릿 구현 방식
어노테이션 기반
@WebServlet(name = "helloServlet", urlPatterns = "/hello")
public class HelloServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
super.service(req, resp);
System.out.println("HelloServlet");
System.out.println("Request : " + req);
System.out.println("Response : " + resp);
String name = req.getParameter("name");
System.out.println(name);
resp.setCharacterEncoding("utf-8");
resp.getWriter().write("hello" + name);
}
}
XML
서블릿 3.0 이전에는 xml로 클래스와 url을 맵핑했다.
(우리의) 웹 서버와 서블릿 관계
HTTP 서버 | 서블릿 |
WebServer | Servlet Container |
Controller | Servlet |
AbstractController | HttpServlet |
HttpRequest | HttpServletRequest |
HttpResponse | HttpSerlvetResponse |
서블릿 컨테이너
- 웹 서버와 서블릿이 통신할 수 있는 API를 제공 (이 덕분에 개발자는 비즈니스 로직 구현에만 집중)
- 생명 주기 관리
- init : 서블릿 초기화 메서드 호출
- service : HTTP 요청에 해당되는 적절한 서블릿 메서드를 호출
- destroy : 서블릿 종료 메서드 호출
- 새로운 요청이 들어오면 스레드를 부여함 = 멀티 스레딩
- TMI : 때문에 서블릿에 상태를 두면 안된다.
- 요청마다 Request, Response 객체를 만들기 때문에 여기는 상태가 있어도 안전하다.
- 요청이 들어왔을 때 컨테이너가 해당 서블릿을 찾고 실행까지 시켜준다!
서블릿 컨테이너의 멀티 스레딩
몇가지 설정으로 서블릿 컨테이너가 최대 생성할 수 있는 스레드의 갯수를 제한할 수 있다.
스레드 풀을 사용해 제한된 스레드를 만들어두고 재사용한다.
server:
tomcat:
threads:
max: 200 # Connector에 의해 처리할 수 있는 최대 요청 수
min-spare: 10 # 항상 활성화 되어있는(idle) thread의 개수
max-connections: 8192 # 수립가능한 connection의 총 개수
accept-count: 100 # Maximum queue length
connection-timeout: 20000 # timeout 판단 기준 시간, 20초
스프링에서는 default 값이 있어서 굳이 안해도 된다.
Q. maxConnection 과 작업큐는 다른가? 별도로 커넥션을 들고있다가 작업큐에 하나하나 넣어주나?
A. selector 라는 녀석이 있어서 번갈아가면서 processSocket을 실행한다고 생각하면 되요.그럼 내부에서 exectuor가 Thread를 할당해 작업을 수행해요.
참고로 tomcat은 Thread pool의 backing queue를 사용하지 않아요.(maxQueueCapacity는 사용하지 않음) jetty에는 구현되어 있어요.
서블릿 필터
서블릿으로 요청이 전송되기 전에 추가적인 처리를 할 수 있다.
스프링에서 제공하는 필터들
- Form Data
- 브라우저에서 HTTP GET, POST만 양식 데이터를 제출할 수 있지만 PUT, PATCH 및 DELETE를 사용할 수 있도록 만듦
- Forwarded Headers
- 요청이 프록시(예: 로드 밸런서)를 통과할 때 host, port가 클라이언트가 아닌 프록시로 바뀔 수 있음
- 원래 클라이언트의 host, port를 가리키도록 만듦
- Shallow ETag
- CORS
리플렉션
런타임에 클래스의 메타정보들을 얻을 수 있게 하는 라이브러리.
즉 컴파일한 클래스를 동적으로 프로그래밍 가능하도록 자바에서 지원하는 기능이다.
1, 2 단계 - MVC 프레임워크 구현하기 & 리팩터링
요구사항
HTTP 미션에서 만들었던 애플리케이션은 Controller를 추가할 때마다
Request Mapping 클래스에 URL과 컨트롤러 객체를 추가해야했다.
개발자인 내가 비즈니스 로직 구현에만 집중 할 수 있도록 어노테이션 기반의 MVC 프레임워크로 개선한다.
또한 이전에 작성한 레거시 컨트롤러와 어노테이션 기반의 컨트롤러가 공존해야 한다.
레거시 컨트롤러
public class LoginController implements Controller {
private static final Logger log = LoggerFactory.getLogger(LoginController.class);
@Override
public String execute(HttpServletRequest req, HttpServletResponse res) throws Exception {
if (UserSession.isLoggedIn(req.getSession())) {
return "redirect:/index.jsp";
}
return InMemoryUserRepository.findByAccount(req.getParameter("account"))
.map(user -> {
log.info("User : {}", user);
return login(req, user);
})
.orElse("redirect:/401.jsp");
}
private String login(HttpServletRequest request, User user) {
if (user.checkPassword(request.getParameter("password"))) {
final HttpSession session = request.getSession();
session.setAttribute(UserSession.SESSION_KEY, user);
return "redirect:/index.jsp";
} else {
return "redirect:/401.jsp";
}
}
}
어노테이션 기반 컨트롤러
@Controller
public class RegisterController {
@RequestMapping(value = "/register", method = RequestMethod.GET)
public ModelAndView show(HttpServletRequest req, HttpServletResponse res) {
return new ModelAndView(new JspView("/register.jsp"));
}
@RequestMapping(value = "/register", method = RequestMethod.POST)
public ModelAndView save(HttpServletRequest req, HttpServletResponse res) {
final User user = new User(2,
req.getParameter("account"),
req.getParameter("password"),
req.getParameter("email"));
InMemoryUserRepository.save(user);
return new ModelAndView(new JspView("redirect:/index.jsp"));
}
}
BeanScanner
리플렉션을 사용해서 @Controller 어노테이션이 붙어있는 클래스를 스캔해 인스턴스로 생성한다.
public class BeanScanner {
private static final Logger log = LoggerFactory.getLogger(BeanScanner.class);
private BeanScanner() {
}
public static List<Object> getBeansWithAnnotation(Object[] basePackage, Class<? extends Annotation> annotation) {
log.info("Scan Beans ! package: {}, annotation: {}", basePackage, annotation);
Reflections reflections = new Reflections(basePackage);
Set<Class<?>> beanClasses = reflections.getTypesAnnotatedWith(annotation);
List<BeanDefinition> beanDefinitions = getBeanDefinitions(beanClasses);
return beanDefinitions.stream()
.map(BeanDefinition::getBean)
.collect(Collectors.toList());
}
private static List<BeanDefinition> getBeanDefinitions(Set<Class<?>> controllerClasses) {
return controllerClasses.stream()
.map(BeanDefinition::new)
.collect(Collectors.toList());
}
}
해당 클래스에서는 어노테이션 정보를 인자로 받아 해당 어노테이션이 붙어있는 클래스 정보를 리플렉션을 사용해 찾는다.
또 여기서 사용된 BeanDefination은 리뷰어 크루가 제안해준 실제 스프링 키워드인데,
해당 클래스에 클래스 정보를 넘겨주면, 해당 인스턴스를 생성하여 필드를 가지고 있도록 역할을 분리해보았다.
AnnotationHandlerMapping
public class AnnotationHandlerMapping implements HandlerMapping {
private static final Logger log = LoggerFactory.getLogger(AnnotationHandlerMapping.class);
private final Object[] basePackage;
private final Map<HandlerKey, HandlerExecution> handlerExecutions;
public AnnotationHandlerMapping(Object... basePackage) {
this.basePackage = basePackage;
this.handlerExecutions = new HashMap<>();
}
public void initialize() {
log.info("Initialized AnnotationHandlerMapping!");
List<Object> controllers = BeanScanner.getBeansWithAnnotation(basePackage, Controller.class);
initializeHandlerExecutions(controllers);
}
private void initializeHandlerExecutions(List<Object> controllers) {
try {
for (Object controller : controllers) {
List<Method> methodsWithAnnotation = Arrays.stream(controller.getClass().getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(RequestMapping.class))
.collect(Collectors.toList());
addHandlerExecutions(controller, methodsWithAnnotation);
}
} catch (Exception e) {
log.error("Annotation Handler Mapping Fail!", e);
}
}
// ...
}
디스패쳐 서블릿에서 initialize가 호출될 때 Controller를 스캔하여,
HandlerKey를 키로 HandlerExecution를 값으로 가진 Map을 초기화 한다.
- HandlerKey : url과 요청 메서드를 인스턴스 변수로 가짐
- HandlerExecution : 실행할 메서드의 인스턴스와 실행할 메서드를 인스턴스 변수로 가짐
기존의 DispatcherServlet
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException {
log.debug("Method : {}, Request URI : {}", request.getMethod(), request.getRequestURI());
try {
final Controller controller = getController(request);
final String viewName = controller.execute(request, response);
move(viewName, request, response);
} catch (Throwable e) {
log.error("Exception : {}", e.getMessage(), e);
throw new ServletException(e.getMessage());
}
}
레거시 컨트롤러는 요청을 처리 후 무조건 뷰 네임을 반환하도록 되어있다.
하지만 어노테이션 기반의 컨트롤러는 ModelAndView 객체를 반환하는데,
이 둘을 호환하기 위해서는 다음과 같이 로직이 지저분해졌다.
final Object handler = getHandler(request);
if (handler instanceof Controller) {
handleController(request, response, (Controller) handler);
return;
}
handleHandler(request, response, (HandlerExecution) handler);
이를 개선하기 위해, 다른 반환 값을 가지는 두 컨트롤러를 호환할 수 있도록 어댑터 패턴을 적용했다.
HandlerAdapter 인터페이스
public interface HandlerAdapter {
boolean supports(Object handler);
ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception;
}
- supports(Object handler) : 어댑터가 해당 컨트롤러를 처리할 수 있는지 반환
- handle(...) : 어댑터는 실제 컨트롤러를 호출하고 결과로 ModelView를 반환한다.
- 만약 컨트롤러가 ModelView를 반환하지 못하면, 어댑터가 직접 생성한다.
- 즉 레거시 컨트롤러의 핸들러 어댑터는 컨트롤러가 반환한 뷰 네이밍을 통해 직접 ModelView를 생성한다.
- 개인적으로 이러한 과정들 덕분에 어댑터 패턴의 존재 이유를 알게 될 수 있었다.
public class AnnotationHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof HandlerExecution);
}
@Override
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HandlerExecution handlerExecution = (HandlerExecution) handler;
return handlerExecution.handle(request, response);
}
}
public class HandlerExecution {
private final Object handler;
private final Method method;
public HandlerExecution(Object handler, Method method) {
this.handler = handler;
this.method = method;
}
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response) throws Exception {
return (ModelAndView) method.invoke(handler, request, response);
}
}
AnnotationHandlerAdapter는 실행할 메서드를 가진 HandlerExecution의 handle 메서드를 통해 컨트롤러의 메서드를 실행시킨다.
어댑터 패턴 적용 후 DispatcherServlet
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException {
log.debug("Method : {}, Request URI : {}", request.getMethod(), request.getRequestURI());
try {
Object handler = getHandler(request);
if (Objects.isNull(handler)) {
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
return;
}
HandlerAdapter adapter = getHandlerAdapter(handler);
ModelAndView mv = adapter.handle(request, response, handler);
mv.render(request, response);
} catch (Exception e) {
log.error("Exception : {}", e.getMessage(), e);
throw new ServletException(e.getMessage());
}
}
어댑터 패턴 적용 후 어떤 핸들러가 와도 복잡한 로직 없이 깔끔하게 호환이 되도록 변경될 수 있었다.
3 단계 - View 구현하기
요구사항
HTML 이외의 Json도 반환할 수 있도록 JsonView를 만들어 준다.
ContentType은 MediaType.APPLICATION_JSON_UTF8_VALUE으로 반환하며,
model에 데이터가 1개면 값을 그대로 반환하고 2개 이상이면 Map 형태 그대로 JSON으로 변환해서 반환한다.
Json을 자바 객체로 변환할 수 있도록 도와주는 Jackson 라이브러리를 사용한다.
gradle - Jackson 라이브러리 추가
implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.5'
JsonView 구현
public class JsonView implements View {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.setContentType(APPLICATION_JSON_UTF8_VALUE);
Writer writer = response.getWriter();
writeToResponse(writer, model);
}
private void writeToResponse(Writer writer, Map<String, Object> model) throws IOException {
if (model.size() == 1) {
Map.Entry<String, ?> entry = model.entrySet().iterator().next();
String key = entry.getKey();
objectMapper.writeValue(writer, model.get(key));
}
if (model.size() > 1) {
objectMapper.writeValue(writer, model);
}
}
}
ObjectMapper를 필드로 가지고 있고, response객체에서 writer를 얻어
objectMapper.writeValue(writer, model)로 json을 만든다.
RedirectView
public class RedirectView implements View {
public static final String REDIRECT_PREFIX = "redirect:";
private final String viewName;
public RedirectView(String viewName) {
this.viewName = viewName;
}
@Override
public void render(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
response.sendRedirect(viewName.substring(REDIRECT_PREFIX.length()));
}
}
기존에는 JspView에서 리다이렉트까지 처리했는데,
reidrect url이 왔을 때, JSP에서 리다이렉트를 처리하는 역할을 분리하고자, 해당 뷰도 만들게 되었다.
ViewResolver 구현
해당 미션을 진행하면서 실제 스프링의 구조를 보았더니,
이름으로부터 사용할 뷰 객체를 결정해주는 뷰 리졸버도 구현해보고 싶은 실험정신이 생겼다.
다음과 같은 참고자료들을 가지고 JsonViewResolver, JspViewResolver, UrlBasedViewResolver를 만들어주었다.
만든 뷰 리졸버 인터페이스는 다음과 같다.
public class UrlBasedViewResolver implements ViewResolver {
// 뷰 이름에 해당하는 뷰 리졸버인지 확인
@Override
public boolean supports(String viewName) {
return viewName.startsWith(REDIRECT_PREFIX);
}
// 뷰 이름으로 뷰 객체 결정
@Override
public View resolveViewName(String viewName) {
return new RedirectView(viewName);
}
}
뷰 리졸버를 이용한 DispatcherServlet
private void render(ModelAndView mv, HttpServletRequest request, HttpServletResponse response) throws Exception {
String viewName = mv.getViewName();
ViewResolver resolver = viewResolvers.stream()
.filter(viewResolver -> viewResolver.supports(viewName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("view resolver를 찾을 수 없습니다."));
View view = resolver.resolveViewName(viewName);
view.render(mv.getModel(), request, response);
}
기존에는 ModelAndView 객체에서 뷰를 렌더하는 로직까지 가져갔지만,
역할을 분명히하기 위해 디스패쳐 서블릿에서 render 메서드를 만들고 해당 메서드에서
뷰이름으로 뷰 리졸버를 찾고, 뷰 리졸버가 반환한 뷰를 렌더한다.