- Published on
bash set -euo pipefail 함정과 안전한 예외처리
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 프로비저닝, CI/CD, 배포 스크립트에서 set -euo pipefail은 거의 관용구처럼 붙습니다. 실패를 조기에 감지하고(`-e`), 미정의 변수를 막고(`-u`), 파이프라인 실패를 전파(`pipefail`)해 “조용히 망하는” 상황을 줄여주기 때문입니다.
하지만 이 조합은 안전장치이면서 동시에 함정입니다. 특히 Bash의 에러 처리 규칙은 직관적이지 않은 부분이 많아서, “예외적으로 실패를 허용하려던 코드”가 전체 스크립트를 종료시키거나, 반대로 “실패를 잡았다고 생각했는데” 실제로는 실패를 놓치는 일이 발생합니다.
이 글에서는 set -euo pipefail이 왜 위험해질 수 있는지, 그리고 **안전한 예외처리(의도된 실패 허용)**를 어떻게 설계해야 하는지 실전 패턴 위주로 정리합니다. 운영 환경에서 이런 스크립트 실패가 서비스 재시작 루프나 장애로 이어질 수 있는데, 증상이 비슷한 케이스를 다룬 글로는 systemd 서비스가 무한 재시작되는 원인 7가지도 함께 참고하면 좋습니다.
set -euo pipefail의 의미와 “기대”
보통 다음처럼 시작합니다.
#!/usr/bin/env bash
set -euo pipefail
# -e: 명령이 실패하면 즉시 종료 (단, 예외 규칙이 많음)
# -u: 정의되지 않은 변수 사용 시 오류
# -o pipefail: 파이프라인 중간 실패도 전체 실패로 간주
기대하는 효과는 명확합니다.
- 실패를 빨리 표면화: 중간 명령 실패를 무시한 채 다음 단계로 진행하지 않음
- 변수 오타/누락 방지: 빈 값이 조용히 들어가서 파괴적 명령이 실행되는 사고 방지
- 파이프라인 실패 탐지:
cmd1 | cmd2에서cmd1이 실패해도cmd2가 성공하면 성공으로 보이는 문제 방지
문제는, 이 “기대”가 Bash의 실제 규칙과 자주 어긋난다는 점입니다.
함정 1) -e는 ‘모든 실패’에 반응하지 않는다
set -e는 “어떤 명령이 0이 아닌 종료 코드를 내면 종료”로 알려져 있지만, 실제로는 종료하지 않는 경우가 많습니다. 대표적으로 다음 상황에서는 -e가 무시되거나 다르게 동작합니다.
if,while,until의 조건식&&,||체인의 일부! cmd형태- 일부 서브셸/커맨드 치환 상황
예를 들어 다음 코드는 직관과 다르게 동작할 수 있습니다.
set -euo pipefail
if grep -q "needle" file.txt; then
echo "found"
else
echo "not found"
fi
echo "script continues"
grep -q가 못 찾으면 종료코드 1인데, 이는 “오류”가 아니라 “조건 불일치”로 흔히 사용되므로 if 컨텍스트에서는 -e가 스크립트를 죽이지 않습니다. 이건 오히려 바람직하죠.
반대로, 개발자가 “이건 실패하면 바로 죽어야 한다”고 믿고 조건문 밖에서 쓰면 예상치 못한 종료가 발생합니다.
안전한 규칙: ‘실패가 정상인 명령’을 명시적으로 예외 처리
grep의 미매치는 정상 흐름일 수 있음test/[의 false도 정상 흐름일 수 있음curl로 health check를 할 때 404/503을 분기 처리할 수 있음
이럴 때는 의도를 코드로 박아두는 편이 안전합니다.
set -euo pipefail
if ! grep -q "needle" file.txt; then
echo "needle not found (expected in some cases)" >&2
fi
또는 명시적으로 “실패 허용”을 표시합니다.
set -euo pipefail
grep -q "needle" file.txt || true
단, || true는 남발하면 실제 오류도 덮어버리므로(관측 불가), 아래에서 소개할 예외처리 래퍼 함수를 권합니다.
함정 2) pipefail이 켜지면 grep/head 조합이 지뢰가 된다
pipefail은 좋은 옵션이지만, 파이프라인에서 “중간 명령이 SIGPIPE로 죽는” 상황을 자주 만듭니다.
대표 예:
set -euo pipefail
# 첫 줄만 필요해서 head로 자름
first_line=$(cat big.log | head -n 1)
head는 1줄 읽고 종료합니다. 그러면 cat은 파이프가 끊겨 SIGPIPE로 종료(보통 141)할 수 있고, pipefail이 켜져 있으면 파이프라인 전체가 실패로 간주되어 스크립트가 종료될 수 있습니다.
해결: 불필요한 cat 제거 + 파이프라인 최소화
set -euo pipefail
first_line=$(head -n 1 big.log)
또 다른 흔한 예:
set -euo pipefail
# 매칭이 없으면 grep이 1로 종료 -> 파이프라인 실패
count=$(grep -E "ERROR" app.log | wc -l)
매칭이 없는 건 정상일 수 있는데, pipefail 때문에 실패로 처리되어 종료될 수 있습니다.
해결: grep의 실패를 “정상 0건”으로 변환
set -euo pipefail
count=$( (grep -E "ERROR" app.log || true) | wc -l )
하지만 이 패턴은 “진짜 grep 에러(파일 없음 등)”까지 덮을 수 있습니다. 그래서 더 안전한 방식은 파일 존재/권한을 먼저 확인하거나, grep 에러와 “미매치”를 분리하는 것입니다.
set -euo pipefail
if [[ ! -r app.log ]]; then
echo "app.log not readable" >&2
exit 1
fi
# 미매치(1)는 허용, 그 외는 실패
count=$( { grep -E "ERROR" app.log; rc=$?; if (( rc == 1 )); then exit 0; else exit $rc; fi; } | wc -l )
함정 3) -u는 ‘옵션 파싱’에서 특히 자주 터진다
set -u는 미정의 변수 접근을 막아주지만, 다음 같은 코드에서 흔히 폭발합니다.
set -euo pipefail
# $1이 없으면 -u로 즉시 종료
env="$1"
해결: 기본값/필수값을 파라미터 확장으로 강제
set -euo pipefail
env="${1:?usage: script.sh <env>}"
region="${2:-ap-northeast-2}"
echo "env=$env region=$region"
${var:?msg}: 비어있거나 미정의면 msg 출력 후 종료${var:-default}: 미정의/빈 값이면 default 사용
또한 배열/연관배열에서도 -u는 예상치 못한 종료를 만들 수 있습니다.
set -euo pipefail
declare -A m
# 키가 없으면 -u로 죽을 수 있음
val="${m[missing]}"
안전하게는 다음처럼 기본값을 둡니다.
val="${m[missing]-}" # 없으면 빈 문자열
함정 4) trap cleanup에서 실패하면 원인 분석이 더 어려워진다
set -e 상태에서 trap 'cleanup' EXIT를 걸어두면, cleanup 내부에서 실패가 나면서 원래 실패 원인을 가리는 경우가 있습니다.
set -euo pipefail
tmpdir=$(mktemp -d)
cleanup() {
rm -rf "$tmpdir" # 여기서 권한/경로 문제로 실패하면?
}
trap cleanup EXIT
false # 원래 원인
이때 로그가 cleanup 실패로 덮여 “왜 실패했는지”가 흐려질 수 있습니다.
해결: cleanup은 실패해도 스크립트를 망치지 않게 설계
- cleanup 내부는
set +e로 보호 - 원래 exit code를 보존
set -euo pipefail
tmpdir=$(mktemp -d)
cleanup() {
local ec=$?
set +e
rm -rf "$tmpdir" >/dev/null 2>&1
set -e
exit $ec
}
trap cleanup EXIT
# ...
false
또는 cleanup은 “최선을 다하되 실패는 로깅만” 하는 형태가 운영에 더 안전합니다.
함정 5) command substitution 안의 실패가 기대대로 전파되지 않거나 과하게 전파됨
$(...) 안에서 실패가 나면 -e가 어떻게 반응할지 Bash 버전/문맥에 따라 헷갈리는 케이스가 많습니다. 특히 다음은 위험합니다.
set -euo pipefail
token=$(curl -fsS https://example.com/token)
curl -f는 400/500에서 실패로 처리합니다. 토큰이 일시적으로 실패할 수 있는 시스템이라면 “재시도/대체 경로”가 필요합니다. 이런 네트워크/인증 실패가 배포 파이프라인을 끊어 장애로 이어지는 패턴은 클라우드에서도 흔하고, 예를 들어 OIDC/STS 계열 문제는 GitHub Actions OIDC로 AWS AssumeRoleAccessDenied 해결 같은 글에서 다루는 것처럼 원인 분리가 중요합니다.
해결: 실패를 ‘잡아서’ 메시지+재시도 정책으로 승격
set -euo pipefail
retry() {
local -r max=${1:?max}
local -r delay=${2:?delay}
shift 2
local i=1
while true; do
if "$@"; then
return 0
fi
if (( i >= max )); then
return 1
fi
sleep "$delay"
((i++))
done
}
get_token() {
curl -fsS https://example.com/token
}
if ! token=$(retry 5 2 get_token); then
echo "failed to get token after retries" >&2
exit 1
fi
이렇게 하면 set -e에 운명을 맡기지 않고, 실패를 정책적으로 처리할 수 있습니다.
안전한 예외처리의 핵심: “무시”가 아니라 “분류”
|| true는 가장 쉬운 예외처리지만, 실제 운영에서는 다음 문제가 생깁니다.
- 실패가 발생했는지 로그로 남지 않음
- 어떤 실패는 허용하면 안 되는데 같이 덮임
- 이후 단계에서 더 큰 파괴적 명령이 실행될 수 있음
따라서 예외처리는 “무시”가 아니라 (1) 실패를 분류하고 (2) 관측 가능하게 만들고 (3) 의도된 경우만 계속 진행해야 합니다.
추천 패턴 1) try/warn/die 래퍼로 의도를 코드화
#!/usr/bin/env bash
set -euo pipefail
log() { printf '%s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
warn() { log "WARN: $*"; }
# 실패하면 즉시 종료 (set -e에 의존하지 않고 메시지 보장)
run() {
"$@"
}
# 실패를 허용하되, 실패 사실은 남김
try() {
if "$@"; then
return 0
else
local rc=$?
warn "command failed (rc=$rc): $*"
return $rc
fi
}
# 예: optional cleanup
try rm -f /tmp/nonexistent
# 예: 필수 명령
run mkdir -p /var/myapp
이 패턴의 장점은:
set -e의 예외 규칙을 덜 신경 써도 됨- 실패 메시지 표준화
- “이건 실패해도 됨/안 됨”이 코드에서 한눈에 보임
추천 패턴 2) ERR 트랩으로 실패 지점과 콜스택 남기기
Bash는 기본적으로 어디서 실패했는지 정보가 빈약합니다. trap '...' ERR와 BASH_COMMAND, BASH_LINENO를 활용하면 디버깅이 급격히 쉬워집니다.
#!/usr/bin/env bash
set -euo pipefail
on_err() {
local rc=$?
echo "ERROR rc=$rc at ${BASH_SOURCE[1]}:${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2
}
trap on_err ERR
# 예시
cp /no/such/file /tmp/
주의할 점:
- 함수/서브셸로 에러가 전파되도록
set -E(errtrace)를 추가하는 경우가 많습니다.
set -Eeuo pipefail
trap on_err ERR
추천 패턴 3) “의도적 실패”는 블록으로 감싸고 모드 전환
특정 구간에서는 실패가 정상(탐색/프로빙)일 수 있습니다. 그럴 땐 잠깐 -e를 끄고, 결과를 직접 해석하는 편이 가장 명확합니다.
set -euo pipefail
set +e
curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1
rc=$?
set -e
if (( rc != 0 )); then
echo "health check failed (rc=$rc), continue with fallback" >&2
fi
이 방식은 “여긴 예외 구간”이라는 의도가 분명하고, || true처럼 광범위하게 실패를 덮지 않습니다.
추천 패턴 4) 파이프라인은 PIPESTATUS로 원인 분해
파이프라인이 실패했을 때 어느 단계가 실패했는지 알아야 예외처리가 가능합니다.
set -euo pipefail
set +e
output=$(grep -E "ERROR" app.log | head -n 10)
rc=$?
ps=("${PIPESTATUS[@]}")
set -e
# rc: pipefail 기준 파이프라인 종료코드
# ps: 각 단계의 종료코드 배열
if (( rc != 0 )); then
echo "pipeline failed rc=$rc pipe=${ps[*]}" >&2
fi
이걸 기반으로 “grep 미매치(1)는 허용, 파일 없음(2)은 실패” 같은 정책을 구현할 수 있습니다.
운영에서 자주 겪는 시나리오: 스크립트 실패가 재시작 루프를 만든다
set -euo pipefail로 엄격하게 만든 스크립트가 systemd 서비스의 ExecStartPre=/ExecStart=에 걸려 있으면, 사소한 조건 실패도 서비스 전체를 죽이고 재시작을 유발할 수 있습니다. 이때는 스크립트 자체의 예외처리뿐 아니라, 서비스 레벨에서 재시작 정책/백오프/로그를 함께 봐야 합니다.
- systemd 재시작 루프의 전형적인 원인과 점검 포인트: systemd 서비스 자동 재시작 무한루프 진단 가이드
또한 컨테이너 환경에서는 스크립트의 즉시 종료가 곧바로 CrashLoopBackOff로 보이기도 합니다. 이 경우 ERR 트랩으로 실패 지점을 남기면 원인 파악 시간이 크게 줄어듭니다.
- 컨테이너 재시작/Probe/로그 관점 디버깅: Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅
체크리스트: set -euo pipefail을 “안전하게” 쓰는 법
set -e에만 의존하지 말고, 필수/선택 명령을run/try같은 래퍼로 구분한다.pipefail을 켰다면head,grep,xargs조합에서 SIGPIPE/미매치(1)로 종료될 수 있음을 전제로 한다.-u를 켰다면${1:?},${var:-}같은 파라미터 확장을 기본 습관으로 한다.trap EXITcleanup은 실패해도 원인 은폐가 되지 않게set +e/exit code 보존을 적용한다.trap ERR(+set -E)로 실패 지점/명령을 로깅해 관측 가능성을 확보한다.- “실패를 허용해야 하는 구간”은
set +e로 모드를 전환하고, rc를 직접 해석한다.
결론
set -euo pipefail은 좋은 기본값이지만, Bash의 에러 전파 규칙이 복잡하기 때문에 “엄격 모드”만으로 안전성이 완성되진 않습니다. 핵심은 실패를 무조건 막는 게 아니라, 실패를 정책적으로 분류하고(허용/불허), 관측 가능하게 만들고(로그/트랩), 예외 구간을 명시적으로 격리하는 것입니다.
위에서 소개한 run/try/die 패턴과 ERR/EXIT 트랩, 그리고 파이프라인/변수 처리의 기본기를 적용하면, set -euo pipefail이 ‘지뢰’가 아니라 ‘안전장치’로 기능하는 스크립트를 만들 수 있습니다.