야간 정기 배포 시간입니다. WAR 파일을 Tomcat에 올려야 하고, React 빌드 결과물은 Nginx로 서빙되는 서버에 올려야 합니다. 그리고 Spring Boot JAR로 동작하는 API 서버도 있습니다. 배포마다 순서를 헷갈리고, 실수로 백업 없이 WAR를 덮어쓴 뒤 기동에 실패한 적도 있습니다. 롤백하려 하니 이전 버전 파일이 없어서 빌드 서버에서 다시 받는 데 20분이 걸렸습니다.
이 모듈은 WAR/JAR/정적파일 배포를 실수 없이 반복 가능한 구조로 만드는 방법을 다룹니다. 배포 스크립트 작성, 헬스체크, 실패 시 롤백 절차까지 포함합니다.
- 1WAR(Tomcat)/JAR(Spring Boot)/정적파일(Nginx) 배포 방식의 차이를 설명하고 각 절차를 실행할 수 있다
- 2배포 스크립트의 구성 요소(백업/배포/기동/헬스체크/롤백)를 이해하고 기본 스크립트를 작성할 수 있다
- 3Nginx reload와 Tomcat restart를 상황에 따라 선택하고 실행할 수 있다
- 4배포 후 헬스체크(curl 응답코드/로그/프로세스)로 성공 여부를 확인할 수 있다
- 5기동 실패 시 catalina.out에서 원인을 찾고 이전 버전으로 롤백하는 절차를 따를 수 있다
배포 유형 비교
WAR / JAR / 정적파일 배포 방식 차이
배포할 산출물의 종류에 따라 절차가 달라집니다. 셋을 혼동하면 엉뚱한 서버를 재기동하거나 서비스 중단이 생깁니다.

야간 배포를 진행하다가 React 빌드 결과물을 Tomcat webapps 디렉터리에 복사했습니다. Nginx를 재기동했는데 서비스가 안 뜨고, 알고 보니 JAR는 아직 구버전이 실행 중이었습니다. 산출물 종류를 구분하지 못하면 엉뚱한 서버를 재기동하거나 잘못된 위치에 파일을 올리는 실수가 반복됩니다. WAR/JAR/정적파일은 실행 환경이 다르고, 재기동 대상도 다르고, 배포 후 확인 방법도 다릅니다. 이 차이를 먼저 이해해야 배포 스크립트를 실수 없이 작성할 수 있습니다.
| 산출물 | 실행 환경 | 재기동 대상 | 배포 후 확인 |
|---|---|---|---|
| WAR | Tomcat (webapps/) | Tomcat 재기동 | catalina.out, HTTP 응답 |
| JAR | JVM 직접 (systemd) | 서비스 재기동 | systemctl status, HTTP 응답 |
| 정적파일(dist/) | Nginx (html/) | Nginx reload | 브라우저 확인, curl |
WAR 배포 절차
Tomcat WAR 배포 — 표준 절차
WAR 파일을 Tomcat webapps/ 디렉터리에 복사하면 배포됩니다. 자동 배포(autoDeploy)를 꺼두고 수동으로 Tomcat을 재기동하는 것이 운영 표준입니다.
# ── 배포 전 준비 ─────────────────────────────────────
# 현재 버전 백업 (날짜와 시간 포함)
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backup/tomcat"
mkdir -p ${BACKUP_DIR}
cp /opt/tomcat/webapps/myapp.war ${BACKUP_DIR}/myapp_${TIMESTAMP}.war
echo "백업 완료: ${BACKUP_DIR}/myapp_${TIMESTAMP}.war"
# ── Tomcat 중지 ───────────────────────────────────────
systemctl stop tomcat
# 완전히 종료됐는지 확인
sleep 3
ps aux | grep catalina | grep -v grep
# 아무것도 안 나와야 함
# ── 배포 ─────────────────────────────────────────────
# 이전 배포 디렉터리 제거 (exploded WAR 캐시 정리)
rm -rf /opt/tomcat/webapps/myapp/
# 새 WAR 복사
cp /tmp/myapp.war /opt/tomcat/webapps/myapp.war
ls -lh /opt/tomcat/webapps/myapp.war
# ── Tomcat 기동 ───────────────────────────────────────
systemctl start tomcat
# ── 기동 확인 ─────────────────────────────────────────
# 로그 실시간 확인 (기동 완료까지 대기)
timeout 120 tail -f /opt/tomcat/logs/catalina.out | grep -m1 -E "Server startup|SEVERE|Exception"
JAR 배포 절차
Spring Boot JAR 배포 — systemd 서비스
Spring Boot는 내장 Tomcat이 포함된 실행 가능한 JAR를 만듭니다. 별도 WAS 없이 java -jar myapp.jar로 실행합니다. 운영 환경에서는 systemd 서비스로 등록해 관리합니다.
# /etc/systemd/system/myapp.service (서비스 파일 예시)
# [Unit]
# Description=My Spring Boot Application
# After=network.target
#
# [Service]
# User=appuser
# WorkingDirectory=/opt/app
# ExecStart=/usr/bin/java -jar /opt/app/myapp.jar
# EnvironmentFile=/etc/myapp/env
# Restart=on-failure
# RestartSec=5
#
# [Install]
# WantedBy=multi-user.target
# ── JAR 배포 절차 ─────────────────────────────────────
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backup/app"
mkdir -p ${BACKUP_DIR}
# 현재 JAR 백업
cp /opt/app/myapp.jar ${BACKUP_DIR}/myapp_${TIMESTAMP}.jar
# 서비스 중지
systemctl stop myapp
sleep 2
systemctl is-active myapp # inactive 여야 함
# 새 JAR 배포
cp /tmp/myapp-1.2.0.jar /opt/app/myapp.jar
ls -lh /opt/app/myapp.jar
# 서비스 시작
systemctl start myapp
# 기동 상태 확인
sleep 5
systemctl status myapp | head -20
journalctl -u myapp -n 30 --no-pager | grep -E "Started|ERROR|Exception"

정적파일 배포 절차
React/Vue 정적파일 배포 — Nginx
프론트엔드 빌드 결과물(dist/ 또는 build/)을 Nginx root 디렉터리에 복사하고 reload합니다. 프로세스 재시작 없이 설정만 재로드하므로 연결 중단이 없습니다.
# ── 정적파일 배포 절차 ────────────────────────────────
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
WEBROOT="/var/www/myapp"
BACKUP_DIR="/opt/backup/frontend"
mkdir -p ${BACKUP_DIR}
# 현재 버전 백업
tar -czf ${BACKUP_DIR}/frontend_${TIMESTAMP}.tar.gz -C /var/www myapp
echo "백업: ${BACKUP_DIR}/frontend_${TIMESTAMP}.tar.gz"
# 배포 (rsync로 변경분만 전송)
rsync -avz --delete /tmp/dist/ ${WEBROOT}/
# --delete : 이전에 있던 파일 중 새 dist에 없는 파일 제거
# 또는 단순 복사
cp -r /tmp/dist/* ${WEBROOT}/
# 파일 권한 설정
chown -R nginx:nginx ${WEBROOT}
find ${WEBROOT} -type f -exec chmod 644 {} \;
find ${WEBROOT} -type d -exec chmod 755 {} \;
# Nginx 설정 검증 후 reload (연결 유지)
nginx -t && systemctl reload nginx
# 배포 확인
curl -s -o /dev/null -w "%{http_code}" http://localhost/
배포 스크립트 작성
배포 스크립트는 백업 → 배포 → 기동 → 헬스체크 → 실패 시 롤백 흐름을 자동화합니다. 아래는 기본 구조입니다.
#!/bin/bash
# /opt/scripts/deploy-war.sh
set -e # 오류 발생 시 즉시 중단
WAR_FILE="$1"
APP_NAME="myapp"
TOMCAT_HOME="/opt/tomcat"
WEBAPPS="${TOMCAT_HOME}/webapps"
BACKUP_DIR="/opt/backup/tomcat"
HEALTH_URL="http://localhost:8080/myapp/health"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
if [ -z "$WAR_FILE" ]; then
echo "Usage: $0 <war_file_path>"
exit 1
fi
echo "=== 배포 시작: $(date) ==="
echo "배포 파일: $WAR_FILE"
# ① 백업
mkdir -p ${BACKUP_DIR}
if [ -f "${WEBAPPS}/${APP_NAME}.war" ]; then
cp "${WEBAPPS}/${APP_NAME}.war" "${BACKUP_DIR}/${APP_NAME}_${TIMESTAMP}.war"
echo "백업 완료: ${BACKUP_DIR}/${APP_NAME}_${TIMESTAMP}.war"
fi
# ② Tomcat 중지
echo "Tomcat 중지 중..."
systemctl stop tomcat
sleep 3
# ③ 배포
rm -rf "${WEBAPPS}/${APP_NAME}/"
cp "$WAR_FILE" "${WEBAPPS}/${APP_NAME}.war"
echo "WAR 복사 완료: $(ls -lh ${WEBAPPS}/${APP_NAME}.war)"
# ④ Tomcat 기동
echo "Tomcat 기동 중..."
systemctl start tomcat
# ⑤ 헬스체크 (최대 60초 대기)
echo "헬스체크 대기 중..."
for i in $(seq 1 12); do
sleep 5
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${HEALTH_URL}" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
echo "헬스체크 통과: HTTP ${HTTP_CODE}"
echo "=== 배포 완료: $(date) ==="
exit 0
fi
echo "대기 중... (${i}/12) HTTP ${HTTP_CODE}"
done
# ⑥ 헬스체크 실패 → 롤백
echo "ERROR: 헬스체크 실패. 롤백 시작..."
systemctl stop tomcat
LATEST_BACKUP=$(ls -t ${BACKUP_DIR}/${APP_NAME}_*.war 2>/dev/null | head -1)
if [ -n "$LATEST_BACKUP" ]; then
cp "$LATEST_BACKUP" "${WEBAPPS}/${APP_NAME}.war"
echo "롤백 파일: $LATEST_BACKUP"
systemctl start tomcat
echo "롤백 완료"
else
echo "CRITICAL: 백업 파일 없음. 수동 조치 필요"
fi
exit 1
bash /opt/scripts/deploy-war.sh /tmp/myapp.war- 스크립트 실행 후 '헬스체크 통과: HTTP 200' 메시지가 출력됐는가
- BACKUP_DIR 에 타임스탬프 포함 백업 파일이 생성됐는가 (ls -lh /opt/backup/tomcat/)
- systemctl status tomcat 이 active (running) 상태인가
- tail -f /opt/tomcat/logs/catalina.out 에서 SEVERE 또는 Exception 없이 'Server startup in' 메시지가 있는가
배포 후 세 가지 관점에서 확인합니다: HTTP 응답, 프로세스 상태, 로그 에러 여부.
# 1. HTTP 응답 확인
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/myapp/health)
echo "HTTP 상태코드: $HTTP_CODE"
# 2. 프로세스 상태 확인
systemctl status tomcat | grep -E "Active|Main PID"
ps aux | grep java | grep catalina | grep -v grep | awk '{print "PID:", $2, "메모리:", $6/1024"MB"}'
# 3. 최근 로그 오류 확인
grep -E "SEVERE|Exception|OutOfMemory|StackOverflow" /opt/tomcat/logs/catalina.out | tail -10
# 4. 배포된 버전 확인 (MANIFEST.MF 에 버전 정보가 있으면)
unzip -p /opt/tomcat/webapps/myapp.war META-INF/MANIFEST.MF 2>/dev/null | grep "Implementation-Version"
# 5. 정적파일 배포 후 확인 (Nginx)
curl -I http://localhost/ | grep -E "HTTP|Last-Modified|Content-Type"
HTTP 상태코드: 200
Active: active (running) since Fri 2026-05-30 10:45:00 KST
PID: 12345 메모리: 512.00MB
curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/myapp/health- HTTP 상태코드가 200인가 (500이면 애플리케이션 오류, 502면 Tomcat 미기동)
- Tomcat/앱 프로세스 메모리 사용량이 비정상적으로 높지 않은가 (xmx 설정 대비)
- catalina.out 에 OutOfMemoryError 또는 ClassNotFoundException 없는가
- 배포 후 첫 요청 응답 시간이 콜드 스타트 이후 정상화됐는가 (최초 요청은 느릴 수 있음)
트러블슈팅
원인: 이전 Tomcat 프로세스가 완전히 종료되지 않은 상태에서 새 Tomcat이 기동을 시도했습니다. 8080 포트를 이전 프로세스가 아직 점유하고 있습니다.
# 1. 8080 포트 점유 프로세스 확인
ss -tlnp | grep ':8080'
lsof -i:8080
# 2. 이전 Tomcat 프로세스 확인 및 종료
ps aux | grep catalina | grep -v grep
# PID 확인 후
kill -15 <PID> # 정상 종료 시도
sleep 3
# 종료 안 되면 강제
kill -9 <PID>
# 3. Tomcat PID 파일 잔존 확인
ls -la /opt/tomcat/work/catalina.pid 2>/dev/null
rm -f /opt/tomcat/work/catalina.pid
# 4. 재기동
systemctl start tomcat
systemctl status tomcat
# 5. 재발 방지: 배포 스크립트에 종료 대기 로직 추가
# systemctl stop tomcat
# while pgrep -f catalina > /dev/null; do sleep 1; done
# echo "Tomcat 완전 종료 확인"
다른 원인 패턴 (catalina.out 에서 확인):
# 클래스 충돌: java.lang.ClassNotFoundException
# → 이전 exploded 디렉터리가 남아 있음 → rm -rf webapps/myapp/
# 설정 오류: java.io.FileNotFoundException: application-prod.yml
# → setenv.sh 경로 확인
grep -E "Exception|Error" /opt/tomcat/logs/catalina.out | tail -20
원인: 두 가지 캐시 레이어가 있습니다. ① 브라우저 캐시(로컬) ② CDN 엣지 캐시(원격). 파일을 서버에 올려도 이 캐시들이 남아 있으면 구버전이 계속 제공됩니다.
# 1. 서버에 새 파일이 올라갔는지 먼저 확인
ls -lh /var/www/myapp/static/js/
curl -I http://localhost/static/js/main.a3f9c21.js | grep "Last-Modified"
# 2. Nginx 캐시 확인 (proxy_cache를 쓰는 경우)
# Nginx 캐시 디렉터리 비우기
rm -rf /var/cache/nginx/*
nginx -s reload
# 3. 브라우저 캐시 우회 (개발자 도구에서 확인)
# Chrome: F12 → Network 탭 → "Disable cache" 체크 후 새로고침
# curl 로 캐시 없이 직접 확인
curl -H "Cache-Control: no-cache" http://your-domain.com/static/js/main.js -I
# 4. CDN 캐시 purge (CloudFront 예시)
aws cloudfront create-invalidation \
--distribution-id ABCDEFG123 \
--paths "/*"
# 5. 근본 해결: 파일명에 컨텐츠 해시 포함 (Create React App 기본 동작)
# main.a3f9c21.js ← 파일 내용이 바뀌면 해시가 바뀜
# 이 방식이면 CDN 캐시 무효화 없이도 새 버전이 자동 적용됨
ls /var/www/myapp/static/js/
# main.abc12345.chunk.js ← 배포마다 파일명 변경 확인
배포 체크리스트 산출물
배포 전후 체크리스트
반복 가능한 배포를 위해 체크리스트를 표준화합니다. 야간 배포나 긴급 배포에서 실수를 줄이는 핵심 도구입니다.
배포 전 체크리스트
□ 빌드 산출물 확인 (파일명, 크기, 버전)
□ DB 마이그레이션 스크립트 준비 여부 확인
□ setenv.sh 프로파일 설정 확인 (prod 여야 함)
□ 이전 버전 백업 경로 확인
□ 헬스체크 URL 확인
□ 롤백 절차 숙지 (이전 WAR/JAR 경로)
배포 후 체크리스트
□ 헬스체크 HTTP 200 확인
□ systemctl status 프로세스 active 확인
□ catalina.out 또는 app.log 에 ERROR 없음 확인
□ 주요 기능 smoke test (로그인, 핵심 API 1~2개)
□ 모니터링 대시보드 정상 확인 (CPU/메모리/응답시간)
□ 배포 완료 팀 공유 (Slack/Teams 알림)
실제 업무에서 이 지식이 쓰이는 상황:
인프라 엔지니어가 배포를 담당하는 세 가지 대표 상황입니다.
1. 정기 야간 배포:
# 표준 배포 실행 루틴
# 21:00 — 배포 준비
echo "배포 예정 파일:"
ls -lh /tmp/myapp-*.war
# DB 마이그레이션 먼저 (애플리케이션 배포 전)
mysql -h prod-db -u dba -p mydb < /deploy/V10__add_column.sql
mysql -h prod-db -u dba -p mydb -e "DESCRIBE users;" | grep phone
# 애플리케이션 배포
bash /opt/scripts/deploy-war.sh /tmp/myapp-1.2.0.war
# 배포 후 확인
curl -s http://localhost:8080/myapp/version
# {"version":"1.2.0","buildTime":"2026-05-30T18:30:00"}
2. 긴급 롤백 — 배포 후 문제 발생:
# 장애 감지 즉시 실행
systemctl stop tomcat
# 직전 백업 파일 확인
ls -lt /opt/backup/tomcat/ | head -5
# 롤백
cp /opt/backup/tomcat/myapp_20260530_210015.war /opt/tomcat/webapps/myapp.war
rm -rf /opt/tomcat/webapps/myapp/
systemctl start tomcat
# DB 롤백이 필요하면 (UNDO SQL)
mysql -h prod-db -u dba -p mydb < /deploy/V10__undo_add_column.sql
# 복구 확인
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/myapp/health
3. 다중 서버 배포 (로드밸런서 뒤에 서버 2대):
# 1대씩 배포해 서비스 중단 없이 진행
# 서버 1 LB에서 제외 → 배포 → 헬스체크 통과 → LB 복귀
# 서버 2 동일 반복
# LB 제외/복귀는 LB 종류에 따라 다름
# HAProxy:
echo "disable server myapp/web01" | socat stdio /var/run/haproxy.sock
# 배포 완료 후
echo "enable server myapp/web01" | socat stdio /var/run/haproxy.sock
배포는 기술보다 절차가 중요합니다. 체크리스트와 스크립트가 있으면 어떤 팀원이 배포해도 결과가 일관됩니다. 다음 모듈에서는 컨테이너 배포와 WAS 배포를 비교하고 실무 전환 방법을 다룹니다.