앱 하나에 폴더 하나를 /opt/앱이름 아래 두고 역할별로 나눕니다. 01 레슨의 구조를 그대로 씁니다.
| 폴더 | 내용 |
|---|---|
releases/날짜/app.jar |
버전별 jar |
current |
지금 버전을 가리키는 링크 |
conf/env |
JAVA_OPTS 등 서버별 값 |
conf/application.yml |
외부 설정 |
logs/ |
앱 로그·stdout |
run/ |
pid 파일 |
current 가 링크이므로 새 버전 배포는 링크 교체이고 롤백은 이전 폴더로 링크를 되돌리는 것입니다. jar 를 덮어쓰지 않으니 이전 버전이 항상 남습니다.
java [JVM 옵션] -jar app.jar [앱 인자] 순서입니다. JVM 옵션은 -jar 앞, Spring 인자는 뒤에 옵니다.
java -Xms512m -Xmx1g -Dspring.profiles.active=prod -jar app.jar --server.port=8081옵션을 스크립트에 직접 박지 않고 JAVA_OPTS 변수에 모읍니다. 서버마다 메모리가 다르니 값은 conf/env 파일에서 읽습니다.
| 옵션 | 뜻 | 권장 |
|---|---|---|
-Xms |
시작 힙 | -Xmx 와 같게 |
-Xmx |
최대 힙 | 서버 RAM 의 50~70% |
-XX:MaxMetaspaceSize |
클래스 영역 상한 | 256m |
-XX:+HeapDumpOnOutOfMemoryError |
OOM 시 덤프 | 항상 켬 |
-XX:HeapDumpPath |
덤프 위치 | logs/ |
-Xmx 를 안 주면 JVM 은 물리 메모리의 1/4 을 씁니다. 8GB 서버에서 2GB 만 쓰다 OOM 이 나는 이유입니다. -Xms 와 -Xmx 를 같게 두면 힙 확장 비용이 없어집니다.
-Djava.security.egd=file:/dev/./urandom # 난수 대기로 시작 지연 방지
-Dfile.encoding=UTF-8 # 로그·파일 한글 깨짐 방지
-Duser.timezone=Asia/Seoul # 서버 시간대와 JVM 시간대 일치
-XX:+UseG1GC # JDK 21 기본, 명시하면 의도가 보임egd 옵션이 없으면 /dev/random 이 엔트로피를 기다려 첫 요청이 수십 초 멈추는 경우가 있습니다. 시간대는 DB 서버와 맞추지 않으면 날짜 계산이 하루씩 어긋납니다.
jar 안의 application.yml 은 개발 기본값이고, 서버 값은 밖에서 덮습니다. Spring Boot 는 실행 위치의 config/ 폴더를 자동으로 읽습니다.
| 방법 | 예 |
|---|---|
실행 폴더의 config/ |
/opt/myapp/config/application.yml |
| 위치 지정 | --spring.config.additional-location= |
| 프로필 | -Dspring.profiles.active=prod |
| 환경 변수 | SERVER_PORT=8081 |
비밀번호는 jar 에 넣지 않고 서버의 설정 파일이나 환경 변수에 둡니다. 파일 권한을 600 으로 하고 소유자를 앱 계정으로 맞춥니다.
app.sh 는 start, stop, status, restart, deploy, rollback 여섯 명령을 받습니다. 모든 명령이 같은 변수와 pid 파일을 공유합니다.
APP_HOME=${APP_HOME:-/opt/myapp}
JAVA_OPTS=${JAVA_OPTS:--Xms256m -Xmx512m}
PID_FILE=$APP_HOME/run/myapp.pid
[ -f "$APP_HOME/conf/env" ] && . "$APP_HOME/conf/env" # 서버별 덮어쓰기${VAR:-기본값} 문법이 핵심입니다. 환경 변수가 있으면 그것을 쓰고 없으면 기본값을 씁니다. conf/env 를 . 으로 읽어 들이면 그 파일의 값이 최종 우선입니다.
시작할 때 pid 를 파일에 적어 두면 나중에 stop 과 status 가 그 번호를 씁니다. ps | grep 으로 찾는 것보다 정확합니다.
nohup "$JAVA" $JAVA_OPTS -jar current/app.jar >> logs/stdout.log 2>&1 &
echo $! > "$PID_FILE"$JAVA_OPTS 를 따옴표 없이 쓰는 것이 의도입니다. 따옴표로 감싸면 옵션 전체가 인자 하나로 들어가 JVM 이 인식하지 못합니다. 시작 1초 뒤 kill -0 으로 살아 있는지 확인해 즉시 죽는 경우를 잡습니다.
03 레슨의 원칙대로 TERM 을 먼저 보내고 기다립니다. 30초 안에 안 죽으면 KILL 입니다.
kill "$pid"
for _ in $(seq 1 30); do
kill -0 "$pid" 2>/dev/null || { rm -f "$PID_FILE"; return 0; }
sleep 1
done
kill -9 "$pid"이 대기 시간이 Spring 의 완전 종료 시간보다 길어야 합니다. systemd 로 관리하면 이 로직이 TimeoutStopSec 으로 대체됩니다.
새 jar 를 releases/날짜/ 에 복사하고 current 링크를 바꿉니다. 오래된 릴리스는 5개만 남기고 지웁니다.
rel=$APP_HOME/releases/$(date +%Y%m%d_%H%M%S)
mkdir -p "$rel" && cp "$jar" "$rel/app.jar"
ln -sfn "$rel" "$APP_HOME/current"
ls -1dt "$APP_HOME"/releases/*/ | tail -n +6 | xargs -r rm -rfln -sfn 은 기존 링크를 새 대상으로 원자적으로 바꿉니다. 실행 중인 JVM 은 이미 연 jar 를 계속 쓰므로 링크를 바꿔도 죽지 않고, restart 때 새 jar 를 읽습니다.
이전 릴리스 경로를 배포 시점에 기록해 두면 롤백은 그 경로로 링크를 되돌리는 것입니다. 스크립트는 run/current.rel 파일에 현재 경로를 적고, 새 릴리스 폴더에 .prev 로 이전 경로를 남깁니다.
prev=$(cat "$APP_HOME/current/.prev")
do_stop; ln -sfn "$prev" "$APP_HOME/current"; do_startreadlink current 로도 이전 경로를 알 수 있지만 Git Bash 는 링크가 복사로 동작해 실패합니다. 파일에 기록하는 방식은 어느 환경에서나 같게 동작합니다.
status 는 사람이 읽는 메시지와 함께 종료 코드를 돌려줍니다. 실행 중이면 0, 정지면 3 이 systemd 관례입니다.
if pid_alive; then echo "running PID=$(cat "$PID_FILE")"
else echo "stopped"; return 3; fi배포 자동화 스크립트나 모니터링이 app.sh status; echo $? 로 판단합니다. 메시지 문자열을 파싱하지 않아도 됩니다.
03 레슨의 유닛 파일에서 ExecStart 가 이 스크립트를 부르게 할 수도 있고, 스크립트 없이 java 를 직접 부를 수도 있습니다.
[Service]
Type=forking
PIDFile=/opt/myapp/run/myapp.pid
ExecStart=/opt/myapp/app.sh start
ExecStop=/opt/myapp/app.sh stop스크립트가 백그라운드로 띄우고 돌아오므로 Type=forking 과 PIDFile 을 씁니다. 단순한 앱은 03 레슨처럼 Type=simple 로 java 를 직접 부르는 편이 더 간단합니다.