[UMC] 서블릿 VS 스프링 MVC

Backend·2025-09-23·by 1000hyehyang

UMC 2주차 미션 첫 번째는 서블릿과 스프링 MVC 비교하기. 사실 스프링을 뜯어보면서 개념을 파악하는 건 여전히 어렵다. 그럼에도 항상 기본부터 충실하라는 말처럼 이번 기회에 개념을 다잡고 가려고 한다! 과제를 통해 다시 정리해 볼 기회를 준 UMC에게 감사하다!

서블릿(Servlet)에서 Spring MVC까지: 왜 우리는 DispatcherServlet을 쓰게 되었을까?

웹 개발을 처음 접하면 흔히 “서블릿(Servlet)”이라는 개념을 마주한다. 서블릿은 자바 진영에서 HTTP 요청을 처리하기 위한 기본 단위다.

공식 스펙(Jakarta Servlet 6.0)에 따르면,

“Servlet은 요청-응답 프로그래밍 모델을 따르는 서버 애플리케이션을 확장하기 위해 작성된 자바 클래스다.”

즉, 서버가 클라이언트 요청을 받아 동적으로 응답을 생성하도록 해주는 표준 규격이다.

HttpServlet을 상속받아 doGet()이나 doPost() 같은 메서드를 오버라이드하면, 브라우저에서 들어온 요청을 직접 읽어 처리할 수 있다. 요청 파라미터를 꺼낼 때는 request.getParameter()를 쓰고, 응답을 돌려줄 때는 response.getWriter()를 사용한다.

@WebServlet(name = "helloServlet", urlPatterns = "/hello-servlet")
public class HelloServlet extends HttpServlet {
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String name = req.getParameter("name");
        if (name == null || name.isBlank()) name = "World";

        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().write("Hello, " + name + " from Servlet!");
    }

    @Override
    protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        String message = req.getParameter("message");
        resp.setContentType("text/plain;charset=UTF-8");
        resp.getWriter().write("You posted: " + message);
    }
}

doGet()은 주소창 파라미터를 통해 데이터를 받아오고, doPost()는 HTML 폼이나 JSON 본문에서 데이터를 받아올 때 주로 사용된다. 전통적인 방식에서는 이 두 메서드를 직접 구현해 요청을 해석하고 응답을 작성해야 했다.

 

서블릿의 불편함과 발전 과정

이 방식은 단순해 보이지만, 조금만 기능이 복잡해지면 문제가 터진다.

  • URL 매핑을 모두 직접 관리해야 한다.
  • 파라미터를 일일이 꺼내야 한다.
  • 공통 기능(예외 처리, 로깅, 인증 등)을 모든 서블릿마다 반복해야 한다.

그래서 전통적으로는 web.xml 안에 서블릿 매핑을 일일이 적어주었다.

<!-- web.xml -->
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
                             https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">

    <!-- 서블릿 등록 -->
    <servlet>
        <servlet-name>helloServlet</servlet-name>
        <servlet-class>com.example.servlet.HelloServlet</servlet-class>
    </servlet>

    <!-- 서블릿 매핑 -->
    <servlet-mapping>
        <servlet-name>helloServlet</servlet-name>
        <url-pattern>/hello-servlet</url-pattern>
    </servlet-mapping>

</web-app>

Spring 등장 전, 서블릿/JSP 시절의 전통적인 매핑 방법으로 xml 파일 안에 모든 url을 매핑해줘야 했다...

즉, 순수 서블릿 시절에는 URL과 서블릿 클래스를 직접 매핑해야 했고, 화면 출력은 JSP로 처리하다 보니 뷰와 로직이 섞여 스파게티 코드가 되곤 했다.

 

이런 불편함을 해결하기 위해 등장한 것이 바로 Spring MVC다. Spring MVC는 사실 서블릿 위에 만들어진 프레임워크라고 생각하면 된다. 핵심에는 DispatcherServlet이라는 프론트 컨트롤러가 자리 잡고 있다.

이제는 요청이 들어오면 각 서블릿이 직접 처리하는 대신, 무조건 DispatcherServlet을 거치게 된다. 이 Servlet 하나가 문지기 역할을 하면서, 어떤 컨트롤러로 요청을 보낼지 결정해주는 것이다. 개발자는 더 이상 doGet() 안에서 요청을 직접 파싱하지 않는다. 대신 @Controller@RequestMapping 같은 애노테이션으로 “이 URL이 들어오면 이 메서드를 실행해줘”라고 선언하기만 하면 된다.

@Controller
public class HelloController {

    @GetMapping("/hello")
    @ResponseBody
    public String hello(@RequestParam(defaultValue = "World") String name) {
        return "Hello, " + name + " from Spring MVC!";
    }

    @PostMapping("/hello")
    @ResponseBody
    public String postHello(@RequestParam String message) {
        return "You posted: " + message;
    }
}

➡ @GetMapping, @PostMapping만 달아주면 GET/POST 요청을 자동으로 구분해서 처리할 수 있다. 파라미터 바인딩도 알아서 해주기 때문에 개발자가 request.getParameter() 같은 저수준 API를 다룰 필요가 없다.

예를 들어 /hello라는 요청이 들어온다고 해보자. 전통적인 서블릿 방식에서는 web.xml에 URL과 서블릿을 매핑하고, 서블릿 안에서 doGet()을 구현해 직접 이름 파라미터를 꺼내서 출력했을 것이다.

하지만 Spring MVC에서는 @GetMapping("/hello")라고 선언한 메서드를 만들어두면, DispatcherServlet이 알아서 요청을 해당 메서드로 전달한다. 그 과정에서 파라미터 바인딩도 자동으로 이루어지고, 반환 값이 문자열이라면 뷰 리졸버(ViewResolver)가 JSP나 타임리프 같은 뷰로 연결해 렌더링 해준다. 만약 @ResponseBody가 붙어 있다면 JSON으로 변환해 응답을 만들어주기도 한다.

 

그렇다면 DispatcherServlet은 내부에서 어떤 일을 할까?

DispatcherServlet

클라이언트 요청이 들어오면 DispatcherServlet이 가장 먼저 받아낸다. 그리고 HandlerMapping에게 물어본다. “이 요청을 처리할 컨트롤러가 누구니?” 그러면 HandlerMapping이 URL과 HTTP 메서드 정보를 보고 적절한 핸들러(즉, 컨트롤러 메서드)를 찾아 반환한다.

컨트롤러를 찾았다면, 이제 HandlerAdapter가 나선다. HandlerAdapter는 단순히 컨트롤러 메서드를 호출하는 역할 이상을 한다. 메서드에 선언된 파라미터들을 자동으로 바인딩해주고, 요청 본문이 JSON이면 HttpMessageConverter를 이용해 객체로 변환하는 작업까지 해준다.

컨트롤러 메서드가 실행된 후에는 반환값을 어떻게 응답으로 바꿀지 고민해야 할 것이다. 뷰 이름을 문자열로 반환했다면 ViewResolver가 이를 실제 뷰 파일로 찾아가 렌더링한다. JSON 같은 데이터를 직접 반환했다면 HttpMessageConverter가 객체를 직렬화해 응답 본문으로 내려보낸다. 이 모든 과정이 끝나면 최종적으로 브라우저에 응답이 도착한다.

 

Filter와 Interceptor

여기서 또 하나 꼭 짚고 넘어가야 할 개념이 있다. 바로 Filter와 Interceptor다.

Filter는 서블릿 표준에 정의된 개념으로, DispatcherServlet이 실행되기 전후에 동작한다. 웹 요청이 톰캣 같은 서블릿 컨테이너에 도착하면 제일 먼저 필터 체인을 거치게 되고, 그다음에 DispatcherServlet으로 넘어간다. 그래서 보통 인코딩 처리, 보안(XSS 방어), 공통 로깅 같은 저수준 단에서 공통 기능을 적용할 때 쓴다. 스프링과 무관하게 컨테이너 레벨에서 동작한다는 게 포인트다.

Interceptor는 스프링 MVC가 제공하는 기능이다. DispatcherServlet 이후에 컨트롤러가 실행되기 전(preHandle), 실행 직후(postHandle), 그리고 요청이 완전히 끝난 뒤(afterCompletion)에 동작한다. 그래서 로그인 여부 확인, 세션 검증, 공통 로그 같은 걸 처리할 때 자주 쓴다.

즉, Filter는 서블릿 스펙, Interceptor는 스프링 MVC 확장 기능이라는 차이를 기억하면 된다.

@Component
public class LoggingInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        System.out.println("[preHandle] " + request.getMethod() + " " + request.getRequestURI());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        System.out.println("[afterCompletion] status=" + response.getStatus());
    }
}

정리해보면, 서블릿은 저수준 API를 직접 다루는 방식이고, Spring MVC는 DispatcherServlet이라는 중앙 허브가 요청과 응답 과정을 표준화하고 자동화한 방식이다. 덕분에 개발자는 비즈니스 로직에만 집중할 수 있고, 공통 기능은 프레임워크가 알아서 처리하는 것이다.

댓글 로딩 중...