고객사 서비스 모듈마다 Jenkins 파이프라인을 새로 만들던 중이었다. 파이프라인에 옮겨 담으려고 기존 배포 절차를 받아 보니 이랬다. 로컬에서 ui5.war의 압축을 풀고, 풀린 파일을 서버의 webapps/ui5 디렉터리에 복사한 뒤 Tomcat을 재시작한다. 이 과정을 sh 스크립트로 돌리고 있었다.

파이프라인으로 그대로 옮기기 전에 걸리는 게 있었다. WAR의 압축을 푸는 건 원래 Tomcat이 하는 일이다. 그런데 server.xml을 열어 보니 <Context> 태그와 <Host>의 appBase가 한 Host 안에 섞여 있었고, 기존 관리자는 <Context>를 쓰면 압축 해제를 사람이 해야 한다고 이해하고 있었다.

이 글에서는 두 배포 방식이 섞인 설정에서 Tomcat이 실제로 무엇을 먼저 배포하는지 따라가 보고, 그 결과 배포 절차를 어떻게 바꿨는지 정리한다.

Context와 appBase가 한 Host 안에 섞여 있었다

운영 서버의 server.xml은 다음과 같았다.

<Host name="localhost" appBase="/home/manager/app/webapps"
      unpackWARs="true" autoDeploy="true">

  <Context path="" docBase="/home/manager/app/webapps/legacy" reloadable="false" />
  <Context path="/v5" docBase="/home/manager/app/webapps/ui5" reloadable="false" />

  <!-- ... AccessLogValve 설정 ... -->
</Host>

배포 경로가 두 갈래다. 하나는 <Context>로 경로와 디렉터리를 직접 묶는 명시적 배포다. /v5로 들어온 요청은 ui5 디렉터리가 받는다. 다른 하나는 appBase에 놓인 WAR나 디렉터리를 Tomcat이 알아서 올리는 자동 배포다. unpackWARs="true", autoDeploy="true"가 둘 다 켜져 있으니 이쪽도 살아 있다.

기존 절차는 앞의 갈래만 보고 있었다. <Context>가 ui5 디렉터리를 가리키니 그 디렉터리를 사람이 채워 넣어야 한다고 본 것이다. 뒤의 갈래가 같은 디렉터리를 대신 채워 줄 수 있다는 건 절차에 없었다.

Tomcat이 배포 대상을 고르는 순서

정말 Tomcat이 알아서 풀어 주는지 확인하려고 서버가 뜰 때의 로그부터 봤다.

# Tomcat 로그에서 확인되는 초기화 순서
INFO [main] org.apache.catalina.startup.HostConfig.deployApps Deploying web application directory [/home/manager/app/webapps/ui5]
INFO [main] org.apache.catalina.startup.HostConfig.deployApps Deployment of web application directory [/home/manager/app/webapps/ui5] has finished
INFO [main] org.apache.catalina.startup.HostConfig.deployWARs Deploying web application archive [/home/manager/app/webapps/shop.war]
INFO [main] org.apache.catalina.startup.HostConfig.deployWARs Deployment of web application archive [/home/manager/app/webapps/shop.war] has finished

로그에 찍힌 것은 전부 HostConfig다. HostConfig는 appBase를 훑어서 WAR와 디렉터리를 올리는 자동 배포 담당이다. server.xml의 <Context>는 이 로그에 나오지 않는다. 그쪽은 server.xml을 읽는 시점에 이미 Host의 자식으로 등록되기 때문이다.

그래서 순서는 이렇게 된다. server.xml의 Context가 먼저 경로 이름을 차지하고, HostConfig는 appBase를 훑으면서 이미 차지된 이름을 건너뛴다.

flowchart TD
    A["server.xml 파싱"] --> B["Context 등록<br/>'' → legacy, /v5 → ui5"]
    B --> C["HostConfig가 appBase 스캔"]
    C --> D{"이미 등록된<br/>경로 이름인가?"}
    D -->|예| E["건너뜀"]
    D -->|아니오| F["WAR면 압축 해제 후 배포<br/>디렉터리면 그대로 배포"]

처음에는 이 로그를 “appBase를 먼저 스캔해 WAR를 풀고, 그다음 Context 설정을 처리한다"로 읽었다. 하지만 로그에는 Context 처리가 찍히지 않으니 그렇게 읽을 근거가 없다. 결과는 같다. appBase에 WAR를 두면 Tomcat이 디렉터리로 풀고, <Context>는 그 디렉터리를 가리키기만 한다.

WAR를 appBase에 두기만 하면 풀린다

appBase에 ui5.war를 복사하고 디렉터리가 어떻게 바뀌는지 봤다.

# WAR 파일 배치 전
$ ls -la /home/manager/app/webapps/
total 12
drwxr-xr-x 3 manager manager 4096 Jan 15 10:00 .
drwxr-xr-x 3 manager manager 4096 Jan 15 09:30 ..
drwxr-xr-x 8 manager manager 4096 Jan 15 10:00 legacy

# ui5.war 파일 업로드
$ cp ui5.war /home/manager/app/webapps/

# Tomcat이 자동으로 처리한 결과
$ ls -la /home/manager/app/webapps/
total 15240
drwxr-xr-x 4 manager manager     4096 Jan 15 10:01 .
drwxr-xr-x 3 manager manager     4096 Jan 15 09:30 ..
drwxr-xr-x 8 manager manager     4096 Jan 15 10:00 legacy
drwxr-xr-x 6 manager manager     4096 Jan 15 10:01 ui5
-rw-r--r-- 1 manager manager 15595520 Jan 15 10:01 ui5.war

ui5.war가 놓이자 Tomcat이 곧바로 ui5 디렉터리를 만들고 압축을 풀었다. 로컬에서 풀어 복사하던 두 단계가 통째로 필요 없었다.

<Context>에 정의되지 않은 새 애플리케이션으로도 해 봤다. shop.war를 넣고 로그를 따라갔다.

# shop.war 파일 배치
$ cp shop.war /home/manager/app/webapps/

# 실시간 로그 확인
$ tail -f /opt/tomcat/logs/catalina.out
INFO [ContainerBackgroundProcessor[StandardEngine[Catalina]]] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive [/home/manager/app/webapps/shop.war]
INFO [ContainerBackgroundProcessor[StandardEngine[Catalina]]] org.apache.catalina.startup.HostConfig.deployWAR Deployment of web application archive [/home/manager/app/webapps/shop.war] has finished in [1,234] ms

# 자동 생성된 디렉터리 확인
$ ls -la /home/manager/app/webapps/shop/
total 28
drwxr-xr-x 5 manager manager 4096 Jan 15 10:05 .
drwxr-xr-x 5 manager manager 4096 Jan 15 10:05 ..
drwxr-xr-x 3 manager manager 4096 Jan 15 10:05 META-INF
drwxr-xr-x 2 manager manager 4096 Jan 15 10:05 WEB-INF
-rw-r--r-- 1 manager manager  150 Jan 15 10:05 index.html

스레드 이름이 [main]이 아니라 ContainerBackgroundProcessor다. 서버 시작과 상관없이 백그라운드 스레드가 주기적으로 appBase를 보고 있다가 새 WAR를 잡아낸 것이다. 감지부터 압축 해제, 서비스 시작까지 1~2초 안에 끝났다.

같은 이름이면 Context가 이긴다

두 방식이 같은 경로를 두고 부딪히면 어떻게 되는지도 확인했다. <Context path="/v5">가 있는 상태에서 appBase에 v5.war를 함께 둬 봤다.

<Context path="/v5" docBase="/home/manager/app/webapps/ui5" />
# 요청 테스트
$ curl -I http://localhost:8080/v5/

# 결과: Context로 지정된 ui5 디렉터리에서 응답
# appBase의 v5.war는 완전히 무시됨

로그에도 v5.war를 처리한 흔적이 없었다. /v5라는 이름은 server.xml을 읽을 때 이미 <Context>가 가져갔고, HostConfig는 그 이름을 건너뛴다. 앞에서 본 순서 그대로다.

바꾼 배포 절차

기존 절차와 바꾼 절차를 나란히 놓으면 차이가 분명하다.

# ❌ 기존: 사람이 WAR를 풀어서 docBase 디렉터리에 복사
unzip ui5.war -d /tmp/ui5_temp
cp -r /tmp/ui5_temp/* /home/manager/app/webapps/ui5/
systemctl restart tomcat
# ✓ 변경: WAR를 appBase에 두고 압축 해제는 Tomcat에 맡긴다
cp ui5.war /home/manager/app/webapps/
# Tomcat이 ui5 디렉터리를 만들고 압축을 푼다
# /v5 Context는 그 디렉터리를 가리키기만 한다

두 방식 자체를 비교하면 이렇다.

구분appBase 자동 배포Context 명시적 배포
배포 대상Context에 정의되지 않은 모든 경로server.xml에 명시된 고정 경로
경로 결정WAR 파일명 기반 (shop.war → /shop)path 속성으로 임의 지정 가능
재시작 필요성불필요 (Hot Deploy)경우에 따라 필요
운영 편의성매우 높음중간 (설정 관리 필요)
서비스 안정성중간높음 (명시적 제어)

<Context>를 버리지는 않았다. /v5처럼 WAR 이름과 다른 경로가 필요하면 <Context>가 맞다. 바뀐 건 <Context>는 경로를 정하고, 파일을 채우는 건 자동 배포에 맡긴다는 역할 분담이다.

배포시간 30초로 단축

새 Jenkins 파이프라인을 만들면서 모듈마다 같은 방식으로 절차를 고쳤다. 사실 이보다 더 처참한 방식으로 배포되던 모듈도 있었다.

구분기존변경 후
배포 시간약 5~10분약 30초
파일 권한수동 압축 해제 과정에서 권한 문제 발생해결
휴먼 에러압축 해제·복사·재시작 단계마다 개입최소화

배포 중에 서비스가 잠깐 끊기는 문제는 이 작업의 범위 밖이었다. 그건 나중에 “배포할 때마다 끊기던 서비스를 무중단으로: Jenkins Rolling Deployment에서 내린 네 가지 결정"에서 따로 다뤘다.

아직 확인해야 할 것

  1. ui5 디렉터리가 /ui5로도 한 번 더 배포되고 있을 수 있다. 시작 로그의 Deploying web application directory [.../ui5]는 HostConfig가 ui5 디렉터리를 자기 이름(/ui5)으로 올렸다는 뜻으로 읽힌다. <Context>의 docBase가 appBase 안에 있으면 같은 애플리케이션이 /v5와 /ui5로 두 번 뜰 수 있다고 Tomcat 문서도 경고한다. legacy도 같은 구조다. 막으려면 docBase를 appBase 밖으로 빼거나 Host의 deployIgnore로 해당 디렉터리를 제외한다.

  2. WAR를 교체할 때 /v5가 새 파일을 언제 반영하는지. /v5는 reloadable="false"이고, 디렉터리를 새로 푸는 주체는 /ui5 쪽 자동 배포다. /v5가 재시작 없이 새 클래스를 읽는지, 교체 순간 디렉터리가 지워졌다 다시 생기면서 /v5 요청이 실패하지 않는지는 따로 확인해야 한다.

정리

  • WAR의 압축 해제는 Tomcat의 일이다. unpackWARs와 autoDeploy가 켜져 있으면 appBase에 WAR를 두는 것으로 충분하다.

  • server.xml의 Context가 먼저 이름을 차지한다. HostConfig는 appBase를 훑을 때 이미 등록된 경로를 건너뛰므로, 같은 이름이면 Context가 이긴다.

  • Context는 경로를, 자동 배포는 파일을 맡는다. 둘을 섞어 쓸 때는 역할을 이렇게 나누면 사람이 끼어들 단계가 사라진다.

  • docBase를 appBase 안에 두면 이중 배포가 생길 수 있으니 deployIgnore나 경로 분리를 같이 챙긴다.

한 번 돌아가기 시작한 배포 스크립트는 좀처럼 다시 읽지 않게 된다. 파이프라인으로 옮기는 작업은 그 스크립트를 한 줄씩 다시 읽을 기회였다. 옮기기 전에 의심해 볼 만하다.