운영팀에서 급하게 연락이 왔습니다. 고객사 배포 요청이 들어와 myapp.war 파일을 /opt/tomcat/webapps/에 복사했는데 5분이 지나도 서비스가 뜨질 않습니다. 브라우저에서 8080 포트로 접근하면 Tomcat 기본 화면이 나오거나 404가 뜹니다. 로그를 봐야 한다는 건 알겠는데 로그 파일이 어디 있는지, 어떤 줄을 봐야 하는지 막막합니다.
WAR가 자동으로 풀리는 원리, catalina.out에서 에러를 찾는 법, 그리고 배포가 실패했을 때 롤백하는 루틴 — 이 모든 것이 이번 모듈의 주제입니다. Tomcat을 "그냥 WAR 올리는 곳"이 아니라 이해하고 운영하는 것을 목표로 합니다.
- 1Tomcat 디렉터리 구조와 각 폴더의 역할을 설명할 수 있다
- 2server.xml의 Connector 설정(maxThreads, acceptCount, connectionTimeout)을 읽고 튜닝할 수 있다
- 3context.xml에 JNDI DataSource를 설정하고 연결 풀 파라미터를 조정할 수 있다
- 4WAR 배포 절차와 배포 실패 시 catalina.out에서 원인을 찾을 수 있다
- 5catalina.out에서 OutOfMemoryError, 클래스 충돌, 한국어 인코딩 문제를 진단할 수 있다
Tomcat 디렉터리 구조
$CATALINA_HOME 구조와 각 디렉터리의 역할
Tomcat을 제대로 운영하려면 어떤 파일이 어디에 있는지를 알아야 합니다. 로그를 찾지 못해 장애 대응이 늦어지거나, 라이브러리를 잘못된 경로에 두어 클래스를 못 찾는 문제가 현장에서 반복됩니다. 디렉터리 구조는 한 번 제대로 익혀두면 어떤 버전의 Tomcat이든 동일하게 적용됩니다.
확대
$CATALINA_HOME/의 주요 디렉터리입니다.
bin/— Tomcat 실행·종료 스크립트startup.sh(→ catalina.sh start),shutdown.sh(→ catalina.sh stop),catalina.sh(실제 JVM 기동 파라미터, JAVA_OPTS 등)
conf/— 핵심 설정 파일server.xml(Connector·Host·Engine 등 서버 구조),context.xml(전역 컨텍스트, JNDI DataSource),web.xml(서블릿 기본 동작),tomcat-users.xml(Manager App 계정 — 운영 주의)
logs/— 장애 대응 시 가장 먼저 열 곳catalina.out(표준 출력+에러, 주 진단 대상),localhost.YYYY-MM-DD.log(앱별),localhost_access_log.YYYY-MM-DD.txt(HTTP 접근)
webapps/— WAR 배포 위치 (Tomcat이 감시):ROOT/(/ 경로 기본 앱),myapp/(myapp.war 압축 해제)work/— JSP 컴파일 캐시 (Catalina/localhost/myapp/)lib/— Tomcat + 모든 앱이 공유하는 JAR (mysql-connector-j-8.x.jar등 JDBC 드라이버)
CATALINA_HOME vs CATALINA_BASE — 멀티 인스턴스 운영:
| 변수 | 의미 | 공유 여부 |
|---|---|---|
CATALINA_HOME | Tomcat 바이너리 설치 경로 (bin/, lib/) | 여러 인스턴스가 공유 |
CATALINA_BASE | 인스턴스별 설정·배포·로그 경로 | 인스턴스마다 독립 |
서버에 Tomcat 인스턴스를 2개 이상 운영할 때(8080, 8090 포트 등) CATALINA_HOME은 하나로 두고, CATALINA_BASE를 인스턴스마다 다르게 지정합니다. /opt/tomcat-8080/, /opt/tomcat-8090/ 식으로 분리하면 로그와 설정이 섞이지 않습니다.
# 실행 중인 Tomcat 프로세스와 CATALINA_HOME 확인
ps aux | grep catalina | grep -v grep
# 출력 예시:
# tomcat 1234 2.1 12.3 3456789 234567 ? Sl 09:00 0:45
# /usr/lib/jvm/java-17/bin/java ... -Dcatalina.home=/opt/tomcat ...
server.xml 핵심 설정
Connector 설정 — 스레드와 연결을 제어하는 핵심 파라미터
server.xml은 Tomcat 전체 구조를 정의합니다. 그 중 Connector는 외부 HTTP 요청을 받는 입구입니다. 여기서 스레드 수, 대기 큐, 타임아웃을 잘못 설정하면 트래픽 급증 시 서비스가 멈추거나, 반대로 리소스를 낭비합니다.
<!-- /opt/tomcat/conf/server.xml — Connector 핵심 설정 -->
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="200"
minSpareThreads="10"
acceptCount="100"
redirectPort="8443"
URIEncoding="UTF-8" />
파라미터별 역할과 튜닝 기준:
| 파라미터 | 기본값 | 의미 | 튜닝 기준 |
|---|---|---|---|
maxThreads | 200 | 동시에 처리할 수 있는 요청 스레드 최대 수 | CPU 코어 수 × 2~4에서 시작, 부하 테스트로 결정 |
minSpareThreads | 10 | 항상 대기 중인 최소 스레드 수 | 트래픽 패턴에 따라 25~50 사이 |
acceptCount | 100 | maxThreads 소진 시 OS 큐에서 대기할 요청 수 | 너무 크면 클라이언트 타임아웃 전에 대기, 너무 작으면 연결 거부 |
connectionTimeout | 20000(ms) | 클라이언트가 연결 후 요청을 보내지 않을 때 대기 시간 | Keep-alive 환경에서는 줄이는 경우 있음 |
URIEncoding | ISO-8859-1 | URL 경로의 문자 인코딩 | 한국어 서비스라면 UTF-8 필수 |
요청 처리 흐름 — 스레드가 소진되면 어떻게 되나:
- 요청이 들어오면 가용 스레드가 할당되어 처리
- 가용 스레드가
maxThreads에 도달하면 →acceptCount크기의 OS 큐에 대기 - OS 큐도 가득 차면 → 새 연결은 즉시 거부(Connection refused)
따라서 maxThreads를 무작정 높이는 것이 해결책이 아닙니다. 스레드가 오래 점유되는 근본 원인(느린 DB 쿼리, 외부 API 지연)을 먼저 찾아야 합니다.
# server.xml 설정 확인 (grep으로 빠르게)
grep -A 5 'Connector port="8080"' /opt/tomcat/conf/server.xml
확대
서블릿 요청이 Tomcat을 통과하는 7단계 — Connector부터 service()까지
브라우저가 http://shop.example.com/shop/cart를 요청하면 화면에 장바구니가 뜹니다. 그 사이 Tomcat 안에서는 Connector 수신 → 스레드 배정 → Engine·Host·Context 매핑 → 필터 체인 → 서블릿 service() → 응답이 순서대로 일어납니다. 이 단계를 나눠 두면 "왜 404지", "왜 요청이 catalina.out에 로그도 안 남고 사라지지", "왜 로그인이 풀리지"를 특정 단계로 좁힐 수 있습니다. Tomcat은 WAR를 올리는 상자가 아니라 아래 7단계를 밟는 컨테이너입니다.
[클라이언트] GET /shop/cart HTTP/1.1 → :8080
│
① Connector 수신 (HTTP/1.1 or AJP, NIO poller가 소켓 감시)
│
② 스레드풀에서 워커 스레드 배정 (maxThreads 한도, 없으면 acceptCount 큐 대기)
│
③ Engine → Host → Context 매핑 (도메인→Host, 컨텍스트경로 /shop→앱)
│
④ 필터 체인 통과 (인코딩·인증 Filter, web.xml 순서대로)
│
⑤ 서블릿 매핑 → service() 실행 (url-pattern 매칭 → doGet/doPost)
│ → 이 안에서 세션(JSESSIONID) 조회·JNDI DB 연결 사용
│
⑥ 응답 작성 → 필터 역순 통과 → Connector
│
⑦ 워커 스레드 반납 (keep-alive면 연결 유지하며 대기)
▼
[클라이언트] 200 OK (스레드는 풀로 복귀)
각 단계에서 무슨 일이 일어나고, 막히면 어떤 증상인가:
| 단계 | 하는 일 | 여기서 막히면 |
|---|---|---|
| ① Connector 수신 | 소켓에서 요청 바이트를 읽고 URIEncoding으로 경로를 디코딩 | 포트 미기동 → Connection refused · URIEncoding 미설정(Tomcat 8 이하) → 한국어 경로가 ???로 깨짐 |
| ② 스레드 배정 | maxThreads 안에서 유휴 워커 스레드에 요청을 넘김 | 스레드 전부 busy → acceptCount 크기의 커널 큐에서 대기 → 큐마저 차면 거부. 처리 스레드에 못 닿아 catalina.out에 흔적 없음 |
| ③ Engine·Host·Context 매핑 | 도메인으로 Host, 컨텍스트 경로로 배포된 앱(Context)을 선택 | 컨텍스트 경로 오타·미배포 → 404 · 루트(ROOT) 앱으로 잘못 감 |
| ④ 필터 체인 | web.xml의 filter-mapping 순서대로 인코딩·인증 필터 실행 | 문자 인코딩 필터 누락·순서 오류 → POST 바디 한글 깨짐 · 인증 필터 우회 |
| ⑤ 서블릿 service() | url-pattern으로 서블릿을 매칭해 실행. 세션 조회·DB 연결 사용 | JNDI 풀(maxTotal) 고갈 → 스레드가 여기서 maxWaitMillis 대기(슬로우 요청) · 세션 없으면 로그인 풀림 |
| ⑥ 응답 작성 | 상태코드·바디를 기록하고 필터를 역순으로 통과 | 서블릿에서 예외 → 500 · 스택트레이스는 catalina.out에 남음 |
| ⑦ 스레드 반납 | 워커를 풀에 반납, keep-alive면 연결 유지 | 다운스트림(DB·외부 API) 지연으로 스레드가 오래 붙잡힘 → ②의 고갈로 번짐 |
즉 요청이 어느 단계까지 갔는지에 따라 로그가 남는 위치가 갈립니다. ①②(커넥터·스레드 층)에서 막히면 요청이 애플리케이션에 닿지 못해 catalina.out에 앱 로그가 한 줄도 없고, 서버는 멀쩡한데 클라이언트만 타임아웃·Connection refused를 겪습니다 — 이때는 Manager status의 currentThreadsBusy와 ss -tlnp의 Recv-Q 적체를 봅니다. ⑤ 이후에서 막히면 스택트레이스·슬로우 요청이 앱 로그와 access_log(%D)에 남습니다. 그래서 "로그가 있느냐 없느냐"가 커넥터 문제(②)와 애플리케이션 문제(⑤)를 가르는 첫 분기점입니다.
JNDI DataSource 설정
context.xml에서 DB 연결 풀 설정하기 — 실무 핵심
운영 중인 서비스가 갑자기 "DB 연결 실패" 에러를 쏟아내기 시작했습니다. 로그를 보니 Cannot get a connection, pool error Timeout waiting for connection from pool입니다. DB 자체는 살아있는데 연결을 못 받는 상황 — 연결 풀이 고갈된 것입니다. 처음 배포할 때 기본값 그대로 뒀다가 트래픽이 늘면서 터지는 전형적인 패턴입니다. context.xml 설정 한 줄 차이가 서비스 가용성을 결정합니다.
애플리케이션이 DB에 접근하는 방법은 두 가지입니다. 코드 내에서 직접 JDBC 연결을 생성하거나, Tomcat이 관리하는 JNDI DataSource를 통해 연결 풀을 빌려 쓰는 방식입니다. 실무에서는 거의 항상 JNDI DataSource를 씁니다 — DB 연결 생성 비용을 줄이고, 연결 수를 제어하며, 코드와 DB 설정을 분리할 수 있기 때문입니다.
<!-- /opt/tomcat/conf/context.xml — 전역 DataSource 설정 -->
<Context>
<Resource name="jdbc/myapp"
auth="Container"
type="javax.sql.DataSource"
driverClassName="com.mysql.cj.jdbc.Driver"
url="jdbc:mysql://db-server:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul"
username="appuser"
password="secret"
maxTotal="30"
maxIdle="10"
minIdle="5"
maxWaitMillis="10000"
validationQuery="SELECT 1"
testOnBorrow="true"
timeBetweenEvictionRunsMillis="60000"
minEvictableIdleTimeMillis="300000" />
</Context>
연결 풀 파라미터 튜닝 기준:
| 파라미터 | 의미 | 실무 가이드 |
|---|---|---|
maxTotal | 풀에서 생성할 수 있는 최대 연결 수 | DB 서버의 max_connections와 맞춰서 설정. 보통 20~50 |
maxIdle | 유휴 상태로 유지할 최대 연결 수 | maxTotal의 30~50% |
minIdle | 항상 유지할 최소 유휴 연결 수 | 트래픽이 갑자기 올 때 연결 대기 없이 바로 처리 |
maxWaitMillis | 풀에서 연결을 못 받을 때 대기할 최대 시간(ms) | 10000(10초)이 일반적. 초과 시 SQLException |
validationQuery | 연결을 빌리기 전 DB에 보내는 확인 쿼리 | SELECT 1 (MySQL), SELECT 1 FROM DUAL (Oracle) |
testOnBorrow | 연결 대출 시마다 validationQuery 실행 여부 | true 권장 — 끊어진 연결을 앱에 전달하지 않음 |
JDBC 드라이버 배치 위치:
드라이버 JAR(mysql-connector-j-8.x.jar 등)는 반드시 $CATALINA_HOME/lib/에 둬야 합니다. webapps/앱명/WEB-INF/lib/에 드라이버를 두면 Tomcat 레벨의 JNDI DataSource가 드라이버를 찾지 못해 ClassNotFoundException이 발생합니다.
# JDBC 드라이버 배치 및 확인
sudo cp mysql-connector-j-8.x.jar /opt/tomcat/lib/
ls -la /opt/tomcat/lib/ | grep mysql
# Tomcat 재시작 후 반영됨 (lib 변경은 재시작 필요)
WAR 배포와 운영 루틴
WAR 배포 방식 — 자동 배포와 수동 배포의 차이
밤 11시에 긴급 배포를 해야 합니다. WAR 파일을 webapps/ 에 복사했는데 15분째 서비스가 뜨질 않습니다. 로그를 보러 갔더니 catalina.out이 수백 MB — 어디서부터 봐야 할지 모릅니다. 설상가상으로 롤백해야 하는데 이전 WAR 파일을 어디 뒀는지도 불확실합니다. 배포 방식을 이해하지 못한 채 운영에 들어가면 이런 상황이 반복됩니다.
Tomcat에 애플리케이션을 배포하는 방법은 상황에 따라 다릅니다. 개발 환경과 운영 환경에서 다른 방식을 써야 하고, 각 방식의 동작 원리를 이해해야 배포가 왜 실패했는지를 알 수 있습니다.
배포 방식 비교:
| 방식 | 절차 | 적합한 환경 | 주의사항 |
|---|---|---|---|
| autoDeploy (자동) | WAR를 webapps/에 복사 → Tomcat이 감지·배포 | 개발, 테스트 | 운영에서는 autoDeploy=false 권장 |
| 수동 배포 | Tomcat 중지 → WAR 복사 → Tomcat 시작 | 운영 (중단 허용 시) | 서비스 다운타임 발생 |
| Manager App | 웹 UI에서 undeploy → deploy | 운영 (간헐적 변경) | tomcat-users.xml 계정 관리 필요 |
autoDeploy 설정 위치 (server.xml):
<!-- server.xml의 Host 엘리먼트 -->
<Host name="localhost" appBase="webapps"
unpackWARs="true"
autoDeploy="true"> <!-- 운영 환경에서는 false 권장 -->
배포 후 성공 확인 기준:
# catalina.out에서 정상 기동 확인
grep "Server startup in" /opt/tomcat/logs/catalina.out | tail -3
# 정상 출력 예시:
# INFO [main] org.apache.catalina.startup.Catalina.start
# Server startup in [3456] milliseconds
기동 완료 메시지가 나오기 전까지는 서비스가 준비되지 않은 것입니다. 로드밸런서 헬스체크가 이 시간보다 짧으면 503이 발생할 수 있습니다.
WAR 파일을 webapps/에 복사합니다. autoDeploy=true(기본값)이면 Tomcat이 자동으로 감지해 배포를 시작합니다. 복사 직후부터 배포가 완료되기까지 catalina.out을 실시간으로 확인해야 합니다.
# WAR 복사 후 압축 해제 상태 확인 (디렉터리가 생겨야 정상)
ls -la /opt/tomcat/webapps/myapp/
# 배포 로그 실시간 모니터링 (INFO, ERROR, WARN 라인만 필터)
tail -f /opt/tomcat/logs/catalina.out | grep -E "INFO|ERROR|WARN|SEVERE"
# 배포 완료 확인 (Server startup 메시지)
grep "Server startup in" /opt/tomcat/logs/catalina.out | tail -3
# 애플리케이션 헬스체크
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/myapp/health
배포 후 webapps/myapp/ 디렉터리가 생성되고 catalina.out에 "Server startup in X ms" 가 출력되면 정상 배포입니다.
sudo cp myapp.war /opt/tomcat/webapps/- ls /opt/tomcat/webapps/myapp/ 로 WAR 압축 해제 확인 먼저, 그 다음 grep "Server startup in" /opt/tomcat/logs/catalina.out 으로 기동 완료 메시지 확인 — WEB-INF/ 없으면 WAR 배포 자체 실패
- 기동 시간 기준: "Server startup in N ms" 에서 N이 30초(30000ms) 이상이면 초기화 지연 — DB 연결 풀 초기화 또는 Spring 컴포넌트 스캔이 느린 것이므로 DB 연결 상태 확인
- catalina.out에 "Server startup in" 메시지가 있는데 curl 200이 안 나오면 → 헬스체크 엔드포인트 경로 오류 또는 서블릿 매핑 문제 — curl http://localhost:8080/myapp/ 로 컨텍스트 루트부터 테스트
배포 후 정상 동작하더라도 스레드 점유와 응답 시간을 주기적으로 확인해야 합니다. Manager App 상태 페이지와 액세스 로그를 결합해 병목 요청을 찾습니다.
# Manager App XML 상태에서 현재 스레드 수 확인
# (tomcat-users.xml에 manager-status 롤 계정 필요)
curl -s -u admin:adminpass \
http://localhost:8080/manager/status?XML=true \
| grep -i "maxThreads\|currentThreadCount\|currentThreadsBusy"
# 액세스 로그에서 응답시간 5000ms 초과 요청 찾기
# 액세스 로그 마지막 필드가 응답시간(ms)인 경우
awk '$NF > 5000 {print}' /opt/tomcat/logs/localhost_access_log.*.txt | tail -20
# catalina.out에서 에러 라인 추출
grep -E "Exception|Error|SEVERE" /opt/tomcat/logs/catalina.out | tail -30
# OutOfMemoryError 발생 여부 확인
grep "java.lang.OutOfMemoryError" /opt/tomcat/logs/catalina.out
액세스 로그 형식은 server.xml의 Valve 설정에 따라 다릅니다. %D 또는 %T 포맷이 응답시간을 기록하도록 설정되어 있어야 합니다.
grep -E 'Exception|Error|SEVERE' /opt/tomcat/logs/catalina.out | tail -30- Manager status의 currentThreadsBusy 먼저 확인, 그 다음 액세스 로그에서 5000ms 초과 요청 확인, 마지막으로 catalina.out grep 으로 Exception 패턴 확인 — 스레드 포화 → 슬로우 요청 → 에러 순서로 원인을 좁힘
- 스레드 기준: currentThreadsBusy가 maxThreads의 70% 이상이면 부하 증가 주의, 90% 이상이면 신규 요청 거부 임박 — 5000ms 초과 요청이 반복되는 특정 URI가 있으면 해당 API의 DB 쿼리 또는 외부 API 호출이 병목
- catalina.out에 OutOfMemoryError와 슬로우 응답이 동시에 보이면 → GC pause로 인한 스레드 정지 — jstat -gcutil <PID> 1000 으로 Old Gen 사용률과 Full GC 빈도 확인
트러블슈팅
원인: JNDI DataSource 연결 풀의 모든 연결(maxTotal)이 소진되어 maxWaitMillis 시간 동안 대기했지만 연결을 받지 못한 상황입니다. 두 가지 원인이 가장 흔합니다.
- DB 슬로우 쿼리: 쿼리가 오래 걸려 스레드가 DB 연결을 오래 잡고 있음
- 연결 누수(Leak): 코드에서 Connection.close()를 호출하지 않아 연결이 반환되지 않음
# 1. catalina.out에서 에러 발생 시각 확인
grep "Cannot get a connection" /opt/tomcat/logs/catalina.out | tail -10
# 2. 에러 발생 시각의 DB 슬로우 쿼리 로그 대조 (MySQL 예시)
# /var/log/mysql/slow.log 또는 MySQL slow query log 설정 필요
# 3. 현재 DB 연결 상태 확인 (MySQL)
# MySQL 접속 후:
# SHOW STATUS LIKE 'Threads_connected';
# SHOW PROCESSLIST;
# 4. context.xml에서 연결 풀 설정 확인
grep -A 15 'Resource name="jdbc' /opt/tomcat/conf/context.xml
# 5. 연결 누수 감지 설정 추가 (context.xml)
# removeAbandonedOnBorrow="true"
# removeAbandonedTimeout="60"
# logAbandoned="true"
단기 조치: maxTotal 값을 임시로 늘려 여유를 확보하고, 동시에 슬로우 쿼리 분석을 병행합니다. maxTotal을 무한정 늘리면 DB 서버의 max_connections를 초과해 DB 자체가 다운될 수 있으므로 주의합니다.
원인: WAR 내부에 클래스 충돌이나 의존성 문제가 있을 때 배포 자체가 실패합니다. 가장 흔한 케이스는 WEB-INF/lib/에 포함된 JAR와 $CATALINA_HOME/lib/의 JAR 버전 충돌입니다.
# 1. catalina.out에서 상세 스택 트레이스 확인
grep -A 30 "SEVERE: Error deploying" /opt/tomcat/logs/catalina.out | tail -40
# 2. 스택 트레이스에서 충돌 클래스 확인
# 예시: java.lang.NoSuchMethodError, java.lang.ClassNotFoundException
# 예시: org.springframework.beans.factory.BeanDefinitionStoreException
# 3. WAR 내부 lib 목록 확인
jar -tf /opt/tomcat/webapps/myapp.war | grep "WEB-INF/lib/" | sort
# 4. CATALINA_HOME/lib과 중복 여부 비교
ls /opt/tomcat/lib/ | sort
# 5. 충돌 JAR 제거 후 WAR 재빌드 (개발팀 협조)
# 또는 이전 배포 버전으로 롤백
sudo cp /backup/myapp-previous.war /opt/tomcat/webapps/myapp.war
# 6. work 디렉터리 캐시 삭제 (클래스 캐시 충돌 해소)
sudo systemctl stop tomcat
WORK_DIR="/opt/tomcat/work/Catalina/localhost/myapp"
test "$(sudo readlink -f "$WORK_DIR")" = "$WORK_DIR" || { echo "work 경로 검증 실패" >&2; exit 1; }
sudo test -d "$WORK_DIR" && sudo rm -rf -- "$WORK_DIR"
sudo systemctl start tomcat
배포 실패 시 webapps/myapp/ 디렉터리가 생성되다 멈추거나 불완전하게 남을 수 있습니다. work/ 캐시까지 같이 지우는 것이 깔끔합니다.
원인: server.xml의 Connector에 URIEncoding="UTF-8"이 설정되어 있지 않으면, URL 경로에 포함된 한국어(URL 인코딩된 멀티바이트 문자)가 ISO-8859-1로 디코딩되어 깨집니다. POST 바디는 애플리케이션에서 설정하지만, URI 경로는 Tomcat Connector 레벨에서 처리합니다.
# 1. 현재 Connector 설정 확인
grep -i "URIEncoding\|encoding" /opt/tomcat/conf/server.xml
# 2. server.xml Connector에 URIEncoding 추가
# (변경 후 Tomcat 재시작 필요)
# <Connector port="8080" ... URIEncoding="UTF-8" />
# 3. 변경 적용을 위한 재시작
sudo systemctl restart tomcat
# 4. 인코딩 확인 테스트 (한국어 URL 파라미터)
curl -v "http://localhost:8080/myapp/search?keyword=%ED%85%8C%EC%8A%A4%ED%8A%B8"
# 애플리케이션에서 "테스트"로 제대로 받는지 확인
# 5. Tomcat 9 이상에서는 기본값이 UTF-8이므로
# 버전 확인도 중요
/opt/tomcat/bin/version.sh | grep "Server version"
Tomcat 9.0부터는 URIEncoding 기본값이 UTF-8로 변경되었습니다. Tomcat 8.x 이하를 사용 중이라면 반드시 명시적으로 설정해야 합니다.
심화 — 요청은 스레드에 닿기 전에 이미 커넥터에서 막힌다
심화: 커넥터 내부 — acceptCount 큐, NIO poller, 스레드풀·DB풀 정렬
maxThreads를 아는 것과, 그 스레드가 전부 찼을 때 '그다음 요청이 어디로 가는지'를 아는 것은 다릅니다. 부하 장애의 진짜 병목은 대개 스레드풀이 아니라 그 앞뒤에 있습니다.
acceptCount는 애플리케이션이 아니라 커널 큐입니다:maxThreads가 전부 busy면 새로 맺어진 연결은acceptCount크기의 OS accept 큐(소켓 백로그)에서 대기합니다. 이 큐는 커널 소켓 백로그라 Tomcat이 처리하기 전이고, 그래서 대기·거부가catalina.out에 한 줄도 안 남습니다. 큐마저 차면 커널이 연결을 거부해 클라이언트는 Connection refused나 타임아웃을 받습니다.- NIO 커넥터는 연결과 스레드를 분리합니다: 옛 BIO 커넥터는 연결 하나가 스레드 하나를 물었지만, 현재 기본인 NIO 커넥터는 poller 스레드가 여러 연결의 소켓을 감시하다 실제 데이터가 올 때만 워커 스레드에 넘깁니다(Apache event MPM과 같은 발상). 그래서 keep-alive 유휴 연결이 워커를 붙잡지 않습니다.
maxConnections(NIO 기본 8192)는 커넥터가 동시에 들고 있을 수 있는 연결 수로, 동시 처리 스레드인maxThreads와는 별개 값입니다. - 스레드풀과 DB 풀은 함께 사이징해야 합니다:
maxThreads가 200인데 JNDImaxTotal이 30이면, 최대 200개의 요청 스레드가 30개의 DB 연결을 놓고 경쟁합니다. DB를 쓰는 동시 요청이 30을 넘는 순간 나머지는maxWaitMillis만큼 풀에서 대기하므로, 슬로우 쿼리가 없어도 지연이 생깁니다. 두 값은 '요청 중 DB를 쓰는 비율'에 맞춰 정렬합니다.
핵심 한계: maxThreads·acceptCount를 키우는 것은 병목을 뒤로 미룰 뿐입니다. 스레드가 오래 붙잡히는 근본 원인(느린 다운스트림 — DB·외부 API)을 없애지 않으면 큐만 길어지고 클라이언트 체감 지연은 그대로입니다.
상황: 로드밸런서 헬스체크는 초록이고 catalina.out도 깨끗한데, 사용자는 피크 시간마다 응답 없음이나 연결 거부를 겪습니다. 서버는 멀쩡해 보여 원인을 어디서 찾을지 막막합니다.
원인: 요청이 애플리케이션 스레드에 도달하기 전에 커넥터 층에서 막혔습니다. maxThreads가 전부 busy라 새 연결은 acceptCount 크기의 커널 accept 큐에서 대기하고, 그 큐마저 차면 커널이 연결을 거부합니다. 이 대기·거부는 요청이 처리 스레드에 닿지 못한 단계라 애플리케이션 로그(catalina.out)에 흔적이 남지 않습니다 — 그래서 서버는 조용하고 클라이언트만 아픕니다.
진단: ss -tlnp 'sport = :8080'에서 대기 중인 미accept 연결(Recv-Q)이 acceptCount 근처까지 쌓였는지 확인합니다. Manager status의 currentThreadsBusy가 maxThreads와 같으면 스레드 포화가 확정입니다. 이어 kill -3 $(pgrep -f catalina)로 스레드 덤프를 떠, 워커들이 어느 다운스트림(DB 커넥션 대기·외부 API)에서 멈춰 있는지 봅니다.
해결: 근본 조치는 스레드를 오래 붙잡는 다운스트림 지연을 없애는 것입니다. 그와 함께 maxThreads와 JNDI maxTotal을 정렬하고, keep-alive 유휴 연결이 워커를 잡아먹는 상황이면 NIO 커넥터의 maxConnections를 점검합니다. acceptCount만 키우면 거부가 지연으로 바뀔 뿐 체감은 나아지지 않습니다. 스레드가 어디서 멈추는지는 Heap/GC/Thread Dump 분석과 OOM 대응 실무의 스레드 덤프 분석과 함께 봐야 합니다.
실무 운영 루틴
금융사·공공기관 Tomcat 운영에서 반복되는 패턴:
1. 배포 전 체크리스트
운영 환경에서 WAR 배포 전 반드시 확인해야 하는 항목입니다.
# 현재 운영 중인 WAR 백업
sudo cp /opt/tomcat/webapps/myapp.war \
/backup/myapp-$(date +%Y%m%d-%H%M%S).war
# 배포 전 Tomcat 상태 확인
sudo systemctl status tomcat
# 배포 전 JVM 힙 사용량 확인 (jstat 사용)
jstat -gc $(pgrep -f catalina) 5000 3
2. 무중단 배포 (인스턴스 2대 이상 운영 시)
로드밸런서(Nginx, L4) 뒤에 Tomcat 인스턴스가 2대 있을 때의 표준 절차입니다.
# 1단계: 첫 번째 인스턴스를 로드밸런서에서 제외 (Nginx 설정에서 weight=0 또는 down)
# 2단계: 첫 번째 인스턴스에 새 WAR 배포
sudo systemctl stop tomcat-8080
sudo cp new-myapp.war /opt/tomcat-8080/webapps/myapp.war
sudo systemctl start tomcat-8080
# 3단계: 헬스체크로 정상 확인
curl -s http://localhost:8080/myapp/health
# 4단계: 로드밸런서에 첫 번째 인스턴스 복귀
# 5단계: 두 번째 인스턴스 동일 절차 반복
3. JNDI 패스워드 암호화 (보안 요구사항)
공공기관·금융권에서는 context.xml의 DB 패스워드를 평문으로 두면 보안 감사에서 지적됩니다.
# Jasypt 또는 Tomcat 자체 암호화 도구 사용
# context.xml에서 password 대신 암호화된 값 + 복호화 DataSource 구현 필요
# 보안 담당자와 사전 협의 필요
# 최소한: context.xml 파일 권한 제한
sudo chmod 600 /opt/tomcat/conf/context.xml
sudo chown tomcat:tomcat /opt/tomcat/conf/context.xml
4. 장애 대응 첫 3분 루틴
# 1. Tomcat 프로세스 존재 여부
pgrep -a -f catalina
# 2. 포트 리스닝 확인
ss -tlnp | grep 8080
# 3. 최근 에러 로그 빠른 확인
grep -E "SEVERE|OutOfMemoryError|StackOverflowError" \
/opt/tomcat/logs/catalina.out | tail -20
# 4. 헬스체크 응답코드 확인
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/myapp/health
# 5. JVM 스레드 덤프 (응답은 있지만 느릴 때)
kill -3 $(pgrep -f catalina)
# 스레드 덤프는 catalina.out에 출력됨
Tomcat은 여전히 국내 금융·공공 환경에서 가장 많이 쓰이는 WAS입니다. server.xml과 context.xml을 자신 있게 다루고, catalina.out에서 원인을 찾는 루틴을 익혀두면 배포·장애 대응 속도가 확실히 달라집니다.
명령어·단축키 빠른 참조
이 모듈에서 다룬 Tomcat 배포·진단 명령을 모았습니다. WAR 배포와 장애 첫 3분 대응 시 이 순서대로 확인합니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
pgrep -a -f catalina | Tomcat 프로세스·인자 확인 | ps aux | grep catalina 로 CATALINA_HOME도 확인 |
ss -tlnp | grep 8080 | 포트 리스닝·accept 큐 확인 | ss -tlnp 'sport = :8080' 로 Recv-Q 적체 관찰 |
tail -f catalina.out | 배포 로그 실시간 추적 | tail -f catalina.out | grep -E "INFO|ERROR|SEVERE" |
grep "Server startup in" | 기동 완료 확인 | grep "Server startup in" catalina.out | tail -3 |
grep OutOfMemoryError | OOM 발생 확인 | grep java.lang.OutOfMemoryError catalina.out | tail -20 |
jstat -gc <pid> | GC·힙 사용량 확인 | jstat -gc $(pgrep -f catalina) 5000 3 |
kill -3 <pid> | 스레드 덤프(→catalina.out) | 응답 느릴 때 워커가 어디서 막혔는지 |
curl -w "%{http_code}" | 헬스체크 상태코드 | curl -s -o /dev/null -w "%{http_code}" .../myapp/health |
jar -tf myapp.war | WAR 내부 목록 확인 | jar -tf myapp.war | grep WEB-INF/lib (JAR 충돌 추적) |
awk '$NF > 5000' | 슬로우 요청 추출 | awk '$NF>5000' localhost_access_log.*.txt | tail |
curl .../manager/status?XML=true | 스레드 상태 확인 | currentThreadsBusy vs maxThreads 비교 |
rm -rf work/Catalina/localhost/<app> | JSP·클래스 캐시 삭제 | 배포 충돌 해소(중지 후 삭제) |
systemctl restart tomcat | 재시작(lib·URIEncoding 변경 시) | systemctl status tomcat 로 상태 확인 |
관련 모듈로 더 깊이:
- Tomcat 세션 클러스터링과 Redis 세션 외부화 — Tomcat 2대 이상 운영 시 세션 유지와 무중단 배포
- Heap/GC/Thread Dump 분석과 OOM 대응 실무 — maxThreads 튜닝 너머의 JVM 힙·GC 튜닝과 로그 분석
- Web Server와 WAS 미들웨어 구조의 이해 — Tomcat이 Nginx 뒤에서 WAS로 동작하는 전체 구조
다음 모듈에서는 Tomcat 세션 클러스터링과 Redis 세션 외부화를 다룹니다.