예전에는 톰캣을 따로 설치하고 WAR 파일을 배포했는데, 스프링부트에서는 java -jar로 바로 실행됩니다. 톰캣이 내장되어 있다는 게 정확히 어떤 의미이고, 어떻게 동작하는 걸까요?

개념 정의

임베디드 서버(Embedded Server) 는 웹 서버(톰캣, Jetty, Undertow 등)를 애플리케이션의 라이브러리로 포함하여, 별도의 서버 설치 없이 main() 메서드로 직접 서버를 시작하는 방식입니다. 스프링부트는 기본으로 내장 톰캣을 사용합니다.

왜 필요한가

전통적인 배포 방식과 비교해봅시다.

PLAINTEXT
[전통적 방식]
1. 톰캣 서버 설치 (버전 관리 필요)
2. 톰캣 설정 파일 수정 (server.xml 등)
3. WAR 파일 빌드
4. WAR를 톰캣의 webapps/ 디렉토리에 배포
5. 톰캣 재시작

[임베디드 방식]
1. JAR 파일 빌드 (톰캣 포함)
2. java -jar app.jar 실행

전통 방식은 서버 설치, 설정, WAR 빌드, 배포, 재시작까지 5단계를 거칩니다. 임베디드 방식은 JAR 빌드와 실행 2단계로 줄어듭니다. 서버 버전이 앱과 함께 관리되기 때문에 "내 로컬에서는 되는데 서버에서는 안 된다"는 문제가 사라지고, Docker 컨테이너화도 java -jar 한 줄이면 끝납니다.

내부 동작

부팅 과정

PLAINTEXT
SpringApplication.run()
  └── createApplicationContext()
      └── ServletWebServerApplicationContext 생성
          └── onRefresh()
              └── createWebServer()
                  └── ServletWebServerFactory.getWebServer() 호출
                      └── TomcatServletWebServerFactory
                          └── Tomcat 인스턴스 생성
                              └── Connector 설정 (포트, 프로토콜)
                              └── Engine → Host → Context 설정
                              └── DispatcherServlet 등록
                              └── tomcat.start() 호출

ServletWebServerFactory

JAVA
// 스프링부트 내부 (개념적)
public class TomcatServletWebServerFactory implements ServletWebServerFactory {

    @Override
    public WebServer getWebServer(ServletContextInitializer... initializers) {
        Tomcat tomcat = new Tomcat();

        // Connector 설정 (HTTP 포트)
        Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
        connector.setPort(this.port); // 기본 8080
        tomcat.setConnector(connector);

이어서 나머지 구현 부분입니다.

JAVA
        // Context 설정
        Context context = tomcat.addContext("", System.getProperty("java.io.tmpdir"));

        // 서블릿 초기화 (DispatcherServlet 등록)
        for (ServletContextInitializer initializer : initializers) {
            initializer.onStartup(context.getServletContext());
        }

        tomcat.start();
        return new TomcatWebServer(tomcat);
    }
}

스레드 모델

PLAINTEXT
[요청 처리 흐름]
클라이언트 → Acceptor Thread (연결 수락)
           → Poller Thread (I/O 이벤트 감지, NIO)
           → Worker Thread Pool (실제 요청 처리)
              └── DispatcherServlet → Controller → Service → ...

톰캣은 기본적으로 스레드 풀 기반 으로 요청을 처리합니다. 각 HTTP 요청은 하나의 워커 스레드에서 처리되며, 스레드가 모두 사용 중이면 대기열(accept-count)에 들어갑니다.

코드 예제

기본 톰캣 설정

YAML
server:
  port: 8080                          # 서버 포트
  servlet:
    context-path: /api                # 컨텍스트 패스

  tomcat:
    threads:
      max: 200                        # 최대 워커 스레드 수 (기본 200)
      min-spare: 10                   # 최소 유지 스레드 수 (기본 10)
    max-connections: 8192             # 최대 동시 연결 수 (기본 8192)
    accept-count: 100                 # 대기열 크기 (기본 100)
    connection-timeout: 20000         # 연결 타임아웃 (ms)
    keep-alive-timeout: 20000         # Keep-Alive 타임아웃 (ms)
    max-keep-alive-requests: 100      # Keep-Alive 요청 수 제한

스레드 풀 설정 가이드

PLAINTEXT
[요청 처리 용량]
동시 처리 가능: max-connections (8192)
    └── 실제 처리: threads.max (200)
        └── 대기열: accept-count (100)
            └── 거부: Connection Refused

총 수용 가능 연결 = max-connections + accept-count

스레드 수를 무작정 늘리면 안 됩니다.

  • 스레드 하나당 약 512KB~1MB의 스택 메모리 소모
  • 200 스레드 × 1MB = 200MB
  • 컨텍스트 스위칭 비용도 증가

프로그래밍 방식으로 톰캣 커스터마이징

JAVA
@Component
public class TomcatCustomizer implements WebServerFactoryCustomizer<TomcatServletWebServerFactory> {

    @Override
    public void customize(TomcatServletWebServerFactory factory) {
        factory.setPort(9090);

        factory.addConnectorCustomizers(connector -> {
            Http11NioProtocol protocol = (Http11NioProtocol) connector.getProtocolHandler();
            protocol.setMaxThreads(300);
            protocol.setMinSpareThreads(20);
            protocol.setConnectionTimeout(30000);

이어서 나머지 구현 부분입니다.

JAVA
            // 압축 설정
            connector.setProperty("compression", "on");
            connector.setProperty("compressibleMimeType",
                "text/html,text/xml,text/plain,application/json");
            connector.setProperty("compressionMinSize", "1024");
        });
    }
}

HTTPS 설정

YAML
server:
  port: 8443
  ssl:
    key-store: classpath:keystore.p12
    key-store-password: changeit
    key-store-type: PKCS12
    key-alias: myapp

HTTP와 HTTPS 동시 사용

JAVA
@Configuration
public class HttpsConfig {

    @Bean
    public ServletWebServerFactory servletContainer() {
        TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();

        // 추가 HTTP 커넥터 (HTTPS는 application.yml에서 설정)
        Connector httpConnector = new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
        httpConnector.setPort(8080);
        factory.addAdditionalTomcatConnectors(httpConnector);

        return factory;
    }
}

다른 서버로 전환

Undertow로 전환:

XML
<!-- pom.xml -->
<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>
GROOVY
// build.gradle
implementation('org.springframework.boot:spring-boot-starter-web') {
    exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
implementation 'org.springframework.boot:spring-boot-starter-undertow'

** 서버 비교:**

서버특징적합한 경우
Tomcat안정적, 커뮤니티 크고 레퍼런스 많음대부분의 경우 (기본값)
Undertow가볍고 빠름, Non-blocking I/O경량 서비스, 높은 동시성
Jetty유연한 설정, WebSocket 지원 우수WebSocket 중심 앱

Graceful Shutdown

YAML
server:
  shutdown: graceful                    # 우아한 종료 활성화

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s     # 종료 대기 시간

Graceful Shutdown이 활성화되면:

  1. 새 요청 수락을 중단합니다
  2. 진행 중인 요청이 완료될 때까지 대기합니다
  3. 타임아웃 내에 완료되지 않으면 강제 종료합니다
JAVA
// 종료 이벤트 감지
@Component
public class ShutdownListener {

    @EventListener(ContextClosedEvent.class)
    public void onShutdown() {
        log.info("애플리케이션 종료 중... 리소스 정리");
    }
}

주의할 점

1. 스레드 수를 무작정 늘리면 오히려 성능이 떨어진다

Tomcat 스레드를 200에서 1000으로 늘리면 처리량이 5배가 될 것 같지만, 실제로는 컨텍스트 스위칭 비용과 메모리 사용(스레드당 약 1MB)이 급증하여 오히려 응답 시간이 늘어납니다. DB 커넥션 풀 크기가 10인데 스레드가 1000개면, 990개 스레드가 커넥션 대기 상태로 자원만 낭비합니다. 스레드 풀, 커넥션 풀, DB 성능을 함께 고려해야 합니다.

2. Graceful Shutdown을 설정하지 않으면 배포 시 요청이 끊긴다

기본 설정(shutdown: immediate)에서는 SIGTERM을 받으면 진행 중인 요청을 즉시 끊습니다. 결제 처리 중 서버가 종료되면 결제는 완료됐는데 주문 상태 업데이트가 누락되는 사고가 발생합니다. 운영 환경에서는 반드시 server.shutdown: graceful과 적절한 타임아웃을 설정해야 합니다.

3. accept-count 큐가 가득 차면 클라이언트에 Connection Refused가 반환된다

모든 워커 스레드가 사용 중이고 accept-count 큐(기본 100)도 가득 차면, 새 요청은 OS 레벨에서 거부됩니다. 로그에 에러가 남지 않아 서버 측에서 문제를 인지하기 어렵고, 클라이언트에서만 Connection Refused 에러가 보입니다. 모니터링에서 active 스레드 수와 pending 요청 수를 추적해야 합니다.

정리

항목설명
임베디드 방식톰캣을 라이브러리로 포함하여 java -jar로 직접 실행
기본 설정max-threads 200, max-connections 8192, accept-count 100
스레드 튜닝무작정 늘리지 말 것 — CPU 코어 수와 I/O 비율 고려
커스터마이징WebServerFactoryCustomizer로 프로그래밍 방식 설정 가능
서버 전환spring-boot-starter-tomcat exclude 후 Undertow/Jetty starter 추가
Graceful Shutdown운영 환경에서 server.shutdown: graceful 필수
댓글 로딩 중...