Servlet 명세, Tomcat, Spring MVC는 어디서 갈라지는가


Servlet은 인터페이스 묶음이다. Tomcat은 그 명세의 “컨테이너 쪽”을 구현한 것이고, Spring MVC는 그 명세의 “애플리케이션 쪽”을 DispatcherServlet 하나로 대신 구현한 것이다. 그래서 Spring 웹 앱의 요청 처리는 Tomcat > Servlet 계층 > Spring MVC 계층 > 내 코드 네 겹으로 되어 있다. 이 글은 그 경계를 찾다가 나온 질문 여섯 개를 정리한 것이다.

Q1. Spring은 어떤 구조로 요청을 받나

브라우저
  │ HTTP

Tomcat (Servlet 컨테이너)          ← 소켓, 스레드, HTTP 파싱
  │ HttpServletRequest / Response

Filter 체인                        ← Servlet 표준. Spring Security도 여기

DispatcherServlet                  ← Spring MVC의 유일한 Servlet
  ├─ HandlerMapping   : URL → 어떤 @Controller 메서드?
  ├─ HandlerAdapter   : @RequestBody, @PathVariable 바인딩 후 호출
  ├─ HandlerInterceptor : preHandle / postHandle
  └─ HttpMessageConverter : 반환값 → JSON

@Controller → @Service → @Repository   ← Spring IoC 컨테이너 영역

GET /users/1 한 건을 따라가면 이렇다.

  1. Tomcat이 HTTP를 파싱해 HttpServletRequest를 만들고 스레드 풀에서 스레드 하나를 꺼낸다.
  2. Filter 체인을 통과한다.
  3. DispatcherServlet.doDispatch()에 들어간다. HandlerMapping/users/{id}에 매핑된 컨트롤러 메서드를 찾는다.
  4. HandlerAdapter"1"Long id로 바꿔 메서드를 호출한다.
  5. 반환된 객체를 Jackson이 JSON으로 써서 HttpServletResponse에 넣는다. 스레드는 풀로 돌아간다.

Spring Boot는 이 위에 “Tomcat 내장 + 자동 설정”을 얹은 것이다.

Q2. Spring, Servlet, Tomcat, Spring MVC의 의존 방향은

한 방향뿐이다.

        Servlet API (jakarta.servlet-api)  ← 인터페이스만 있는 jar
              ▲                    ▲
    구현한다   │                    │  사용한다
              │                    │
           Tomcat              Spring MVC (spring-webmvc)


                              Spring Core (spring-context, spring-beans)
                              ← Servlet도 Tomcat도 모른다
  • Tomcat과 Spring MVC는 서로 모른다. 둘 다 Servlet API만 본다. 그래서 Tomcat을 Jetty로 바꿔도 Spring 코드는 안 바뀐다.
  • Spring Core는 웹을 모른다. 배치 앱은 Servlet 없이 ApplicationContext만 띄운다.
  • 런타임에서 넷이 만나는 접점은 하나다. Tomcat이 ServletContainerInitializer를 호출하면 Spring이 DispatcherServlet을 만들어 servletContext.addServlet()으로 등록한다. 이후 Tomcat 입장에서 그것은 그냥 HttpServlet 하나다.
  • Boot는 기동 방향만 뒤집는다. main()에서 Spring이 먼저 뜨고, Spring이 new Tomcat()을 해서 띄운다. 의존 명세는 그대로다.

Q3. Servlet이 정말 인터페이스인가

그렇다. jakarta.servlet-api jar를 열면 이런 것뿐이다.

public interface Servlet {
    void init(ServletConfig config);
    void service(ServletRequest req, ServletResponse res);
    void destroy();
}
public interface HttpServletRequest extends ServletRequest { ... }
public interface Filter { ... }
public abstract class HttpServlet extends GenericServlet { ... }  // doGet 등 편의 메서드

소켓을 열고 HTTP를 읽어 service()를 호출하는 코드는 한 줄도 없다. 그 빈칸을 채우는 것이 컨테이너다.

정밀하게 말하면 같은 명세를 양쪽에서 구현한다.

인터페이스 누가 구현 누가 호출
Servlet, Filter 내 코드, Spring (DispatcherServlet extends HttpServlet) Tomcat
HttpServletRequest, HttpSession, ServletContext Tomcat (RequestFacade 등) 내 코드, Spring

컨트롤러에서 request.getHeader()를 부르면 Tomcat 클래스가 실행된다. Tomcat이 servlet.service()를 부르면 Spring 코드가 실행된다. JDBC와 같은 패턴이다. Servlet API가 java.sql.Connection이고, Tomcat/Jetty/Undertow가 MySQL/PostgreSQL 드라이버이고, Spring MVC가 Spring JDBC다.

패키지가 javax.servlet에서 jakarta.servlet으로 바뀐 것은 2019년 Oracle에서 이클립스 재단으로 명세가 이관됐기 때문이다. Spring 6, Boot 3부터 jakarta만 쓴다.

Q4. 스레드 관리도 Servlet 명세에 있나

없다. 명세는 “service()는 여러 스레드에서 동시에 호출될 수 있다”는 계약만 정한다. 스레드 생성, 배정, 회수는 컨테이너 몫이다.

Tomcat 내부는 스레드 세 종류로 나뉜다.

[Acceptor]  ── 소켓 accept()만 (기본 1개)

[Poller]    ── NIO Selector로 읽을 데이터 있는 연결 감시 (기본 1개)

[Worker 풀] ── HTTP 파싱 → Filter → DispatcherServlet → 응답 (기본 max 200)
              ↑ 이 스레드가 내 컨트롤러 코드를 실행한다
  • 커넥션 수와 스레드 수는 별개다. Keep-Alive 연결 1만 개가 있어도 Poller가 감시만 하고, 요청이 온 순간에만 Worker를 꺼낸다. maxConnections(8192)와 maxThreads(200)가 따로 있는 이유다.
  • 컨트롤러에서 Thread.sleep(5000)을 하면 그 5초 동안 Worker 하나가 묶인다. “블로킹 I/O가 스레드를 잡아먹는다”의 실체다.
  • Boot의 server.tomcat.threads.max는 Spring 설정이 아니라 Spring이 Tomcat에 전달해주는 값이다. Jetty로 바꾸면 키가 server.jetty.threads.*로 바뀐다.
  • Java 21 + Boot 3.2의 spring.threads.virtual.enabled=true는 Worker 풀을 가상 스레드로 바꾼다. 명세는 그대로이고 컨테이너 구현만 바뀌므로 컨트롤러 코드는 안 건드린다.

세션 저장과 만료, 클래스로더 격리, HTTP/2와 TLS, 정적 파일 서빙, graceful shutdown도 전부 명세에는 “해라”만 있고 방법은 컨테이너가 정한다.

Q5. 그러면 Spring 앱의 본체는 Tomcat인가

반은 맞다. 요청을 받아 스레드 위에서 실행시키는 실행 엔진은 Tomcat이다. 하지만 어떤 객체를 어떻게 조립해서 실행할지 정하는 것은 Spring이다.

질문
프로세스의 스레드는 누구 것인가 Tomcat
main()은 누구 것인가 Spring. Spring이 Tomcat을 만든다
이 앱을 배치로 바꾸면 남는 것은 Spring. Tomcat은 사라진다
코드 안 바꾸고 교체 가능한 쪽은 Tomcat

교체 가능한 쪽은 본체가 아니다. Spring 앱에서 Tomcat은 “웹이라는 입출력 방식을 담당하는 플러그인”에 가깝다.

몰랐던 이유는 Spring MVC의 설계 목표가 “개발자가 Servlet과 컨테이너를 의식하지 않게 하기”였고, 그것이 성공했기 때문이다. 이 구분이 실무에서 쓰이는 곳은 이렇다.

  • 타임아웃이 두 군데인 이유. server.tomcat.connection-timeout은 Tomcat 소켓 레벨, spring.mvc.async.request-timeout은 Spring 레벨이다.
  • 요청이 컨트롤러에 안 들어올 때. 로그가 DispatcherServlet 전에 끊기면 Tomcat이나 Filter 문제, 후에 끊기면 Spring 문제다.
  • Worker 200개가 느린 DB 호출에 묶이면 Tomcat이 새 요청을 못 받는다. Spring 로그에는 아무것도 안 남는다.
  • @WebMvcTest + MockMvc는 Tomcat을 안 띄운다. 그래서 빠르다.

Q6. Filter와 Interceptor는 어느 층인가

Filter는 Servlet 명세 객체이고 Tomcat이 호출한다. Interceptor는 Spring 객체이고 DispatcherServlet이 호출한다. 코드는 둘 다 내가 짜지만 “누가 나를 부르느냐”가 다르다.

┌─ Tomcat ─────────────────────────────────────────────┐
│  소켓, HTTP 파싱, 스레드 배정, Request/Response 생성   │
│  ┌─ Servlet 계층 (Tomcat이 호출) ──────────────────┐ │
│  │  Filter 1 → Filter 2 → Servlet.service()        │ │
│  │   ┌─ Spring MVC 계층 (DispatcherServlet 내부) ─┐│ │
│  │   │  HandlerMapping → Interceptor.preHandle    ││ │
│  │   │   ┌─ 내 코드 ────────────────┐             ││ │
│  │   │   │ Controller → Service     │             ││ │
│  │   │   └──────────────────────────┘             ││ │
│  │   │  MessageConverter → Interceptor.postHandle ││ │
│  │   └────────────────────────────────────────────┘│ │
│  └─────────────────────────────────────────────────┘ │
│  응답 바이트 쓰기, 스레드 반환                          │
└──────────────────────────────────────────────────────┘
항목 Filter Interceptor
정의 jakarta.servlet.Filter HandlerInterceptor (Spring)
호출 주체 Tomcat DispatcherServlet
@ControllerAdvice 못 잡음. Tomcat 에러 페이지로 감 잡음
적용 대상 정적 파일, 404 포함 전부 DispatcherServlet에 매핑된 것만

예외 처리 줄이 가장 자주 발목을 잡는다. 인증 Filter에서 예외를 던지면 @ExceptionHandler가 안 잡히고 Tomcat 기본 500이 나간다. 그래서 Spring Security는 Filter 안에서 직접 response.setStatus(401)을 쓴다.

Filter가 @Component로 등록되는 것은 Boot가 중개하기 때문이다. 기동 시 Filter 타입 빈을 찾아 servletContext.addFilter()로 Tomcat에 등록해준다. 호출은 Tomcat이 하지만 인스턴스는 Spring이 만든 것이라 주입이 살아 있다. Spring Security는 Tomcat에 DelegatingFilterProxy 하나만 등록하고 그 안에서 자기 필터 체인 15개를 돌린다.

한 층 더 안쪽에 AOP가 있다. @Transactional은 Interceptor보다 안쪽, 빈 메서드 호출 지점에 있고 HTTP를 모른다. 그래서 배치에서도 동작한다.

정리: 명세 · Tomcat · Spring 3분할

구분 Servlet 명세 Tomcat 구현 Spring 구현
정체 인터페이스, 추상클래스, 규칙 문서 명세의 “컨테이너 측” 명세의 “애플리케이션 측” + 그 위의 프레임워크
요청/응답 객체 HttpServletRequest 인터페이스 RequestFacade 사용만 함
Servlet Servlet, HttpServlet StandardWrapperservice() 호출 DispatcherServlet
Filter Filter, FilterChain ApplicationFilterChain이 순서대로 호출 OncePerRequestFilter, Security 필터
스레드, 소켓, HTTP 없음 NioEndpoint, Http11Processor 없음
라우팅 url-pattern으로 Servlet 고르기까지 Mapper HandlerMapping이 메서드까지
바인딩, JSON, 예외 변환 없음 없음 ArgumentResolver, MessageConverter, ExceptionResolver

Spring 코드 중 jakarta.servlet 타입을 구현하는 곳은 셋뿐이다. DispatcherServlet, Filter 구현체들, SpringServletContainerInitializer. 나머지 Spring MVC 전체는 명세와 무관한 일반 자바 코드다. 예외는 Boot가 new Tomcat()을 하는 TomcatServletWebServerFactory로, 여기만 명세 밖 Tomcat 고유 API를 쓴다.

스택트레이스를 아래에서 위로 읽으면 이 층이 그대로 보인다.

at com.example.UserController.get(...)                          ← 내 코드
at org.springframework.web.servlet.DispatcherServlet.doDispatch  ← Spring MVC
at org.springframework.web.servlet.FrameworkServlet.service      ← Spring MVC (Servlet 구현)
at jakarta.servlet.http.HttpServlet.service(...)                 ← Servlet API
at org.apache.catalina.core.ApplicationFilterChain.doFilter      ← Tomcat (Filter 체인)
at org.apache.coyote.http11.Http11Processor.service              ← Tomcat (HTTP 파싱)
at java.base/java.lang.Thread.run(...)                           ← Tomcat Worker 스레드

패키지가 org.apache.catalinajakarta.servletorg.springframework.web.servlet → 내 패키지로 바뀌는 지점이 각 층의 경계다.