셸 변수·따옴표·종료 코드: bash 와 zsh 차이를 직접 실행해 확인
변수, 따옴표, $?, set -euo pipefail, if/for/while, 함수를 macOS 의 bash 3.2 와 zsh 5.9 에서 모두 실행. 실제 에러 메시지와 strict 모드가 놓치는 경우까지.
시리즈: 커맨드라인 강좌 4/6 (중급). 터미널 이동과 파이프는 익숙해졌고, 이제 그 명령들을 "조용히 틀리는" 스크립트가 아니라 "실패하면 멈추는" 스크립트로 묶고 싶은 분을 위한 회차입니다.
내 터미널에서는 잘 돌던 스크립트가 서버나 CI 에서는 빈 보고서를 만들었다거나, 종료 코드 0 으로 "성공"했는데 실제로는 아무것도 안 했다거나 하는 일을 겪어 본 분을 위한 글입니다. 원인은 대부분 세 가지입니다. 변수가 어떻게 펼쳐지는지, $? 에 실제로 무엇이 들어 있는지, 그리고 set -euo pipefail 이 잡지 못하는 것. 회사 맥의 기본 셸 zsh 에서 테스트한 스크립트를 동료가 bash deploy.sh 로 돌리면 같은 줄이 다르게 동작하기 때문에, 아래 예제는 전부 두 셸 모두에서 실행했습니다.
환경: macOS 26, Apple Silicon Mac(M1 Pro), zsh 5.9 와 GNU bash 3.2.57(애플 기본 /bin/bash). 작업 폴더는 ~/demo/ws 로 표기합니다. 리눅스 서버는 대부분 bash 5 계열이라, 결과가 달라지는 곳은 "여기서는 실행하지 않음"이라고 적었습니다.
시리즈 전체 흐름은 학습 로드맵에, 지난 회는 find·xargs·sed·awk입니다. 이번에는 그 원라이너들을 스크립트로 감쌉니다.
변수: = 앞뒤에 공백을 넣지 않는다
셸 변수는 이름에 문자열을 담은 것입니다. name=value 로 넣고 $name 으로 읽습니다. 첫날 거의 모두가 밟는 함정부터 봅시다.
# t1.sh
name = demo
echo "exit: $?"
name=demo
echo "hello $name"
echo 'hello $name'
echo "file: ${name}_log.txt / $name_log.txt"
## bash
t1.sh: line 1: name: command not found
exit: 127
hello demo
hello $name
file: demo_log.txt / .txt
## zsh
t1.sh:1: command not found: name
exit: 127
hello demo
hello $name
file: demo_log.txt / .txt
공백이 있으면 셸은 name = demo 를 "name 이라는 명령을 인자 =, demo 로 실행"으로 읽습니다. 그런 명령은 없으니 종료 코드 127 입니다. 이 몇 줄에서 배울 것이 더 있습니다.
- 큰따옴표는 변수를 펼치고, 작은따옴표는 글자 그대로 둡니다.
$name_log는name_log라는 다른(빈) 변수입니다. 경계는${name}_log.txt처럼 중괄호로 표시합니다.- 빈 변수는 기본적으로 에러가 아닙니다.
.txt만 출력하고 그냥 진행했습니다. 아래에서set -u로 고칩니다.
따옴표: bash 와 zsh 차이는 한 번 눈으로 봐야 한다
"제 컴퓨터에서는 되는데요"의 정체가 이것입니다. 폴더에 my report.txt 와 other.txt 가 있습니다. 실무에서는 회의록 최종.txt, 견적서 (수정).xlsx 같은 공백 들어간 파일명이 흔하니 남의 일이 아닙니다.
# split.sh
f="my report.txt"
ls $f
echo "rc=$?"
ls "$f"
list="a b c"
for x in $list; do echo "item: $x"; done
arr=(one two three)
echo "first=${arr[1]} count=${#arr[@]}"
pat="*.txt"
echo $pat
## bash
ls: my: No such file or directory
ls: report.txt: No such file or directory
rc=1
my report.txt
item: a
item: b
item: c
first=two count=3
my report.txt other.txt
## zsh
my report.txt
rc=0
my report.txt
item: a b c
first=one count=3
*.txt
한 화면에 차이가 네 개입니다.
- 단어 분리: bash 는 따옴표 없는
$f를 공백으로 쪼개ls에my와report.txt두 인자를 넘겼습니다. zsh 는 기본적으로 쪼개지 않습니다. - 글롭 확장: bash 는 변수 안의
*.txt를 파일 이름으로 펼쳤고, zsh 는 글자 그대로 출력했습니다. - 문자열 반복:
for x in $list가 bash 에서는 세 번, zsh 에서는 한 번 돕니다. 목록이 필요하면 배열"${arr[@]}"를 씁니다. - 배열 인덱스: bash 는 0부터(
${arr[1]}은two), zsh 는 1부터(one) 셉니다.
같은 파일을 zsh -c 'emulate sh; source ./split.sh' 로 돌리면 zsh 도 bash 와 똑같이 동작했습니다. 즉 동작을 정하는 것은 "입력한 셸"이 아니라 "실행하는 셸"이고, 맨 위의 #!/bin/bash 줄이 그래서 중요합니다.
이 모든 것을 없애는 규칙은 하나입니다. 확장은 전부 따옴표로 감싼다("$f", "${arr[@]}", "$@"), 목록은 배열로 다룬다. 그러면 bash 와 zsh 의 답이 같아집니다.
함수 인자도 마찬가지입니다. $* 와 $@ 는 비슷해 보여도 다릅니다.
# args.sh
count_args() { echo "$# args"; }
show() {
count_args $*
count_args "$*"
count_args "$@"
}
show "my report.txt" other.txt
## bash ## zsh
3 args 2 args
1 args 1 args
2 args 2 args
두 셸 모두에서 인자 두 개를 그대로 넘긴 것은 "$@" 뿐이었습니다.
종료 코드와 $?
모든 명령은 0~255 의 종료 코드를 남기고 끝납니다. 0 이 성공이고 나머지는 어떤 형태의 실패입니다. $? 는 바로 앞 명령의 종료 코드라서, echo 하나만 끼워도 바뀝니다. 곧바로 읽어야 합니다.
# codes.sh
true; echo "true -> $?"
false; echo "false -> $?"
ls nope.txt; echo "ls missing -> $?"
grep -q zzz other.txt; echo "grep no match -> $?"
grep -q zzz nope.txt; echo "grep missing file -> $?"
nosuchcmd; echo "unknown command -> $?"
printf 'echo hi\n' > noexec.sh; ./noexec.sh; echo "not executable -> $?"
sh -c 'exit 300'; echo "exit 300 -> $?"
true -> 0
false -> 1
ls: nope.txt: No such file or directory
ls missing -> 1
grep no match -> 1
grep: nope.txt: No such file or directory
grep missing file -> 2
codes.sh: line 6: nosuchcmd: command not found
unknown command -> 127
codes.sh: line 7: ./noexec.sh: Permission denied
not executable -> 126
exit 300 -> 44
숫자는 bash 와 zsh 가 같았습니다(zsh 는 메시지가 command not found: nosuchcmd, permission denied: ./noexec.sh 형식일 뿐입니다).
| 코드 | 의미 | 이번 예 |
|---|---|---|
| 0 | 성공 | true |
| 1 | 일반 실패·"아니오" | false, 없는 파일 ls, 일치 없는 grep |
| 2 | 사용법 오류·진짜 에러(도구마다 다름) | 없는 파일 grep |
| 126 | 찾았지만 실행 불가 | chmod +x 안 한 ./noexec.sh |
| 127 | 명령을 찾지 못함 | 오타, PATH 문제 |
| 128+N | 시그널 N 으로 종료 | 5강(130·137·143) |
의외인 점 두 가지. grep 은 일치가 없으면 1 을 돌려주는데 이건 에러가 아니고, 진짜 실패는 2 입니다. 그리고 종료 코드는 256 에서 한 바퀴 돌아 exit 300 이 44(300−256)로 도착했습니다. 직접 만드는 에러 코드는 125 이하로 씁니다.
set -euo pipefail 이 잡아 주는 것
기본 셸 스크립트는 실패해도 계속 갑니다. 아래 파일은 버그가 세 개인데도 종료 코드가 0 입니다.
# strict1.sh
echo "user is $USERNAME_TYPO"
cp nope.txt backup/
echo "after cp, rc=$?"
false | sort
echo "pipeline rc=$?"
echo "reached the end"
user is
cp: backup: No such file or directory
after cp, rc=1
pipeline rc=0
reached the end
[exit 0]
흔히 strict 모드라 부르는 것은 옵션 세 개입니다. 첫 줄에 set -euo pipefail 을 넣은 파일로 하나씩 실행했습니다.
| 옵션 | 잡는 것 | 실제 메시지 (bash 3.2) | zsh 5.9 | 종료 |
|---|---|---|---|---|
-e |
단순 명령의 실패 | cp: backup: No such file or directory |
같음 | 1 |
-u |
설정 안 된 변수 읽기 | strict3.sh: line 2: USERNAME_TYPO: unbound variable |
strict3.sh:2: USERNAME_TYPO: parameter not set |
1 |
-o pipefail |
파이프라인 중간의 실패 | (아무 출력 없이 멈춤) | 같음 | 1 |
pipefail 이 없으면 파이프라인의 종료 코드는 마지막 명령의 것이라서, sort 가 성공한 false | sort 는 성공으로 처리됐습니다.
-u 아래에서 값이 없을 때 기본값을 쓰고 싶다면 명시합니다. ${MISSING:-fallback} 은 fallback 을 출력하고, : "${DIR:?DIR is not set}" 은 DIR: DIR is not set 을 내고 멈춥니다(두 셸 모두 종료 1). 뒤의 것은 rm -rf "$DIR/..." 앞에 반드시 둘 가드입니다.
흔한 실수: set -e 가 다 잡아 줄 거라고 믿기
검색 요약 한 줄로는 얻을 수 없는 부분입니다. 아래는 전부 실패하는데도 스크립트가 계속 돕니다.
# gotcha.sh
set -euo pipefail
f() { local out=$(false); echo "A: local hid the failure"; }
f
g() { false; echo "B: still running inside g"; }
if g; then echo "B2: g counted as success"; fi
false && echo no
echo "C: 'false && ...' did not stop the script"
echo "D: default -> ${MISSING:-fallback}"
n=$(printf 'a\nb\n' | grep -c zzz || true); echo "E: matches=$n"
count=0
((count++))
echo "F: never printed in bash"
## bash ## zsh
A: local hid the failure A: local hid the failure
B: still running inside g B: still running inside g
B2: g counted as success B2: g counted as success
C: 'false && ...' did not stop the script C: 'false && ...' did not stop the script
D: default -> fallback D: default -> fallback
E: matches=0 E: matches=0
F: never printed in bash [exit 1]
[exit 0]
실제로 일어난 일:
- A:
local out=$(false)의 종료 코드는local자신의 것(성공)입니다. 해결: 선언과 대입을 나눕니다(local out; out=$(false)). 이렇게 고치면 bash·zsh 모두 종료 1 로 멈췄습니다. - B/B2: if 조건으로 부른 함수 안에서는 함수 전체에서
set -e가 꺼집니다.g는 마지막echo가 성공했으니 0 을 돌려줬습니다. 해결: 조건에 쓰는 함수에서는-e에 기대지 말고 마지막에return을 명시합니다. - C:
&&·||왼쪽의 실패는 원래-e대상이 아닙니다. 반대로false && echo no가 스크립트의 마지막 줄이면 스크립트 전체 종료 코드가 1 이 되는 함정도 있습니다(실행해서 확인). - E:
grep -c는 일치가 없으면0을 출력하고 1 로 끝납니다.pipefail이면 멈추므로|| true가 맞는 해결이지만, "일치 없음이 정말 괜찮은" 곳에서만 씁니다. - F:
((count++))는 원래 값이 0 이면 1 을 돌려줍니다. zsh 5.9 는 멈췄고, bash 3.2 는 멈추지 않았습니다($?는 1 이었습니다). 최신 bash 는 멈춥니다(여기서는 실행하지 않음).count=$((count + 1))로 쓰면 어느 셸에서나 똑같습니다.
교훈은 "strict 모드를 쓰지 마라"가 아닙니다. strict1.sh 의 버그 세 개는 확실히 잡습니다. strict 모드는 안전망이지 에러 처리가 아니다 가 교훈입니다. 실패가 중요한 명령은 직접 확인합니다.
if ! cp "$src" "$dst"; then
echo "error: copy failed: $src" >&2
exit 1
fi
if, test, [[ ]]
if 는 명령을 실행하고 종료 코드를 봅니다. [ 도 명령(이름이 [)이라서 공백이 필수이고, 빈 변수 하나에 깨집니다.
# tests.sh
answer=""
if [ $answer = yes ]; then echo yes; else echo "not yes"; fi
if [ "$answer" = yes ]; then echo yes; else echo "quoted: not yes"; fi
x=10
if [ "$x" -gt 9 ]; then echo "10 > 9 numerically"; fi
if [ "$x" \> 9 ]; then echo "string compare says 10 > 9"; else echo "string compare says 10 < 9"; fi
## bash
tests.sh: line 2: [: =: unary operator expected
not yes
quoted: not yes
10 > 9 numerically
string compare says 10 < 9
## zsh
tests.sh:2: parse error: condition expected: =
not yes
quoted: not yes
10 > 9 numerically
tests.sh:6: condition expected: >
string compare says 10 < 9
따옴표 없는 빈 $answer 가 사라져 [ = yes ] 가 되었고, 에러를 찍으면서 else 로 갔습니다. set -e 가 없으면 아무도 모릅니다. 또 > 는 문자열 비교("10" 이 "9" 보다 앞)이고, zsh 의 [ 는 \> 자체를 받지 않습니다. 숫자는 -gt, -lt, -eq 나 (( x > 9 )) 로 비교합니다. bash/zsh 전용이라면 [[ $answer == yes ]] 는 쪼개지지 않아 안전하지만 POSIX sh 에는 없습니다.
반복문, 그리고 while read 에서 사라지는 변수
total=0
printf '3\n4\n5\n' | while read n; do total=$((total + n)); done
echo "pipe+while total=$total"
total=0
while read n; do total=$((total + n)); done < <(printf '3\n4\n5\n')
echo "process-substitution total=$total"
## bash ## zsh
pipe+while total=0 pipe+while total=12
process-substitution total=12 process-substitution total=12
bash 에서는 파이프라인의 각 부분이 서브셸(셸의 자식 복사본)에서 돌기 때문에, 반복문 안에서 바꾼 total 이 버려졌습니다. zsh 는 파이프라인의 마지막 부분을 현재 셸에서 돌려 12 가 남았습니다. 어디서나 되는 방법은 파이프 대신 리다이렉트(< file 이나 < <(cmd))로 반복문에 넘기는 것입니다.
함수: 종료 코드와 출력은 다르다
함수는 인자를 $1, $2…, "$@" 로 받고, return N(0~255)으로 종료 코드를, 데이터는 출력해서 돌려줍니다.
greet() { echo "hi $1"; return 3; }
msg=$(greet tom); echo "msg=[$msg] rc=$?"
x=1; change() { x=2; local y=5; }; change; echo "x=$x y=${y:-unset}"
msg=[hi tom] rc=3
x=2 y=unset
local 이 없는 변수는 전역입니다. 함수 두 개가 둘 다 i 를 써서 "반복 카운터가 혼자 바뀌는" 버그의 단골 원인입니다. 파이썬의 변수 범위와 비교해 보고 싶다면 파이썬 기초: 타입과 가변성도 참고하세요.
실측: 내장 산술 vs 프로세스 실행
외부 프로그램을 실행하는 $(...) 는 매번 fork 와 exec 를 합니다. 반복문 안에서는 이 비용이 쌓입니다. 10,000 까지 세 가지 방식으로 셌습니다: i=$(expr "$i" + 1)(외부 명령), i=$(echo $((i + 1)))(서브셸만), i=$((i + 1))(내장). 각각 /usr/bin/time -p 로 3회 재고 중앙값을 적었습니다.
| 10,000회 반복 | bash 3.2 | zsh 5.9 |
|---|---|---|
$(expr ...) |
28.04초 | 25.22초 |
$(echo $((...))) |
7.32초 | 8.79초 |
$((...)) |
0.04초 | 0.02초 |
측정 당시 맥은 다른 작업으로 바빴기 때문에(8코어에 로드 평균 9~12 안팎) 절대값은 부풀려져 있고, 회차별 차이도 최대 25% 였습니다. 봐야 할 것은 비율입니다. 반복마다 프로세스를 띄우면 약 700배 느렸고, expr 1회에 약 2.8 ms 였습니다. 반복문 안의 $(date), $(basename ...), $(cat file) 도 같습니다. 6강에서 로그 타임스탬프로 다시 잽니다.
솔직한 한계와 버전 메모
- 검증은 macOS 의 bash 3.2.57 과 zsh 5.9 에서만 했습니다. bash 3.2 에는 연관 배열(
declare -A),mapfile,${x^^}가 없어서 실제로declare: -A: invalid option,mapfile: command not found,bad substitution이 나왔습니다. 세 가지 모두 bash 4.0 에서 추가됐습니다(bash NEWS 기준, 여기서는 실행하지 않음). set -e에서((count++))가 멈추는지는 셸과 버전마다 다릅니다. 어느 쪽에도 기대지 말고count=$((count + 1))로 씁니다.- 이 맥의
/bin/sh(sh 모드 bash 3.2)에서는pipefail이 됐습니다. Debian/Ubuntu 의/bin/sh는 bash 가 아니라 dash 이고,set -o pipefail이 통하는지는 dash 버전에 달려 있습니다(여기서는 실행하지 않음). bash 기능을 쓴다면 첫 줄에#!/bin/bash를 쓰고sh script.sh가 아니라 파일로 실행합니다. set -e의 예외는 실제로 더 많습니다. 위 목록은 이번에 재현한 것들입니다.
연습 문제
dir=$1다음에rm -rf "$dir/cache"가 있는 스크립트를 인자 없이 실행하면 어떻게 될까요? 한 줄로 막는 가드는?cat names.txt | while read n; do count=$((count+1)); done; echo $count를 bash 에서도 올바른 수가 나오도록 고쳐 보세요.
정답
$dir이 비어서rm -rf "/cache"가 됩니다. 쓰기 전에: "${1:?usage: $0 DIR}"를 둡니다(set -u와dir=$1조합도 bash 3.2 에서$1: unbound variable로 멈추는 것을 확인했습니다).count=0; while IFS= read -r n; do count=$((count + 1)); done < names.txt; echo "$count". 리다이렉트면 반복문이 현재 셸에서 돌고,IFS=와-r덕분에 앞쪽 공백과 백슬래시도 그대로 남습니다.
이전: 3강 find·xargs·sed·awk · 다음: 5강 프로세스·잡·시그널 · 6강: 튼튼한 스크립트·cron·로그
자주 묻는 질문
공백이 있는 변수를 따옴표 없이 쓰면 왜 bash 에서는 깨지고 zsh 에서는 되나요?
bash 는 따옴표 없는 $f 의 값을 공백으로 쪼개고 글롭도 펼칩니다. f="my report.txt" 일 때 ls $f 는 ls my report.txt 가 되어 에러가 두 번 납니다. zsh 는 기본적으로 따옴표 없는 변수를 쪼개지 않아서 같은 줄이 동작합니다. 모든 확장을 "$f" 처럼 따옴표로 감싸면 두 셸 결과가 같아집니다.
set -e 를 켜면 실패한 명령에서 항상 멈추나요?
아닙니다. if 조건 안, && 나 || 의 왼쪽, if 에서 부른 함수 안, local x=$(cmd) 로 가려진 실패는 무시됩니다. 전부 실행해 봤고 모두 스크립트가 계속 돌았습니다. 중요한 명령은 if ! cmd; then … 처럼 직접 확인해야 합니다.
종료 코드 126 과 127 은 무슨 뜻인가요?
127 은 명령을 찾지 못함(오타나 PATH 문제), 126 은 파일은 찾았지만 실행할 수 없음(대개 실행 권한 없음)입니다. macOS 의 bash 3.2 와 zsh 5.9 에서 같은 값이 나왔습니다.