서블릿 컨테이너 수동 조립
Tomcat 커넥터·컨텍스트·서블릿 매핑을 직접 구성해 내장 서버의 생명 주기와 Spring MVC 연결 책임을 확인합니다.
내장 서버도 안을 들여다보면 커넥터(connector)와 서블릿 컨테이너(servlet container)로 이루어져 있습니다. Tomcat을 직접 띄워 보면 HTTP 소켓, 컨텍스트 경로, 서블릿 등록, 시작과 중지를 각각 누가 맡는지 드러납니다. Boot의 ServletWebServerApplicationContext와 웹 서버 팩토리 커스터마이저는 이 반복을 일관되게 조립해 주는 추상화입니다.
예제를 따라 하려면 프로젝트에 Tomcat 내장 라이브러리와 jakarta.servlet API가 서로 맞는 조합으로 준비돼 있어야 합니다. 아래 HelloServlet과 ManualTomcat은 이 클래스 경로에서 따로 실행하는 수동 예제이며, BoardApplication과 함께 자동으로 시작되는 설정이 아닙니다. 본문의 8080은 예제를 위한 값이고, 테스트에서는 포트 0으로 시작한 뒤 실제로 선택된 포트를 씁니다.
MANUAL TOMCAT · SETUP AND REQUEST
수동 서버는 /healthz를 HelloServlet에 직접 연결한다
본문 ManualTomcat은 DispatcherServlet을 사용하지 않습니다. 먼저 컨텍스트와 서블릿 매핑을 조립한 뒤 서버를 시작하고 실제 HTTP 요청으로 연결을 확인합니다.
- 실제 포트 → /healthz 매핑 → HelloServlet
본문은 8080으로 시작합니다. 테스트에서 포트0을 쓰면 시작 뒤 실제 localPort를 읽어 GET 요청하고 200·UTF-8 board-ok를 확인합니다.
조립과 종료 · 리소스 소유
- 시작 전에 매핑 등록
base dir·connector·root context·HelloServlet과 /healthz 매핑을 준비합니다.
- start → 요청 → stop/destroy
실제 포트로 요청하고 종료 후 새 연결 거절과 서버 스레드 종료를 확인합니다. 부분 초기화 실패도 정리합니다.
실패 위치와 MVC 확장
포트 BindException과 매핑 누락 404를 구분합니다. MVC로 확장할 때는 웹 Spring context, DispatcherServlet 등록·매핑과 handler/adapter/converter가 추가됩니다. 모의 MVC 테스트는 실제 소켓을 증명하지 않습니다.
설정 순서와 요청 경로는 서로 다른 흐름입니다. 서블릿은 여러 요청에서 공유될 수 있으므로 요청 상태를 필드에 저장하지 않습니다.
그림의 위쪽은 서버가 시작된 뒤 실제 포트로 들어온 GET /healthz가 커넥터와 루트 컨텍스트의 /healthz 매핑을 거쳐 HelloServlet.doGet에 닿아 200과 board-ok를 돌려주는 경로입니다. 이 수동 코드에는 Spring MVC의 DispatcherServlet과 컨트롤러가 없습니다. 아래 칸들은 시작 전에 기반 디렉터리, 커넥터, 루트 컨텍스트, 서블릿 매핑을 준비하는 단계와, 시작·요청·중지·파기로 이어지는 리소스 정리, 그리고 포트 바인딩 실패와 매핑 누락 404를 구분해야 한다는 점을 정리합니다.
커넥터와 컨테이너의 역할
서버 쪽의 커넥터는 주소, 포트, 프로토콜, 연결 대기열을 관리하고, 서블릿 컨테이너는 URL 매핑, 필터 체인, 세션, 서블릿의 생명 주기를 관리합니다. 둘을 Tomcat이라는 이름 하나로 뭉뚱그리면, 포트 바인딩 실패와 매핑이 없어서 생긴 404를 구분하기 어렵습니다.
package board.manual;
import java.io.IOException;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public final class HelloServlet extends HttpServlet {
@Override
protected void doGet(
HttpServletRequest request,
HttpServletResponse response
) throws IOException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("board-ok");
}
}컨테이너는 하나의 서블릿 객체를 여러 스레드에서 동시에 호출할 수 있으므로, 요청별 값을 필드에 저장하지 않습니다. 응답이 커밋되기 전에 상태 코드와 콘텐츠 타입을 정합니다.
Tomcat 수동 조립
다음 메인 메서드는 임시 기반 디렉터리와 루트 컨텍스트를 만들고, 서블릿을 /healthz에 등록합니다. getConnector()를 호출하면 기본 설정의 커넥터가 만들어집니다.
package board.manual;
import java.nio.file.Files;
import org.apache.catalina.Context;
import org.apache.catalina.startup.Tomcat;
public final class ManualTomcat {
public static void main(String[] args) throws Exception {
var base = Files.createTempDirectory("board-tomcat");
var tomcat = new Tomcat();
tomcat.setBaseDir(base.toString());
tomcat.setPort(8080);
tomcat.getConnector();
Context context = tomcat.addContext("", base.toString());
Tomcat.addServlet(context, "health", new HelloServlet());
context.addServletMappingDecoded("/healthz", "health");
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
tomcat.stop();
tomcat.destroy();
} catch (Exception failure) {
failure.printStackTrace(System.err);
}
}));
tomcat.start();
System.out.println("server=http://127.0.0.1:8080/healthz");
tomcat.getServer().await();
}
}코드 가운데의 addShutdownHook은 JVM이 종료 절차를 시작할 때 실행할 스레드, 곧 종료 훅(shutdown hook)을 등록합니다. 여기서는 Ctrl+C 같은 종료 신호로 JVM이 끝날 때 이 훅이 Tomcat을 중지하고 파기합니다. 훅이 실행되는 경우와 실행되지 않는 경우는 Java 교재의 단계적 서버 종료에서 다룹니다. 마지막 줄의 await()는 서버가 중지될 때까지 main 스레드를 그 자리에서 기다리게 합니다.
addContext의 빈 문자열은 루트 컨텍스트를 뜻하므로, 서버가 시작된 뒤 GET /healthz는 등록한 HelloServlet.doGet으로 전달됩니다. 시작 메시지와 요청 결과를 함께 적으면 다음과 같습니다.
server=http://127.0.0.1:8080/healthz
GET /healthz -> 200
body=board-ok여기에는 Spring의 DispatcherServlet이나 컨트롤러 빈이 없으므로, 이 응답은 MVC의 핸들러 매핑이 아니라 Tomcat의 서블릿 매핑을 확인한 결과입니다. 등록하지 않은 /missing을 요청하면 같은 서버가 404를 돌려줍니다. 반대로 포트 바인딩 단계에서 시작이 실패하면, /missing에 404를 돌려줄 서버 자체가 없습니다. 연결 실패와 HTTP 404를 같은 매핑 오류로 묶지 않습니다.
포트를 0으로 지정하면 운영체제가 비어 있는 포트를 고르고, 시작한 뒤 커넥터에서 그 실제 로컬 포트를 읽을 수 있습니다. 테스트끼리 포트가 겹치지 않게 하는 데 쓰입니다.
Spring MVC를 연결하는 요소
Spring MVC를 이 서버에 올리려면, 애노테이션 기반 웹 애플리케이션 컨텍스트에 컨트롤러, 핸들러 매핑, 어댑터, 메시지 컨버터를 등록하고, 그 컨텍스트를 DispatcherServlet 생성자에 넘겨 서블릿으로 등록합니다. 서블릿 컨텍스트의 생명 주기와 Spring 컨텍스트의 종료도 같은 서버 종료에 묶어야 합니다.
Boot는 MVC 스타터와 자동 구성으로 이 빈 그래프를 제공하고, 서블릿 등록 빈을 구성합니다. 사용자가 DispatcherServlet 빈을 바꾸거나 경로를 바꾸면, 매핑과 오류 디스패치도 함께 검증합니다.
루트 컨텍스트와 자식 서블릿 컨텍스트를 나눌 수도 있지만, 작은 내장 애플리케이션은 웹 컨텍스트 하나로 단순화할 수 있습니다. 디스패처를 여러 개 쓴다면 빈이 어느 컨텍스트에서 보이는지와 어떤 경로를 어느 디스패처가 맡는지를 명시합니다.
더 깊이: web.xml 없이 시작하는 두 가지 경로
외부 서블릿 컨테이너에 WAR로 배포하는 Spring 애플리케이션은 서블릿 명세의 ServletContainerInitializer로 시작합니다. spring-web 모듈은 META-INF/services/jakarta.servlet.ServletContainerInitializer 파일에 SpringServletContainerInitializer를 등록해 두고, 이 클래스에는 @HandlesTypes(WebApplicationInitializer.class)가 붙어 있습니다. 그래서 컨테이너가 시작할 때 클래스 경로의 WebApplicationInitializer 구현을 모아 이 초기화기에 넘기고, 애플리케이션은 그 안에서 지금 본 것처럼 DispatcherServlet을 직접 등록합니다. 컨테이너가 애플리케이션 코드를 찾아 부르는 방향입니다.
Spring Boot의 내장 서버는 방향이 반대입니다. Spring Boot 문서에 따르면 내장 서블릿 컨테이너는 ServletContainerInitializer나 Spring의 WebApplicationInitializer를 직접 실행하지 않으며, WAR 안에서 돌도록 만들어진 서드파티 라이브러리가 Boot 애플리케이션을 깨뜨릴 위험을 줄이려는 의도적인 설계라고 설명합니다. 대신 애플리케이션이 먼저 Spring 컨텍스트를 만들고, 그 과정에서 서버를 띄우며 ServletContextInitializer 빈과 서블릿·필터 빈을 등록합니다. 그래서 WAR 시절의 WebApplicationInitializer 구현을 내장 서버 프로젝트에 그대로 옮기면 아무 일도 일어나지 않으므로, 같은 일을 ServletContextInitializer 빈이나 등록 빈으로 다시 표현해야 합니다.
시작 실패 진단
BindException이 나면 그 포트를 누가 쓰고 있는지 확인하고, 운영 환경에서 임의로 다른 포트로 돌리지 않습니다. 기반 디렉터리를 만들지 못했다면 파일 시스템 권한과 디스크 상태를 봅니다. 서버는 시작했는데 404라면 커넥터보다 컨텍스트 경로와 서블릿 매핑을 먼저 확인합니다.
Spring 컨텍스트의 새로 고침이 실패했다면, Tomcat이 열린 채로 남지 않게 부분적으로 초기화된 것까지 정리합니다. 시작 단계별 리소스는 역순으로 닫습니다. 위 종료 훅에서는 stop()이 예외를 던지면 destroy()에 도달하지 않는다는 점도 기억해 둡니다. 정리를 종료 훅 하나에만 맡기면, 테스트에서 컨텍스트를 반복해 띄울 때 느려집니다.
스레드 풀의 최댓값을 크게 한다고 처리량이 늘어나지는 않습니다. 하위 시스템의 DB 풀, CPU, 요청 타임아웃을 함께 조정합니다. 단계적 종료는 새 요청의 수락을 멈추고, 처리 중인 요청이 제한 시간 안에 끝나기를 기다린 뒤 강제로 종료합니다.
Boot에서 바꿀 수 있는 지점
포트와 압축은 속성으로, 커넥터별 옵션은 웹 서버 팩토리 커스터마이저로 바꿀 수 있습니다. Tomcat 팩토리 빈을 통째로 대체해도 포트, SSL 번들, 단계적 종료 같은 server.* 속성은 Boot의 ServletWebServerFactoryCustomizer가 그 빈에 그대로 적용하고, 서버의 시작과 중지도 컨텍스트가 맡습니다. 대신 자동 구성의 팩토리 메서드가 모아 넣어 주던 TomcatConnectorCustomizer, TomcatContextCustomizer, TomcatProtocolHandlerCustomizer 빈은 직접 만든 팩토리에 연결되지 않으므로, 팩토리를 바꾸기 전에 이런 커스터마이저 빈으로 해결할 수 있는지 먼저 봅니다.
통합 테스트는 임의의 포트로 실제 소켓을 열고 HTTP 상태, 헤더, 본문을 확인합니다. 컨트롤러를 모의 객체로 시험한 것만으로 커넥터의 인코딩, 필터 디스패치, 오류 페이지까지 검증했다고 말하지 않습니다.
수동 서버의 종료까지 확인한다면, 커넥터의 로컬 포트가 0보다 크고, /healthz는 200과 UTF-8 board-ok, 등록하지 않은 /missing은 404여야 합니다. 중지한 뒤 같은 포트로 새로 연결하면 거절되어야 하고, 서버 스레드가 테스트 프로세스를 붙잡아 타임아웃을 일으킨다면 생명 주기 관리가 완전하지 않은 것입니다.
selectedPort > 0 = true
GET /healthz = 200 board-ok
GET /missing = 404
connection after close = refused
remaining non-daemon server threads = 0정리
- 커넥터는 주소와 포트, 프로토콜을, 서블릿 컨테이너는 URL 매핑과 필터, 서블릿 생명 주기를 맡습니다.
- 수동 조립은 기반 디렉터리, 커넥터, 컨텍스트, 서블릿 매핑을 준비한 뒤 시작하고, 중지와 파기를 역순으로 정리합니다.
- 포트 바인딩 실패는 시작 단계의 실패이고, 404는 살아 있는 서버의 매핑 결과이므로 서로 다른 신호로 다룹니다.
- Boot는 이 조립을 자동 구성으로 대신하며, 바꿀 때는 속성과 커스터마이저를 먼저 쓰고 실제 소켓을 여는 테스트로 확인합니다.
연습 문제
1. 포트 0으로 시작하는 테스트 도구
수동 서버를 포트 0으로 시작하고, 실제로 선택된 포트로 GET 요청을 보낸 뒤 정리하는 테스트 도구를 작성하세요. 매핑 누락과 포트 바인딩 실패를 서로 다른 신호로 보고해야 합니다.
해설 보기
서버 리소스를 가진 래퍼를 AutoCloseable로 만들면, 테스트의 try-with-resources 구문에서 close()가 호출됩니다.
package board.manual;
import org.apache.catalina.startup.Tomcat;
public final class RunningTomcat implements AutoCloseable {
private final Tomcat tomcat;
public RunningTomcat(Tomcat tomcat) {
this.tomcat = tomcat;
}
public int localPort() {
return tomcat.getConnector().getLocalPort();
}
@Override
public void close() throws Exception {
tomcat.stop();
tomcat.destroy();
}
}이 순차 구현은 stop()이 예외를 던지면 destroy()에 도달하지 않으므로 정리의 성공까지 보장하지는 않습니다. 또 커넥터가 포트를 열기 전, 곧 start()가 성공하기 전에는 getLocalPort()가 실제 포트 대신 -1을 돌려주므로, 생성자 대신 시작에 성공한 뒤에만 래퍼를 돌려주는 팩토리 메서드로 바꾸면 더 안전합니다.
2. 세 테스트와 정리 실패
실제 소켓으로 실행한다고 가정하고 세 테스트에서 기대할 결과를 쓰세요. A는 시작에 성공한 뒤 선택된 포트로 /healthz를 요청하고, B는 같은 포트로 /missing을 요청하며, C는 다른 서버가 이미 쓰고 있는 고정 포트로 시작하다 실패합니다. 이어서 별도의 정리 실험에서 RunningTomcat의 tomcat.stop()이 예외 E를 던지면, close의 destroy와 try-with-resources 블록 다음의 코드는 어떻게 되나요?
해설 보기
A는 HTTP 200, text/plain;charset=UTF-8, board-ok를, B는 같은 살아 있는 서버의 HTTP 404를 기대합니다. C는 시작 단계의 바인딩 실패이며, HTTP 응답 상태와 구별해 보고합니다.
정리 실험에서 close는 stop 다음 줄로 가지 못하므로 destroy는 0회 호출되고, E가 try-with-resources 밖으로 전달되어 블록 다음의 코드는 정상적으로 이어지지 않습니다. try-with-resources는 close를 호출해 준다는 보장일 뿐, 그 안의 두 정리가 모두 성공한다는 보장이 아닙니다. 개선할 때는 stop이 실패해도 destroy를 시도하되, 처음 실패 E를 유지하고 두 번째 정리 실패는 억제된 예외로 남기는 동작을 따로 시험합니다.
3. 컨텍스트 경로를 바꾸면
ManualTomcat에서 tomcat.addContext("", ...)를 tomcat.addContext("/board", ...)로 바꾸고 다른 코드는 그대로 둡니다. 서버가 시작된 뒤 GET /healthz와 GET /board/healthz는 각각 어떤 결과를 받나요?
해설 보기
서블릿 매핑 /healthz는 컨텍스트 경로 /board 아래에 놓이므로, GET /board/healthz가 HelloServlet에 닿아 200과 board-ok를 받습니다. GET /healthz는 어느 컨텍스트에도 속하지 않으므로 404를 받습니다.
이때도 서버는 살아 있고 포트 바인딩은 성공했으므로, 이 404는 시작 실패가 아니라 경로 구성의 문제입니다. 404를 보면 커넥터보다 컨텍스트 경로와 서블릿 매핑을 먼저 확인하라는 앞 절의 진단 순서가 이런 경우에 쓰입니다.
다음 글 내장 Tomcat·실행 JAR에서는 Boot가 이 조립을 대신하는 내장 Tomcat 구성과, 애플리케이션을 하나의 실행 JAR로 묶어 실행하는 구조를 다룹니다.