본문 바로 가기

스프링 직접 구현하기 #3: DispatcherServlet 구현

들어가며

이번에는 스프링 MVC의 핵심 엔진, DispatcherServlet의 기능을 직접 구현해보겠습니다. 처음부터 너무 복잡하게 만들기보다는, 가장 기본적인 기능부터 차근차근 만들어보는 것을 목표로 삼았어요.

 

실제 HTTP 통신 대신, 콘솔 입출력을 통해 기능을 모의로 구현해볼 겁니다. 요청은 다음과 같은 간단한 형식으로 정의했어요.

  • 요청 형식: /{path} {body} (예: /climb Tiger1)

이 요청을 처리할 컨트롤러는 두 가지 스타일을 지원하도록 구상했습니다.

@RequestMapping 어노테이션 스타일 컨트롤러:

@Component
@Controller
public class TigerRunController {

    @RequestMapping("/run")
    public String tigerRun(@Body String body) {
        return body + " is Running";
    }
}

 

HttpRequestHandler 인터페이스 구현 스타일 컨트롤러:

public interface HttpRequestHandler {
    void handleRequest(HttpMessage input, HttpMessage output);
}
-----

@Component
@Controller("/climb")
public class TigerClimbController implements HttpRequestHandler {
    @Override
    public void handleRequest(HttpMessage input, HttpMessage output) {
        String message = input.getMessage() + " is Climbing";
        output.setMessage(message);
    }
}

그럼 이 두 가지 유형의 컨트롤러를 호출할 수 있는 DispatcherServlet v1을 만들어보겠습니다.

🐤 DispatcherServlet v1: 기본 아이디어와 구현

DispatcherServlet v1의 핵심 아이디어는 다음과 같습니다.

  1. 핸들러 매핑 정보 저장: 요청 경로(Path)와 이를 처리할 메서드(Method 또는 핸들러 객체)를 Map에 저장합니다.
  2. 핸들러 조회: 들어온 요청의 path를 보고, 미리 저장된 매핑 정보에서 적절한 핸들러(메서드)를 찾습니다.
  3. 핸들러 실행 및 응답: 찾은 핸들러의 메서드를 실행하고, 그 결과를 콘솔에 출력합니다.

WebServer: 콘솔의 입출력 담당

실제 스프링 MVC는 Tomcat과 같은 WAS(Web Application Server)를 통해 HTTP 요청을 받지만, 이 프로젝트에서는 WebServer 클래스를 통해 콘솔 입력을 받고 처리 결과를 콘솔에 출력하는 방식으로 단순화했습니다.

@Component
public class WebServer {

    private final DispatcherServlet dispatcherServlet;

    @AutoWire
    public WebServer(DispatcherServlet dispatcherServlet) {
        this.dispatcherServlet = dispatcherServlet;
    }

    public void listenConsole() {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
        String input;

        try {
            while ((input = reader.readLine()) != null) {
                if (input.equalsIgnoreCase("exit")) {
                    System.out.println("서버를 종료합니다.");
                    break;
                }
                String[] splitInput = input.split(" ", 2); // path와 body를 분리 (body가 없을 수도 있음)

                String path = splitInput[0];
                String body = splitInput[1];

                HttpMessage request = new HttpMessage(path, body);
                String output = dispatcherServlet.dispatch(request);
                System.out.println(output);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } 
    }
}

PathContext: 경로-메서드 매핑 정보 저장소

요청 경로(path)와 이를 처리할 Method를 매핑하는 PathContext를 정의했습니다.

public class PathContext {
    public static final Map<String, Method> PATH_METHOD_MAP = new HashMap<>();
}

PathContextInitiator: 핸들러 매핑 정보 스캔 및 등록

애플리케이션 시작 시, @Controller 어노테이션이 붙은 빈들을 스캔하여 PathContext에 핸들러 매핑 정보를 미리 저장합니다.

public class PathContextInitiator {

    public void scanHandlers() {
        for (Class<?> clazz : SpringContext.BEAN_MAP.keySet()) {
            if (!clazz.isAnnotationPresent(Controller.class)) {
                continue;
            }

            // 1. HttpRequestHandler 인터페이스 구현체인 경우
            if (HttpRequestHandler.class.isAssignableFrom(clazz)) {
                Controller controllerAnnotation = clazz.getAnnotation(Controller.class);
                String path = controllerAnnotation.value(); // @Controller("/path")
                if (path.isEmpty()) {
                    System.err.println("경고: " + clazz.getSimpleName() + " HttpRequestHandler는 @Controller에 경로가 명시되어야 합니다.");
                    continue;
                }
                Method method;
                try {
                    // HttpRequestHandler 인터페이스의 메서드를 가져옵니다.
                    method = clazz.getMethod("handleRequest", HttpMessage.class, HttpMessage.class);
                } catch (NoSuchMethodException e) {
                    throw new RuntimeException("HttpRequestHandler 인터페이스의 handleRequest 메서드를 찾을 수 없습니다: " + clazz.getName(), e);
                }

                PathContext.PATH_METHOD_MAP.put(path, method);
                continue; 
            }

            // 2. @RequestMapping 어노테이션을 사용하는 경우
            for (Method method : clazz.getDeclaredMethods()) {
                if (method.isAnnotationPresent(RequestMapping.class)) {
                    String path = method.getAnnotation(RequestMapping.class).value();
                    PathContext.PATH_METHOD_MAP.put(path, method);
                }
            }
        }
    }
}

DispatcherServlet - dispatch()

이제 DispatcherServlet의 핵심 로직인 dispatch 메서드입니다. 요청을 받아 적절한 핸들러 메서드를 실행합니다.

 @Component
public class DispatcherServlet {

    public String dispatch(HttpMessage request) {

        if(!PathContext.PATH_METHOD_MAP.containsKey(request.getPath())) {
            throw new RuntimeException("Path not found");
        }

        Method method = PathContext.PATH_METHOD_MAP.get(request.getPath());
        String response = "";

        //HttpRequestHadler 형태라면
        if(HttpRequestHandler.class.isAssignableFrom(method.getDeclaringClass())) {
            HttpRequestHandler controller = (HttpRequestHandler) SpringContext.BEAN_MAP.get(method.getDeclaringClass());
            HttpMessage output = new HttpMessage();
            controller.handleRequest(request, output);
            response = output.getBody();
        }

        //RequestMapping 형태라면
        if(method.isAnnotationPresent(RequestMapping.class)) {
            Object controller =  SpringContext.BEAN_MAP.get(method.getDeclaringClass());

            Parameter[] parameters = method.getParameters();
            Object[] argsToPass = new Object[parameters.length];

            for (int i = 0; i < parameters.length; i++) {
                if(parameters[i].isAnnotationPresent(Body.class)) {
                    argsToPass[i] = request.getBody();
                }
            }

            try {
                response = (String) method.invoke(controller, argsToPass);
            } catch (IllegalAccessException | InvocationTargetException e) {
                throw new RuntimeException(e);
            }
        }

        return response;
    }

}

결과

콘솔에서 요청을 보내면, 위 이미지처럼 각 컨트롤러가 잘 실행되어 응답을 출력하는 것을 확인할 수 있습니다!

v1의 아쉬운 점: OCP 위반 가능성

v1 구현은 잘 동작하지만, DispatcherServletdispatch 메서드를 보면 다음과 같은 if 분기문이 존재합니다.

// HttpRequestHandler 형태라면
if (HttpRequestHandler.class.isAssignableFrom(method.getDeclaringClass())) {
    // ...
}
// RequestMapping 형태라면
else if (method.isAnnotationPresent(RequestMapping.class)) {
    // ...
}

이 구조의 문제는 명확합니다. 만약 새로운 유형의 핸들러를 추가하고 싶다면, DispatcherServletdispatch 메서드 내부에 else if 블록을 계속해서 추가해야 합니다. 이는 OCP(Open/Closed Principle, 개방-폐쇄 원칙)를 위반하게 됩니다. 즉, 기능 확장을 위해 기존 코드를 수정해야 하는 상황이죠.

🐂 DispatcherServlet v2

v1의 dispatch 메서드에서 핸들러 유형에 따라 if문으로 분기하여 처리 로직을 작성해야 했던 점을 개선하고자 합니다.

여기서는 간단히 Handler 인터페이스를 정의하고, 실제 실행 로직을 이 Handler 구현체로 옮기기로 했습니다. 즉, Path 스캐닝 단계에서 어떤 Handler 구현체가 해당 요청을 처리할지 미리 결정하고 매핑하는 것입니다.

Handler 인터페이스 및 구현체

먼저, 모든 핸들러가 구현할 Handler 인터페이스와 두 가지 구체적인 핸들러 구현체를 준비했습니다.

public interface Handler {
    String handleRequest(HttpMessage request);
}

----

public class RequestMappingHandler implements Handler {

    public RequestMappingHandler(Method method) {
        this.method = method;
    }

    private final Method method;

    @Override
    public String handleRequest(HttpMessage request) {

        Object controller =  SpringContext.BEAN_MAP.get(method.getDeclaringClass());
        String response = "";

        Parameter[] parameters = method.getParameters();
        Object[] argsToPass = new Object[parameters.length];

        for (int i = 0; i < parameters.length; i++) {
            if(parameters[i].isAnnotationPresent(Body.class)) {
                argsToPass[i] = request.getBody();
            }
        }

        try {
            response = (String) method.invoke(controller, argsToPass);
        } catch (IllegalAccessException | InvocationTargetException e) {
            throw new RuntimeException(e);
        }

        return response;
    }
}

----

public class HttpRequestHandlerHandler implements Handler {

    private final Method method;

    public HttpRequestHandlerHandler(Method method) {
        this.method = method;
    }

    @Override
    public String handleRequest(HttpMessage request) {
        HttpRequestHandler controller = (HttpRequestHandler) SpringContext.BEAN_MAP.get(method.getDeclaringClass());
        HttpMessage output = new HttpMessage();
        controller.handleRequest(request, output);
        return output.getBody();
    }
}

v1 DispatcherServletdispatch 메서드에 있던 각 분기 로직이 해당 Handler 구현체 내부로 옮겨진 것을 볼 수 있습니다.

PathContext 변경

PathContext는 이제 Method 대신 Handler 인터페이스의 구현체를 값으로 저장하도록 변경합니다.

public class PathContext {
    public static final Map<String, Handler> PATH_HANDLER_MAP = new HashMap<>();
}

PathContextInitiator 변경

PathContextInitiator에서는 각 Method에 맞는 Handler 구현체를 생성하여 PathContext에 등록하도록 수정했습니다.

public class PathContextInitiator {

    public void scanHandlers() {

        for (Class<?> clazz : SpringContext.BEAN_MAP.keySet()) {
            if (!clazz.isAnnotationPresent(Controller.class)) {
                continue;
            }

            //HttpRequestHandler 상속인경우
            if (HttpRequestHandler.class.isAssignableFrom(clazz)) {
                String path = clazz.getAnnotation(Controller.class).value();
                Method method;
                try {
                    method = clazz.getMethod("handleRequest", HttpMessage.class, HttpMessage.class);
                } catch (NoSuchMethodException e) {
                    throw new RuntimeException(e);
                }

                //이 부분 변경
                HttpRequestHandlerHandler handler = new HttpRequestHandlerHandler(method);
                PathContext.PATH_HANDLER_MAP.put(path, handler);
                continue;
            }

            //RequestMapping 이용
            for (Method method : clazz.getDeclaredMethods()) {
                if (method.isAnnotationPresent(RequestMapping.class)) {
                    String path = method.getAnnotation(RequestMapping.class).value();

                    //이 부분 변경
                    RequestMappingHandler handler = new RequestMappingHandler(method);
                    PathContext.PATH_HANDLER_MAP.put(path, handler);
                }
            }

        }
    }
}

DispatcherServlet (v2)

이제 DispatcherServletdispatch 메서드는 훨씬 간결해집니다.

 @Component
public class DispatcherServlet {

    public String dispatch(HttpMessage request) {

        if(!PathContext.PATH_HANDLER_MAP.containsKey(request.getPath())) {
            throw new RuntimeException("Path not found");
        }

        Handler handler = PathContext.PATH_HANDLER_MAP.get(request.getPath());
        return handler.handleRequest(request);
    }

}

이제 DispatcherServletdispatch 메서드에서는 if 분기문 없이, PathContext에서 가져온 HandlerhandleRequest 메서드를 호출하기만 하면 됩니다.

 

훨씬 깔끔해졌죠? 새로운 유형의 핸들러가 추가되더라도, 해당 핸들러를 위한 Handler 구현체만 새로 만들고 PathContextInitiator에 등록 로직만 추가하면 됩니다. DispatcherServlet 코드는 변경할 필요가 없어요!

 

물론, PathContextInitiator에서 핸들러를 등록할 때는 여전히 어떤 Handler 구현체를 사용할지 결정하기 위한 if분기가 필요합니다. 하지만 이는 초기 설정 단계의 책임이며, 요청 처리 로직의 복잡도를 낮추고 OCP를 준수하는 데 성공했습니다.

마치며

지금까지 스프링 MVC의 핵심 컴포넌트인 DispatcherServlet의 기능을 간략하게 직접 구현해보았습니다.

 

v2를 만들면서 어떻게 설계할지 처음엔 감이 잡히지 않았고, 많은 고민을 했었습니다. 뭔가 확장성있는 구조를 설계하는데 있어 아직 많이 부족함을 느끼게 되네요. 또한, 이름짓는게 세상에서 제일 어렵습니다. 처음엔 method 를 대신 처리해준다고 해서 Handler 라는 이름을 붙였는데 뭔가 잘못되어 가고 있다는 점을 느꼈어요. 책임의 분리를 더 확실히 해야할 것 같다고 다시 한번 다짐하게 되었습니다.

 

다음 글에서는 실제 스프링의 DispatcherServlet는 어떻게 더 정교하게 구현되어 있는지 비교하며 살펴보면 더욱 재미있을 것 같습니다.

 

👉 깃허브 소스코드