infra
Platform

모듈 맵

[Infra Ops] WAR 배포부터 server.xml 튜닝, 장애 대응까지

0 / 52 완료

펼치기
0 / 52 완료0%

인프라 운영 & SRE · 16 / 52

[Infra Ops] WAR 배포부터 server.xml 튜닝, 장애 대응까지

WAR 배포, server.xml/context.xml 설정, JNDI DataSource, maxThreads 튜닝, catalina.out 분석까지 — 현장 인프라 엔지니어의 Tomcat 운영 실무

🚨INCIDENT ALERT
HIGH

운영팀에서 급하게 연락이 왔습니다. 고객사 배포 요청이 들어와 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이든 동일하게 적용됩니다.

Tomcat 디렉터리 구조 — bin(기동 스크립트)·conf(server.xml·web.xml 설정)·webapps(배포 WAR)·logs(catalina.out)·lib(공용 라이브러리)·work(JSP 컴파일 캐시). 로그 위치를 알아야 장애 대응이 빠르고, 라이브러리를 잘못된 경로에 두면 ClassNotFound가 발생확대

$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_HOMETomcat 바이너리 설치 경로 (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 요청을 받는 입구입니다. 여기서 스레드 수, 대기 큐, 타임아웃을 잘못 설정하면 트래픽 급증 시 서비스가 멈추거나, 반대로 리소스를 낭비합니다.

XML
<!-- /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" />

파라미터별 역할과 튜닝 기준:

파라미터기본값의미튜닝 기준
maxThreads200동시에 처리할 수 있는 요청 스레드 최대 수CPU 코어 수 × 2~4에서 시작, 부하 테스트로 결정
minSpareThreads10항상 대기 중인 최소 스레드 수트래픽 패턴에 따라 25~50 사이
acceptCount100maxThreads 소진 시 OS 큐에서 대기할 요청 수너무 크면 클라이언트 타임아웃 전에 대기, 너무 작으면 연결 거부
connectionTimeout20000(ms)클라이언트가 연결 후 요청을 보내지 않을 때 대기 시간Keep-alive 환경에서는 줄이는 경우 있음
URIEncodingISO-8859-1URL 경로의 문자 인코딩한국어 서비스라면 UTF-8 필수

요청 처리 흐름 — 스레드가 소진되면 어떻게 되나:

  1. 요청이 들어오면 가용 스레드가 할당되어 처리
  2. 가용 스레드가 maxThreads에 도달하면 → acceptCount 크기의 OS 큐에 대기
  3. OS 큐도 가득 차면 → 새 연결은 즉시 거부(Connection refused)

따라서 maxThreads를 무작정 높이는 것이 해결책이 아닙니다. 스레드가 오래 점유되는 근본 원인(느린 DB 쿼리, 외부 API 지연)을 먼저 찾아야 합니다.

로컬 터미널
# server.xml 설정 확인 (grep으로 빠르게)
grep -A 5 'Connector port="8080"' /opt/tomcat/conf/server.xml

Tomcat Thread Pool — 스레드 상태와 튜닝 기준확대

💡개념

서블릿 요청이 Tomcat을 통과하는 7단계 — Connector부터 service()까지

브라우저가 http://shop.example.com/shop/cart를 요청하면 화면에 장바구니가 뜹니다. 그 사이 Tomcat 안에서는 Connector 수신 → 스레드 배정 → Engine·Host·Context 매핑 → 필터 체인 → 서블릿 service() → 응답이 순서대로 일어납니다. 이 단계를 나눠 두면 "왜 404지", "왜 요청이 catalina.out에 로그도 안 남고 사라지지", "왜 로그인이 풀리지"를 특정 단계로 좁힐 수 있습니다. Tomcat은 WAR를 올리는 상자가 아니라 아래 7단계를 밟는 컨테이너입니다.

TEXT
[클라이언트]  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의 currentThreadsBusyss -tlnpRecv-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 설정을 분리할 수 있기 때문입니다.

XML
<!-- /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&amp;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):

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이 발생할 수 있습니다.

1WAR 배포 및 기동 확인

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/ 로 컨텍스트 루트부터 테스트
2스레드·연결 상태 확인 및 슬로우 요청 탐지

배포 후 정상 동작하더라도 스레드 점유와 응답 시간을 주기적으로 확인해야 합니다. 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.xmlValve 설정에 따라 다릅니다. %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인데 JNDI maxTotal이 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의 currentThreadsBusymaxThreads와 같으면 스레드 포화가 확정입니다. 이어 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.xmlcontext.xml을 자신 있게 다루고, catalina.out에서 원인을 찾는 루틴을 익혀두면 배포·장애 대응 속도가 확실히 달라집니다.

명령어·단축키 빠른 참조

이 모듈에서 다룬 Tomcat 배포·진단 명령을 모았습니다. WAR 배포와 장애 첫 3분 대응 시 이 순서대로 확인합니다.

명령어/단축키용도자주 쓰는 예
pgrep -a -f catalinaTomcat 프로세스·인자 확인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 OutOfMemoryErrorOOM 발생 확인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.warWAR 내부 목록 확인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 세션 외부화를 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

WAR 배포 후 Tomcat이 webapps 하위에 자동으로 압축 해제하도록 제어하는 server.xml Host 속성은?

Q2

JNDI DataSource를 server.xml이 아닌 context.xml에 정의하는 가장 중요한 이유는?

Q3

Tomcat Connector의 maxThreads 기본값과, 이 값을 지나치게 높게 설정했을 때 발생할 수 있는 주요 문제는?

Q4

Tomcat catalina.out 파일에서 OutOfMemoryError 발생 라인을 빠르게 찾는 grep 명령어는?

Q5

Tomcat의 $CATALINA_HOME에서 conf, webapps, logs 디렉터리의 역할은?

Q6

catalina.out 파일이 무한히 커져 디스크를 채운다. 운영에서 다루는 법은?

Q7

[심화] maxThreads가 전부 사용 중일 때 새로 도착한 연결은 어떻게 되는가?

Q8

[심화] 부하 시 클라이언트는 타임아웃·Connection refused를 겪는데 catalina.out에는 에러가 한 줄도 없다. 가장 그럴듯한 원인과 확인 방법은?

0 / 8 답변

🧪 실습으로 확인하기

Tomcat WAS 응답 지연 — 스레드 풀·GC 진단

중급

CPU는 한가한데 WAS가 느리다. 원인은 보통 둘 중 하나다: 스레드 풀 고갈(maxThreads 부족·느린 외부 호출로 스레드가 묶임) 또는 GC 폭주(힙 부족·풀 GC 빈발). 스레드 덤프와 GC 로그로 어느 쪽인지 가르고, Connector/힙 설정을 근거 있게 조정한다.

45📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요