명령을 모으는 파일에서 안전한 자동화 도구로

필수 Bash 스크립트: 안전한 자동화의 기초의 핵심 흐름도
그림. 안전한 Bash 자동화는 입력 경계와 실패 상태를 숨기지 않는다.

Bash 스크립트란

Bash는 명령을 해석하는 셸이면서 스크립트 언어다. 터미널에서 반복하던 명령을 파일로 옮겨 빌드, 백업, 배포, 로그 정리 같은 작업을 자동화할 수 있다.

첫 줄의 shebang은 이 파일을 어떤 인터프리터로 실행할지 정한다.

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

printf '%s\n' "Hello, Bash"

`chmod +x script.sh`로 실행 권한을 준 뒤 `./script.sh`로 실행한다. `bash script.sh`로 실행하면 실행 권한과 shebang을 거치지 않고 현재 bash가 파일을 읽는다.

Bash 전용 문법을 썼다면 `#!/bin/sh`라고 적지 않는다. `/bin/sh`는 시스템에 따라 dash나 다른 셸일 수 있다.

안전 옵션

운영 스크립트는 보통 다음 옵션에서 시작한다.

set -Eeuo pipefail
옵션의미
`-e`처리하지 않은 명령 실패 시 스크립트를 끝낸다.
`-u`정의하지 않은 변수를 읽으면 실패한다.
`-o pipefail`파이프라인 중간 명령의 실패도 전체 실패로 본다.
`-E`함수와 서브셸에서도 ERR trap을 상속한다.

`set -e`는 모든 문맥에서 단순하게 동작하지 않는다. `if command`, `command || fallback`, `while command`처럼 상태를 직접 검사하는 자리에서는 종료하지 않는다. 예상한 실패는 조건문으로 명시한다.

if ! curl --fail --silent --show-error "$url"; then
  printf '요청 실패: %s\n' "$url" >&2
  exit 1
fi

변수와 따옴표

대입문의 `=` 양쪽에는 공백을 넣지 않는다.

name="onceai"
port=3009
printf 'name=%s port=%s\n' "$name" "$port"

변수 확장은 특별한 이유가 없으면 큰따옴표로 감싼다. 따옴표가 없으면 공백으로 단어가 나뉘고 `*`, `?`, `[]`가 파일명 패턴으로 확장된다.

file="report final.txt"
rm -- "$file"

`--`는 그 뒤 값을 옵션으로 해석하지 말라는 구분자다. 파일명이 `-rf`처럼 하이픈으로 시작해도 안전하다.

작은따옴표 안에서는 `$name`과 명령 치환이 확장되지 않는다. 큰따옴표에서는 변수와 `$(command)`가 확장된다.

literal='$HOME 그대로'
current="$(date +%Y%m%d)"

위치 인자와 입력 검증

`$0`은 스크립트 이름, `$1`부터는 인자다. `$#`는 인자 개수이고 `"$@"`는 각 인자를 원래 경계대로 보존한다.

usage() {
  printf '사용법: %s <source> <destination>\n' "$0" >&2
}

if (( $# != 2 )); then
  usage
  exit 2
fi

source_dir=$1
destination=$2

기본값과 필수값은 매개변수 확장으로 검사할 수 있다.

port=${PORT:-3000}
: "${DATABASE_URL:?DATABASE_URL이 필요합니다}"

`${VAR:-value}`는 변수가 없거나 빈 문자열이면 기본값을 쓴다. `${VAR-value}`는 변수가 아예 없을 때만 기본값을 쓴다. 둘의 빈 문자열 처리가 다르다.

숫자라고 가정하지 말고 먼저 형식을 확인한다.

if [[ ! $port =~ ^[0-9]+$ ]]; then
  printf 'PORT는 숫자여야 합니다.\n' >&2
  exit 2
fi

비밀번호나 토큰을 명령행 인자로 넘기면 프로세스 목록과 셸 기록에 남을 수 있다. 환경변수, 표준 입력, 권한이 제한된 비밀 파일이나 전용 비밀 저장소를 쓴다.

조건문

Bash에서는 `[[ ... ]]`가 문자열과 패턴 검사를 안전하게 표현한다.

if [[ -d $source_dir && -r $source_dir ]]; then
  printf '읽을 수 있는 디렉터리입니다.\n'
elif [[ -e $source_dir ]]; then
  printf '디렉터리가 아닙니다.\n' >&2
else
  printf '경로가 없습니다.\n' >&2
fi

자주 쓰는 파일 검사는 다음과 같다.

검사의미
`-e path`경로가 존재한다.
`-f path`일반 파일이다.
`-d path`디렉터리다.
`-r path`읽을 수 있다.
`-w path`쓸 수 있다.
`-x path`실행할 수 있다.
`-s path`크기가 0보다 크다.

문자열은 `==`, `!=`, `-z`, `-n`으로 비교한다. 정수 계산은 `(( ... ))`를 쓴다.

if (( port < 1 || port > 65535 )); then
  printf '포트 범위를 벗어났습니다.\n' >&2
  exit 2
fi

case로 옵션 나누기

정해진 문자열 명령을 처리할 때는 여러 if보다 case가 읽기 쉽다.

case ${1:-} in
  start)
    start_server
    ;;
  stop)
    stop_server
    ;;
  restart)
    stop_server
    start_server
    ;;
  *)
    printf 'start, stop, restart 중 하나를 입력하세요.\n' >&2
    exit 2
    ;;
esac

`case` 패턴은 정규식이 아니라 셸 패턴이다. 각 갈래는 보통 `;;`로 끝낸다.

반복문

목록을 순회할 때는 배열과 `"${array[@]}"`를 쓴다.

services=(nginx app worker)

for service in "${services[@]}"; do
  printf 'checking %s\n' "$service"
done

`for file in $(find ...)`는 공백과 줄바꿈이 있는 파일명을 깨뜨린다. `find`의 NUL 구분 출력을 읽는다.

while IFS= read -r -d '' file; do
  printf 'file=%s\n' "$file"
done < <(find "$source_dir" -type f -print0)

`IFS=`는 앞뒤 공백을 보존하고, `-r`은 역슬래시를 문자 그대로 읽는다. `< <(...)`는 프로세스 치환이며 while이 현재 셸에서 돌아 변수 변경이 루프 밖에도 남는다.

배열

문자열 하나에 공백으로 명령 인자를 조립하지 않는다. 배열은 인자 경계를 보존한다.

command=(curl --fail --silent --show-error)
command+=(--connect-timeout 5)
command+=("$url")

"${command[@]}"

연관 배열은 Bash 4 이상에서 키와 값을 연결한다.

declare -A ports=(
  [web]=3000
  [api]=8000
)

printf '%s\n' "${ports[api]}"

macOS 기본 Bash 3.2에는 연관 배열이 없다. 여러 운영체제에서 실행해야 하면 Bash 버전을 확인하거나 다른 표현을 사용한다.

함수와 반환 상태

함수 인자는 스크립트 인자처럼 `$1`, `$2`로 받는다. 함수의 `return`은 문자열을 반환하지 않고 0부터 255까지의 종료 상태를 돌려준다. 값은 표준 출력으로 내보내 명령 치환으로 받거나, 전역 변수를 명시적으로 쓴다.

require_command() {
  local command_name=$1
  if ! command -v "$command_name" >/dev/null 2>&1; then
    printf '필수 명령이 없습니다: %s\n' "$command_name" >&2
    return 1
  fi
}

for command_name in curl nginx systemctl; do
  require_command "$command_name" || exit 1
done

함수 지역 변수에는 `local`을 붙인다. 출력으로 값을 전달하는 함수는 진단 메시지를 stderr로 보내야 호출자가 값만 받을 수 있다.

make_backup_name() {
  local base=$1
  printf '%s.%s.bak\n' "$base" "$(date +%Y%m%d%H%M%S)"
}

backup_name=$(make_backup_name "config")

명령의 성공과 실패

Unix 명령은 성공이면 0, 실패면 0이 아닌 종료 상태를 반환한다. 마지막 명령의 상태는 `$?`지만, 가능하면 바로 조건으로 검사한다.

if nginx -t; then
  systemctl reload nginx
else
  printf '설정 검사 실패, reload하지 않습니다.\n' >&2
  exit 1
fi

`command1 && command2`는 첫 명령 성공 시에만 다음을 실행하고, `command1 || command2`는 실패 시에만 다음을 실행한다. 긴 배포 절차를 `&&` 한 줄로 연결하면 어느 단계인지 읽기 어려우므로 단계별 함수와 메시지로 나눈다.

파이프라인과 리다이렉션

파이프 `|`는 앞 명령의 표준 출력을 다음 명령의 표준 입력으로 연결한다.

journalctl -u nginx --since today | rg 'error|failed'

표준 입력은 파일 설명자 0, 표준 출력은 1, 표준 오류는 2다.

command >output.log          # 출력 덮어쓰기
command >>output.log         # 출력 이어쓰기
command 2>error.log          # 오류만 기록
command >all.log 2>&1        # 출력과 오류를 같은 파일로
command >/dev/null 2>&1      # 둘 다 버리기

순서가 중요하다. `2>&1 >file`은 오류를 현재 출력으로 먼저 복제한 뒤 출력만 파일로 바꾸므로 둘이 같은 곳으로 가지 않는다.

파이프 안 각 명령의 상태는 `PIPESTATUS` 배열에 있지만, 일반적으로 `set -o pipefail`로 중간 실패가 숨지 않게 한다.

임시 파일과 종료 정리

예측 가능한 `/tmp/myfile` 이름은 다른 사용자와 충돌하거나 심볼릭 링크 공격에 노출될 수 있다. `mktemp`를 쓰고 trap으로 정리한다.

tmp_dir=$(mktemp -d)

cleanup() {
  rm -rf -- "$tmp_dir"
}
trap cleanup EXIT

EXIT trap은 정상 종료와 오류 종료 모두에서 실행된다. 이미 비어 있을 수 있는 변수를 `rm -rf "$tmp_dir"`에 넘기지 않도록, 생성 성공 뒤에 trap을 등록하고 안전 옵션을 유지한다.

오류 위치를 로그에 남길 수 있다.

on_error() {
  local status=$?
  printf '실패: line=%s status=%s\n' "$1" "$status" >&2
  exit "$status"
}
trap 'on_error $LINENO' ERR

trap 안에서 원래 상태를 가장 먼저 저장하지 않으면 다른 명령이 `$?`를 바꾼다.

현재 경로에 기대지 않기

사용자는 어느 디렉터리에서든 스크립트를 실행할 수 있다. 상대 경로가 스크립트 파일 기준이라면 그 위치를 계산한다.

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
CONFIG="$ROOT/config/app.conf"

`$PWD`는 사용자가 실행한 현재 디렉터리이고, `SCRIPT_DIR`는 스크립트 파일이 있는 곳이다. 둘을 구분한다.

안전한 배포 스크립트 예제

다음 예제는 새 디렉터리를 준비하고 링크를 교체하는 작은 배포 골격이다.

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

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd)
RELEASES="$ROOT/releases"
CURRENT="$ROOT/current"
STAMP=$(date +%Y%m%d%H%M%S)
TARGET="$RELEASES/$STAMP"

die() {
  printf '오류: %s\n' "$*" >&2
  exit 1
}

command -v rsync >/dev/null 2>&1 || die "rsync가 필요합니다"
mkdir -p -- "$TARGET"

rsync -a --delete \
  --exclude .git \
  --exclude node_modules \
  "$ROOT/source/" "$TARGET/"

[[ -f $TARGET/package.json ]] || die "package.json이 없습니다"

ln -sfn -- "$TARGET" "$CURRENT.new"
mv -Tf -- "$CURRENT.new" "$CURRENT"
printf '배포 완료: %s\n' "$TARGET"

실제 배포에서는 빌드와 테스트를 링크 교체 전에 끝내고, 실패하면 기존 current를 그대로 둔다. 데이터베이스 마이그레이션은 되돌릴 수 있는지 별도로 설계한다. `rsync --delete`, `rm -rf`, `mv` 같은 명령은 경로 검증 없이 root 권한으로 실행하지 않는다.

cron과 systemd에서 달라지는 환경

터미널에서는 되는데 cron에서 실패하는 가장 흔한 이유는 PATH와 현재 디렉터리다. 자동 실행 환경은 로그인 셸 설정을 읽지 않을 수 있다.

PATH=/usr/local/bin:/usr/bin:/bin
export PATH
cd /absolute/project/path

필요한 명령은 절대 경로를 쓰거나 PATH를 스크립트에서 정한다. 비대화형 작업은 입력을 기다리지 않게 하고, 표준 출력과 오류를 로그나 journal로 보낸다.

오래 실행되는 서버를 Bash에서 `nohup ... &`로 관리하기보다 systemd, launchd, 컨테이너 오케스트레이터처럼 재시작과 로그를 관리하는 서비스 도구에 맡긴다. Bash는 설치와 제어 명령을 담당하는 편이 낫다.

검사 도구

문법만 확인할 때는 실행하지 않고 다음을 사용한다.

bash -n script.sh

ShellCheck는 따옴표 누락, 잘못된 배열 확장, 사용하지 않는 변수 같은 흔한 실수를 찾는다. shfmt는 들여쓰기와 배치를 통일한다. 검사 경고를 무조건 끄지 말고, 의도적일 때만 가까운 위치에 이유를 남긴다.

Bash는 작은 자동화와 운영 명령 조합에 강하다. 데이터 구조가 복잡해지고 JSON 변환, 병렬 처리, 정교한 오류 복구가 커지면 Python 같은 언어로 옮기는 편이 테스트와 유지보수에 유리하다.

좋은 Bash 스크립트의 기준은 짧음이 아니다. 인자를 따옴표로 보존하고, 실패를 숨기지 않고, 위험한 경로를 검사하고, 어떤 위치에서 실행해도 같은 결과를 내는 것이 필수다.