기존 커맨드 패턴의 한계
이전 버전(V5)에서는 커맨드 패턴 기반의 서블릿을 사용했다. 각 URL 경로마다 별도의 서블릿 클래스를 만들고, 이를 ServletManager에 일일이 등록해야 했다
/site1 -> Site1Servlet /site2 -> Site2Servlet /search -> SearchServlet
이 방식에는 두 가지 명확한 단점이 있다
- 하나의 클래스에 하나의 기능밖에 담을 수 없다
- 새로운 기능을 추가할 때마다 클래스를 만들고 URL과 수동으로 매핑해야 한다
기능이 수십 개로 늘어난다면? 클래스 파일과 매핑 코드가 함께 폭발적으로 늘어난다
해결 아이디어 – 메서드 이름 = URL 경로
리플렉션을 활용하면 런타임에 메서드 이름을 읽을 수 있다. 여기서 핵심 아이디어가 나온다
URL 경로와 컨트롤러 메서드 이름을 동일하게 맞추면, 매핑 코드 없이 자동으로 호출할 수 있다
/site1 → site1() 메서드 자동 호출 /site2 → site2() 메서드 자동 호출 /search → search() 메서드 자동 호출
컨트롤러 작성
개발자는 이제 단순히 컨트롤러 클래스를 작성하기만 하면 된다. 인터페이스 구현도, URL 매핑 등록도 불필요하다
SiteControllerV6
관련 기능을 하나의 클래스에 묶는다
package was.v6;
public class SiteControllerV6 {
public void site1(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>site1</h1>");
}
public void site2(HttpRequest request, HttpResponse response) {
response.writeBody("<h1>site2</h1>");
}
}
SearchControllerV6
복잡도가 높거나 독립적인 기능은 별도 클래스로 분리한다
package was.v6;
public class SearchControllerV6 {
public void search(HttpRequest request, HttpResponse response) {
String query = request.getParameter("q");
response.writeBody("<h1>Search</h1>");
response.writeBody("<ul>");
response.writeBody("<li>query: " + query + "</li>");
response.writeBody("</ul>");
}
}
주의: 컨트롤러의 호출 대상 메서드는 반드시 HttpRequest, HttpResponse를 인자로 받아야 한다. method.invoke()시 인자 타입이 맞지 않으면 런타임 예외가 발생한다
리플렉션 서블릿 구현
package was.httpserver.servlet.reflection;
public class ReflectionServlet implements HttpServlet {
private final List<Object> controllers;
public ReflectionServlet(List<Object> controllers) {
this.controllers = controllers;
}
@Override
public void service(HttpRequest request, HttpResponse response) throws IOException {
String path = request.getPath();
for (Object controller : controllers) {
Class<?> aClass = controller.getClass();
Method[] methods = aClass.getDeclaredMethods();
for (Method method : methods) {
String methodName = method.getName();
if (path.equals("/" + methodName)) {
invoke(controller, method, request, response);
return; // 호출 후 반드시 리턴
}
}
}
throw new PageNotFoundException("request=" + path);
}
private static void invoke(Object controller, Method method,
HttpRequest request, HttpResponse response) {
try {
method.invoke(controller, request, response);
} catch (InvocationTargetException | IllegalAccessException e) {
throw new RuntimeException(e);
}
}
}
동작 방식은 단순하다. 모든 컨트롤러를 순회하면서 각 클래스의 메서드 목록을 가져오고, 요청 경로 (/site1)와 메서드 이름 (site1)을 비교해 일치하면 method.invoke()로 호출한다. 찾지 못하면 PageNotFoundException을 던진다invoke()후 반드시 return을 해야 한다. 없으면 불필요하게 루프가 계속 돌거나, 이미 응답이 완료된 상태에서 추가 처리가 발생할 수 있다
서버 등록
public class ServerMainV6 {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
List<Object> controllers = List.of(new SiteControllerV6(), new SearchControllerV6());
HttpServlet reflectionServlet = new ReflectionServlet(controllers);
ServletManager servletManager = new ServletManager();
servletManager.setDefaultServlet(reflectionServlet); // 핵심
servletManager.add("/", new HomeServlet());
servletManager.add("/favicon.ico", new DiscardServlet());
HttpServer server = new HttpServer(PORT, servletManager);
server.start();
}
}
setDefaultServlet(reflectionServlet)이 핵심이다. ServletManager가 요청 경로를 찾지 못하면 default 서블릿을 호출하는데, 여기에 ReflectionServlet을 등록해두면 등록되지 않은 모든 경로가 자동으로 리플렉션 기반 처리로 넘어온다/와 /favicon.ico는 어쩔 수 없이 수동 등록이 필요하다. 슬래시(/)나 (.)은 자바 메서드 이름으로 사용할 수 없기 때문이다
요청 처리 흐름 정리
- 웹 브라우저가
/site1을 요청한다 ServletManager가 등록된 서블릿 (/,/favicon.ico)에서 경로를 탐색하지만 찾지 못한다- default 서블릿인
ReflectionServlet이 호출된다 ReflectionServlet이SiteControllerV6,SearchControllerV6을 순회하며site1이라는 이름의 메서드를 찾는다SiteControllerV6.site1(request, response)를method.invoke()로 호출한다.response.writeBody()가 실행되고, 이후flush를 통해 클라이언트에 응답이 전달된다
이 방식의 의의
기존 was.httpserver 패키지 코드를 단 한 줄도 수정하지 않았다. 서블릿 인터페이스를 그래도 유지하면서 완전히 새로운 동작 방식을 추가한 것이다. 이것이 인터페이스 기반 설계와 OCP(개방-폐쇄 원칙)의 실질적인 효용이다
V5와 비교하면 차이가 명확하다. 기능을 하나 추가할 때 V5는 클래스 하나 생성 + 매핑 등록이 필요했다. V6는 컨트롤러에 메서드 하나를 추가하는 것으로 끝난다