Spring Boot 내장 서버는 어떻게 뜨는가
Tomcat·Jetty·Undertow 아키텍처 차이부터 ServletWebServerFactory 초기화 경로, SSL/TLS·HTTP/2·다중 포트 설정까지, 내장 서버의 전체 생명주기를 추적한다.
- 01 SpringApplication.run()은 한 줄인데 내부에서 무슨 일이 벌어지는가
- 02 Spring Boot Auto-configuration은 어떻게 스스로를 조립하는가
- 03 Spring Boot 설정은 어떻게 주입되는가
- 04 Spring Boot Actuator는 어떻게 작동하는가
- 05 Spring Boot 내장 서버는 어떻게 뜨는가
- 06 Spring Boot DevTools는 어떻게 개발 루프를 단축하는가
- 07 Spring Boot 앱은 어떻게 실행되는가
java -jar app.jar 한 줄로 서버가 뜨는 건 당연해 보인다. 그런데 그 안에서 Tomcat은 어떤 순서로 초기화되고, SSL 설정은 어느 시점에 Connector에 꽂히며, server.port=0은 OS에서 어떻게 포트를 받아오는가? 내장 서버의 생명주기를 한 번도 끝까지 따라가본 적 없다면, 장애 상황에서 “왜 안 뜨지?”를 해결할 실마리를 갖고 있지 않은 것이다.
세 서버, 하나의 인터페이스
Spring Boot는 Tomcat(기본), Jetty, Undertow를 모두 WebServer 인터페이스 — start(), stop(), getPort() — 로 동일하게 다룬다. 세 서버의 차이는 이 인터페이스 뒤에 숨어 있다.
Tomcat은 AcceptorThread → Poller → ThreadPool의 3계층 구조다. 요청 하나당 스레드 하나를 점유하는 thread-per-request 모델이고, 기본 최대 스레드는 200개다. 스레드당 약 1MB 스택이므로 200개면 최소 200MB가 스레드에만 묶인다.
Undertow는 XNIO 기반으로 IO Thread와 Worker Thread를 분리한다. IO Thread(기본 CPU×2)는 Non-blocking 이벤트 루프만 담당하고, 블로킹 서블릿 처리는 Worker Thread(기본 CPU×8)로 넘긴다. 메모리 풋프린트가 세 서버 중 가장 낮다.
Jetty는 NIO 기반 ServerConnector와 Handler 체인 구조다. OSGi·WebSocket 지원이 강하고, 임베디드 사용에서 커스터마이징 유연성이 높다.
서버 교체는 의존성 스왑 한 번으로 끝난다.
<!-- Maven: Tomcat → Undertow -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
ServletWebServerFactoryAutoConfiguration이 classpath를 스캔해 Tomcat → Jetty → Undertow 순으로 @ConditionalOnClass를 확인하고, @ConditionalOnMissingBean으로 중복 등록을 막는다. 두 서버 JAR가 동시에 있으면 앞선 것이 이긴다.
서버가 시작되는 순서
SpringApplication.run()이 호출된 뒤 실제 포트 바인딩까지의 경로는 다음과 같다.
SpringApplication.run()
→ createApplicationContext() // ServletWebServerApplicationContext 생성
→ refreshContext()
→ AbstractApplicationContext.refresh()
→ finishBeanFactoryInitialization() // Bean 생성
→ finishRefresh()
→ onRefresh() → createWebServer()
→ getWebServerFactory() // TomcatServletWebServerFactory 조회
→ factory.getWebServer(...) // 서버 생성
→ webServer.start() // 포트 바인딩
→ publishEvent(ServletWebServerInitializedEvent)
TomcatServletWebServerFactory.getWebServer()는 Tomcat 인스턴스를 만들고, Connector를 구성하고, prepareContext()로 ServletContext를 초기화한 뒤 TomcatWebServer를 반환한다. 이 생성자 안에서 tomcat.start()가 즉시 호출된다 — 즉 getWebServer() 시점에 포트 바인딩이 일어난다.
WebServerFactoryCustomizer는 이 흐름의 어디에 끼는가? WebServerFactoryCustomizerBeanPostProcessor가 Factory Bean 초기화 직후, getWebServer() 호출 전에 모든 Customizer를 실행한다. 따라서 커스터마이저 안에서 설정한 값이 실제 서버 생성에 반영된다.
@Component
@Order(1)
public class TomcatThreadCustomizer
implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Override
public void customize(TomcatServletWebServerFactory factory) {
factory.addConnectorCustomizers(connector -> {
ProtocolHandler handler = connector.getProtocolHandler();
if (handler instanceof AbstractProtocol<?> protocol) {
protocol.setMaxThreads(300);
protocol.setMinSpareThreads(25);
}
});
}
}
SSL과 HTTP/2가 Connector에 꽂히는 방식
server.ssl.* 프로퍼티는 SslConnectorCustomizer를 통해 SSLHostConfig 객체로 변환되어 Connector에 등록된다. Keystore는 서버 본인의 인증서와 개인키를, Truststore는 신뢰할 CA 인증서를 담는다.
server:
port: 8443
ssl:
key-store: classpath:keystore.p12
key-store-password: changeit
key-store-type: PKCS12
enabled-protocols: [TLSv1.2, TLSv1.3]
client-auth: need # mTLS: 클라이언트 인증서 필수
trust-store: classpath:truststore.p12
trust-store-password: trustpass
client-auth: need가 설정되면 TLS 핸드셰이크 단계에서 클라이언트 인증서가 없는 연결은 즉시 거부된다. want는 인증서가 없어도 연결을 허용하되, 있으면 검증한다.
HTTP/2 활성화는 더 단순하다. server.http2.enabled=true를 설정하면 customizeHttp2()가 Http2Protocol을 Connector의 UpgradeProtocol로 추가한다. 이후 TLS 핸드셰이크 과정에서 ALPN(Application-Layer Protocol Negotiation)이 동작한다.
클라이언트 → 서버: ClientHello + ALPN ["h2", "http/1.1"]
서버 → 클라이언트: ServerHello + ALPN "h2"
이후: HTTP/2 스트림으로 통신
ALPN은 TLS 핸드셰이크에 포함되므로 프로토콜 협상을 위한 추가 RTT가 없다. HTTP/2의 멀티플렉싱(하나의 TCP 연결에서 여러 스트림 동시 처리)과 HPACK 헤더 압축 이점을 기존 서블릿 코드 변경 없이 받을 수 있다.
HTTP/2는 HTTP 레벨 Head-of-line blocking을 멀티플렉싱으로 해소하지만, TCP 패킷 손실 시 모든 스트림이 재전송을 기다리는 TCP 레벨 blocking은 남아 있다. 이를 완전히 해소하려면 QUIC 기반의 HTTP/3이 필요하다. Spring MVC + HTTP/2에서도 스레드 모델은 바뀌지 않는다 — 각 스트림은 서블릿 요청으로 변환되어 기존 스레드 풀에서 처리된다.
포트와 Context Path의 작동 원리
server.port=0은 OS에 포트 선택을 위임한다는 의미다. ServerSocket(0)으로 바인딩하면 커널이 에페머럴 포트 범위(Linux: 32768~60999)에서 사용 가능한 포트를 원자적으로 할당한다. 바인딩 완료 후 connector.getLocalPort()로 실제 포트를 알 수 있고, ServletWebServerInitializedEvent로 외부에 공개된다.
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ApiTest {
@LocalServerPort // = @Value("${local.server.port}")
int port;
@Test
void callApi() {
// port에 실제 할당된 포트가 주입됨
}
}
server.servlet.context-path=/api 설정은 서블릿 컨테이너 레벨에서 URL 접두사를 추가한다. @RequestMapping("/users")는 Context Path 이후의 경로를 매핑하므로 실제 URL은 /api/users가 된다. Spring Security의 antMatchers는 Context Path를 제외한 경로를 기준으로 동작한다.
다중 포트(HTTP + HTTPS 동시)는 Tomcat에 Connector를 추가하는 방식으로 구현한다.
@Component
public class HttpConnectorCustomizer
implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
@Override
public void customize(TomcatServletWebServerFactory factory) {
Connector http = new Connector(
TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
http.setScheme("http");
http.setPort(8080);
http.setRedirectPort(8443);
factory.addAdditionalTomcatConnectors(http);
}
}
management.server.port를 앱 포트와 다르게 설정하면 별도의 WebServerApplicationContext가 생성된다. Actuator 전용 독립 스레드 풀과 DispatcherServlet이 만들어지며, 방화벽으로 앱 포트와 다르게 접근 제어할 수 있다.
정리
- Spring Boot 내장 서버는
refresh()→onRefresh()→createWebServer()순서로 초기화되고,getWebServer()시점에 포트 바인딩이 완료된다. WebServerFactoryCustomizer는 Factory Bean 생성 직후, 서버 생성 전에 적용된다.@Order로 실행 순서를 제어한다.- SSL은
SslConnectorCustomizer→SSLHostConfig경로로 Connector에 적용되고, HTTP/2는Http2Protocol을 UpgradeProtocol로 추가해 ALPN으로 협상한다. server.port=0은 OS가 원자적으로 포트를 할당하고, 결과는WebServerInitializedEvent로 수신하거나local.server.port프로퍼티로 읽는다.
다음 글에서는 DispatcherServlet이 어떻게 등록되고 HandlerMapping 체인이 요청을 어떻게 라우팅하는지 추적한다.