Project Winter
지난 #3-Contoller 기능 구현 중 문제에서 생긴 bean 관련 문제를 해결하고 다시 컨트롤러 기능을 구현했다.
원랜 인터페이스 기반 컨트롤러 기능만 일단 구현하려 했지만 bean 기능을 구현한 김에 애노테이션 기반 컨트롤러까지 진행했다.
컨트롤러
인터페이스 기반
컨트롤러 인터페이스
package com.project.winter.mvc.controller;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface Controller {
String handleRequest(HttpServletRequest req, HttpServletResponse res);
}
인터페이스는 간단히 엔드포인트에서 호출할 메서드 하나만 선언해뒀다.
애노테이션 기반
@Controller
package com.project.winter.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface Controller {
}
지난번 bean 기능 구현편에서도 보였지만 컨트롤러는 컴포넌트 스캔에서 스캔될 수 있도록 했다.
@RequestMapping
package com.project.winter.annotation;
import com.project.winter.web.HttpMethod;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestMapping {
String value() default "";
HttpMethod[] method() default {};
}
지난번 @RequestMapping
애노테이션에서 value 와 method 를 추가해 컨트롤러 클래스/메서드 매핑 정보를 설정할 수 있게 구현했다.
HandlerMapping
여기서부터 복잡하다.
이용자가 생성한 컨트롤러 클래스를 구분하여 요청 path, method 에 맞게 매핑하고 요청이 들어오면 일치하는 핸들러를 반환해주는 HandlerMapping
이다.
인터페이스
package com.project.winter.mvc.handler.mapping;
import com.project.winter.mvc.handler.HandlerExecutionChain;
import javax.servlet.http.HttpServletRequest;
public interface HandlerMapping {
void init();
HandlerExecutionChain getHandler(HttpServletRequest req) throws Exception;
}
기본적인 인터페이스이다.
스프링의 HandlerMapping
인터페이스와는 다르게 init()
메서드를 만들어줬다.
각 HandlerMapping
들을 생성할 때 초기화 시켜주기 위해 해당 메서드를 선언했다.
AbstractHandlerMapping
package com.project.winter.mvc.handler.mapping;
import com.project.winter.mvc.handler.HandlerExecutionChain;
import com.project.winter.mvc.handler.HandlerKey;
import com.project.winter.web.HttpMethod;
import javax.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;
public abstract class AbstractHandlerMapping implements HandlerMapping {
protected Map<HandlerKey, Object> handlerMethods = new LinkedHashMap<>();
private Object getHandlerInternal(HttpServletRequest req) throws Exception {
HandlerKey handlerKey = new HandlerKey(req.getRequestURI(), HttpMethod.valueOf(req.getMethod()));
return handlerMethods.get(handlerKey);
}
public HandlerExecutionChain getHandler(HttpServletRequest req) throws Exception {
Object handler = getHandlerInternal(req);
if (handler == null) return null;
return new HandlerExecutionChain(handler);
}
}
각각의 HandlerMapping
에서 공통적으로 쓰이는 로직을 구현해줬다.Handler
들을 가질 맵을 만들어 두고 요청에 맞는 핸들러를 해당 맵에서 찾아 반환해주는 역할을 한다.
RequestMappingHandlerMapping
애노테이션 기반 컨트롤러를 매핑해주는 HandlerMapping
이다.
package com.project.winter.mvc.handler.mapping;
import com.project.winter.annotation.Controller;
import com.project.winter.annotation.RequestMapping;
import com.project.winter.beans.BeanFactory;
import com.project.winter.beans.BeanInfo;
import com.project.winter.mvc.handler.HandlerKey;
import com.project.winter.mvc.handler.HandlerMethod;
import com.project.winter.web.HttpMethod;
import java.lang.reflect.Method;
import java.util.*;
public class RequestMappingHandlerMapping extends AbstractHandlerMapping {
@Override
public void init() {
Map<BeanInfo, Object> controllers = BeanFactory.getAnnotatedBeans(Controller.class);
controllers.forEach((key, controller) -> {
Class<?> clazz = controller.getClass();
Method[] controllerMethods = clazz.getDeclaredMethods();
StringBuilder parentPath = new StringBuilder();
Set<HttpMethod> parentHttpMethods = new HashSet<>();
boolean classHasRequestMapping = clazz.isAnnotationPresent(RequestMapping.class);
if (classHasRequestMapping) {
RequestMapping requestMapping = clazz.getDeclaredAnnotation(RequestMapping.class);
setRequestMappingInfo(requestMapping, parentPath, parentHttpMethods);
}
Arrays.stream(controllerMethods).forEach(method -> {
StringBuilder path = new StringBuilder(parentPath.toString());
Set<HttpMethod> httpMethods = new HashSet<>(parentHttpMethods);
boolean methodHasRequestMapping = method.isAnnotationPresent(RequestMapping.class);
if (methodHasRequestMapping) {
RequestMapping requestMapping = method.getDeclaredAnnotation(RequestMapping.class);
setRequestMappingInfo(requestMapping, path, httpMethods);
}
if (httpMethods.isEmpty()) httpMethods.addAll(List.of(HttpMethod.values()));
httpMethods.forEach(it -> {
HandlerKey handlerKey = new HandlerKey(path.toString(), it);
HandlerMethod handlerMethod = new HandlerMethod(controller, method);
handlerMethods.put(handlerKey, handlerMethod);
});
});
});
}
private void setRequestMappingInfo(RequestMapping requestMapping, StringBuilder path, Set<HttpMethod> httpMethods) {
String tempPath = requestMapping.value();
if (!tempPath.startsWith("/")) path.append("/");
path.append(tempPath);
httpMethods.addAll(List.of(requestMapping.method()));
}
}
BeanFactory
에서 만들어준 bean 중 @Controller
애노테이션이 붙은 컨트롤러 빈을 가져와 해당 컨트롤러의 메서드 들을 @RequestMapping
정보에 맞게 매핑해준다.
여기서 고민한 점이 class 레벨에 붙은 @RequestMapping
이 GET
방식이고 method 레벨에 붙언 @RequestMapping
이 POST
방식일 때, GET
요청은 응답을 해주면 안된다고 생각했다.
스프링이 구현한 방법을 확인해 보니 해당 경우에도 GET
, POST
모두 응답을 해주고 있었고, 이 방법이 덜 복잡하기 때문에 스프링과 동일한 방법을 따라가기로 했다.
이 HandlerMapping
은 method 단위로 handler 를 생성해 매핑해준다.
BeanNameUrlHandlerMapping
package com.project.winter.mvc.handler.mapping;
import com.project.winter.beans.BeanFactory;
import com.project.winter.beans.BeanInfo;
import com.project.winter.mvc.controller.Controller;
import com.project.winter.mvc.handler.HandlerKey;
import com.project.winter.web.HttpMethod;
import java.util.Map;
public class BeanNameUrlHandlerMapping extends AbstractHandlerMapping {
@Override
public void init() {
Map<BeanInfo, Controller> controllerMap = BeanFactory.getBeans(Controller.class);
controllerMap.forEach((key, controller) -> {
String beanName = key.getBeanName();
if (!beanName.startsWith("/")) beanName = "/" + beanName;
HandlerKey handlerKey = new HandlerKey(beanName, HttpMethod.GET);
handlerMethods.put(handlerKey, controller);
});
}
}
인터페이스를 구현한 컨트롤러 bean 을 가져온 후 bean 이름을 url path 로 매핑해주는 HandlerMapping
이다.
어차피 하나의 구현체에서 요청에 호출되는 메서드는 하나기 때문에 컨트롤러 인스턴스 변수 자체를 handler 로 매핑해준다.
HandlerAdapter
요청에 알맞은 핸들러를 찾은 후 해당 핸들러의 메서드를 호출해주는 역할을 하는 HandlerAdapter
다.
인터페이스
package com.project.winter.mvc.handler.adapter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public interface HandlerAdapter {
boolean supports(Object handler);
String handle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception;
}
구현체 어댑터가 처리해 줄 수 있는 핸들러인지 확인해주는 supports()
메서드와 핸들러 메서드를 호출해주는 handle()
메서드로 이루어져있다.
RequestMappingHandlerAdapter
package com.project.winter.mvc.handler.adapter;
import com.project.winter.mvc.handler.HandlerMethod;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class RequestMappingHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof HandlerMethod);
}
@Override
public String handle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
return ((HandlerMethod) handler).handle(req, res);
}
}
애노테이션 기반 핸들러를 처리해주는 어댑터이다.
SimpleControllerHandlerAdapter
package com.project.winter.mvc.handler.adapter;
import com.project.winter.mvc.controller.Controller;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class SimpleControllerHandlerAdapter implements HandlerAdapter {
@Override
public boolean supports(Object handler) {
return (handler instanceof Controller);
}
@Override
public String handle(HttpServletRequest req, HttpServletResponse res, Object handler) throws Exception {
return ((Controller) handler).handleRequest(req, res);
}
}
인터페이스 기반 핸들러 어댑터이다.
DispatchServlet 수정
private List<HandlerMapping> handlerMappings;
private List<HandlerAdapter> handlerAdapters;
@Override
public void init() throws ServletException {
log.info("DispatcherServlet init() called.");
initHandlerMappings();
initHandlerAdapters();
}
private void initHandlerMappings() {
this.handlerMappings = BeanFactoryUtils.initHandlerMappings
}
private void initHandlerAdapters() {
this.handlerAdapters = new ArrayList<>();
this.handlerAdapters.add(new RequestMappingHandlerAdapter()
this.handlerAdapters.add(new SimpleControllerHandlerAdapter
}
private void doDispatch(HttpServletRequest req, HttpServletResponse res) {
try {
HandlerExecutionChain mappedHandler = getHandler(req);
if (mappedHandler == null) throw new HandlerNotFoundException();
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
String result = ha.handle(req, res, mappedHandler.getHandler());
} catch (Exception e) {
if (e instanceof HandlerNotFoundException) res.setStatus(HttpStatus.NOT_FOUND.getCode());
// TODO ExceptionResolver 개발시 예외 발생시 추가 로직 필요
}
}
private HandlerExecutionChain getHandler(HttpServletRequest req) throws Exception {
for (HandlerMapping handlerMapping : handlerMappings) {
HandlerExecutionChain handler = handlerMapping.getHandler(req);
if (handler != null) return handler;
}
return null;
}
private HandlerAdapter getHandlerAdapter(Object handler) throws Exception {
for (HandlerAdapter handlerAdapter : this.handlerAdapters) {
boolean isSupport = handlerAdapter.supports(handler);
if (isSupport) return handlerAdapter;
}
throw new RuntimeException();
}
새롭게 추가된 코드들이다.HandlerMapping
, HanderAdapter
들을 초기화 해주고, 요청이 들어오면 요청 경로, method 에 맞는 Handler
를 HandlerMapping
이 찾아준다.
이후 찾은 Handler
를 처리해줄 수 있는 HanderAdapter
를 찾아 Handler
메서드를 호출하며 요청을 수행한다.
후기
이번 기능을 구현하면서 생각을 정말 많이 했다.
기존 코드들도 테스트 없이 구현돼있다 보니 기대한 결과값과 다르게 나와 수정하는 일도 있었고, 좀 더 좋은 구조에 대해 고민하다 보니 고려할게 한두개가 아니었다.
구현하는 도중에는 스프링 코드를 까보지 않겠다 생각했는데, 구현에 어려움을 겪으니 나도 모르게 스프링 코드를 까보게 됐다.
스프링 코드도 쉽지 않아 디버그 포인트를 찍어 한줄한줄 실행 로직을 따라가면서 겨우겨우 이해하고 내 프로젝트에 맞게끔 반영했다.
또한 테스트 코드에 대한 중요성도 많이 알게됐고, 이번 컨트롤러 기능은 테스트 코드를 조금이라도 작성했다.
포스팅을 하면서 생각이 났는데 이번 테스트 코드도 http call
에 따른 컨트롤러 응답만 확인했고 HandlerMapping
, HandlerAdappter
에 대한 테스트는 수행하지 않았다.....
앞으로라도 테스트를 조금 꼼꼼하게 작성하고 프로젝트를 끝낸 후 리뷰를 하면서 부족한 테스트를 진행해봐야겠다.