Java로 HTTP 서버 개선기 — 리플렉션 서블릿으로 매핑 자동화하기

기존 커맨드 패턴의 한계

이전 버전(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이 호출된다
  • ReflectionServletSiteControllerV6, SearchControllerV6을 순회하며 site1이라는 이름의 메서드를 찾는다
  • SiteControllerV6.site1(request, response)method.invoke()로 호출한다.
  • response.writeBody()가 실행되고, 이후 flush를 통해 클라이언트에 응답이 전달된다

이 방식의 의의

기존 was.httpserver 패키지 코드를 단 한 줄도 수정하지 않았다. 서블릿 인터페이스를 그래도 유지하면서 완전히 새로운 동작 방식을 추가한 것이다. 이것이 인터페이스 기반 설계와 OCP(개방-폐쇄 원칙)의 실질적인 효용이다
V5와 비교하면 차이가 명확하다. 기능을 하나 추가할 때 V5는 클래스 하나 생성 + 매핑 등록이 필요했다. V6는 컨트롤러에 메서드 하나를 추가하는 것으로 끝난다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션