낯선 셸 스크립트 읽기: Bash·Zsh·POSIX 함정
낯선 셸 스크립트의 인터프리터와 실행 흐름을 판별하고, 인용·확장·조건·set 옵션에서 Bash와 Zsh, POSIX sh의 차이로 생기는 함정을 읽는 방법.
낯선 셸 스크립트는 위에서 아래로 문법을 해석하기 전에 어느 셸이, 어떤 옵션으로, 어떻게 실행하는지부터 확인해야 한다. 같은 줄도 Bash, Zsh, POSIX sh에서 다르게 동작하고, 파일을 실행했는지 source로 현재 셸에 불러왔는지에 따라 변수와 cd의 생존 범위까지 달라진다.
반복해서 찾는
${...}문법표, heredoc, 배열, 반복문, 리다이렉션과 잡 관리 레시피는 devkit Shell Cheatsheet에서 관리한다. 이 글은 그 표를 복제하지 않고, 실제 스크립트를 읽을 때 무엇을 먼저 의심해야 하는지 설명한다.
이 글의 목표는 문법을 모두 외우는 것이 아니다. 다음 세 질문으로 코드를 좁혀 읽는 것이다.
- 이 파일의 언어는 Bash인가, Zsh인가, POSIX
sh인가? - 인용과 확장을 거친 뒤 명령이 실제로 어떤 인자를 받는가?
- 각 명령의 종료 코드가 조건문,
&&, 파이프라인,set -e에 어떻게 소비되는가?
먼저 실행 계약부터 읽는다
첫 줄의 shebang은 문서 장식이 아니라 인터프리터 계약이다.
1
2
#!/usr/bin/env bash
set -euo pipefail
그러나 shebang만 믿으면 안 된다. bash script.sh는 shebang을 무시하고 Bash로 실행하며, zsh script.sh도 마찬가지다. source script.sh 또는 . script.sh는 새 프로세스를 만들지 않고 현재 셸의 문맥에서 읽는다. 그 파일 안의 cd, 변수, 옵션 변경이 호출한 셸에 남을 수 있다.
코드를 훑으며 다음 표식을 찾으면 언어 범위를 빠르게 판별할 수 있다.
[[ ... ]], 배열,(( ... )),<(...)는 POSIXsh문법이 아니다.source는 Bash와 Zsh에서 쓰지만 POSIX 표기는.이다.set -o pipefail은 Bash와 Zsh에는 있지만 POSIX가 보장하지 않는다.${var//a/b},<<<, brace expansion인{1..5}도 이식 가능한sh코드로 보면 안 된다.
Bash 전용 문법을 쓰면서
#!/bin/sh를 적으면 실행 환경에 따라 우연히 되거나 바로 깨진다. 반대로 POSIX 이식성이 목표라면 Bash에서 실행해 봤다는 사실만으로 충분하지 않다.
변수와 대입
셸에서 공백은 대체로 토큰 경계다. 따라서 다음 두 줄은 전혀 다른 프로그램이다.
1
2
name="neo" # 대입
name = "neo" # name이라는 명령에 =와 neo를 인자로 전달
낯선 줄의 맨 앞에 NAME=value가 연속해서 나오면 변수 설정인지, 명령 하나에만 적용하는 환경인지 구분한다.
1
2
mode=debug
MODE=debug ./build.sh
첫 줄은 현재 셸의 변수다. 둘째 줄의 MODE는 ./build.sh의 환경에만 들어간다. 이 차이를 놓치면 “분명 값을 넣었는데 다음 줄에서 왜 없지?”라는 추적을 하게 된다.
함수 안의 변수도 자동으로 지역 변수가 되지 않는다. Bash와 Zsh의 함수에서 local name=...을 빠뜨리면 호출한 문맥의 변수를 덮을 수 있다. 다만 local 자체는 POSIX가 보장하는 문법이 아니므로, sh 이식 코드를 읽을 때는 함수가 전역 상태를 어떻게 다루는지 더 주의해서 본다.
인용 (quoting)
셸에서 인용은 스타일이 아니라 명령의 인자 개수를 결정하는 문법이다.
1
2
3
file="my report.txt"
rm $file
rm "$file"
Bash와 일반적인 POSIX sh에서 첫 번째 rm은 확장 결과를 공백으로 나눈 뒤 glob까지 적용할 수 있다. 두 번째 rm은 my report.txt를 인자 하나로 보존한다. 코드를 읽을 때 $file이라는 글자를 보지 말고, 최종적으로 rm이 몇 개의 인자를 받는지 상상해야 한다.
Zsh는 기본 설정에서 인용하지 않은 파라미터 확장에 word splitting을 적용하지 않는다. 그래서 rm $file이 Zsh에서는 원하는 듯 동작하다가 Bash로 옮기면 깨질 수 있다. Zsh의 편한 기본값이 이식성 버그를 가려 준 셈이다. 셸이 무엇이든 데이터 값은 "$var"로 넘긴다는 기준이 안전하다.
인자 전달에서는 "$@"와 "$*"를 구분한다.
1
2
3
run() {
command "$@"
}
"$@"는 원래 인자를 각각 보존한다. "$*"는 모든 인자를 하나의 문자열로 합친다. wrapper 함수가 command $@ 또는 command "$*"를 쓴다면 공백이 든 인자가 망가지는지 먼저 의심한다.
파라미터 확장
${...}를 보면 문법표부터 찾기보다 값을 읽기만 하는지, 대입까지 하는지, 실패시키는지를 구분한다.
1
2
3
4
root="${APP_ROOT:-/opt/app}" # 기본값만 사용
cache="${CACHE_DIR:=/tmp/cache}" # 비어 있으면 CACHE_DIR에도 대입
: "${TOKEN:?TOKEN is required}" # 비어 있으면 메시지를 내고 종료
name="${path##*/}" # 앞의 경로 제거
특히 :-와 :=의 차이는 부수 효과다. 앞의 식은 APP_ROOT를 바꾸지 않지만 뒤의 식은 CACHE_DIR을 바꾼다. -를 뺀 ${var-default}는 “미정의”만 검사하고 빈 문자열은 값으로 인정한다. 콜론 하나가 설정 우선순위를 바꾼다.
#, ##, %, %% 뒤의 패턴은 정규식이 아니라 glob 패턴이다. ${path##*/}를 “마지막 / 앞을 제거한다”로 읽을 수 있지만, 실제 규칙은 “앞에서 glob */에 맞는 가장 긴 부분을 제거한다”다. 이 규칙을 알아야 변수가 빈 값이거나 /가 없을 때도 결과를 예측할 수 있다.
Bash와 Zsh의 파라미터 확장은 닮았지만 전용 기능의 표기가 자주 갈린다. 예를 들어 Bash의 ${name^^} 대문자 변환은 Zsh에서 같은 뜻이 아니다. 이런 표현이 보이면 “셸 공통 문법”으로 추측하지 말고 shebang과 호출 방식을 다시 확인한다.
조건 — [[ ]], [ ], (( ))
조건문은 괄호 모양보다 어느 평가기가 값을 해석하는지가 중요하다.
1
2
3
4
5
6
7
8
9
10
11
if [[ $file == *.txt ]]; then
printf '%s\n' "text file"
fi
if [ "$mode" = "debug" ]; then
printf '%s\n' "debug enabled"
fi
if (( retries > 3 )); then
printf '%s\n' "too many retries"
fi
[[ ... ]]는 Bash·Zsh 문법이다. 문자열과 glob 조건을 다루기 편하지만 POSIXsh에서는 쓸 수 없다.[ ... ]는test명령과 같은 계열이다. 각 연산자와 피연산자가 별도 인자이므로 공백이 문법이고, 변수는"$mode"처럼 인용한다. 이식 코드의 문자열 비교에는=를 쓴다.(( ... ))는 Bash·Zsh의 산술 명령이다. POSIX의 산술 확장$(( ... ))와 혼동하지 않는다.
[[ $file == *.txt ]]에서 오른쪽을 인용하지 않은 것은 실수가 아니다. 이 문맥의 *.txt는 glob 패턴이다. [[ $file == "*.txt" ]]는 리터럴 문자열 *.txt와 비교한다. 반면 [ "$file" = *.txt ]는 같은 패턴 비교가 아니다. 겉모양이 비슷해도 다른 평가 규칙을 섞어 읽으면 안 된다.
산술 명령은 값뿐 아니라 종료 코드도 만든다. 식의 값이 0이면 실패(종료 코드 1), 0이 아니면 성공이다.
1
2
count=0
(( count++ )) # count는 1이 되지만 이 명령의 종료 코드는 1
이 줄은 계산 자체는 성공한 것처럼 보여도 set -e 아래에서 예상치 못한 종료를 만들 수 있다. 단순 증가가 목적이면 (( ++count ))처럼 결과가 0이 되지 않는 형태가 문맥에 맞는지 확인한다.
제어 흐름
셸의 if와 while은 Boolean 타입을 받지 않는다. 명령의 종료 코드가 0이면 참, 0이 아니면 거짓으로 판단한다.
1
2
3
4
5
6
7
if grep -q '^enabled=true$' config; then
start_service
fi
while read -r line; do
process "$line"
done < input.txt
그래서 grep이 출력을 내지 않아도 -q로 매칭 여부만 반환하면 훌륭한 조건이 된다. 반대로 함수가 stdout에 true라는 문자열을 출력했다고 참이 되는 것은 아니다. 함수의 return은 데이터 반환이 아니라 종료 코드 설정이다. 데이터가 필요하면 stdout을 $(...)로 캡처하고, 성공 여부가 필요하면 함수를 조건 자리에 둔다.
파일을 읽는 루프에서는 read -r뿐 아니라 입력 위치도 본다. 다음 두 코드는 Bash에서 변수의 생존 범위가 다를 수 있다.
1
2
3
4
5
count=0
printf '%s\n' a b | while read -r line; do
(( ++count ))
done
printf '%s\n' "$count"
Bash는 보통 파이프라인의 각 명령을 서브셸에서 실행하므로 루프 안의 count 변경이 바깥에 남지 않는다. Zsh는 파이프라인 마지막 명령을 현재 셸에서 실행할 수 있어 결과가 달라진다. 이식 가능한 의도를 원하면 파이프에 기대지 않고 입력 리다이렉션으로 루프를 구성한다.
1
2
3
while read -r line; do
(( ++count ))
done < input.txt
명령 연결과 그룹핑
&&와 ||는 단순한 문장 연결 기호가 아니라 앞 명령의 종료 코드를 소비하는 제어 흐름이다.
1
2
3
4
5
prepare && deploy
deploy || rollback
( cd /tmp && make_scratch )
{ prepare; deploy; } >deploy.log 2>&1
prepare && deploy는 prepare가 0일 때만 배포한다. deploy || rollback은 배포가 non-zero일 때만 복구한다. 낯선 스크립트에서 긴 a && b || c를 보면 삼항 연산자처럼 단정하지 않는다. b가 실패해도 c가 실행되기 때문이다.
( ... )는 서브셸이라 cd와 변수 변경을 격리한다. { ...; }는 현재 셸에서 명령만 묶는다. 중괄호는 예약어이므로 공백과 마지막 ; 또는 개행도 문법의 일부다. 그룹 전체에 리다이렉션이 붙어 있으면 각 명령의 출력 위치를 따로 찾지 않아도 된다.
배열
배열이 보이면 POSIX 이식성은 이미 벗어났고, Bash와 Zsh 사이에서도 의미가 갈릴 수 있다고 판단한다.
1
2
3
4
files=("one.txt" "two words.txt")
for file in "${files[@]}"; do
process "$file"
done
Bash 배열은 0부터 시작하지만 Zsh 배열은 기본적으로 1부터 시작한다. Bash의 ${files[0]}를 Zsh에서 source하면 조용히 다른 결과가 날 수 있다. 전체 요소를 펼치는 규칙도 세부적으로 다르므로, 배열을 사용하는 파일은 “양쪽에서 대충 호환”시키기보다 실행할 셸을 고정하는 편이 낫다.
배열을 순회할 때 "${files[@]}"는 각 요소를 인자 하나씩 보존한다. 인용을 빼면 Bash에서 two words.txt가 다시 나뉜다. 여기서도 핵심 질문은 문법 이름이 아니라 process가 최종적으로 몇 개의 인자를 받느냐다.
확장 순서와 glob
셸 코드를 읽을 때는 한 줄을 대략 다음 흐름으로 복원하면 된다.
1
2
3
4
5
원문을 셸 문법으로 파싱
→ 파라미터·명령·산술 등의 확장
→ 인용되지 않은 결과의 word splitting
→ glob을 파일명으로 확장
→ 명령과 인자 실행
정확한 확장 단계와 예외는 셸마다 더 복잡하지만, 인용되지 않은 확장 결과가 다시 데이터가 아니라 문법처럼 작동할 수 있다는 점이 핵심이다.
1
2
3
pattern='*.log'
printf '%s\n' $pattern # Bash: 현재 디렉터리의 파일명으로 확장될 수 있음
printf '%s\n' "$pattern" # 리터럴 *.log 한 개
매칭되는 파일이 없을 때도 기본값이 다르다. Bash는 보통 패턴 문자열 *.log를 그대로 남기지만, Zsh는 기본적으로 no matches found 오류를 낸다. Bash의 nullglob·failglob, Zsh의 관련 옵션이 켜져 있으면 또 달라진다. 따라서 glob을 본 순간에는 정상 매칭뿐 아니라 0개일 때의 흐름을 확인한다.
리다이렉션과 heredoc
리다이렉션은 왼쪽에서 오른쪽으로 적용된다. 이 사실 하나로 가장 흔한 함정을 읽을 수 있다.
1
2
cmd >out.log 2>&1 # stdout을 파일로 보낸 뒤 stderr도 그곳으로 복제
cmd 2>&1 >out.log # stderr는 기존 stdout으로, stdout만 파일로 이동
2>&1은 “stderr를 stdout이라는 이름에 영구 연결”한다는 뜻이 아니다. 그 시점에 fd 1이 가리키는 곳을 fd 2에 복제한다. 그래서 순서가 결과를 바꾼다.
heredoc은 시작 기호보다 구분자의 인용 여부를 먼저 본다.
1
2
3
4
5
6
7
cat <<EOF
home=$HOME
EOF
cat <<'EOF'
home=$HOME
EOF
첫 번째 본문은 파라미터와 명령 치환이 일어난다. 두 번째는 $HOME을 글자 그대로 보존한다. 배포 스크립트가 원격 설정 파일을 만들 때 이 차이를 놓치면 “현재 머신에서 확장할 값”과 “생성된 파일을 읽는 쪽에서 확장할 값”이 뒤섞인다. <<< here-string은 편리하지만 POSIX sh 문법은 아니다.
견고한 스크립트 — set 옵션과 trap
set -euo pipefail을 보면 “안전 모드”라고 뭉뚱그리지 말고 각 옵션이 어느 줄의 의미를 바꾸는지 추적한다.
1
set -euo pipefail
-e: 처리되지 않은 실패에서 셸 종료를 유도하지만, 조건문·논리 연결·파이프라인 등 문맥에 따라 예외가 많다.-u: 미정의 파라미터 참조를 오류로 만든다. 선택 인자는${1:-}처럼 다뤄야 한다.pipefail: 마지막 명령만이 아니라 파이프라인 안의 실패도 전체 실패에 반영한다. POSIX 옵션은 아니다.
set -e는 예외 처리기가 아니다. 다음 두 코드는 실패를 서로 다른 방식으로 소비한다.
1
2
3
4
5
6
if ! fetch_data; then
printf '%s\n' "fetch failed" >&2
exit 1
fi
fetch_data || true
첫 코드는 실패를 명시적으로 처리한다. 둘째 코드는 실패를 무시한다. || true가 보이면 필요한 예외인지, 실제 장애까지 숨기는지 호출 목적을 확인한다. 앞서 본 (( count++ ))처럼 평범해 보이는 산술 명령도 종료 코드 1을 만들 수 있으므로 함께 점검한다.
정리 코드는 trap의 등록 시점과 인용을 읽는다.
1
2
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
작은따옴표 때문에 $tmp는 trap을 등록할 때가 아니라 종료 시점에 확장된다. 이후 tmp가 바뀔 수 있는 코드라면 어느 파일이 지워질지 달라진다. EXIT trap은 Bash와 Zsh에서 유용하지만 POSIX가 보장하는 signal 이름은 아니므로 이식성 계약에 포함해서 판단한다.
함정 정리
낯선 스크립트는 다음 순서로 읽으면 빠르다.
- shebang과 실제 호출 명령,
source여부를 확인한다. [[ ]], 배열, process substitution,pipefail로 필요한 셸을 판별한다.- 모든 인용되지 않은
$var,$@, 명령 치환 결과에서 최종 인자 개수를 복원한다. - glob은 매칭 1개뿐 아니라 0개와 여러 개일 때를 본다.
if,&&,||,!, 파이프라인이 어느 종료 코드를 소비하는지 표시한다.set -euo pipefail이 켜지는 위치와 잠시 꺼지는 구간을 찾는다.- 파이프라인·괄호로 생긴 서브셸 때문에 변수와
cd가 사라지는지 확인한다. - 리다이렉션은 왼쪽부터 적용하고, heredoc 구분자의 인용을 확인한다.
- Bash와 Zsh의 word splitting, glob 불일치, 배열 인덱스 차이를 의심한다.
- POSIX 이식성이 목표라면 Bash에서 실행됐다는 사실 대신 전용 문법이 없는지 확인한다.
문법의 전체 목록이 필요할 때는 devkit Shell Cheatsheet를 옆에 둔다. 이 글의 읽기 순서와 함께 쓰면 ${...} 하나를 외우는 데서 끝나지 않고, 그 확장이 실제 명령과 실패 흐름을 어떻게 바꾸는지까지 추적할 수 있다.
실제 태스크와 셸 학습 순서는 셸 로드맵으로 이어진다. 초기화 파일 로딩 순서와 프로세스 동작을 먼저 이해하면 shebang, source, 서브셸 차이가 더 선명해진다.