야간 정기 배포 시간입니다. 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 |
배포 전체 흐름 — 산출물부터 롤백까지
배포 한 번에 서버에서 무슨 일이 일어나나 — 산출물부터 롤백 보관까지 6단계
WAR·JAR·정적파일은 산출물만 다를 뿐, "서버에 새 버전이 올라가 트래픽을 받기까지"의 뼈대는 같습니다. 빌드 산출물이 대상 서버로 전송되고 → 릴리스 디렉터리에 배치돼 current 링크가 새 버전을 가리키고 → 서비스가 재시작·리로드되고 → 헬스로 확인한 뒤 → 이전 릴리스를 롤백용으로 남깁니다. 이 흐름을 한 줄로 꿰면 "배포했는데 안 바뀐다", "배포 중 502가 뜬다", "롤백이 오래 걸린다"를 각각 어느 단계가 빠졌는지로 좁힐 수 있습니다.
[CI·빌드 서버] build → myapp-1.4.2.war · app.jar · dist/
│
① 산출물 확정 버전 태그가 붙은 불변 아티팩트로 고정
│
② 대상 서버 전송 scp·rsync 로 releases/20260707_2100/ 에 통째로 풀기
│
③ current 링크 전환 ln -sfn 새릴리스 current_tmp → mv -T current_tmp current (원자적)
│
④ 재시작·리로드 프로세스형(WAR·JAR)=재기동 / 정적·Nginx=reload 로 새 경로 반영
│
⑤ 헬스 확인 curl 200 · systemctl active · 로그 정상 startup
│
⑥ 이전 릴리스 보관 직전 releases 유지 → 문제 시 current 되돌리기
▼
[운영] current 가 새 릴리스를 가리키고, 이전 릴리스는 롤백 카드로 대기
각 단계에서 하는 일과, 막히면 나타나는 증상:
| 단계 | 하는 일 | 막히면 증상 |
|---|---|---|
| ① 산출물 확정 | CI가 WAR·JAR·dist/를 버전 태그가 붙은 불변 아티팩트로 만든다 | latest·태그 없이 배포하면 지금 뭐가 떠 있는지 불명확하고 되돌릴 기준 버전이 없음 |
| ② 대상 서버 전송 | scp·rsync로 대상 서버의 releases/<날짜> 디렉터리에 통째로 푼다 | 전송 중 끊기면 반쯤 풀린 릴리스, 소유자·권한이 어긋나면 서비스가 파일을 못 읽어 기동 실패(Permission denied) |
| ③ current 링크 전환 | current 심볼릭 링크를 새 릴리스로 원자적(mv -T)으로 교체한다 | rm current 후 ln 하는 2단계면 링크가 비는 찰나에 404(정적)·502(업스트림) — 원자성은 아래 심화에서 상술 |
| ④ 재시작·리로드 | 프로세스형(WAR·JAR)은 재기동, 정적·Nginx는 reload로 새 경로를 다시 읽게 한다 | 링크만 바꾸고 재시작을 빠뜨리면 프로세스가 옛 경로·옛 클래스로 계속 실행돼 변경이 반영 안 됨 |
| ⑤ 헬스 확인 | curl 응답코드·systemctl active·로그의 정상 기동 메시지로 성공을 확정한다 | 확인을 건너뛰면 기동 실패·불완전 배포를 사용자가 먼저 발견 |
| ⑥ 이전 릴리스 보관 | 직전 릴리스를 지우지 않고 남겨 롤백 대상으로 둔다 | 이전 릴리스를 안 남기면 롤백 때 빌드 서버에서 재빌드·재전송해야 해 복구 시간(MTTR)이 급증 |
즉 배포가 틀어질 때 이 6칸 중 어디서 끊겼는지로 좁힙니다 — "변경이 반영 안 됨"은 대개 ④(재시작·리로드 누락), "배포 중 간헐 404/502"는 ③(비원자적 링크 교체), "롤백이 오래 걸림"은 ⑥(이전 릴리스 미보관)입니다. 각 단계의 산출물(전송된 릴리스 디렉터리 · current가 가리키는 실제 경로 · 헬스 응답코드)을 확인하면 어느 칸이 비었는지 드러납니다.
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 캐시 정리)
TARGET_DIR="/opt/tomcat/webapps/myapp"
test "$(readlink -f "$TARGET_DIR")" = "/opt/tomcat/webapps/myapp" || { echo "예상 경로가 아니므로 중단" >&2; exit 1; }
test -d "$TARGET_DIR" && rm -rf -- "$TARGET_DIR"
# 새 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
# ③ 배포
TARGET_DIR="${WEBAPPS}/${APP_NAME}"
case "$TARGET_DIR" in
/opt/tomcat/webapps/*) ;;
*) echo "허용되지 않은 배포 경로: $TARGET_DIR" >&2; exit 1 ;;
esac
test -n "$APP_NAME" && test "$APP_NAME" != "ROOT" || { echo "APP_NAME 확인 필요" >&2; exit 1; }
test "$(readlink -f "$TARGET_DIR")" = "/opt/tomcat/webapps/$APP_NAME" || { echo "심볼릭 링크/경로 불일치" >&2; exit 1; }
test -d "$TARGET_DIR" && rm -rf -- "$TARGET_DIR"
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- BACKUP_DIR의 파일 목록을 먼저 확인(ls -lh /opt/backup/tomcat/) — 타임스탬프 포함 파일이 없으면 백업 단계 실패로 배포 즉시 중단. 백업 없는 배포는 롤백 불가
- catalina.out에서 "Server startup in" 메시지를 찾는다 — 이 줄이 없으면 Tomcat이 기동 중 실패한 것. SEVERE 또는 Exception 줄이 먼저 나오면 그 줄이 실제 원인
- 헬스체크 HTTP 200이 오고 catalina.out에 오류가 없는 조합이면 배포 성공 — 200이지만 catalina.out에 ClassNotFoundException이 있으면 일부 기능만 동작 중인 불완전 배포 상태
배포 후 세 가지 관점에서 확인합니다: 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이면 애플리케이션 오류(catalina.out 확인), 502면 Tomcat 미기동(systemctl status tomcat), 503이면 Nginx upstream 설정 오류
- ps aux | grep java에서 메모리 사용량(RSS)이 -Xmx 설정의 80%를 넘으면 위험 — Xmx=2G 설정 시 RSS 1.6GB 이상이면 OutOfMemoryError 임박 상태로 즉각 모니터링 강화
- 배포 후 첫 요청 응답 시간이 10초 이상이어도 정상(JVM 웜업) — 3번째 요청 이후 응답 시간이 배포 전 기준치의 2배 이상이면 콜드 스타트가 아닌 실제 성능 저하
트러블슈팅
원인: 이전 Tomcat 프로세스가 완전히 종료되지 않은 상태에서 새 Tomcat이 기동을 시도했습니다. 8080 포트를 이전 프로세스가 아직 점유하고 있습니다.
# 1. 8080 포트 점유 프로세스 확인
ss -tlnp | grep ':8080'
lsof -i:8080
# 2. 이전 Tomcat 프로세스 확인 및 종료
ps aux | grep catalina | grep -v grep
# PID 확인 후, 해당 PID의 실행 파일과 cmdline이 내가 중지하려는 Tomcat인지 재확인
kill -15 <PID> # 정상 종료 시도
sleep 3
# 3초 후에도 같은 Tomcat PID가 남아 있을 때만 마지막 수단으로 강제 종료
ps -p <PID> -o pid=,comm=,args=
# 출력이 예상한 Tomcat 프로세스일 때만 운영 승인 후 실행: 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 캐시 디렉터리 비우기
CACHE_DIR="/var/cache/nginx"
test "$(readlink -f "$CACHE_DIR")" = "$CACHE_DIR" || { echo "캐시 경로 검증 실패" >&2; exit 1; }
find "$CACHE_DIR" -mindepth 1 -maxdepth 1 -print
read -r -p "위 목록을 확인했습니까? [y/N] " ok
case "$ok" in y|Y) sudo find "$CACHE_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + ;; *) echo "취소"; exit 1 ;; esac
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 알림)
심화 — 배포의 '전환'을 원자적 연산 하나로
심화: releases 디렉터리와 current 심볼릭 링크 — 서버 내 원자성과 그 한계
cp로 덮어쓰는 배포는 복사 도중의 순간이 노출되고, 롤백은 백업 파일을 다시 복사해야 해 느립니다. 심볼릭 링크를 바꾸는 방식은 '전환'을 원자적 연산 하나로 만들어 이 두 문제를 동시에 줄입니다.
- 구조:
releases/20260706_2100처럼 타임스탬프 디렉터리에 새 버전을 통째로 풀어 둡니다(복사가 끝날 때까지 서비스는 옛 버전을 봄). 준비가 끝나면current라는 심볼릭 링크를 새 릴리스로 교체하고, 웹서버·서비스의 경로는 항상current를 가리키게 둡니다. 롤백은current를 이전 릴리스로 다시 링크하는 것으로 끝나 초 단위입니다(Capistrano 같은 도구가 이 구조). - 원자성의 진짜 조건:
rm current && ln -s ... current2단계는 그 사이 링크가 없는 창을 만듭니다. 확실히 원자적으로 하려면 임시 링크를 만든 뒤mv -T current_tmp current로 덮어씁니다 —rename(2)는 원자적이라 '링크가 없는 순간'이 사라집니다. - 파일 교체 ≠ 프로세스 교체: 심볼릭 링크는 '어떤 파일을 볼지'를 원자적으로 바꿀 뿐, 실행 중인 JAR/Tomcat 프로세스를 무중단으로 갈아 끼우지는 못합니다. 프로세스형 앱은 링크 교체 뒤 reload/재기동으로 새 경로를 다시 읽게 해야 하고, Nginx도 열어 둔 파일 디스크립터 때문에
nginx -s reload가 짝을 이뤄야 새 파일을 잡습니다. - 블루그린과의 경계: 한 서버 안 심볼릭 스왑에도 프로세스 재기동의 짧은 창은 남습니다. 진짜 무중단은 블루/그린 두 세트를 함께 띄워 두고 로드밸런서(또는 리버스 프록시 upstream)를 전환하는 것입니다. 심볼릭 링크는 '서버 내 원자성', 블루그린은 '서버 간 원자성'을 담당한다고 나눠 이해하면 어디까지 무중단이 보장되는지가 분명해집니다.
상황: cp 덮어쓰기의 다운타임이 싫어 releases + current 심볼릭 링크 방식으로 바꿨습니다. 그런데 배포 스크립트가 도는 몇 초 동안 사용자 일부가 404(정적 파일) 또는 502(업스트림)를 받았다고 신고합니다. 배포가 끝나면 증상이 사라져 재현이 까다롭습니다.
원인: 배포 스크립트가 링크를 rm -f current && ln -s releases/new current 두 단계로 교체합니다. rm과 ln 사이의 짧은 순간 current가 존재하지 않아, 그 찰나에 들어온 요청이 경로를 찾지 못합니다. '심볼릭 링크 방식'이라고 원자적인 게 아니라, 교체를 원자적 연산으로 해야 원자적이라는 사실을 놓친 것입니다.
진단: Nginx 액세스 로그에서 404/502 발생 시각과 배포 스크립트의 링크 교체 시각을 대조합니다. 발생이 rm~ln 구간에 몰려 있으면 확정입니다. 배포 스크립트를 열어 링크 교체가 rm 후 ln 2단계인지 확인합니다.
해결: 링크 교체를 원자적 rename으로 바꿉니다 — ln -sfn releases/new current_tmp 로 임시 링크를 만든 뒤 mv -T current_tmp current 로 덮어쓰면, rename(2)의 원자성 덕분에 링크가 비는 순간이 사라집니다. 교체 후에는 프로세스형 앱은 reload/재기동으로, Nginx는 nginx -s reload로 새 경로를 다시 읽게 해 짝을 맞춥니다. 재발 방지로 배포 스크립트에서 rm current 패턴을 금지하고 원자적 스왑을 표준으로 고정합니다.
실제 업무에서 이 지식이 쓰이는 상황:
인프라 엔지니어가 배포를 담당하는 세 가지 대표 상황입니다.
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
TARGET_DIR="/opt/tomcat/webapps/myapp"
test "$(readlink -f "$TARGET_DIR")" = "$TARGET_DIR" || { echo "배포 경로 검증 실패" >&2; exit 1; }
test -d "$TARGET_DIR" && rm -rf -- "$TARGET_DIR"
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
배포는 기술보다 절차가 중요합니다. 체크리스트와 스크립트가 있으면 어떤 팀원이 배포해도 결과가 일관됩니다.
명령어·단축키 빠른 참조
이 모듈에서 다룬 WAR/JAR/정적파일 배포 명령을 산출물 종류별로 모았습니다. 배포 스크립트의 백업→기동→헬스체크→롤백 순서에 그대로 대응합니다.
| 명령어/단축키 | 용도 | 자주 쓰는 예 |
|---|---|---|
cp (백업) | 배포 전 현재 산출물 백업 | cp myapp.war /opt/backup/myapp_$(date +%F).war |
systemctl stop/start | WAS·서비스 재기동 | systemctl stop tomcat → 배포 → systemctl start tomcat |
systemctl status | 프로세스 active·PID 확인 | systemctl status tomcat --no-pager | head |
find ... -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + | 승인한 exploded WAR 캐시 항목 정리 | 대상 목록·경로·백업 확인 후에만 실행 |
rsync -avz --delete | 정적파일 변경분만 전송 | rsync -avz --delete dist/ /var/www/myapp/ |
nginx -t && reload | 설정 검증 후 무중단 반영 | nginx -t && systemctl reload nginx |
curl -w '%{http_code}' | 배포 후 헬스체크 | curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/myapp/health |
tail -f catalina.out | 기동 로그 실시간 확인 | tail -f catalina.out | grep -m1 "Server startup" |
ss -tlnp | 포트 점유 프로세스 확인 | ss -tlnp | grep :8080 (BindException 진단) |
unzip -p … MANIFEST.MF | 배포된 버전 확인 | unzip -p myapp.war META-INF/MANIFEST.MF | grep Version |
ln -sfn + mv -T | current 링크 원자적 교체 | ln -sfn releases/20260706 current_tmp && mv -T current_tmp current |
journalctl -u | JAR 서비스 기동 로그 | journalctl -u myapp -n 30 --no-pager |
관련 모듈로 더 깊이:
- Maven/Gradle/npm 빌드와 산출물 관리 — 배포할 산출물(WAR/JAR)을 만드는 빌드 단계
- 파일/설정/DB/인증서 롤백 절차와 판단 기준 — 배포 실패 시 빠르게 되돌리는 롤백 절차 설계
- 컨테이너 배포 vs WAS 배포 비교와 실무 전환 — 같은 산출물을 컨테이너로 배포할 때의 차이
다음 모듈에서는 컨테이너 배포와 WAS 배포를 비교하고 실무 전환 방법을 다룹니다.