셸 스크립트 문법 종합 가이드 (bash 기준, zsh 차이 표기)
셸 스크립트를 읽고 고치는 데 필요한 bash 문법을 한 번에 정리. 변수·인용·파라미터 확장·산술·조건·제어 흐름·함수·배열·리다이렉션·heredoc·확장 순서·set 옵션까지, mac 기본 zsh와 갈리는 지점은 그때그때 표기.
셸 스크립트를 읽고 고치는 데 필요한 문법을 한 번에 정리한 글이다. 본문은 bash(3.2+) 기준이고, mac 기본 셸인 zsh와 갈리는 지점은 그때그때 zsh:로 표기했다. 명령어 각론(파일명 일괄 변경, find -exec, 인자 컨벤션)은 셸 로드맵의 1단계에 따로 있고, 이 글은 그 각론이 매달릴 문법 줄기다.
스크립트 첫 줄 shebang은 실제 인터프리터와 맞춰야 한다. bash 전용 문법(배열,
[[ ]],${var/})을 쓸 거면#!/usr/bin/env bash이지#!/bin/sh가 아니다. macOS/bin/sh는 POSIX 모드 bash라 배열조차 안 된다.
변수와 대입
1
2
3
4
5
6
name="neo" # = 양쪽에 공백 없음 (핵심)
name = "neo" # 에러 — name 명령을 =,"neo" 인자로 실행하려 함
count=$((count+1)) # 산술
readonly PI=3.14 # 상수
local x=1 # 함수 안에서만 — 지역 변수
unset name # 삭제
= 양옆에 공백이 있으면 대입이 아니다. 셸에서 공백은 토큰 구분자라, name = "neo"는 name이라는 명령에 =와 neo를 넘기는 꼴이 된다. 스크립트 입문자가 가장 먼저 밟는 지뢰.
전역이 기본이고, 함수 안에서 local을 붙여야 지역이 된다. local을 빠뜨리면 호출한 쪽 변수를 덮어쓴다.
인용 (quoting)
셸에서 인용은 스타일이 아니라 동작을 바꾼다. $var를 그냥 두면 값 안의 공백·glob이 재해석된다.
| 형태 | 변수 확장 | 용도 |
|---|---|---|
"$var" | O | 기본값. 값을 하나의 단어로 보존 |
'$var' | X | 리터럴 그대로 (확장 전부 차단) |
$var (raw) | O | 값이 단어 쪼개짐 + glob 확장 — 대개 버그 |
1
2
3
file="my report.txt"
rm $file # rm 'my' 'report.txt' — 파일 2개 지우려 함 (버그)
rm "$file" # rm 'my report.txt' — 올바름
zsh:zsh는 기본적으로 word splitting을 하지 않는다. 위rm $file가 zsh에선 의도대로 동작해서, bash로 옮기면 깨진다. 인용은 zsh에서도 항상 붙이는 습관이 이식성의 핵심.
"$@" vs "$*"도 같은 결: "$@"는 인자를 각각 하나의 단어로, "$*"는 전부 이어붙여 하나로. 인자 전달은 거의 항상 "$@".
파라미터 확장
$var의 확장 시점에 기본값·치환·잘라내기를 끼워 넣는 문법. sed를 부르기 전에 이걸로 끝나는 경우가 많다.
| 문법 | 의미 |
|---|---|
${var:-default} | var가 비었으면 default (var는 안 바뀜) |
${var:=default} | 비었으면 default를 대입까지 |
${var:?msg} | 비었으면 msg 출력 후 종료 (필수 인자 검증) |
${var:+alt} | var가 있으면 alt, 없으면 빈값 |
${#var} | 길이 |
${var:offset:len} | 부분 문자열 (0-indexed) |
${var#pat} / ${var##pat} | 앞에서 짧게 / 길게 제거 |
${var%pat} / ${var%%pat} | 뒤에서 짧게 / 길게 제거 |
${var/pat/repl} / ${var//pat/repl} | 첫 / 전체 치환 |
${var^^} / ${var,,} | 대문자 / 소문자 (bash 4+) |
1
2
3
4
5
6
7
8
path="/home/neo/report.txt"
echo "${path##*/}" # report.txt (basename — 앞에서 / 까지 최장 제거)
echo "${path%/*}" # /home/neo (dirname — 뒤에서 / 부터 최단 제거)
echo "${path##*.}" # txt (확장자)
echo "${path%.*}" # /home/neo/report (확장자 뗀 것)
name="report.txt"
echo "${name/.txt/.md}" # report.md
#/##/%/%%의 패턴은 정규식이 아니라 glob(*, ?, [...])이다.
zsh:${var^^}/${var,,}는 zsh에서 안 된다. zsh는${(U)var}/${(L)var}. 대소문자 변환처럼 확장 문법은 갈리는 게 많으니, 이식 스크립트에선tr로 우회하는 편이 안전.
조건 — [[ ]], [ ], (( ))
세 개가 용도가 다르다.
| 구문 | 용도 | 비고 |
|---|---|---|
[[ ... ]] | 문자열·파일 테스트 | bash/zsh 전용. 가장 안전. &&, \|\|, < 직접 사용 |
[ ... ] | POSIX 테스트 (test의 별칭) | sh 이식용. 변수 인용 필수 |
(( ... )) | 산술 비교 | <, >, ==를 숫자로. $ 생략 가능 |
1
2
3
4
5
6
7
if [[ "$name" == "neo" ]]; then ...; fi # 문자열
if [[ "$file" == *.txt ]]; then ...; fi # glob 매칭 (인용 안 함에 주의)
if [[ "$str" =~ ^[0-9]+$ ]]; then ...; fi # 정규식 (=~)
if [[ -f "$path" ]]; then ...; fi # 파일 존재
if (( count > 10 )); then ...; fi # 산술
if (( count % 2 == 0 )); then ...; fi
파일 테스트 연산자: -f 일반 파일, -d 디렉토리, -e 존재, -r/-w/-x 권한, -s 크기>0, -L 심볼릭 링크. 문자열: -z 비었음, -n 안 비었음, ==/!=, </> 사전순.
[[ ]]안에서==의 우변은 인용하면 리터럴, 안 하면 glob 패턴이다.[[ $f == *.txt ]]는 매칭이지만[[ $f == "*.txt" ]]는 리터럴*.txt와 같은지 본다.=~의 우변 정규식도 인용하면 리터럴로 죽으니 인용하지 않는다.
[ ](홑괄호)는 변수가 비면 [ = neo ]처럼 인자가 사라져 문법 에러가 난다. 그래서 [ "$x" = "neo" ]로 반드시 인용한다. [[ ]]는 이 문제가 없어서 bash/zsh에선 [[ ]]가 기본.
산술 연산
(( ))는 비교뿐 아니라 계산의 문맥이기도 하다. 안에서는 변수에 $를 안 붙여도 된다.
1
2
3
4
(( x = 3 + 4 )) # 산술 평가 (변수에 $ 불필요)
echo $(( 2 ** 10 )) # 1024 — 값으로 사용하려면 $(( ))
(( i++ )); (( i += 5 )) # 증감·복합 대입
n=$(( 0x1F )) # 31 (16진법). 2#101 → 2진법 5
연산자: + - * / %, **(거듭제곱), ++ --, 비트 & | ^ << >>, 논리 && || !, 비교 == != < <= > >=. 셸에서 </>가 사전순이 아니라 숫자 비교인 유일한 문맥.
(( ))는 결과가 0이면 종료 코드 1(거짓)이다.set -e아래에서(( count++ ))가 count 0→1일 때 반환값 0이라 스크립트가 통째로 죽는다. 산술을set -e와 섞을 땐(( count++ )) || true.
제어 흐름
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if cond; then
elif cond; then
else
fi
for f in *.txt; do ...; done # glob 순회
for i in {1..10}; do ...; done # 범위 (brace expansion)
for ((i=0; i<10; i++)); do ...; done # C 스타일
while read -r line; do ...; done < file # 파일 한 줄씩
until cond; do ...; done # 조건이 참이 될 때까지
case "$1" in
start) ... ;;
stop) ... ;;
*.txt) ... ;; # glob 매칭됨
*) echo "unknown" ;; # default
esac
for f in *.txt에서 매칭되는 파일이 없으면 패턴 문자열 *.txt 자체가 넘어온다(bash 기본). shopt -s nullglob으로 “없으면 빈 목록”으로 바꿀 수 있다.
파일을 한 줄씩 읽을 땐 while read -r line이 정석이다. -r은 백슬래시를 리터럴로 두는 옵션 — 안 붙이면 \가 이스케이프로 먹혀 데이터가 깨진다. for line in $(cat file)은 쓰지 않는다(공백·glob에서 쪼개짐).
zsh:zsh는 매칭 파일이 없으면 기본적으로 에러를 내고 멈춘다(no matches found). bash의 nullglob과 정반대 기본값이라, 두 셸을 오가면 자주 부딪힌다.
명령 연결과 그룹핑
명령을 잇는 연산자와, 여러 명령을 하나로 묶는 두 가지 괄호.
1
2
3
4
5
6
7
cmd1; cmd2 # 순차 (앞 결과 무관)
cmd1 && cmd2 # cmd1 성공(0)이면 cmd2
cmd1 || cmd2 # cmd1 실패(non-zero)면 cmd2
mkdir -p build && cd build # 흔한 관용구
( cd /tmp; rm tmp.txt ) # 서브셸 — cd·변수 변경이 밖에 안 남음
{ echo a; echo b; } > out # 그룹 — 현재 셸에서 묶어 한 번에 리다이렉트
( )는 서브셸(별도 프로세스, cd·변수 격리), { }는 현재 셸에서 묶기만 한다. 여러 명령의 출력을 한 파일로 모을 땐 { ...; } > file. { }는 앞뒤 공백과 마지막 ;(또는 개행)이 문법상 필수.
함수
1
2
3
4
5
6
7
8
greet() {
local name="$1" # 위치 인자 $1, $2, ...
echo "hi $name" # 반환값은 stdout으로
return 0 # 종료 코드 (0~255, 성공/실패만)
}
result="$(greet neo)" # 출력을 캡처
greet neo world # $1=neo $2=world, $#=2, "$@"=전체
셸 함수는 값을 return으로 못 돌려준다. return은 0~255의 종료 코드(성공/실패)일 뿐이고, 실제 데이터는 echo로 stdout에 내보내 $(...)로 받는다. 이 구분이 다른 언어와 가장 다른 지점.
인자 관련 변수: $1~$9/${10} 위치 인자, $# 개수, "$@" 전체(각각 단어), $0 스크립트/함수 이름, $? 직전 종료 코드.
특수 파라미터
셸이 자동으로 채워주는 변수들. 스크립트 상태를 읽는 창구다.
| 변수 | 의미 |
|---|---|
$? | 직전 명령의 종료 코드 (0=성공) |
$$ | 현재 셸의 PID |
$! | 마지막으로 백그라운드로 띄운 잡의 PID |
$# / "$@" / $0 | 인자 개수 / 전체 인자 / 스크립트 이름 |
$- | 현재 켜진 set 플래그 |
1
2
tmp="/tmp/work.$$" # PID로 충돌 없는 임시 이름
long_task & pid=$! # 백그라운드 PID 확보 → 나중에 wait "$pid"
$?는 바로 다음 줄에서 잡아야 한다. 사이에 다른 명령이 하나라도 끼면 그 명령의 종료 코드로 덮인다. 여러 곳에서 쓸 거면 즉시 rc=$?로 변수에 담는다.
배열
1
2
3
4
5
6
7
8
9
10
11
arr=(a b c) # 생성
arr+=(d) # 추가
echo "${arr[0]}" # a — bash는 0-indexed
echo "${arr[@]}" # 전체 요소
echo "${#arr[@]}" # 개수
for x in "${arr[@]}"; do ...; done # 순회 (인용 필수)
declare -A map # 연관 배열 (bash 4+)
map[key]="value"
echo "${map[key]}"
for k in "${!map[@]}"; do ...; done # 키 순회
"${arr[@]}"의 인용을 빠뜨리면 공백 든 요소가 쪼개진다. 배열 순회는 항상 "${arr[@]}".
zsh:zsh 배열은 1-indexed다.${arr[1]}이 첫 요소. bash 스크립트를 zsh에서 sourcing하면 인덱스가 하나씩 밀려 조용히 틀린다. 이식 배열 코드에서 가장 악명 높은 함정. 또 zsh는${arr}가 첫 요소가 아니라 전체를 뜻하는 등 확장 규칙도 달라, 배열 다루는 스크립트는 셸을 확실히 고정하는 게 낫다.
확장 순서와 glob
셸은 한 줄을 정해진 순서로 확장한다. 이 순서를 알면 “왜 인용이 필요한가”가 한 번에 풀린다.
- brace
{1..3},{a,b}— 텍스트 전개 (변수 못 씀) - 틸드
~→ 홈 디렉토리 - 파라미터/변수
$var,${var} - 명령 치환
$(cmd) - 산술
$((...)) - word splitting — 인용 안 된 확장 결과를 공백(
$IFS)으로 쪼갬 ← 버그의 근원 - glob
*,?,[...]→ 파일명 매칭 - 인용 제거
"$var"로 감싸면 6·7이 건너뛰어져 값이 그대로 보존된다. 6·7이 변수 확장 뒤에 오기 때문에, 값 안에 든 공백·*가 재해석되는 것이다.
glob은 정규식이 아니다: * 0+ 문자, ? 한 문자, [abc]/[a-z] 문자 클래스. **(재귀)는 shopt -s globstar(bash) / zsh 기본. 매칭 없을 때의 동작은 위 nullglob 참고.
출력 — echo vs printf
1
2
3
4
echo "hi" # 간단하지만 -e/-n·이스케이프 처리가 구현마다 다름
printf '%s\n' "$var" # 이식성 있는 기본형
printf '%s=%d\n' key 42 # 포맷 (Lua string.format과 유사)
printf '%s\n' "${arr[@]}" # 배열 각 요소를 한 줄씩
데이터를 출력할 땐 echo보다 printf가 안전하다. echo는 셸·플래그마다 \n 같은 이스케이프 해석이 갈려서, 값에 \나 -n이 섞이면 깨진다. 지정자는 %s 문자열, %d 정수, %q 안전 인용, %% 리터럴 %. 포맷 문자열은 인자가 남으면 재사용되어 printf '%s\n' a b c는 세 줄이 된다.
리다이렉션과 heredoc
스크립트가 파일에 로그를 남기고 설정을 생성하는 거의 모든 곳의 문법. fd는 0 stdin, 1 stdout, 2 stderr.
1
2
3
4
5
6
7
8
cmd > out.txt # stdout을 파일로 (덮어씀)
cmd >> out.txt # 이어쓰기
cmd 2> err.txt # stderr만
cmd > out 2>&1 # stdout+stderr를 한 파일로 (순서 중요)
cmd &> out # 위의 bash 축약
cmd < in.txt # stdin을 파일에서
cmd 2>/dev/null # stderr 버리기
echo "bad arg" >&2 # 에러 메시지는 stderr로 (표준 관행)
2>&1은 “fd 2를 지금 fd 1이 가리키는 곳으로 복제”라는 뜻이라 > out 뒤에 와야 한다. cmd 2>&1 > out은 아직 fd 1이 터미널일 때 stderr를 복제해서, stderr가 파일이 아니라 터미널로 샌다 — 리다이렉션 순서 함정 1위.
heredoc — 여러 줄을 그대로 stdin으로:
1
2
3
4
5
6
7
8
9
10
11
12
cat > config.yml <<EOF
name: $USER # 변수·명령 치환 확장됨
path: $(pwd)
EOF
cat <<'EOF' # 구분자를 인용하면 확장 안 함 (리터럴 그대로)
literal $USER
EOF
cat <<-EOF # <<- 는 각 줄 앞의 '탭'을 제거 (본문 들여쓰기 허용)
indented body
EOF
구분자를 인용(<<'EOF')하면 안쪽 $var·$(...)가 확장되지 않는다. 설정 파일 생성·다중 행 메시지의 표준 패턴.
herestring — 한 줄 문자열을 stdin으로:
1
2
grep foo <<< "$content" # echo "$content" | grep foo 와 동등, 서브셸 없음
read -r a b c <<< "1 2 3" # 문자열을 필드로 분해
명령·프로세스 치환
1
2
3
4
5
today="$(date +%Y-%m-%d)" # 명령 치환 — 출력을 값으로
count=$(( $(wc -l < f) + 1 )) # 중첩 가능
diff <(sort a) <(sort b) # 프로세스 치환 — 출력을 파일처럼
while read -r l; do ...; done < <(grep foo f) # 파이프 대신 (서브셸 회피)
$(...)는 역따옴표 `...`의 현대적 대체다. 중첩과 인용이 명확해서 역따옴표는 안 쓴다.
<(...) 프로세스 치환은 명령 출력을 임시 파일 경로처럼 넘긴다. diff처럼 파일 인자를 받는 명령에 파이프를 먹일 수 없을 때, 그리고 while ... done < <(...)로 파이프의 서브셸 문제(파이프 오른쪽이 서브셸이라 루프 안 변수가 밖에 안 남는 문제)를 피할 때 쓴다.
zsh:프로세스 치환·명령 치환은 zsh에서도 동일하게 동작한다. 이 절은 이식 걱정이 적은 편.
견고한 스크립트 — set 옵션과 trap
1
2
3
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
프로덕션 스크립트의 관용 헤더. 각 옵션:
| 옵션 | 효과 |
|---|---|
set -e | 명령이 실패(non-zero)하면 즉시 종료 |
set -u | 미정의 변수 참조 시 에러 (오타 방어) |
set -o pipefail | 파이프 중 하나라도 실패하면 전체 실패 처리 |
IFS=$'\n\t' | 단어 분리 기준을 공백 제외 개행·탭으로 (파일명 공백 방어) |
set -e는 만능이 아니다. if·&&·\|\|·파이프 안에서는 발동하지 않고, 의도적으로 실패를 허용할 땐 cmd || true를 붙인다.
정리 훅은 trap:
1
2
3
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT # 어떻게 끝나든 임시 파일 삭제
trap 'echo interrupted; exit 130' INT # Ctrl-C
trap ... EXIT는 스크립트가 정상 종료·에러·시그널 어느 쪽으로 끝나도 실행돼서, 임시 파일·락 정리의 표준 패턴이다.
함정 정리
=양옆에 공백 금지.name = x는 대입이 아니라 명령 실행.- 변수는 항상
"$var"로 인용. raw$var는 공백·glob에서 쪼개진다. zsh는 word splitting을 기본으로 안 한다 → zsh에서 되던 게 bash에서 깨진다. 인용을 습관으로.- 함수는
return으로 값을 못 준다. 데이터는echo→$(...),return은 종료 코드. local을 빠뜨리면 바깥 변수를 덮어쓴다.- bash 배열은 0-indexed, zsh는 1-indexed — 이식 시 조용히 밀린다.
- 파일은
while read -r line; do ... done < file.for x in $(cat file)은 쓰지 않는다. [ ](홑괄호)는 변수 인용 필수. bash/zsh에선[[ ]]가 안전한 기본.for f in *.txt는 매칭 없으면 bash는 리터럴*.txt, zsh는 에러 — 정반대 기본값.- 파이프 오른쪽은 서브셸이라 루프 안 변수 변경이 밖에 안 남는다 →
< <(...)로 회피. 2>&1은> out뒤에 와야 한다. 순서가 반대면 stderr가 파일로 안 간다.(( count++ ))는 결과 0일 때 종료 코드 1 →set -e와 물리면 스크립트가 죽는다.
이 문법 위에서 실제 태스크로 넘어가려면 셸 로드맵 1단계의 각론들(파일명·문자열 일괄 변경, find -exec vs 파이프, CLI 인자 컨벤션)로 이어진다. 초기화 파일 로딩 순서와 프로세스 동작은 로드맵 입문 두 글을 먼저 보면 이 글의 shebang·서브셸 이야기가 더 선명해진다.