← all posts
DEV 2026.05.02 · 12 min read Intermediate

Spring Boot 내장 서버는 어떻게 뜨는가

Tomcat·Jetty·Undertow 아키텍처 차이부터 ServletWebServerFactory 초기화 경로, SSL/TLS·HTTP/2·다중 포트 설정까지, 내장 서버의 전체 생명주기를 추적한다.


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은 SslConnectorCustomizerSSLHostConfig 경로로 Connector에 적용되고, HTTP/2는 Http2Protocol을 UpgradeProtocol로 추가해 ALPN으로 협상한다.
  • server.port=0은 OS가 원자적으로 포트를 할당하고, 결과는 WebServerInitializedEvent로 수신하거나 local.server.port 프로퍼티로 읽는다.

다음 글에서는 DispatcherServlet이 어떻게 등록되고 HandlerMapping 체인이 요청을 어떻게 라우팅하는지 추적한다.