본문으로 건너뛰기 Bash 스크립팅 완전 가이드 — 내부 동작·리다이렉션·트랩·프로덕션 패턴

Bash 스크립팅 완전 가이드 — 내부 동작·리다이렉션·트랩·프로덕션 패턴

Bash 스크립팅 완전 가이드 — 내부 동작·리다이렉션·트랩·프로덕션 패턴

이 글의 핵심

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

Bash 스크립트는 운영 자동화·CI·배포·장비 점검에서 여전히 가장 널리 사용됩니다. 표면적인 문법을 넘어 확장 규칙, 프로세스 경계(서브셸), FD(파일 디스크립터) 모델, 시그널·trap, 엄격 모드와 실패 처리를 이해해야 장애가 나도 원인을 역추적할 수 있습니다. 아래에서는 실무에서 자주 막히는 지점을 중심으로 “동작 원리 → 함정 → 권장 패턴” 순으로 정리합니다.


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

1.1 확장이 일어나는 순서(개념)

Bash는 한 줄을 실행하기 전에 단어를 나누고, 여러 종류의 확장(expansion) 을 순차적으로 적용합니다. 여기서 말하는 순서는 버전·모드에 따라 미세하게 달라질 수 있으나, 실무적으로는 다음 계층을 머릿속에 두면 디버깅이 쉬워집니다.

  1. 브레이스 확장 {a,b}, {1..3}
  2. 틸드 확장 ~, ~user
  3. 매개변수 확장 $var, ${var...}
  4. 명령 치환 $(cmd), `cmd`
  5. 산술 확장 $((expr))
  6. 단어 분할(word splitting)IFS에 따라 결과 문자열이 여러 인자로 쪼개짐
  7. 경로명 확장(pathname expansion)*, ?, [...] 글롭

중요한 함정은 “확장이 끝난 뒤에야 글롭이 적용된다”는 점입니다. 변수 안에 *.log 문자열을 넣어두었다면, 단어 분할 이후에 글롭이 시도되므로 현재 디렉터리의 파일명과 연결됩니다. 반대로 따옴표로 감싼 문자열은 단어 분할과 글롭을 막아 “문자 그대로” 한 인자로 남습니다.

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

# unset/empty이면 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}는 위와 비슷하지만, 조건이 맞을 때 parameter에 대입까지 합니다(주의: 의도치 않은 전역 상태 변경).

${parameter:?word}는 unset/empty면 치명적 오류로 종료에 가깝게 동작하며, 필수 설정 누락을 빨리 드러냅니다.

${parameter:+word}는 “값이 있을 때만 word”로, 플래그성 출력에 사용됩니다.

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

  • ${var#pat} / ${var##pat}: 접두에서 짧게/길게 일치분 제거
  • ${var%pat} / ${var%%pat}: 접미에서 짧게/길게 일치분 제거

경로에서 파일명만 남기거나 확장자를 떼는 패턴이 여기에 해당합니다. # 한 개와 두 개의 차이는 탐욕(greedy) 매칭 여부입니다.

${var/pattern/replacement}, ${var//pattern/replacement}는 첫 일치만 바꾸거나 전부 바꿉니다. ${var^}, ${var^^}, ${var,}, ${var,,}는 첫 글자/전체의 대소문자 변환(Bash 4+)입니다.

1.4 간접 참조·배열·@*

${!name} 형태는 간접(indirect) 확장으로, name 변수에 적힌 이름의 값을 다시 변수명으로 해석합니다. 동적 설정 키를 다룰 때 강력하지만 가독성이 떨어지므로 팀 규칙이 필요합니다.

배열에서 "${arr[@]}""${arr[*]}"따옴표 유무와 함께 쓰일 때 의미가 달라집니다. "${arr[@]}"요소마다 따로 인자로 펼치며, IFS에 묶인 "${arr[*]}"하나의 인자로 이어 붙입니다. 반복문 인자 전달에는 거의 항상 "${arr[@]}"가 안전합니다.

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

1.5 set -u와의 상호작용

set -u(nounset)는 unset 변수 확장을 오류로 바꿉니다. 기본값 문법 ${var:-}처럼 “비어 있음을 허용”하는 패턴과 함께 쓰면 조기 실패와 안전성을 동시에 얻습니다. 반대로 디버깅 전 단계에서만 끄는 경우도 있으나, 프로덕션 스크립트는 nounset을 기본으로 두는 편이 유지보수에 유리합니다.


2. 서브셸과 명령 치환

2.1 괄호 () — 서브셸

( command1; command2 )새로운 셸 환경에서 명령을 실행합니다. 변수 할당·디렉터리 이동·함수 내 로컬 상태가 부모 셸로 돌아오지 않습니다. 파이프라인의 각 단계는 POSIX 관점에서 구현에 따라 서브셸과 유사한 격리가 생기기 쉬워, 파이프 뒤 변수 갱신이 “안 먹는” 현상으로 이어집니다. Bash에서는 lastpipe 옵션 등으로 동작이 달라질 수 있어, 상태 공유가 필요하면 파이프 대신 임시 파일·프로세스 치환·명시적 그룹을 검토합니다.

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

{ command1; command2; }는 같은 셸에서 순차 실행합니다. 서브셸 비용이 없고 변수 변경이 유지됩니다. 마지막 명령 뒤에 세미콜론 또는 줄바꿈 규칙을 지켜야 하는 등 문법 제약이 있으니 스타일 가이드를 맞춥니다.

2.3 명령 치환 $(...) 와 백틱

$(command)는 명령의 표준출력을 문자열로 가져와 확장 단계에 끼워 넣습니다. 중첩이 자연스럽고 가독성이 좋아 백틱보다 권장됩니다. 치환 결과는 후속 단계에서 단어 분할·글롭 대상이 되므로, 안전하게 하려면 "$(...)"로 감싸는 습관이 중요합니다.

백틱 `...`은 이스케이프 규칙이 까다롭고 중첩이 어렵습니다. 레거시만 아니면 새 코드에서는 피합니다.

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

Bash의 <(command)읽기 가능한 이름(대개 /dev/fd/...)을 만들어 다른 명령의 인자로 넘깁니다. diff <(sort a) <(sort b)처럼 임시 파일 없이 스트림을 비교할 때 유용합니다. >(command)는 출력을 다른 프로세스로 보내는 패턴입니다. /dev/fd에 의존하므로 환경 이식성을 확인해야 합니다.

2.5 요약: “왜 내 변수가 안 바뀌지?”

  • 파이프라인/서브셸 안에서 바꾼 값은 부모와 공유되지 않는 것이 정상입니다.
  • 명령 치환은 자식 프로세스 stdout을 읽는 구조라, 부작용(변수 변경)을 기대하지 않는 편이 낫습니다.
  • 부모에 상태를 남기려면 현재 셸 그룹, 파일, 나중에 읽을 변수 설계, 또는 적절한 IPC를 선택합니다.

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

3.1 0, 1, 2의 모델

  • 0: 표준입력(stdin)
  • 1: 표준출력(stdout)
  • 2: 표준에러(stderr)

리다이렉션은 “이번 명령에서 FD N이 가리키는 스트림을 어디로 연결할지”를 재배선합니다.

3.2 >, >>, <, 2>, &>

  • >덮어쓰기, >>추가입니다.
  • 2>file은 stderr만 파일로.
  • Bash에서 &>file 또는 >&filestdout+stderr를 함께 묶는 축약(가독성과 이식성 확인 필요).

3.3 2>&1의 순서

# 의도: stdout·stderr 모두 log.txt로
exec >>log.txt 2>&1

# 흔한 실수: stderr가 터미널에 남을 수 있음(리다이렉션 적용 순서 때문)
# some_cmd 2>&1 >log.txt

cmd >out 2>&1stdout을 파일로 보낸 뒤, stderr를 “현재의 stdout”으로 복제합니다. 결과적으로 둘 다 파일로 갑니다.

반면 cmd 2>&1 >out은 먼저 stderr를 터미널에 붙어 있던 stdout에 붙였다가, stdout을 파일로 바꿔 stderr는 터미널에 남는 류의 실수로 이어질 수 있습니다. 운영 로그를 모을 때 흔한 버그입니다.

3.4 exec으로 셸 자체의 FD를 고정

exec 3<>file처럼 사용자 FD(3 이상) 를 열어두며, exec 3>&-로 닫습니다. 긴 스크립트에서 로그·락·상태 파일을 안정적으로 유지할 때 씁니다. exec은 현재 셸 프로세스의 FD 테이블을 바꾸므로, 서브셸 안에서만 쓰면 부모에 영향을 주지 않게 제어할 수 있습니다.

3.5 Here-document / Here-string

<<EOF는 여러 줄 리터럴을 stdin으로 붙입니다. <<'EOF'확장을 막아 설정 파일 조각을 그대로 넣을 때 안전합니다. <<<"string"here-string으로 짧은 입력을 붙일 때 편합니다.

3.6 /dev/null, close, 중복 FD

불필요한 출력을 버릴 때 >/dev/null 2>&1 패턴이 자주 사용됩니다. 반대로 입력을 끊을 때도 FD를 닫는 방식이 필요할 수 있습니다. 리다이렉션은 프로세스 시작 시점의 스냅샷에 가깝게 적용된다고 이해하면, 배경 프로세스·서브셸과 섞일 때 혼란이 줄어듭니다.


4. 시그널 처리와 trap

4.1 시그널과 비동기성

시그널은 커널·터미널이 프로세스에 보내는 비동기 이벤트입니다. SIGINT(Ctrl+C), SIGTERM(정상 종료 요청), SIGHUP(세션 끊김) 등이 대표적입니다. 셸 스크립트는 핸들러로 반응하거나, 기본 동작에 맡깁니다.

4.2 trap 문법과 흔한 패턴

cleanup() {
  rm -f "${TMPFILE:-}"
}
trap cleanup EXIT INT TERM
  • EXIT: 셸이 종료할 때(정상·비정상 포함, 일부 조건 제외) 실행
  • INT, TERM: 사용자 중단·외부 종료 요청 시 정리

정리 루틴에서는 멱등(idempotent) 하게 작성하며, 다시 종료를 호출하지 않도록 주의합니다.

4.3 SIGKILLSIGSTOP

SIGKILL(9)SIGSTOP 은 잡거나 무시할 수 없습니다. 데이터 일관성이 필요하면 선제적 SIGTERM 처리·타임아웃·외부 오케스트레이션(systemd, k8s preStop)을 설계합니다.

4.4 서브셸·백그라운드와 상속

trap서브셸에 상속되는 방식이 규칙적으로 정리되어 있지 않은 부분이 있어, 복잡한 스크립트는 한 진입점에서만 트랩을 설치하고 자식은 짧게 유지하는 편이 안전합니다. 백그라운드 잡(&)과 함께 쓸 때는 잡 제어(job control) 와 터미널 시그널 전파를 이해해야 합니다.

4.5 ERR 트랩과 set -e

set -e는 단순하지만 예외가 많아 실무에서만으로는 부족합니다. trap 'handler' ERR실패한 명령 위치를 로깅하는 패턴을 쓰기도 합니다. 다만 조건문·|| true 등과의 상호작용을 문서화하지 않으면 “왜 종료 안 했지?” 디버깅이 어려워집니다.


5. 프로덕션 Bash 스크립팅 패턴

5.1 엄격 모드의 기준선

많은 팀이 다음을 기준선으로 삼습니다.

set -Eeuo pipefail
IFS=$' \n\t'
  • -e: 명령 실패 시 종료(단, 예외 규칙 숙지 필요)
  • -u: unset 변수 사용 금지
  • pipefail: 파이프 중 한 단계라도 실패하면 실패로
  • -E: 일부 셸에서 ERR 트랩이 함수·서브셸에서도 기대대로 동작하도록 돕습니다(환경 확인).

IFS를 보수적으로 고정하면 의도치 않은 공백 분할을 줄입니다.

5.2 [[[ — 언제 무엇을

[[는 Bash 내장 조건으로, 글롭·정규식·패턴 매칭이 자연스럽고 인용 규칙이 덜 까다롭습니다. POSIX 이식이 최우선이면 [를 쓰며, Bash 전용 스크립트에서는 [[가 유지보수에 유리한 경우가 많습니다.

5.3 printf를 기본 출력으로

echo는 플래그·이스케이프 처리가 구현마다 달라 이식성과 안전성이 떨어질 수 있습니다. 포맷이 필요하면 printf '%s\n' "$var" 패턴이 표준적입니다.

5.4 임시 파일과 정리

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

mktemp예측 불가능한 경로를 쓰며, EXIT 트랩으로 누수를 막습니다. 동시 실행이 많으면 락 파일·flock 도 검토합니다.

5.5 비밀·로그·재현 가능한 빌드

  • 비밀은 환경 변수·비밀 저장소에서 주입하며, set -x 로그에 노출되지 않게 주의합니다.
  • 로그에는 타임스탬프·스크립트 이름·단계를 남깁니다.
  • 버전 고정: OS, Bash, 외부 명령(--version 확인)을 문서화합니다.

5.6 정적 분석: ShellCheck

ShellCheck은 흔한 함정(따옴표 누락, grep 파이프 실패 등)을 짚어 줍니다. CI에 넣으면 리뷰 부담이 줄어듭니다.

5.7 멱등성과 “다시 실행해도 안전한가”

배포·마이그레이션 스크립트는 두 번 실행해도 같은 결과인지(또는 감지 후 중단인지)를 팀이 합의해야 합니다. mkdir -p, 조건부 복사, 이미 적용된 마이그레이션 스킵 같은 패턴이 여기에 해당합니다.


정리

Bash는 문법이 익숙해지면 빠르게 결과를 내지만, 확장 순서·서브셸 경계·FD 리다이렉션·시그널을 모르면 “간헐적 실패”로 돌아옵니다. 이 글에서 다룬 규칙을 기준으로 스크립트를 읽으며, 엄격 모드·trap·임시 파일·정적 분석을 습관화하면 운영 품질이 한 단계 올라갑니다. 팀에서는 Shell 스타일 가이드(인용, [[/[, 파이프 사용 한도, 로그 포맷)를 문서로 고정해 두는 것을 권장합니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 배포 스크립트, CI 파이프라인, 서버 점검·자동화 작업에서 특히 유용합니다. 파라미터 확장 순서와 FD 리다이렉션 동작을 정확히 알면 로그가 엉뚱한 곳으로 새거나 변수가 예상과 다르게 비어 있는 문제를 빠르게 진단할 수 있습니다. trap과 엄격 모드(set -Eeuo pipefail)는 특히 프로덕션 배포 스크립트에서 실패를 조기에 감지하는 데 필수적입니다.

Q. sh(POSIX 셸)와 Bash 전용 문법을 섞어도 되나요?

A. 스크립트 첫 줄의 셔뱅(#!/bin/bash)과 실제 실행 방식이 일치해야 합니다. [[, 배열, ${var^^} 같은 문법은 Bash 확장이므로 #!/bin/sh로 실행되거나 dash 등 다른 셸에서 실행되면 오류가 납니다. 이식성이 필요하면 POSIX [와 표준 기능만 사용하고, Bash 기능이 필요하면 셔뱅과 실행 방식을 명확히 bash로 고정하는 편이 안전합니다.

Q. 더 깊이 공부하려면?

A. GNU Bash Reference Manual의 파라미터 확장·잡 제어·시그널 챕터를 참고하시고, ShellCheck를 CI에 연동해 작성한 스크립트를 지속적으로 검증하시기를 권장합니다.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

Bash, Shell, Linux, DevOps, 스크립팅, trap, 리다이렉션 등으로 검색하시면 이 글이 도움이 됩니다.