포스트

셸 스크립트 문법 종합 가이드 (bash 기준, zsh 차이 표기)

셸 스크립트를 읽고 고치는 데 필요한 bash 문법을 한 번에 정리. 변수·인용·파라미터 확장·산술·조건·제어 흐름·함수·배열·리다이렉션·heredoc·확장 순서·set 옵션까지, mac 기본 zsh와 갈리는 지점은 그때그때 표기.

셸 스크립트 문법 종합 가이드 (bash 기준, 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

셸은 한 줄을 정해진 순서로 확장한다. 이 순서를 알면 “왜 인용이 필요한가”가 한 번에 풀린다.

  1. brace {1..3}, {a,b} — 텍스트 전개 (변수 못 씀)
  2. 틸드 ~ → 홈 디렉토리
  3. 파라미터/변수 $var, ${var}
  4. 명령 치환 $(cmd)
  5. 산술 $((...))
  6. word splitting — 인용 안 된 확장 결과를 공백($IFS)으로 쪼갬 ← 버그의 근원
  7. glob *, ?, [...] → 파일명 매칭
  8. 인용 제거

"$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는 스크립트가 정상 종료·에러·시그널 어느 쪽으로 끝나도 실행돼서, 임시 파일·락 정리의 표준 패턴이다.

함정 정리

  1. = 양옆에 공백 금지. name = x는 대입이 아니라 명령 실행.
  2. 변수는 항상 "$var"로 인용. raw $var는 공백·glob에서 쪼개진다.
  3. zshword splitting을 기본으로 안 한다 → zsh에서 되던 게 bash에서 깨진다. 인용을 습관으로.
  4. 함수는 return으로 값을 못 준다. 데이터는 echo$(...), return은 종료 코드.
  5. local을 빠뜨리면 바깥 변수를 덮어쓴다.
  6. bash 배열은 0-indexed, zsh는 1-indexed — 이식 시 조용히 밀린다.
  7. 파일은 while read -r line; do ... done < file. for x in $(cat file)은 쓰지 않는다.
  8. [ ](홑괄호)는 변수 인용 필수. bash/zsh에선 [[ ]]가 안전한 기본.
  9. for f in *.txt는 매칭 없으면 bash는 리터럴 *.txt, zsh는 에러 — 정반대 기본값.
  10. 파이프 오른쪽은 서브셸이라 루프 안 변수 변경이 밖에 안 남는다 → < <(...)로 회피.
  11. 2>&1> out 뒤에 와야 한다. 순서가 반대면 stderr가 파일로 안 간다.
  12. (( count++ ))는 결과 0일 때 종료 코드 1 → set -e와 물리면 스크립트가 죽는다.

이 문법 위에서 실제 태스크로 넘어가려면 셸 로드맵 1단계의 각론들(파일명·문자열 일괄 변경, find -exec vs 파이프, CLI 인자 컨벤션)로 이어진다. 초기화 파일 로딩 순서와 프로세스 동작은 로드맵 입문 두 글을 먼저 보면 이 글의 shebang·서브셸 이야기가 더 선명해진다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.