Bash 쉘 스크립트 자동화 실전 가이드 — cron·에러 핸들링·로깅 패턴

목차

bash 쉘 스크립트 자동화는 결국 "사람이 키보드로 칠 명령을 안 정해진 시간에 안 정해진 환경에서 안 부서지게 돌리는 기술"이다. 이걸 신입한테 한 번에 설명하려면 항상 같은 순서로 풀어야 한다. 옵션 → 트랩 → cron → 락 → 로깅 → 알림. 이 글은 그 순서를 그대로 따라간다.

3년차쯤 되면 셸 스크립트는 "잘 쓰는 사람"과 "건드리기 무서운 사람"으로 나뉜다. 차이는 문법이 아니라 실패를 다루는 방식에 있다. 한 줄짜리 mysqldump | gzip > backup.sql.gz는 누구나 쓴다. 그게 새벽 3시에 디스크 풀이면서 죽을 때 알람을 띄우고, 다음 실행이 겹치지 않게 막고, 잘못된 가짜 백업 파일을 안 남기는 건 다른 얘기다.

자동화 스크립트가 일반 셸 명령과 다른 점

결국, 대화형 셸에서 명령을 칠 때는 결과를 사람이 본다. 잘못되면 멈추고 다시 친다. 자동화 스크립트는 그게 안 된다. cron이 새벽에 돌리고, systemd timer가 1분마다 깨우고, CI 러너가 컨테이너 안에서 호출한다. 화면에 출력해도 보는 사람이 없다.

그래서 자동화 스크립트는 세 가지를 미리 정해둬야 한다.

  • 실패 시 어떻게 멈출 것인가: 중간에 에러가 나면 다음 줄로 넘어가지 말아야 한다.
  • 실패를 누가 알게 할 것인가: 종료 코드, 로그 파일, 슬랙 알림 중 어디로 보낼지.
  • 다시 실행해도 안전한가: idempotent하지 않으면 cron이 두 번 깨운 순간 데이터가 깨진다.

이처럼, 이 세 가지를 의식하지 않고 쓴 스크립트는 처음 한 달은 잘 돌아간다. 그 다음에 부서진다.

set -euo pipefail부터 깔고 시작한다

bash 자동화 스크립트의 첫 줄은 거의 정해져 있다.

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

각 옵션이 막아주는 사고가 다르다.

옵션 막아주는 사고 안 켰을 때 결과
-e 명령 실패 시 계속 진행 rm -rf $DIR/*에서 $DIR이 비어도 다음 줄로 넘어감
-u 정의 안 된 변수 사용 rm -rf $TARGE/data 오타가 rm -rf /data로 실행됨
-o pipefail 파이프 중간 명령의 실패 무시 curl ... | tar xz에서 curl이 죽어도 tar 종료 코드 0
IFS=$'\n\t' 공백 들어간 파일명에서 단어 분리 for f in $(ls)가 공백마다 잘림

한편, 신입에게 처음 가르치는 건 -e 하나로 끝나지 않는다는 점이다. -e만 켜면 cmd1 | cmd2에서 cmd1이 죽어도 스크립트는 계속 간다. pipefail 없이 백업 스크립트를 쓰면 빈 tar 파일을 S3에 올리고 "성공"으로 끝낸다. 이게 실무에서 가장 흔한 함정이다.

예외 처리가 필요한 곳

set -e를 켜둔 상태에서 일부러 실패해도 되는 명령이 있다. 예를 들어 "파일이 있으면 지우고, 없어도 OK"인 경우.

# 파일 없어도 계속 진행
rm -f /tmp/cache.lock || true

# grep 결과 없어도 계속 진행
matched=$(grep "ERROR" app.log || true)

|| true를 붙이면 해당 라인만 예외가 된다. if grep -q ...; then 패턴으로 감싸는 것도 같은 효과가 있다. 어느 쪽이든 "여기는 일부러 실패를 허용했다"가 코드에 드러나는 게 중요하다.

trap으로 청소와 알림을 묶는다

즉, 자동화 스크립트가 도중에 죽었을 때 임시 파일이 남고, 락이 안 풀리고, 슬랙에 알람도 안 가면 운영자가 두 번 일한다. trap은 이걸 한 번에 처리해주는 도구다.

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

TMPDIR=$(mktemp -d)
LOCKFILE="/var/lock/db_backup.lock"

cleanup() {
  local exit_code=$?
  rm -rf "$TMPDIR"
  rm -f "$LOCKFILE"
  if [[ $exit_code -ne 0 ]]; then
    notify_slack "db_backup 실패 (exit=$exit_code, line=$LINENO)"
  fi
  exit $exit_code
}

trap cleanup EXIT
trap 'echo "중단 신호 받음"; exit 130' INT TERM

trap cleanup EXIT는 정상 종료, 에러 종료, 시그널 종료 모두에서 호출된다. 그래서 cleanup 함수 안에서 $?로 종료 코드를 확인해 정상/실패를 갈라준다. INT/TERM은 따로 잡아서 종료 코드를 명시한다. SIGINT는 130, SIGTERM은 143이 관용이다.

멱등성 — 두 번 실행해도 같은 결과

결국, cron이 1분마다 돌리는 작업에서 이전 실행이 5분 걸리면 어떻게 될까. 락 없이 두면 다섯 번 겹친다. 가장 단순한 방법은 flock이다.

#!/usr/bin/env bash
exec 200>/var/lock/sync_users.lock
flock -n 200 || { echo "이미 실행 중"; exit 0; }

# 이 아래부터 실제 작업
sync_users.sh

flock -n은 락을 못 잡으면 바로 종료한다. 대기시키고 싶으면 -w 60으로 60초까지 기다리게 할 수 있다. 락 파일을 직접 만들고 PID 비교하는 방법도 있지만, 죽은 프로세스의 락이 남는 경우가 흔해서 결국 flock으로 돌아오게 된다.

cron 스케줄링의 함정

그러나, cron은 단순해 보여서 자주 부서지는 영역이다. 대부분의 사고는 "내 셸에서는 되는데 cron에서는 안 됨"으로 시작한다. 원인은 거의 두 가지다.

환경변수가 없다

또한, cron이 실행하는 셸의 PATH는 /usr/bin:/bin이 끝이다. nvm, pyenv, conda로 깐 도구는 전부 못 찾는다. 대처법은 셋 중 하나다.

# 1. 절대 경로로 호출
0 3 * * * /home/deploy/.nvm/versions/node/v20.11.0/bin/node /opt/app/cleanup.js

# 2. 스크립트 안에서 환경 로드
#!/usr/bin/env bash
source /home/deploy/.nvm/nvm.sh
nvm use 20 > /dev/null

# 3. crontab 상단에 PATH 명시
PATH=/home/deploy/.nvm/versions/node/v20.11.0/bin:/usr/local/bin:/usr/bin:/bin
0 3 * * * cleanup.sh

세 번째가 가장 깔끔하지만, crontab 자체에 환경이 박혀서 노드 버전 올릴 때 같이 고쳐야 한다. 팀 규모가 있으면 1번이 안전하다.

표준출력을 안 잡으면 메일로 쌓인다

반면, cron은 표준출력/표준에러를 root 메일로 보낸다. 메일 데몬이 없으면 /var/spool/clientmqueue에 무한히 쌓인다. 디스크 풀의 흔한 원인 중 하나다.

# 표준출력 버리기, 에러는 로그로
0 * * * * /opt/app/cleanup.sh >/dev/null 2>>/var/log/cleanup.err

# 둘 다 로그로
0 * * * * /opt/app/cleanup.sh >>/var/log/cleanup.log 2>&1

신입한테는 2>&1&>>로 줄여 쓰지 말라고 한다. bash에서만 되고 sh/dash에서는 안 되기 때문에 cron의 기본 셸이 뭔지 의식하게 만드는 편이 낫다.

스케줄 표현은 crontab.guru로 검증

*/15 * * * *0,15,30,45 * * * *는 같은 결과를 낸다. 그런데 */3 * 1-7 * *처럼 헷갈리는 표현이 들어가면 머리로 풀지 말고 crontab.guru에 붙여넣는 게 빠르다. 한 번 잘못 박은 cron 표현은 새벽 3시가 아니라 매분 깨우는 사고로 자주 이어진다.

로깅: stdout 중심으로, 파일은 나중에

그러나, 자동화 스크립트의 로깅은 두 가지를 분리해야 한다. 사람이 디버깅할 때 보는 로그, 시스템이 파싱하는 종료 코드. 둘을 섞으면 슬랙 알림이 시끄러워진다.

log() {
  local level=$1; shift
  printf '%s [%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$level" "$*" >&2
}

log INFO "백업 시작: target=$DB_NAME"
mysqldump "$DB_NAME" | gzip > "$TMPDIR/dump.sql.gz"
log INFO "백업 완료: size=$(du -h "$TMPDIR/dump.sql.gz" | cut -f1)"

실제로, 세 가지 규칙을 지킨다.

  • 시간은 UTC + ISO8601 형식으로. 서버 타임존 다르면 로그 정렬할 때 머리 아프다.
  • 로그는 stderr로. stdout은 "이 스크립트가 다음 파이프에 넘길 진짜 결과물"만 담는다.
  • 로그 레벨을 한 글자가 아니라 INFO/WARN/ERROR 같은 단어로. grep 필터링이 편하다.

파일 로테이션은 스크립트 안에서 하지 않는다. logrotate에 맡기는 게 표준이다. /etc/logrotate.d/myapp에 7일치만 남기게 설정해두면 끝.

실패 알림: 처음엔 슬랙 웹훅 한 줄로

예를 들어, 알림 인프라는 단계별로 키운다. 처음에는 슬랙 incoming webhook 하나면 충분하다.

notify_slack() {
  local msg=$1
  local webhook="${SLACK_WEBHOOK_URL:-}"
  [[ -z "$webhook" ]] && return 0

  curl -sS -X POST -H 'Content-Type: application/json' \
    --max-time 5 \
    --data "$(jq -nc --arg t "$msg" '{text:$t}')" \
    "$webhook" >/dev/null || true
}

여기서 --max-time 5와 끝의 || true가 핵심이다. 알림 보내려다 스크립트가 멈추면 본말이 전도된다. 슬랙이 느려도, 웹훅이 만료됐어도, 본 작업의 종료 코드는 영향받지 않게 막아둔다.

반면, jq로 메시지를 감싼 이유는 메시지에 따옴표나 줄바꿈이 들어갔을 때 JSON이 깨지는 걸 방지하기 위해서다. 신입이 자주 빠지는 함정 중 하나가 --data "{\"text\":\"$msg\"}"로 직접 만들어서 $msg"가 들어간 순간 깨지는 경우다.

또한, 알림 양이 늘어나면 그때 Alertmanager나 PagerDuty로 옮기면 된다. 처음부터 무겁게 깔 필요는 없다.

신입에게 던져줄 체크리스트

여기까지 다 합친, 실제로 회사 위키에 박아두는 템플릿이다.

#!/usr/bin/env bash
# 작업: PostgreSQL 일일 백업
# 실행: cron, 매일 03:00 UTC
# 의존: pg_dump 16+, aws-cli 2, jq

set -euo pipefail
IFS=$'\n\t'

readonly SCRIPT_NAME=$(basename "$0")
readonly TMPDIR=$(mktemp -d -t pgbackup.XXXXXX)
readonly LOCKFD=200
readonly LOCKFILE="/var/lock/${SCRIPT_NAME}.lock"
readonly S3_BUCKET="${S3_BUCKET:?S3_BUCKET 환경변수 필요}"

log() {
  printf '%s [%s] %s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" "$1" "${*:2}" >&2
}

notify_slack() {
  [[ -z "${SLACK_WEBHOOK_URL:-}" ]] && return 0
  curl -sS --max-time 5 -X POST \
    -H 'Content-Type: application/json' \
    --data "$(jq -nc --arg t "$1" '{text:$t}')" \
    "$SLACK_WEBHOOK_URL" >/dev/null || true
}

cleanup() {
  local code=$?
  rm -rf "$TMPDIR"
  if [[ $code -ne 0 ]]; then
    log ERROR "종료 코드 $code (line $LINENO)"
    notify_slack ":x: $SCRIPT_NAME 실패 (exit=$code)"
  fi
  exit $code
}

trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

# 중복 실행 방지
exec {LOCKFD}>"$LOCKFILE"
flock -n "$LOCKFD" || { log WARN "이미 실행 중, 종료"; exit 0; }

# 본 작업
log INFO "백업 시작"
pg_dump -Fc "$DB_URL" > "$TMPDIR/dump.pgdump"

SIZE=$(stat -c%s "$TMPDIR/dump.pgdump")
log INFO "덤프 완료: ${SIZE} bytes"

# 최소 크기 검증 (빈 백업 방지)
if (( SIZE < 1024 )); then
  log ERROR "덤프 파일이 비정상적으로 작음"
  exit 2
fi

aws s3 cp "$TMPDIR/dump.pgdump" \
  "s3://${S3_BUCKET}/pg/$(date -u +%Y/%m/%d)/dump.pgdump"

log INFO "업로드 완료"
notify_slack ":white_check_mark: $SCRIPT_NAME 성공 (${SIZE} bytes)"

한편, 이 템플릿은 신입에게 그대로 던져주고 "작업 내용만 바꿔서 써라"라고 한다. 외워야 할 부분은 위쪽 30줄 정도다. 거기까지가 자동화 스크립트의 80%다.

따라서, 체크리스트로 압축하면 이렇게 된다.

  • [ ] set -euo pipefail + IFS=$'\n\t' 적용했는가
  • [ ] 임시 디렉터리 mktemp -d로 만들고 trap으로 정리하는가
  • [ ] 중복 실행 막는 flock 있는가
  • [ ] 환경변수는 ${VAR:?메시지}로 미리 검증하는가
  • [ ] 로그는 stderr로, UTC ISO8601 형식인가
  • [ ] 실패 알림이 본 작업의 종료 코드에 영향 안 주는가
  • [ ] 결과물 크기/행 수 등 sanity check 한 줄 있는가
  • [ ] cron 등록 시 2>&1로 출력 잡았는가

주의사항 몇 가지

마지막으로 자주 부서지는 지점들. 신입한테 한 번씩 당해봐야 진짜로 외운다.

  • bash -x는 운영에 켜두지 말 것: 디버깅용이다. 켜두면 비밀번호가 로그에 그대로 찍힌다. BASH_XTRACEFD로 별도 fd에 내보내는 방법은 있지만 임시 디버깅일 때만.
  • /bin/sh/bin/bash는 다른 셸이다: Alpine 컨테이너에서 #!/bin/bash 쓰면 안 깔려 있어서 안 돈다. 컨테이너 안에서는 #!/bin/sh로 쓰거나, bash를 의존성에 명시하거나.
  • 종료 코드는 0~255만 유효: 256은 0이 된다. 음수도 안 된다. 종료 코드로 의미를 전달하지 말고 로그로 보내자.
  • $*$@는 따옴표 안에서 다르게 동작: "$@"만 외워두면 된다. 인자에 공백 있어도 안 깨진다.
  • rm 앞에는 한 박자 쉬어라: 변수 비어 있을 때 rm -rf "$DIR/" 가 어떻게 되는지 한 번씩 떠올린다. set -u가 막아주지만, 빈 문자열로 정의된 경우는 못 막는다. [[ -n "$DIR" ]] || exit 1 한 줄 추가하는 게 안전하다.

그러나, 다음엔 같은 스크립트를 systemd timer로 옮기면서 cron 대비 뭐가 달라지는지 — OnFailure=, RuntimeMaxSec=, journald 연동 — 를 정리해볼 생각이다.

관련 글