Bash 스크립팅 깊이 보기: 파라미터 확장·서브셸·리다이렉션·trap과 프로덕션 패턴

이 글의 핵심

Bash는 명령 나열이 아니라 토큰화·확장·리다이렉션·프로세스 생성이 맞물리는 작은 실행 엔진입니다. 이 글에서는 파라미터 확장의 정확한 규칙, 서브셸과 명령 치환의 차이, 파일 디스크립터 제어, 시그널과 trap, 그리고 운영 환경에서 쓰는 패턴을 다룹니다.

Bash 스크립트는 운영 자동화, CI, 배포, 장비 점검에서 여전히 가장 흔하게 쓰입니다. 그런데 문법만 알고 확장 규칙, 서브셸이라는 프로세스 경계, 파일 디스크립터 모델, 시그널과 trap, 엄격 모드의 예외를 모르면, 스크립트가 이상하게 동작할 때 원인을 찾기 어렵습니다. 아래에서는 실무에서 자주 막히는 지점을 동작 원리부터 설명합니다.


셸 파라미터 확장의 메커니즘

확장이 일어나는 순서

Bash는 한 줄을 단어로 나눈 뒤 여러 종류의 확장을 적용합니다. Bash 매뉴얼이 정한 순서는 다음과 같습니다.

  1. 브레이스 확장: {a,b}, {1..3}
  2. 틸드 확장, 매개변수 확장, 산술 확장, 명령 치환, 프로세스 치환: 이 다섯 가지는 한 단계에서 왼쪽부터 오른쪽으로 함께 처리됩니다.
  3. 단어 분할(word splitting): 따옴표 밖에서 확장된 결과를 IFS 문자 기준으로 여러 인자로 나눕니다.
  4. 경로명 확장(pathname expansion): *, ?, [...] 글롭을 파일 이름으로 바꿉니다.
  5. 따옴표 제거(quote removal)

브레이스 확장이 가장 먼저 일어나기 때문에 n=3; echo {1..$n}은 1 2 3이 아니라 {1..3}을 출력합니다. 브레이스 확장 시점에는 $n이 아직 확장되지 않아 올바른 시퀀스 식으로 인식되지 않기 때문입니다. 이럴 때는 seq 1 "$n"이나 for ((i=1; i<=n; i++))를 씁니다.

글롭이 단어 분할 뒤에 적용된다는 점도 자주 문제를 일으킵니다. pattern='*.log'를 따옴표 없이 rm $pattern으로 쓰면, 변수 확장 결과가 단어 분할과 글롭을 거쳐 현재 디렉터리의 .log 파일 목록으로 바뀝니다. 공백이 들어간 파일 이름은 단어 분할로 쪼개지기도 합니다. 변수를 큰따옴표로 감싸면 단어 분할과 글롭이 일어나지 않고 값이 그대로 한 인자로 전달됩니다.

매개변수 확장: 기본값·필수값·부분 문자열

# unset 또는 빈 값이면 30을 대입(이후 $TIMEOUT_SECONDS 참조 가능)
: "${TIMEOUT_SECONDS:=30}"

# 필수 변수: 없으면 메시지를 출력하고 스크립트 종료
: "${DATABASE_URL:?DATABASE_URL is required}"

# 경로에서 파일명만(마지막 / 이후)
basename_var="${path##*/}"
# 확장자 제거(마지막 . 이후 제거)
name_only="${basename_var%.*}"

${parameter:-word}는 parameter가 unset이거나 비어 있으면 word를 쓰고, 아니면 원래 값을 씁니다. 환경 변수에 기본값을 줄 때 가장 흔한 형태입니다. 콜론을 뺀 ${parameter-word}는 unset일 때만 word를 쓰고, 빈 문자열은 그대로 둡니다.

${parameter:=word}는 같은 조건에서 parameter에 값을 대입까지 합니다. 위치 매개변수($1)나 특수 매개변수에는 쓸 수 없습니다.

${parameter:?word}는 parameter가 unset이거나 비어 있으면 word를 stderr에 출력하고, 비대화형 셸이라면 스크립트를 종료합니다. 필수 설정이 빠졌을 때 엉뚱한 단계까지 진행하지 않고 바로 멈추게 할 수 있습니다.

${parameter:+word}는 값이 있을 때만 word로 확장되고, 없으면 빈 문자열이 됩니다. curl ${TOKEN:+-H "Authorization: Bearer $TOKEN"}처럼 선택적인 옵션을 붙일 때 씁니다.

패턴 제거·치환·대소문자

${var#pat}와 ${var##pat}는 앞에서부터 패턴과 일치하는 부분을 제거하는데, # 하나는 가장 짧은 일치를, 두 개는 가장 긴 일치를 제거합니다. ${var%pat}와 ${var%%pat}는 같은 규칙을 뒤에서부터 적용합니다. 예를 들어 file=archive.tar.gz에서 ${file%.*}는 archive.tar, ${file%%.*}는 archive입니다.

${var/pattern/replacement}는 첫 일치만, ${var//pattern/replacement}는 모든 일치를 바꿉니다. ${var^}와 ${var^^}는 첫 글자 또는 전체를 대문자로, ${var,}와 ${var,,}는 소문자로 바꿉니다. 대소문자 변환은 Bash 4.0 이상에서만 동작하므로, macOS 기본 /bin/bash(3.2)에서는 오류가 납니다.

간접 참조와 배열

${!name}은 간접 확장으로, name 변수에 담긴 문자열을 다시 변수 이름으로 해석해 그 값을 가져옵니다. Bash 4.3 이상이라면 declare -n ref=name 형태의 nameref가 의도를 더 분명하게 드러냅니다. 둘 다 코드를 따라 읽기 어렵게 만들므로 꼭 필요한 곳에만 씁니다.

배열에서 "${arr[@]}"와 "${arr[*]}"는 큰따옴표 안에서 의미가 다릅니다. "${arr[@]}"는 요소마다 별도 인자로 펼쳐서 공백이 들어간 요소도 그대로 보존합니다. "${arr[*]}"는 모든 요소를 IFS의 첫 문자로 이어 붙인 하나의 문자열이 됩니다. 명령에 인자 목록을 넘기거나 반복할 때는 거의 항상 "${arr[@]}"가 맞습니다.

${#parameter}는 문자열 길이, ${#arr[@]}는 배열 요소 수입니다.

set -u와의 상호작용

set -u(nounset)는 unset 변수를 확장하려 하면 오류로 처리합니다. 선택적인 변수는 ${var:-}처럼 빈 기본값을 명시해 “비어 있어도 된다”는 의도를 드러내고, 나머지는 오타나 설정 누락을 즉시 드러내게 됩니다. Bash 4.4 이전 버전에서는 빈 배열의 "${arr[@]}"도 unset 오류를 냈기 때문에, 오래된 환경을 지원해야 한다면 ${arr[@]+"${arr[@]}"} 같은 우회가 필요합니다.


서브셸과 명령 치환

괄호 (): 서브셸

( command1; command2 )는 fork된 자식 셸에서 명령을 실행합니다. 안에서 바꾼 변수, cd로 바꾼 디렉터리, 설정한 옵션은 부모 셸로 돌아오지 않습니다. 그래서 (cd build && make)처럼 현재 디렉터리를 잠깐 바꿀 때 유용합니다.

Bash에서 파이프라인의 각 명령은 기본적으로 각각 서브셸에서 실행됩니다. 그래서 다음 코드는 0을 출력합니다.

count=0
printf '%s\n' a b c | while read -r line; do
  count=$((count + 1))
done
echo "$count"   # 0

while 루프가 서브셸에서 돌았기 때문에 count 변경이 사라진 것입니다. 입력을 프로세스 치환으로 넣으면 루프가 현재 셸에서 실행됩니다.

count=0
while read -r line; do
  count=$((count + 1))
done < <(printf '%s\n' a b c)
echo "$count"   # 3

Bash 4.2 이상의 shopt -s lastpipe도 파이프라인 마지막 명령을 현재 셸에서 실행하게 해 주지만, job control이 꺼져 있을 때(스크립트 실행 시 기본값)만 적용됩니다.

중괄호 {}: 현재 셸에서 그룹

{ command1; command2; }는 같은 셸에서 순서대로 실행하므로 fork 비용이 없고 변수 변경이 유지됩니다. { 뒤에는 공백이 필요하고, 닫는 } 앞에는 세미콜론이나 줄바꿈이 있어야 합니다. { echo a; echo b; } > out.txt처럼 여러 명령의 출력을 한 번에 리다이렉션할 때 자주 씁니다.

명령 치환 $(...)과 백틱

$(command)는 명령을 서브셸에서 실행해 그 표준출력을 문자열로 가져옵니다. 결과 끝의 줄바꿈 문자는 모두 제거됩니다. 중첩이 자연스럽고 읽기 쉬워 백틱보다 권장됩니다. 따옴표 없이 쓰면 결과가 단어 분할과 글롭을 거치므로, 한 값으로 쓰려면 "$(...)"로 감싸야 합니다.

백틱 `...`은 안에서 백슬래시 처리 규칙이 다르고 중첩하려면 이스케이프가 필요해서, 새 코드에서는 쓸 이유가 없습니다.

프로세스 치환 <(...), >(...)

<(command)는 명령의 출력을 읽을 수 있는 파일 이름(대개 /dev/fd/63 같은 경로)으로 바꿔 다른 명령의 인자로 넘깁니다. diff <(sort a) <(sort b)처럼 임시 파일 없이 두 출력을 비교할 때 유용합니다. >(command)는 반대로 쓰기용 파일 이름을 만들어, 예를 들어 tee >(gzip > out.gz)처럼 출력을 다른 프로세스로 보냅니다. Bash 전용 기능이며 POSIX sh에는 없습니다.


파일 디스크립터 리다이렉션

0, 1, 2의 모델

모든 프로세스는 기본으로 0번(표준입력), 1번(표준출력), 2번(표준에러) 파일 디스크립터를 가지고 시작합니다. 리다이렉션은 명령을 실행하기 전에 이 번호가 가리키는 대상을 다시 연결하는 작업입니다.

>는 파일을 비우고 쓰며, >>는 뒤에 추가합니다. 2>file은 stderr만 파일로 보냅니다. Bash에서 &>file은 >file 2>&1과 같고, 추가 모드는 &>>file입니다. >&file도 같은 뜻이지만 파일 이름이 숫자일 때 헷갈리므로 &>를 쓰는 편이 낫습니다.

2>&1의 순서

# stdout·stderr 모두 log.txt로
some_cmd >log.txt 2>&1

# 흔한 실수: stderr는 터미널로 감
some_cmd 2>&1 >log.txt

2>&1은 “fd 1이 지금 가리키는 대상을 fd 2도 가리키게 하라”는 복사 연산입니다. 연결이 유지되는 것이 아니라 그 순간의 대상을 복사할 뿐입니다. 첫 줄은 fd 1을 파일로 바꾼 뒤 복사하므로 둘 다 파일로 갑니다. 둘째 줄은 fd 1이 아직 터미널일 때 복사한 뒤 fd 1만 파일로 바꾸므로 stderr는 터미널에 남습니다. 이 성질을 이용하면 cmd 2>&1 >/dev/null | grep error처럼 stderr만 파이프로 보낼 수도 있습니다.

exec으로 셸 자체의 FD 바꾸기

명령 없이 exec에 리다이렉션만 주면 현재 셸 프로세스의 FD가 바뀌어 이후 모든 명령에 적용됩니다. exec >>run.log 2>&1을 스크립트 앞부분에 두면 그 뒤의 출력이 모두 로그 파일로 갑니다. exec 3<>file처럼 3번 이상의 FD를 열어 두고 exec 3>&-로 닫을 수 있으며, flock으로 잠금을 잡을 때 이 방식이 자주 쓰입니다.

exec 9>/var/lock/deploy.lock
flock -n 9 || { echo "already running" >&2; exit 1; }

Here-document와 here-string

<<EOF는 여러 줄 텍스트를 stdin으로 넣습니다. 구분자를 <<'EOF'처럼 따옴표로 감싸면 본문에서 변수 확장과 명령 치환이 일어나지 않으므로, $가 들어간 설정 파일이나 스크립트 조각을 그대로 쓸 때 안전합니다. <<-EOF는 본문 줄 앞의 탭 문자(공백은 아님)를 제거합니다. <<<"string"은 here-string으로 짧은 문자열을 stdin으로 넣을 때 씁니다.


시그널 처리와 trap

시그널

시그널은 커널이나 다른 프로세스가 보내는 비동기 알림입니다. SIGINT(Ctrl+C), SIGTERM(종료 요청, kill의 기본값이자 systemd·Kubernetes가 먼저 보내는 시그널), SIGHUP(터미널 세션 종료)이 스크립트에서 주로 다루는 것들입니다.

trap과 정리 패턴

cleanup() {
  rm -f "${TMPFILE:-}"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

EXIT 트랩은 스크립트가 끝까지 실행되거나, exit를 호출하거나, set -e로 중단될 때 실행됩니다. 정리 코드는 여기 한 곳에 둡니다.

trap cleanup EXIT INT TERM처럼 정리 함수를 시그널에 직접 거는 코드를 많이 보지만, 함정이 있습니다. INT나 TERM 트랩 핸들러가 exit 없이 끝나면 Bash는 스크립트를 계속 실행합니다. Ctrl+C를 눌렀는데 임시 파일만 지우고 다음 단계가 진행되는 것입니다. 위 예처럼 시그널 트랩에서는 exit만 호출하고 정리는 EXIT 트랩에 맡기면, 정리가 한 번만 실행되고 종료 코드도 관례(128 + 시그널 번호)대로 남습니다. 정리 함수는 이미 지워진 파일을 다시 지우는 경우에도 문제가 없도록 멱등하게 작성합니다.

또 하나 알아 둘 점은, 스크립트가 sleep 300 같은 포그라운드 자식 명령을 기다리는 동안 SIGTERM을 받으면 Bash는 자식이 끝날 때까지 트랩 실행을 미룬다는 것입니다. 긴 대기 중에도 바로 반응해야 한다면 sleep 300 & wait $!처럼 백그라운드로 실행하고 wait로 기다립니다. wait는 트랩이 걸린 시그널을 받으면 즉시 반환합니다.

SIGKILL과 SIGSTOP

SIGKILL(9)과 SIGSTOP은 잡거나 무시할 수 없습니다. Kubernetes는 SIGTERM을 보낸 뒤 terminationGracePeriodSeconds(기본 30초)가 지나면 SIGKILL을 보내고, systemd도 TimeoutStopSec 뒤에 같은 일을 합니다. 데이터 일관성이 중요한 정리는 이 유예 시간 안에 끝나도록 설계해야 합니다.

서브셸과 트랩 상속

서브셸에서는 부모가 설정한 트랩이 원래 동작으로 초기화됩니다(무시하도록 설정한 시그널만 계속 무시됩니다). ERR 트랩은 기본적으로 함수, 명령 치환, 서브셸에 상속되지 않으며, set -E(errtrace)를 켜야 상속됩니다. DEBUG와 RETURN 트랩은 set -T로 같은 효과를 냅니다. 이 규칙 때문에 복잡한 스크립트는 최상위에서만 트랩을 설치하고 서브셸 안에 긴 로직을 두지 않는 편이 예측하기 쉽습니다.

ERR 트랩으로 실패 위치 남기기

trap 'echo "error: line $LINENO: $BASH_COMMAND (exit $?)" >&2' ERR

set -e만으로는 스크립트가 어디서 멈췄는지 알 수 없습니다. ERR 트랩에서 $LINENO와 $BASH_COMMAND를 출력하면 실패한 줄과 명령을 로그에 남길 수 있습니다. ERR 트랩은 set -e가 무시하는 상황(아래 참고)에서는 실행되지 않습니다.


프로덕션 Bash 스크립팅 패턴

엄격 모드

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

-e는 명령이 실패하면 스크립트를 종료합니다. -u는 unset 변수 사용을 오류로 만듭니다. -o pipefail은 파이프라인 중간 명령의 실패도 파이프라인 전체의 실패로 봅니다. -E는 ERR 트랩이 함수와 서브셸 안에서도 실행되게 합니다.

set -e에는 예외가 많다는 점을 알고 써야 합니다. if, while, until의 조건부, &&·|| 목록에서 마지막이 아닌 명령, !로 부정한 명령의 실패는 스크립트를 멈추지 않습니다. 함수를 if my_func; then처럼 조건부에서 호출하면 그 함수 안 전체에서 -e가 꺼진 것처럼 동작합니다. 또 local out=$(cmd)는 local 명령 자체의 종료 상태(0)가 남아서 cmd 실패가 묻힙니다. local out; out=$(cmd)처럼 선언과 대입을 나눠야 합니다.

이 기준선과 함께 IFS=$'\n\t'를 설정하라는 권고도 자주 보이는데, 이는 단어 분할에서 공백을 빼는 설정입니다. 공백이 들어간 파일 이름이 우연히 덜 깨지게 할 뿐이고, 근본적인 해결책은 변수를 항상 큰따옴표로 감싸는 것입니다. 참고로 IFS=$' \t\n'은 Bash의 기본값이라 설정해도 아무 효과가 없습니다.

[[와 [

[[는 Bash 키워드라서 안쪽 변수에 단어 분할과 글롭이 적용되지 않고, == 오른쪽의 따옴표 없는 값은 글롭 패턴으로, =~ 오른쪽은 확장 정규식으로 해석됩니다. [는 일반 명령(test)이라서 [ $var = x ]에서 $var가 비어 있거나 공백을 포함하면 문법 오류가 납니다. POSIX 이식성이 필요하면 [를 쓰되 변수를 반드시 따옴표로 감싸고, Bash 전용 스크립트에서는 [[가 실수할 여지가 적습니다.

출력에는 printf

echo는 -n, -e 옵션과 백슬래시 해석이 셸과 플랫폼마다 다릅니다. 변수 값이 -n으로 시작하면 옵션으로 해석되기도 합니다. printf '%s\n' "$var"는 어디서나 같은 결과를 냅니다.

임시 파일

TMPFILE="$(mktemp)"
trap 'rm -f "$TMPFILE"' EXIT

mktemp는 예측할 수 없는 이름의 파일을 원자적으로 만들어, /tmp/myscript.$$ 같은 고정된 이름을 노리는 심볼릭 링크 공격을 막습니다. 파일을 만든 바로 다음 줄에서 EXIT 트랩을 등록해야 중간에 실패해도 파일이 남지 않습니다. 디렉터리가 필요하면 mktemp -d와 rm -rf를 씁니다.

비밀과 로그

set -x는 확장된 명령을 그대로 출력하므로 토큰이 들어간 명령도 로그에 남습니다. 비밀을 다루는 구간에서는 set +x로 끄거나 디버그 출력을 사용하지 않습니다. 비밀을 명령행 인자로 넘기면 같은 호스트의 다른 사용자가 ps로 볼 수 있으므로, 가능하면 환경 변수나 파일, stdin으로 전달합니다. 로그에는 타임스탬프와 단계 이름을 남겨 두면 CI 로그에서 어느 단계가 실패했는지 찾기 쉽습니다.

ShellCheck

ShellCheck는 따옴표 없는 변수 확장(SC2086), local x=$(cmd)의 종료 상태 은폐(SC2155), cd 실패를 확인하지 않는 코드(SC2164) 같은 함정을 정적으로 찾아 줍니다. 위에서 설명한 문제의 상당수를 기계적으로 잡아 주므로 CI에 넣어 두는 것이 가장 비용 대비 효과가 좋은 조치입니다.

다시 실행해도 안전한가

배포나 마이그레이션 스크립트는 중간에 실패한 뒤 다시 실행되는 일이 흔합니다. mkdir -p처럼 이미 존재해도 성공하는 명령을 쓰고, 파일 교체는 임시 파일에 쓴 뒤 mv로 바꿔 원자적으로 처리하며, 이미 적용된 단계는 상태를 확인해 건너뛰도록 작성하면 재실행이 안전해집니다.


같이 보면 좋은 글