Bash trap과 시그널 처리로 안전한 스크립트 작성하기

배포 스크립트가 Ctrl+C나 SIGTERM으로 중간에 끊기면 임시 파일이 /tmp에 남고, lock 파일이 안 지워져 다음 실행이 죽고, 쓰다 만 결과 파일이 완료된 것처럼 남기도 한다. Bash의 trap은 시그널을 받거나 종료될 때 명령을 실행하도록 예약하는 내장 기능이다. 이 글에서는 trap 기본 문법부터 EXIT/ERR/INT/TERM 활용, set -euo pipefail 조합, 함수/서브셸 스코프 주의사항까지 정리한다.

trap 기본 문법

trap '명령' 시그널이름 [시그널이름 ...]

# 예시
trap 'echo "인터럽트 받음"' SIGINT
trap 'echo "스크립트 종료"' EXIT
시그널발생 상황
EXIT실제 시그널 아님 — 스크립트가 어떤 이유로든(정상 종료, exit, 에러) 끝날 때 발생하는 Bash 의사 이벤트
ERRset -e 상태에서 명령이 0이 아닌 종료 코드를 반환할 때
SIGINT터미널에서 Ctrl+C
SIGTERMkill의 기본 시그널, 정상 종료 요청
SIGHUP터미널 연결 종료(SSH 끊김 등)

트랩 명령은 작은따옴표로 감싸서 등록 시점이 아니라 실행 시점에 변수가 평가되게 한다. 큰따옴표는 등록 순간 바로 치환돼버려 의도와 다르게 동작할 수 있다.

임시 파일 자동 정리 — mktemp + trap EXIT

EXIT 트랩은 종료 경로와 무관하게 마지막에 한 번 실행돼 finally처럼 쓸 수 있다.

#!/usr/bin/env bash
set -euo pipefail

TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT

echo "작업 디렉터리: $TMPDIR"
echo "some data" > "$TMPDIR/work.txt"

# 여기서 에러가 나도, Ctrl+C가 눌려도 EXIT 트랩이 실행되어 TMPDIR이 지워진다
sleep 3
cat "$TMPDIR/work.txt"
$ ./cleanup.sh
작업 디렉터리: /tmp/tmp.Xk3nP9wLqe
^C
$ ls /tmp/tmp.Xk3nP9wLqe
ls: cannot access '/tmp/tmp.Xk3nP9wLqe': No such file or directory

EXIT 트랩 하나로 SIGINT/SIGTERM 비정상 종료와 정상 종료를 모두 커버한다.

여러 시그널 동시 처리

#!/usr/bin/env bash
set -euo pipefail

LOCKFILE="/tmp/myjob.lock"

cleanup_and_exit() {
    local sig="$1"
    echo "[$sig] 수신, lock 해제 후 종료" >&2
    rm -f "$LOCKFILE"
    # 130 = 128+SIGINT(2), 143 = 128+SIGTERM(15) 관례를 따른다
    case "$sig" in
        INT)  exit 130 ;;
        TERM) exit 143 ;;
        *)    exit 1 ;;
    esac
}

touch "$LOCKFILE"
trap 'cleanup_and_exit INT' SIGINT
trap 'cleanup_and_exit TERM' SIGTERM

echo "작업 중... (PID: $$)"
sleep 60

같은 명령을 여러 시그널에 한 번에 걸 수도 있다.

trap 'echo "종료 신호 수신"; rm -f "$LOCKFILE"; exit 1' SIGINT SIGTERM SIGHUP

trap -p로 현재 등록된 트랩 확인

$ trap -p
trap -- 'rm -rf "$TMPDIR"' EXIT
trap -- 'cleanup_and_exit INT' SIGINT
trap -- 'cleanup_and_exit TERM' SIGTERM

$ trap -p EXIT
trap -- 'rm -rf "$TMPDIR"' EXIT

trap -p SIGINT처럼 시그널을 지정하면 그 하나만 볼 수 있다. 완전히 해제하려면 trap - EXIT.

set -euo pipefail과 ERR 트랩 조합

set -e는 실패 시 즉시 중단만 시킬 뿐 “어디서” 실패했는지는 안 남긴다. ERR 트랩으로 라인 번호와 명령을 기록한다.

#!/usr/bin/env bash
set -euo pipefail

on_error() {
    local exit_code=$?
    local line_no="$1"
    echo "에러 발생: line ${line_no}, exit code ${exit_code}" >&2
    echo "실행 중이던 명령: ${BASH_COMMAND}" >&2
}
trap 'on_error $LINENO' ERR

echo "1단계 시작"
false   # 여기서 실패
echo "2단계 (실행되지 않음)"
$ ./err_trap.sh
1단계 시작
에러 발생: line 13, exit code 1
실행 중이던 명령: false

$LINENO/$BASH_COMMAND로 실패 위치를 잡는다. ERR 트랩이 EXIT 트랩보다 먼저 실행되므로 “기록 → 정리” 순서가 보장된다. 단 if cmd; then, cmd && other처럼 set -e가 무시하는 문맥에서는 ERR 트랩도 발생하지 않는다.

종합 예시 — 안전한 배포 스크립트

#!/usr/bin/env bash
set -euo pipefail

LOCKFILE="/tmp/deploy.lock"
WORKDIR="$(mktemp -d)"
EXIT_CODE=0

log() { echo "[$(date '+%H:%M:%S')] $*"; }

on_error() {
    log "에러: line $1에서 실패 (명령: ${BASH_COMMAND})"
    EXIT_CODE=1
}

cleanup() {
    log "정리 시작 (exit code: ${EXIT_CODE})"
    rm -rf "$WORKDIR"
    rm -f "$LOCKFILE"
    log "정리 완료"
    exit "$EXIT_CODE"
}

if [[ -e "$LOCKFILE" ]]; then
    log "이미 실행 중입니다 (lock: $LOCKFILE)"
    exit 1
fi
touch "$LOCKFILE"

trap 'on_error $LINENO' ERR
trap cleanup EXIT
trap 'log "인터럽트 수신"; exit 130' SIGINT SIGTERM

log "작업 디렉터리: $WORKDIR"
log "배포 시작"

cp -r ./build/* "$WORKDIR/"
rsync -a "$WORKDIR/" ./deploy-target/

log "배포 완료"

SIGINT/SIGTERM 트랩의 exit 130이 다시 EXIT 트랩(cleanup)을 발동시키므로, lock 해제와 임시 디렉터리 삭제는 cleanup 한 곳에만 몰아둬도 어떤 종료 경로든 실행된다.

주의사항

  • 함수 안 트랩도 전역이다. 함수 스코프가 아니라 셸 전체에 적용된다 — 함수마다 지역 정리가 필요하면 트랩을 저장/복원하거나 스크립트 레벨에서 한 번만 등록.
  • 서브셸에는 트랩이 안 넘어간다. ( ... ), $(...), 파이프라인 각 단계는 별도 서브셸 — 필요하면 서브셸 내부에서 다시 등록.
  • 재등록은 덮어쓴다. 같은 시그널에 trap을 두 번 걸면 나중 것이 이긴다.
  • SIGKILL(9)/SIGSTOP은 못 잡는다. kill -9는 EXIT 트랩도 실행 안 됨 — 락/임시파일은 PID 생존 확인 같은 별도 방어 수단도 고려.
  • ERR 트랩 예외: if cmd; then, cmd && other, while cmd; do 문맥에서는 set -e가 무시되므로 ERR 트랩도 안 걸림.

마무리

임시 자원 정리는 EXIT 트랩 하나로 몰고, 에러 위치는 ERR 트랩+$LINENO/$BASH_COMMAND로 남기고, 함수/서브셸 스코프와 SIGKILL 한계만 미리 알아두면 된다. 실행 중 끊길 가능성이 있는 스크립트라면 trap 하나로 사고 대부분을 예방할 수 있다.

참고

답글 남기기