Linux Java 서비스 구동 스크립트 작성 가이드 — run.sh 실전 템플릿

왜 구동 스크립트가 필요할까

JAR 파일 하나를 실수 없이 운영한다는 건 생각보다 까다롭습니다. java -jar app.jar로 실행하면 터미널이 닫히는 순간 프로세스가 함께 종료되고, nohup만 붙여도 “이미 떠 있는 프로세스를 또 올려서 중복 실행”되는 사고가 흔하게 일어납니다. 운영 서버에서는 배포 자동화, 재기동, 로그 확인, PID 추적까지 한 번에 처리해주는 얇은 구동 스크립트(run script) 가 반드시 필요합니다.

이 글에서는 실무에서 바로 쓰는 run.sh 템플릿을 단계별로 해부합니다. JDK 8+ 기반의 Java 서비스(PMS라는 이름의 백엔드)를 가정하고, start | stop | restart | status | run 5가지 명령을 지원하는 스크립트를 완성합니다.

스크립트 전체 구조

먼저 완성된 run.sh 전체를 살펴보고, 이후 섹션에서 부분별로 쪼개서 설명합니다. 아래 스크립트는 /sw/pms 경로에 배포된 Java 서비스를 관리한다고 가정합니다.

#!/bin/sh
# run.sh — Java 서비스 구동/중지/재기동/상태 스크립트

ulimit -n 65536

SERVICE_NAME="PMS"
SERVICE_PATH="/sw/pms"
RUN_LOG="/sw/pms/logs/pms.run.log"
NULL_LOG="/dev/null"

JAVA_PATH="/sw/jdk8_471/bin/java"
JAVA_CLASS_PATH="${SERVICE_PATH}:${SERVICE_PATH}/bin:${SERVICE_PATH}/conf:${SERVICE_PATH}/lib/*:${SERVICE_PATH}/dependency/*"
JAVA_MAIN_CLASS="moa.service.pms.MainService"

JAVA_OPT="-Dfile.encoding=UTF-8 \
-Dlog4j.configurationFile=${SERVICE_PATH}/conf/log4j2.xml \
-Djava.security.egd=file:/dev/./urandom \
-Xms256m -Xmx512m"

PID_PATH_NAME="${SERVICE_PATH}/proc/pms.pid"
RUN_SUDO=""

SERVICE_LOG="$NULL_LOG"

if [ "$2" = "log" ]; then
    SERVICE_LOG="$RUN_LOG"
fi

mkdir -p "$(dirname "$RUN_LOG")"
mkdir -p "$(dirname "$PID_PATH_NAME")"

if [ ! -x "$JAVA_PATH" ]; then
    echo "Java not found or not executable: $JAVA_PATH"
    exit 1
fi

if [ ! -d "$SERVICE_PATH" ]; then
    echo "Service path not found: $SERVICE_PATH"
    exit 1
fi

is_running() {
    if [ -f "$PID_PATH_NAME" ]; then
        PID=$(cat "$PID_PATH_NAME")
        if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
            return 0
        fi
    fi
    return 1
}

start_service() {
    echo "Starting $SERVICE_NAME ..."

    if is_running; then
        echo "$SERVICE_NAME is already running ..."
        echo "pid = $(cat "$PID_PATH_NAME")"
        return 0
    fi

    rm -f "$PID_PATH_NAME"

    cd "$SERVICE_PATH" || exit 1

    nohup "$JAVA_PATH" \
        -classpath "$JAVA_CLASS_PATH" \
        $JAVA_OPT \
        "$JAVA_MAIN_CLASS" \
        > "$SERVICE_LOG" 2>&1 &

    PID=$!
    echo "$PID" > "$PID_PATH_NAME"

    sleep 1

    if kill -0 "$PID" 2>/dev/null; then
        echo "$SERVICE_NAME started ..."
        echo "pid = $PID"
    else
        echo "$SERVICE_NAME failed to start. Check log: $SERVICE_LOG"
        rm -f "$PID_PATH_NAME"
        exit 1
    fi
}

stop_service() {
    if ! is_running; then
        echo "$SERVICE_NAME is not running ..."
        rm -f "$PID_PATH_NAME"
        return 0
    fi

    PID=$(cat "$PID_PATH_NAME")
    echo "$SERVICE_NAME stopping ..."
    kill "$PID"

    COUNT=0
    while kill -0 "$PID" 2>/dev/null; do
        COUNT=$((COUNT + 1))

        if [ "$COUNT" -ge 10 ]; then
            echo "$SERVICE_NAME did not stop gracefully. Killing forcefully ..."
            kill -9 "$PID" 2>/dev/null
            break
        fi

        sleep 1
    done

    rm -f "$PID_PATH_NAME"
    echo "$SERVICE_NAME stopped ..."
}

case "$1" in
    run)
        cd "$SERVICE_PATH" || exit 1
        exec "$JAVA_PATH" -classpath "$JAVA_CLASS_PATH" $JAVA_OPT "$JAVA_MAIN_CLASS"
    ;;
    start)
        start_service
    ;;
    stop)
        stop_service
    ;;
    restart)
        stop_service
        start_service
    ;;
    status)
        if is_running; then
            echo "$SERVICE_NAME is running."
            echo "pid = $(cat "$PID_PATH_NAME")"
        else
            echo "$SERVICE_NAME is not running."
        fi
    ;;
    *)
        echo "$SERVICE_NAME Service..."
        echo "Usage: $0 { run | start | stop | restart | status } { log }"
        exit 1
    ;;
esac

크게 네 덩어리로 나뉩니다. 상단의 설정 블록(경로·JVM 옵션 변수), 사전 점검 블록(디렉토리 생성, Java 실행 파일 존재 확인), 함수 블록(is_running, start_service, stop_service), 그리고 마지막 디스패처 블록(case "$1" in ...)입니다. 새 서버에 옮길 때 손댈 부분은 사실상 상단 변수 몇 줄뿐이며, 나머지는 템플릿 그대로 재사용하면 됩니다. 아래에서 각 블록을 차례로 살펴보겠습니다.

1. 셔뱅과 파일 디스크립터 한도

#!/bin/sh
ulimit -n 65536

#!/bin/sh는 POSIX 호환 셸로 실행하겠다는 선언입니다. #!/bin/bash로 바꿔도 되지만, /bin/sh로 작성해두면 Alpine·BusyBox 같은 경량 이미지에서도 동일하게 동작합니다.

ulimit -n 65536은 프로세스가 열 수 있는 파일 디스크립터 수를 65,536개로 늘립니다. Java 서비스가 동시에 많은 소켓·파일을 열 때 Too many open files 에러를 예방하기 위한 설정입니다. 시스템 전체 한도는 /etc/security/limits.conf에서 따로 조정해야 한다는 점만 기억해두세요.

2. classpath와 JVM 옵션

JAVA_CLASS_PATH="${SERVICE_PATH}:${SERVICE_PATH}/bin:${SERVICE_PATH}/conf:${SERVICE_PATH}/lib/*:${SERVICE_PATH}/dependency/*"
JAVA_MAIN_CLASS="moa.service.pms.MainService"

JAVA_OPT="-Dfile.encoding=UTF-8 \
-Dlog4j.configurationFile=${SERVICE_PATH}/conf/log4j2.xml \
-Djava.security.egd=file:/dev/./urandom \
-Xms256m -Xmx512m"
항목설명
lib/*, dependency/*와일드카드는 JVM이 해석해 디렉토리 내 모든 JAR을 classpath에 추가
-Dfile.encoding=UTF-8시스템 로케일과 무관하게 UTF-8로 파일을 읽고 쓴다
-Dlog4j.configurationFileLog4j2 설정 파일 경로 지정
-Djava.security.egd=file:/dev/./urandom/dev/random 블로킹 회피. Tomcat 기동 지연 방지에 필수
-Xms256m -Xmx512m힙 초기/최대 크기. Xms = Xmx로 맞추면 GC 변동이 줄어듭니다

JAVA_OPT는 따옴표로 감싸지 않고 변수 확장 시점에 공백으로 분리되도록 둡니다. 공백이 포함된 옵션이 있다면 배열(JAVA_OPT=(... ))로 바꾸는 편이 안전합니다.

3. PID 기반 중복 실행 방지

PID_PATH_NAME="${SERVICE_PATH}/proc/pms.pid"

is_running() {
    if [ -f "$PID_PATH_NAME" ]; then
        PID=$(cat "$PID_PATH_NAME")
        if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
            return 0
        fi
    fi
    return 1
}

운영 스크립트의 핵심은 “지금 이 서비스가 살아있는가?” 를 한 줄로 판단하는 것입니다. PID 파일만으로는 부족합니다. 프로세스가 비정상 종료되면 PID 파일은 남아도 프로세스는 없는 stale PID 상태가 되기 때문입니다.

kill -0 "$PID"는 실제로 신호를 보내지 않고 프로세스 존재 여부만 확인하는 관용구입니다. 살아있으면 exit 0, 없으면 non-zero를 반환하므로 stale PID를 자동으로 걸러낼 수 있습니다.

4. start: nohup + PID 기록

start_service() {
    echo "Starting $SERVICE_NAME ..."

    if is_running; then
        echo "$SERVICE_NAME is already running ..."
        echo "pid = $(cat "$PID_PATH_NAME")"
        return 0
    fi

    rm -f "$PID_PATH_NAME"

    cd "$SERVICE_PATH" || exit 1

    nohup "$JAVA_PATH" \
        -classpath "$JAVA_CLASS_PATH" \
        $JAVA_OPT \
        "$JAVA_MAIN_CLASS" \
        > "$SERVICE_LOG" 2>&1 &

    PID=$!
    echo "$PID" > "$PID_PATH_NAME"

    sleep 1

    if kill -0 "$PID" 2>/dev/null; then
        echo "$SERVICE_NAME started ..."
        echo "pid = $PID"
    else
        echo "$SERVICE_NAME failed to start. Check log: $SERVICE_LOG"
        rm -f "$PID_PATH_NAME"
        exit 1
    fi
}

핵심 포인트 세 가지입니다.

  1. nohup + & 조합으로 터미널이 끊겨도 계속 실행되는 백그라운드 프로세스를 만듭니다. > "$SERVICE_LOG" 2>&1로 표준출력과 표준에러를 한 파일로 묶습니다.
  2. $! 는 가장 최근에 백그라운드로 띄운 프로세스의 PID입니다. 바로 PID 파일에 저장해야 다음 번 is_running 판단이 가능합니다.
  3. sleep 1kill -0 으로 1초 뒤에도 살아있는지 확인합니다. classpath 오류나 포트 충돌로 즉시 죽는 경우를 잡아내는 최소한의 헬스체크입니다.

$SERVICE_LOG의 기본값은 /dev/null이며, ./run.sh start log로 두 번째 인자를 log로 넘기면 RUN_LOG 파일에 기록합니다. 운영 로그는 Log4j2가 별도 파일로 남기므로, 기본값은 /dev/null로 두고 stdout은 장애 재현 시에만 켜는 패턴입니다.

5. stop: SIGTERM → SIGKILL 단계적 종료

stop_service() {
    if ! is_running; then
        echo "$SERVICE_NAME is not running ..."
        rm -f "$PID_PATH_NAME"
        return 0
    fi

    PID=$(cat "$PID_PATH_NAME")
    echo "$SERVICE_NAME stopping ..."
    kill "$PID"

    COUNT=0
    while kill -0 "$PID" 2>/dev/null; do
        COUNT=$((COUNT + 1))

        if [ "$COUNT" -ge 10 ]; then
            echo "$SERVICE_NAME did not stop gracefully. Killing forcefully ..."
            kill -9 "$PID" 2>/dev/null
            break
        fi

        sleep 1
    done

    rm -f "$PID_PATH_NAME"
    echo "$SERVICE_NAME stopped ..."
}

kill PID는 기본적으로 SIGTERM(15) 을 보냅니다. JVM은 이 신호를 받으면 셧다운 훅을 실행하고 정상 종료합니다. 아직 처리 중인 DB 트랜잭션, 메시지 큐 ACK, Log4j 버퍼 플러시가 모두 이 타이밍에 마무리됩니다.

하지만 셧다운 훅이 블로킹되거나 데드락에 빠지면 프로세스가 영영 내려가지 않을 수 있습니다. 그래서 1초 간격으로 10번까지 대기한 뒤에도 살아있으면 kill -9(SIGKILL)로 강제 종료합니다. 숫자 10은 서비스 특성에 맞게 조정하세요. 큐 처리 시간이 긴 배치 서비스는 30~60 정도로 올리는 게 안전합니다.

6. status와 dispatch

case "$1" in
    run)
        cd "$SERVICE_PATH" || exit 1
        exec "$JAVA_PATH" -classpath "$JAVA_CLASS_PATH" $JAVA_OPT "$JAVA_MAIN_CLASS"
    ;;
    start)   start_service ;;
    stop)    stop_service ;;
    restart) stop_service; start_service ;;
    status)
        if is_running; then
            echo "$SERVICE_NAME is running."
            echo "pid = $(cat "$PID_PATH_NAME")"
        else
            echo "$SERVICE_NAME is not running."
        fi
    ;;
    *)
        echo "$SERVICE_NAME Service..."
        echo "Usage: $0 { run | start | stop | restart | status } { log }"
        exit 1
    ;;
esac

명령어 디스패처입니다. 각 케이스의 의미를 정리합니다.

명령동작언제 쓰나
run포그라운드에서 exec로 실행도커 컨테이너의 ENTRYPOINT, systemd Type=simple
startnohup 백그라운드 실행SSH 접속한 VM에서 수동 기동
stopSIGTERM → 10초 후 SIGKILL배포 직전 안전 종료
restartstop → start 순차 실행설정 변경 후 재기동
statusPID 존재·프로세스 살아있음 확인모니터링, 헬스체크

run 케이스에서 쓴 exec는 셸을 새 프로세스로 덮어쓰는 명령입니다. 중간에 셸이 끼어있지 않아야 systemd/Docker가 JVM 프로세스를 직접 관리할 수 있어, docker stop이 JVM에 그대로 SIGTERM을 전달합니다.

7. 사용 예시

# 실행 권한 부여
chmod +x /sw/pms/run.sh

# 기본 시작 (로그는 /dev/null)
/sw/pms/run.sh start

# 표준출력까지 파일로 남기며 시작
/sw/pms/run.sh start log

# 상태 확인
/sw/pms/run.sh status
# PMS is running.
# pid = 23145

# 재기동
/sw/pms/run.sh restart

# 종료
/sw/pms/run.sh stop

systemd와의 조합 전략

run.sh를 이미 작성했다면, systemd에 등록할 때는 run 하위 명령을 ExecStart에 그대로 사용하는 게 가장 깔끔합니다. 포그라운드 실행이 필요하고 PID 관리를 systemd가 맡기 때문에 PID 파일이 이중으로 관리되는 문제가 사라집니다.

# /etc/systemd/system/pms.service
[Unit]
Description=PMS Service
After=network.target

[Service]
Type=simple
User=pms
WorkingDirectory=/sw/pms
ExecStart=/sw/pms/run.sh run
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

반대로 systemd가 없는 환경(구형 SysVinit, 크론, 간단한 VM)에서는 start/stop만으로 충분합니다. 두 가지 운영 방식을 하나의 run.sh로 커버할 수 있다는 점이 이 패턴의 가장 큰 장점입니다.

실전 팁과 주의사항

  • root로 실행하지 마세요. 서비스 전용 계정(pms 등)을 만들고 chown -R pms:pms /sw/pms 후 해당 계정으로 실행합니다. 보안 사고 시 피해 범위가 줄어듭니다.
  • JDK 경로는 절대경로로 고정합니다. PATHjava를 쓰면 셸 환경에 따라 다른 버전이 선택될 수 있어 재현 불가능한 버그의 원인이 됩니다.
  • PID 파일 디렉토리 쓰기 권한을 미리 확인하세요. /var/run은 재부팅 시 초기화되므로 애플리케이션 디렉토리($SERVICE_PATH/proc)에 두는 편이 운영상 안전합니다.
  • JVM 옵션 튜닝-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$SERVICE_PATH/logs를 기본으로 넣어두면 OOM 사후 분석이 가능합니다.
  • graceful shutdown 타임아웃은 서비스가 가장 오래 걸릴 수 있는 작업(예: 배치 한 사이클)보다 길게 잡습니다. 너무 짧으면 데이터 유실 위험이 있습니다.

이 템플릿을 복사해서 SERVICE_NAME, SERVICE_PATH, JAVA_PATH, JAVA_MAIN_CLASS만 바꾸면 대부분의 Java 서비스에 그대로 적용할 수 있습니다. 운영 스크립트는 한 번 잘 만들어두면 수년간 재사용되는 자산이니, 팀 표준으로 정착시켜 두는 것을 추천합니다.

이 글이 도움이 되었나요?