사이드 프로젝트/순수 자바로 스프링 만들기

순수 자바로 Spring MVC 구현하기

마늘냄새폴폴 2025. 6. 8. 00:53

이번 포스팅에선 Spring MVC를 깊이있게 파헤쳐보고 정리해보도록 하겠습니다. 코드는 깃허브를 참고해주시면 감사하겠습니다. 코드는 웬만하면 적지 않을 예정입니다. 제 블로그 포스팅은 기본적으로 코드를 되도록 적지 않는데 그 이유는 코드가 많아지면 설명해야할게 많아지고 그럼 가독성이 떨어진다고 생각하기 때문입니다.

 

또한, 저도 공부하면서 포스팅을 수백개 봤지만 사실 코드를 잘 안봅니다... 그리고 깃허브를 들어가서 실제 코드를 보면 아시겠지만 변수명과 함수명이 실제 스프링 프로젝트와 다른 부분이 있습니다. 그 이유는 

 

  1. 실제 스프링 프로젝트는 훨씬 더 복잡한 계층 구조와 다양한 팩토리 클래스를 가지고 있습니다. 그래서 제 프로젝트는 최대한 가볍게 만들기 위해서 변수명을 조정했습니다. 
  2. 사실 코드는 별로 중요하지 않다고 생각하고 흐름만 공부하면 좋겠다고 생각했습니다. 
  3. AI의 도움을 받긴 했지만 커서와 같은 AI는 도움만 주었고 실제 코드 작성은 제가 하는 편입니다. 그 중에서 AI가 적어준 변수명보다 제가 이해하기 편한 변수명을 사용한 것도 있습니다. 

https://github.com/garlicpollpoll/springlite

 

GitHub - garlicpollpoll/springlite: 순수 자바로 스프링 만들기 프로젝트입니다.

순수 자바로 스프링 만들기 프로젝트입니다. Contribute to garlicpollpoll/springlite development by creating an account on GitHub.

github.com

 

 

이제 본격적으로 포스팅 시작해보겠습니다. DispatcherServlet을 중심으로 Spring MVC는 다양한 구조체들로 이루어져있습니다. 

 

  • DispatcherServlet : DispatcherServlet은 모든 요청을 받아서 적절한 Controller로 라우팅 해주는 역할을 합니다. Spring MVC에서 핵심적인 역할을 맡고 있습니다. 
  • HandlerMapping : Spring MVC에선 HandlerMapping이라는 객체를 이용해서 DispatcherServlet이 처리할 다양한 정보들을 저장하고 있습니다. 주로 어떤 Controller인지 그 안에 메서드는 어떤게 있는지 url pattern은 뭔지 같은 정보들을 가지고 있습니다. DTO 비슷한거라고 생각해주시면 될 것 같습니다. 
  • HandlerAdapter : HandlerAdapter는 Spring MVC에서 다양한 매개변수들의 처리를 담당하고 있습니다. @PathVariable, @RequestParam, @RequestBody와 같은 매개변수에 사용하는 어노테이션을 처리하기위해 존재하고 있죠. 
  • ViewResolver : ViewResolver는 다양한 View구현체들로 연결해주는 매개체입니다. 우리는 경우에 따라서 JSP를 사용할 수도 있고 Thymeleaf같은 다양한 템플릿 엔진을 사용합니다. 그 때마다 적절한 View 구현체 (JspView, ThymeleafView) 로 라우팅해주는 역할을 합니다. 
  • View : 실제로 렌더링되는 규칙을 가지고 있는 구현체입니다. 보통 템플릿 엔진에 의존하고 있으며 render라는 중요한 메서드가 렌더링에대한 프로토콜을 구현하고 있습니다. 
  • 번외) ModelAndView : Model은 데이터를 운반하는 객체입니다. 이 데이터를 이용해서 템플릿 엔진에 값을 뿌려주는 것이죠. 보통 DispatcherSerlvet에서 ViewResolver가 View를 렌더링 할 때 Model에 있는 다양한 값들을 템플릿 엔진으로 넘겨주는 역할을 합니다. 

우리의 목표는 다음과 같습니다. 

 

  1. JSP 렌더링 요청은 JSP 페이지를 렌더링 해줘야한다. 
  2. @PathVariable 요청이 정상작동해야함
  3. @RequestParam 요청이 정상작동해야함
  4. @RequestBody 요청이 정상작동해야함

 

Springlite에서는 이정도만 구현하는 것을 목표로 했습니다. 더 나아가서 thymeleaf로 렌더링하는 경우와 JSP를 렌더링하는 경우에 따라 인터페이스로 분리하고 추상화하는 작업이 요구되지만 프로젝트의 복잡성을 최대한 줄이고자 이 것은 구현하지 않도록 결정했습니다. 

 

코드는 많이 업로드하지 않겠습니다. 핵심 로직만 짚고 넘어가겠습니다. 

 

순수 자바로 Spring MVC 구현하기

/**
 * HTTP 요청을 처리할 Handler(Controller 메소드)와 URL 패턴 정보를 담는 클래스
 * Spring MVC의 HandlerMapping 개념을 구현
 */
public class HandlerMapping {
    private final Object handler;      // Controller 인스턴스
    private final Method method;       // Controller 메소드
    private final String urlPattern;   // URL 패턴 (예: /api/users/{id})
    
    public HandlerMapping(Object handler, Method method, String urlPattern) {
        this.handler = handler;
        this.method = method;
        this.urlPattern = urlPattern;
    }
    
    public Object getHandler() {
        return handler;
    }
    
    public Method getMethod() {
        return method;
    }
    
    public String getUrlPattern() {
        return urlPattern;
    }
    
    @Override
    public String toString() {
        return "HandlerMapping{" +
                "handler=" + handler.getClass().getSimpleName() +
                ", method=" + method.getName() +
                ", urlPattern='" + urlPattern + '\'' +
                '}';
    }
}

 

우선 HandlerMapping이라는 클래스를 이용해서 HandlerAdapter에서 처리할 요청들에 대한 정보를 저장하고 있는 클래스를 만들어야합니다. 

 

크게 보면 DispatcherServlet에서 사용할 다양한 정보들을 가지고 있고 작게 보면 HandlerAdapter에서 리플렉션으로 실제로 실행해야되는 메서드들의 정보를 가지고 있습니다. 

 

    private void initHandlerMappings() {
        String[] beanNames = applicationContext.getBeanDefinitionNames();
        
        for (String beanName : beanNames) {
            Object bean = applicationContext.getBean(beanName);
            Class<?> beanClass = bean.getClass();
            
            if (beanClass.isAnnotationPresent(Controller.class)) {
                String baseMapping = "";
                if (beanClass.isAnnotationPresent(RequestMapping.class)) {
                    RequestMapping requestMapping = beanClass.getAnnotation(RequestMapping.class);
                    baseMapping = requestMapping.value();
                }
                
                Method[] methods = beanClass.getDeclaredMethods();
                for (Method method : methods) {
                    if (method.isAnnotationPresent(RequestMapping.class) ||
                        method.isAnnotationPresent(GetMapping.class) || 
                        method.isAnnotationPresent(PostMapping.class)) {
                        
                        String path = getMethodPath(method);
                        String fullPath = baseMapping + path;
                        String httpMethod = getHttpMethod(method);
                        
                        String key = httpMethod + ":" + fullPath;
                        handlerMappings.put(key, new HandlerMapping(bean, method, fullPath));
                        
                        System.out.println("Mapped [" + httpMethod + " " + fullPath + "] -> " + 
                                         beanClass.getSimpleName() + "." + method.getName());
                    }
                }
            }
        }
    }

 

DispatcherServlet이 처음 생성되는 시점인 런타임 때 RequestMapping, GetMapping, PostMapping이 붙어있는 메서드들을 HandlerMapping에 저장합니다. 

 

이후 Request에서 어떤 HTTP Method인지, 어떤 URL Path 인지에 대한 값을 이용해서 해당 값을 key로 HandlerAdapter가 어떤 컨트롤러에 어떤 메서드를 실행해야하는지를 위해 저장해놓는 값들입니다. 

 

    @Override
    public Object handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        
        HandlerMapping handlerMapping = (HandlerMapping) handler;
        Object controllerInstance = handlerMapping.getHandler();
        Method controllerMethod = handlerMapping.getMethod();
        
        System.out.println("RequestMappingHandlerAdapter: Executing " + 
                          controllerInstance.getClass().getSimpleName() + "." + controllerMethod.getName());
        
        // 확장된 매개변수 해결: @PathVariable, @RequestParam, @RequestBody 지원
        Object[] methodArgs = resolveMethodArguments(controllerMethod, request, response, handlerMapping);
        
        // Controller 메소드 실행
        return controllerMethod.invoke(controllerInstance, methodArgs);
    }

 

위의 코드는 HandlerAdapter의 구현체 중 제가 만든 RequestMappingHandlerAdapter입니다. 이 구현체에서 리플렉션을 이용해 실제 resolveMethodArguments라는 메서드를 이용해서 메서드 값들을 가져와 Controller의 메서드를 실행합니다. 

 

 

  1. Request 가 들어온다
  2. DispatcherServlet이 Request Method와 URL Path를 key로 하는 HandlerMapping을 가져온다. 
  3. DispatcherServlet이 HandlerMapping에 있는 값을 이용해서 HandlerAdapter를 이용해 실제 메서드를 실행시키고 리턴값이 ModelAndView인지 아닌지에 따라 분기를 태운다.
    1. ModelAndView인 경우 ViewResolver를 이용해서 View를 만들어 JSP를 렌더링한다
    2. ModelAndView가 아닌 경우 ObjectMapper를 이용해서 JSON으로 만들어 리턴한다. 

 

이렇게 한사이클을 돌면 Request에 의한 응답이 완성됩니다. 

 

트러블 슈팅

Spring MVC 이놈 구현하는데 꽤 고생을 했습니다. 

 

1. 무한루프

  1. View에서 JSP를 렌더링하는 과정에서 forward를 사용하면 StackOverFlow가 발생한다. 
  2. 그래서 include를 사용했더니 이번엔 무한루프는 발생하지 않았지만 HandlerAdapter를 찾을 수 없다고 에러 메세지가 나옴

Spring MVC가 JSP 렌더링을 구현하는 방식은 forward이기 때문에 forward를 사용하긴 해야했는데 무한루프가 발생하기에 첫 요청은 forward로 그 이후 요청이 있는지 확인하고 있다면 include로 빠지게하여 무한루프를 방지했습니다. 

 

2. JSP가 되니까 REST가 안되고 REST가 되니까 JSP가 안됨

모든 요청을 DispatcherServlet이 받는 것이 Spring MVC의 핵심이지만 이는 핸들러가 있는 상황에서만 유효합니다. 

 

Spring MVC에서 DispatcherServlet -> HandlerMapping -> HandlerAdapter -> ViewResolver -> View 으로 이어지는 상황에서 HandlerMapping을 찾았을 때 없으면 다른 서블릿으로 위임하는 과정이 필요했습니다. 

 

그래서 delegateToNextServlet이라는 메서드를 이용해서 HandlerMapping이 없는 상황에선 다음 서블릿으로 넘어가주는 로직을 추가했습니다. 

 

사실 추가한게 아니구요. Spring MVC에서는 실제로 다음 서블릿으로 위임하는 과정이 있더군요. 제가 AI를 잘 활용하지 못한것이죠..

 

마치며

저번 포스팅에선 Spring 에서 가장 중요한 빈을 등록하고 관리하는 과정을 개발했다면 이번엔 Spring Framework에서 큰 부분을 차지하고 있는 Spring MVC를 구현해봤습니다. 

 

물론 제가 A to Z 전부 구현한건 아니지만 AI가 만들어준 코드를 이해하기 위해 열심히 노력했습니다. 

 

영한님이 Spring MVC를 설명해주실 때 그냥 달달 외우기만 했던 개념들이라 사실 이해하고있다고 얘기할 수 없었는데 이번에 구현하면서 흐름정도는 완벽하게 이해한 것 같습니다. 

 

DispatcherServlet에서 View까지 이르는 흐름만 익혔다면 사실 이번 포스팅의 목적은 거의 다 이뤘다고 할 수 있죠. 

 

세부적인 구현같은 것들은 아직 완벽하게 이해하진 못했지만 사실 구현이 중요하다고는 생각하지 않습니다. Spring이 어떻게 자바라는 객체지향 언어를 십분 활용하는지, 어떤 흐름으로 프레임워크를 만들었는지, 끝까지 놓지 못하는 중요한 철학은 무엇인지 이런것들을 학습하는 것이 이번 프로젝트의 목표라고 생각합니다. 

 

이번 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~