톰캣 설치나 커넥터 설정을 정리해두면 현장에서 꼭 따라오는 질문이 있다. 스프링 부트를 쓰는데 톰캣을 따로 깔아야 하는지, 둘 중 무엇을 먼저 익혀야 하는지 같은 것들이다. 신입 개발자만 묻는 것도 아니다. WAR을 webapps에 던져넣던 시절을 거친 운영자도 java -jar 한 줄로 서비스가 뜨는 걸 보면 한 번 멈칫한다. 그래서 이 관계를 말로 설명하는 대신 명령과 로그로 확인하는 순서로 정리해둔다.
먼저 답부터 적으면, 아파치 톰캣은 서블릿 컨테이너(HTTP 요청을 받아 서블릿 객체에 넘겨주는 실행 환경)이고 스프링 부트는 그 위에서 돌아갈 애플리케이션을 만드는 프레임워크다. 둘은 경쟁 관계가 아니라 층이 다르다. 스프링 부트는 톰캣을 라이브러리로 품고 자기 프로세스 안에서 직접 띄우기 때문에, 스프링 부트 JAR을 실행하는 순간 톰캣은 이미 그 안에서 돌고 있다.
이 관계가 헷갈리면 사고 모양이 꽤 구체적이다. 스프링 부트 JAR을 띄워놓고 별도로 톰캣 서비스를 또 올려서 8080 포트를 두고 싸우거나, 반대로 독립 톰캣에 배포하려고 만든 WAR에 내장 톰캣 JAR이 그대로 들어가서 기동에 실패한다. 아래에서는 용어 구분, 내장 톰캣의 실체, JAR 실행 확인, 독립 톰캣 WAR 배포, 버전 매핑, 설정 대응표, 함정을 차례로 살펴본다.
1. 용어 구분: 서블릿 컨테이너, WAS, 프레임워크
서블릿 컨테이너
서블릿(Servlet)은 자바에서 HTTP 요청 하나를 처리하는 표준 규격의 클래스다. 서블릿 컨테이너는 소켓을 열고 요청을 파싱해서 어떤 서블릿에 넘길지 결정하고, 스레드를 할당하고, 응답을 직렬화해서 돌려보낸다.
세션 관리, 정적 파일 서빙, JSP 컴파일도 여기 몫이다. 아파치 톰캣이 정확히 이 일을 한다.
WAS
국내에서 쓰는 WAS(Web Application Server)라는 말은 범위가 넓다. 톰캣은 서블릿, JSP, EL, WebSocket 규격만 구현하고 EJB나 JTA 같은 Jakarta EE 전체 프로파일은 구현하지 않는다.
그래서 톰캣 공식 문서는 스스로를 서블릿 컨테이너로 소개한다. 실무 대화에서 톰캣을 WAS라 불러도 통하지만, WebLogic이나 JEUS와 같은 급으로 보면 기능 범위를 잘못 잡게 된다.
프레임워크
스프링 부트는 컨테이너가 아니다. 의존성 주입, 트랜잭션, 데이터 접근, 보안, 그리고 요청을 컨트롤러 메서드로 라우팅하는 Spring MVC 같은 것을 제공한다.
Spring MVC의 DispatcherServlet은 이름 그대로 서블릿이고, 결국 서블릿 컨테이너에 등록되어야 동작한다. 즉 스프링 부트는 톰캣을 대체하는 게 아니라 톰캣을 필요로 한다.
2. 내장 톰캣은 별도 제품이 아니라 JAR 라이브러리다
내장 톰캣(embedded Tomcat)은 톰캣의 축소판이나 다른 구현체가 아니다. 아파치가 배포하는 tomcat-embed-core 같은 JAR이고, 코어 코드는 톰캣 배포본과 같은 소스에서 나온다.
차이는 기동 방식이다. 독립 톰캣은 catalina.sh가 JVM을 띄우고 conf/server.xml을 읽어 컨테이너를 구성한 다음 webapps의 애플리케이션을 로드한다. 내장 톰캣은 애플리케이션의 main()이 먼저 실행되고, 스프링 부트가 자바 코드로 커넥터와 컨텍스트를 만들어 컨테이너를 띄운다. 주인이 바뀌었으니 server.xml도 webapps도 등장하지 않는다.
의존성 트리를 보면 바로 확인된다. spring-boot-starter-web이 spring-boot-starter-tomcat을 끌고 오고, 그 안에 톰캣 JAR이 들어 있다.
$ ./mvnw dependency:tree | grep -i tomcat [INFO] | +- org.springframework.boot:spring-boot-starter-tomcat:jar:3.5.6:compile [INFO] | | +- org.apache.tomcat.embed:tomcat-embed-core:jar:10.1.46:compile [INFO] | | +- org.apache.tomcat.embed:tomcat-embed-el:jar:10.1.46:compile [INFO] | | \- org.apache.tomcat.embed:tomcat-embed-websocket:jar:10.1.46:compile
버전 숫자는 사용하는 스프링 부트 릴리스에 따라 달라진다. 위 출력은 스프링 부트 3.5.6 기준이고, 이 릴리스가 관리하는 내장 톰캣이 10.1.46이다. 중요한 건 톰캣이 의존성 목록에 들어 있는 라이브러리라는 사실이다.
패키징된 실행 JAR 안도 마찬가지다.
$ ./mvnw -q package -DskipTests $ jar tf target/demo-0.0.1-SNAPSHOT.jar | grep -E 'JarLauncher|tomcat-embed' org/springframework/boot/loader/launch/JarLauncher.class BOOT-INF/lib/tomcat-embed-core-10.1.46.jar BOOT-INF/lib/tomcat-embed-el-10.1.46.jar BOOT-INF/lib/tomcat-embed-websocket-10.1.46.jar
톰캣 배포본을 안 깔았다고 해서 톰캣을 안 쓰는 게 아니다. 그냥 /opt/tomcat이 아니라 BOOT-INF/lib에 있는 것이다.
3. 실행해서 직접 확인한다
실습 전제는 JDK 17 이상이다. 스프링 부트 3.x와 4.x 모두 최소 Java 17을 요구한다.
$ java -version openjdk version "21.0.7" 2025-04-15 LTS OpenJDK Runtime Environment (build 21.0.7+6-LTS) OpenJDK 64-Bit Server VM (build 21.0.7+6-LTS, mixed mode, sharing)
프로젝트는 start.spring.io에서 받는다. 의존성은 web과 함께 actuator를 같이 고른다. 뒤에서 /actuator/health로 상태를 확인하는데, spring-boot-starter-actuator가 없으면 그 경로는 404다.
$ curl -s https://start.spring.io/starter.tgz \
-d type=maven-project -d language=java \
-d dependencies=web,actuator -d name=demo -d javaVersion=21 \
| tar -xzf -
$ ./mvnw -q package -DskipTests
$ java -jar target/demo-0.0.1-SNAPSHOT.jar
기동 로그에서 확인할 줄을 뽑아보면 이렇다.
INFO 4213 --- [demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http) INFO 4213 --- [demo] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat] INFO 4213 --- [demo] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.46] INFO 4213 --- [demo] [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
StandardService와 StandardEngine은 톰캣 자체 클래스다. 독립 톰캣의 catalina.out에도 같은 로거가 찍힌다. 같은 엔진이 같은 순서로 올라오고 있다는 증거다. 뒤에 붙는 패치 번호는 스프링 부트 버전에 따라 달라진다.
실제로 서비스 중인 프로세스라면 열려 있는 포트가 애플리케이션 프로세스 자신인지도 확인해둔다.
$ ss -ltnp | grep 8080
LISTEN 0 4096 *:8080 *:* users:(("java",pid=4213,fd=27))
$ curl -s http://localhost:8080/actuator/health
{"status":"UP"}
$ curl -i http://localhost:8080/
HTTP/1.1 404
Content-Type: application/json
...
{"timestamp":"...","status":404,"error":"Not Found","path":"/"}
컨트롤러를 안 만들었으니 루트가 404인 건 정상이다. 이 404 JSON은 스프링 부트의 기본 에러 핸들러가 만든 것이고, 톰캣이 직접 내는 404는 HTML 에러 페이지라 모양이 다르다. 장애 대응할 때 어느 층에서 끊겼는지 구분하는 데 쓰인다. 성공.
4. 독립 톰캣에 WAR로 올리는 방식
사내 표준이 독립 톰캣이거나 한 인스턴스에 여러 애플리케이션을 얹어야 하면 WAR 패키징을 쓴다. 스프링 부트 공식 문서가 안내하는 준비를 하나씩 보자.
먼저 메인 클래스가 SpringBootServletInitializer를 상속해야 한다. 컨테이너가 main()을 호출해주지 않으니 서블릿 초기화 지점에서 스프링 컨텍스트를 만들어야 한다.
@SpringBootApplication
public class DemoApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(DemoApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
다음으로 패키징을 war로 바꾸고 톰캣 스타터를 provided로 내린다. 이게 빠지면 배포 대상 톰캣과 WAR 안의 톰캣이 겹쳐서 클래스 로딩이 깨진다.
<packaging>war</packaging>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
그레이들이면 war 플러그인을 적용하고 톰캣 스타터를 providedRuntime으로 선언한다.
마지막으로 web.xml은 만들지 않는다. 서블릿 3.0 이상에서는 필요 없고, 서블릿이나 필터를 직접 등록하려면 ServletRegistrationBean, FilterRegistrationBean을 @Bean으로 정의한다.
빌드하고 배포한다.
$ ./mvnw -q clean package -DskipTests $ jar tf target/demo-0.0.1-SNAPSHOT.war | grep -c 'WEB-INF/lib/tomcat-embed' 0 $ sudo cp target/demo-0.0.1-SNAPSHOT.war /opt/tomcat/webapps/demo.war $ /opt/tomcat/bin/version.sh Server version: Apache Tomcat/11.0.24 Server number: 11.0.24.0 OS Name: Linux $ /opt/tomcat/bin/startup.sh Using CATALINA_BASE: /opt/tomcat Using CATALINA_HOME: /opt/tomcat Using CATALINA_TMPDIR: /opt/tomcat/temp Using CLASSPATH: /opt/tomcat/bin/bootstrap.jar:/opt/tomcat/bin/tomcat-juli.jar Tomcat started. $ tail -f /opt/tomcat/logs/catalina.out
grep -c 결과가 0인지 먼저 보는 습관을 들이면 좋다. 여기서 숫자가 나오면 provided가 안 먹은 것이고, 그대로 배포하면 톰캣 로그에서 원인을 찾는 데 시간을 쓴다.
여기서 한 번 걸리는 지점이 컨텍스트 경로다. 독립 톰캣에서는 WAR 파일명이 컨텍스트 경로가 되므로 위 예시는 /demo 아래에 붙는다.
application.properties의 server.port와 server.servlet.context-path는 내장 서버 전용 설정이라 이 배포에서는 무시된다. 루트로 서비스하려면 파일명을 ROOT.war로 바꾸거나 톰캣 쪽 컨텍스트 설정을 손봐야 한다.
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/demo/actuator/health
200
5. 버전 매핑을 먼저 확인한다
WAR 배포가 실패했을 때 자주 발견되는 원인 중 하나가 서블릿 규격 버전이다. 톰캣 9까지는 javax.servlet 네임스페이스를 쓰고, 톰캣 10부터는 Jakarta EE로 넘어가면서 jakarta.servlet을 쓴다.
스프링 부트 3.0부터 jakarta를 쓰므로 스프링 부트 3.x 이상 WAR은 톰캣 9에 배포할 수 없다. 이 조합은 컴파일도 되고 WAR도 만들어지므로 배포 직전까지 아무 경고가 없다. 그러다 톰캣 로그에서 컨텍스트 기동 실패와 함께 서블릿 클래스를 못 찾는 예외가 뜨고, 요청은 404로 떨어진다.
톰캣 공식 버전표 기준이다.
- Tomcat 11.0.x: Servlet 6.1, JSP 4.0, EL 6.0, WebSocket 2.2, Java 17 이상. 안정 버전이며 Jakarta EE 11 구현
- Tomcat 10.1.x: Servlet 6.0, JSP 3.1, EL 5.0, WebSocket 2.1, Java 11 이상. Jakarta EE 10 구현
- Tomcat 9.0.x: Servlet 4.0, JSP 2.3, EL 3.0, Java 8 이상.
javax네임스페이스를 쓰는 마지막 계열이고 지원 종료는 2027년 3월 31일 이전으로 잡히지 않는다 - Tomcat 8.5.x 이하: 지원 종료. 8.5.x는 2024년 3월 31일자로 끝났다
스프링 부트 쪽 요구사항은 이렇게 대응된다.
- Spring Boot 4.1.x: Java 17부터 26까지, Spring Framework 7.0.8 이상. 내장 서버는 Tomcat 11.0.x와 Jetty 12.1.x(둘 다 Servlet 6.1)이고, 배포 대상은 Servlet 6.1 이상 컨테이너다. Undertow는 목록에서 빠졌다
- Spring Boot 3.5.x: Java 17부터 25까지, Spring Framework 6.2.19 이상. 내장 서버는 Tomcat 10.1.25 이상, Jetty 12.0, Undertow 2.3(Servlet 6.0)이고 Servlet 5.0 이상 컨테이너에 배포할 수 있다
서버에 이미 깔린 톰캣이 9.0.x라면 스프링 부트 버전을 낮추기보다 톰캣을 10.1이나 11로 올리는 쪽을 택한다. 스프링 부트 2.7 계열은 이미 오픈소스 지원이 끝났고, 거기 맞춰 프로젝트를 붙잡아두면 보안 패치를 받을 경로가 사라진다.
6. 톰캣 설정은 어디에 쓰는가
내장 톰캣을 쓰면 server.xml을 편집할 수 없다. 대신 스프링 부트가 server.* 프로퍼티로 커넥터 설정을 노출한다. 독립 톰캣의 <Connector> 속성과 대응 관계를 잡아두면 옮겨 적기가 쉽다.
# application.properties server.port=8080 server.servlet.context-path=/demo # <Connector maxThreads="..."> server.tomcat.threads.max=200 # <Connector acceptCount="..."> server.tomcat.accept-count=100 # <Connector maxConnections="..."> server.tomcat.max-connections=8192 # <Connector connectionTimeout="..."> server.tomcat.connection-timeout=20s # 액세스 로그는 내장 톰캣에서 기본으로 꺼져 있다 server.tomcat.accesslog.enabled=true # 내장 톰캣이 쓸 base 디렉터리(CATALINA_BASE에 해당) server.tomcat.basedir=my-tomcat
server.tomcat.basedir은 로그 디렉터리 지정이 아니다. 내장 톰캣이 작업 파일을 두는 base 디렉터리, 즉 독립 톰캣의 CATALINA_BASE에 해당한다. 지정하지 않으면 임시 디렉터리에 만들어지고 프로세스가 끝나면 사라진다.
액세스 로그는 그 base 디렉터리 하위 logs/에 생긴다. 위 예시대로면 my-tomcat/logs/access_log.*가 된다. 로그 위치만 따로 옮기려면 server.tomcat.accesslog.directory를 쓰는데, 이 값은 절대 경로를 주지 않으면 basedir 기준 상대 경로로 해석된다.
톰캣 11 HTTP 커넥터 문서에 적힌 기본값은 maxThreads 200, acceptCount 100, maxConnections 8192, connectionTimeout 60000ms다. 다만 톰캣 배포본에 들어 있는 server.xml은 connectionTimeout을 20000ms로 지정해서 내려온다.
그래서 독립 톰캣에서 20초로 동작하던 서비스를 내장 톰캣으로 옮기면 이 값이 명시되지 않은 경우 동작이 달라질 수 있다. 옮길 때는 커넥터 속성을 한 줄씩 대조하고, 명시하지 않은 값이 무엇인지 확인해둔다.
설정이 실제로 반영됐는지는 액추에이터로 본다.
# management.endpoints.web.exposure.include=health,env,configprops $ curl -s http://localhost:8080/actuator/configprops \ | python3 -c 'import sys,json; d=json.load(sys.stdin); print(json.dumps(d, indent=2)[:400])'
env와 configprops 엔드포인트는 환경 프로퍼티와 바인딩된 설정값을 그대로 보여주므로 운영망에 노출하지 않는다. 기본적으로 웹으로 열리는 엔드포인트는 health 하나뿐이고, 나머지는 management.endpoints.web.exposure.include로 명시해야 열린다.
7. 어느 방식을 택할지
새로 만드는 서비스라면 내장 톰캣 실행 JAR을 택한다. 애플리케이션과 컨테이너 버전이 한 아티팩트에 묶여서 개발 장비와 운영 서버가 같은 톰캣을 쓰게 되고, 컨테이너 이미지나 systemd 유닛으로 배포하기도 단순하다. 스프링 부트를 쓰면서 톰캣을 굳이 분리할 이유는 많지 않다.
독립 톰캣 WAR 배포는 조건이 있을 때 택한다. 한 인스턴스에 애플리케이션 여러 개를 컨텍스트로 나눠 얹어야 하거나, 운영 표준이 톰캣 매니저 기반 배포로 고정돼 있거나, JSP와 서블릿을 쓰는 기존 애플리케이션에 스프링 부트 모듈을 얹는 경우다. 이때는 앞의 provided 설정과 서블릿 버전 매핑을 반드시 맞춘다.
내장 서버를 톰캣에서 제티로 바꾸는 건 스타터 교체로 끝난다. 다만 성능 때문에 바꾸는 결정은 권하지 않는다. 실측 없이 갈아타면 톰캣 쪽에 쌓인 운영 지식과 문서를 버리는 대가만 치른다.
<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-jetty</artifactId>
</dependency>
8. 자주 걸리는 함정
포트 중복
기존 톰캣 서비스를 끄지 않고 스프링 부트 JAR을 띄우면 이 메시지를 만난다.
*************************** APPLICATION FAILED TO START *************************** Description: Web server failed to start. Port 8080 was already in use. Action: Identify and stop the process that's listening on port 8080 or configure this application to listen on another port.
ss -ltnp | grep 8080으로 점유 프로세스를 확인한다. 독립 톰캣이 잡고 있으면 systemctl stop tomcat으로 내리고, 두 서비스를 함께 운영해야 한다면 server.port를 옮긴 뒤 앞단 리버스 프록시에서 나눈다.
WAR 안에 내장 톰캣이 남은 경우
provided 선언을 빼먹으면 WEB-INF/lib에 tomcat-embed-*.jar가 들어간다. 배포 대상 톰캣과 같은 클래스가 두 벌 로드되면서 컨텍스트 기동 단계에서 클래스 로딩 예외로 실패할 수 있다. 앞서 쓴 jar tf ... | grep -c 확인을 빌드 파이프라인에 넣어두면 재발하지 않는다.
내장 서버 전용 설정을 WAR에 기대하는 경우
server.port, server.servlet.context-path, server.tomcat.*는 내장 서버를 구성할 때 쓰이는 값이다. 독립 톰캣 배포에서는 컨테이너가 이미 커넥터를 만든 뒤이므로 적용되지 않는다. 스레드 수나 타임아웃은 그 톰캣의 conf/server.xml에서 조정한다.
JSP를 실행 JAR에 넣는 경우
실행 가능한 JAR에서는 JSP에 제약이 있다. 새로 만드는 화면은 Thymeleaf 같은 템플릿 엔진을 쓰고, JSP를 꼭 유지해야 하면 WAR 패키징으로 간다.
톰캣만 올려두고 스프링 부트를 기대하는 경우
반대 방향의 오해도 있다. 톰캣을 설치했으니 스프링 애플리케이션이 알아서 돌 거라 보는 경우다. 톰캣은 배포된 애플리케이션을 실행할 뿐이고, 스프링 컨텍스트를 만드는 건 WAR 안의 코드다. 컨텍스트가 등록되지 않으면 요청은 톰캣 기본 404 페이지를 받는다.
9. 정리
톰캣은 서블릿 규격을 구현한 컨테이너, 스프링 부트는 그 컨테이너 위에 올릴 애플리케이션을 만드는 프레임워크다. 둘 중 하나를 고르는 문제가 아니라, 톰캣을 애플리케이션 안에 넣어 실행할지(실행 JAR) 밖에 두고 배포할지(WAR)를 고르는 문제다.
새로 만든다면 내장 톰캣 JAR, 기존 톰캣 운영 표준이 있으면 WAR에 provided 스코프와 SpringBootServletInitializer를 갖춰 배포한다. 어느 쪽이든 서블릿 규격 버전이 맞는지, javax와 jakarta 경계를 넘지 않는지를 배포 전에 확인한다. 이 글의 버전 번호는 확인 시점 기준이며 저장소와 릴리스 상태에 따라 달라질 수 있다.