- Published on
Bash set -e가 안 멈출 때 - pipefail·|| true 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 운영·CI에서 Bash 스크립트를 짤 때 가장 흔한 착각이 set -e만 켜면 “실패 시 즉시 중단”이 보장된다는 믿음입니다. 실제로는 Bash의 errexit 규칙이 생각보다 예외가 많고, 특히 파이프라인과 || true(혹은 || :) 같은 패턴이 섞이면 실패가 조용히 무시됩니다.
이 글에서는 set -e가 “안 멈추는” 대표 케이스를 재현하고, set -o pipefail, ERR 트랩, 안전한 예외 처리 패턴까지 한 번에 정리합니다. CI에서 문제를 잡는 흐름은 GitHub Actions 캐시 미스 - 키·경로 디버깅 실전 같은 글과도 맞닿아 있는데, 결국 원인을 재현하고 로그로 증명하는 방식이 핵심입니다.
set -e의 실제 의미: “항상 종료”가 아니다
set -e는 Bash 옵션 errexit로, “단순 명령(simple command)이 실패하면 종료”에 가깝습니다. 하지만 Bash 매뉴얼에는 여러 예외가 명시되어 있습니다. 대표적으로 다음 문맥에서는 실패해도 종료하지 않을 수 있습니다.
if,while,until의 조건식(테스트) 내부&&,||로 연결된 리스트의 일부- 파이프라인의 일부(기본 설정에서는 마지막 명령의 종료 코드만 반영)
- 명령 치환
$(...)내부에서의 실패가 예상과 다르게 전파 - 서브셸
( ... )내부에서의 실패가 외부에 반영되지 않는 경우
즉, set -e는 “실패를 더 민감하게 만들긴 하지만, 실패 전파를 완전히 보장하지는 않는다”가 정확합니다.
함정 1: 파이프라인에서 앞단 실패가 무시된다
가장 흔한 케이스입니다. 기본적으로 파이프라인 a | b | c의 종료 코드는 마지막 명령 c의 종료 코드입니다. 그래서 a가 실패해도 c가 성공하면 전체가 성공으로 취급됩니다.
재현
#!/usr/bin/env bash
set -e
# grep이 찾지 못하면 종료 코드 1
# 하지만 마지막 cat은 성공(0)
echo "hello" | grep "world" | cat
echo "I am still running"
위 스크립트는 많은 사람이 기대하는 것과 달리 마지막 줄까지 실행될 수 있습니다.
해결: set -o pipefail
pipefail을 켜면 파이프라인 내 어느 명령이든 실패 시(정확히는 마지막으로 실패한 명령의 코드) 파이프라인이 실패로 간주됩니다.
#!/usr/bin/env bash
set -euo pipefail
echo "hello" | grep "world" | cat
echo "You will not see this"
실무에서는 거의 항상 set -euo pipefail을 기본 템플릿으로 두는 편이 안전합니다.
-e: 실패 시 중단(단, 예외 규칙 존재)-u: 선언되지 않은 변수 사용 시 오류pipefail: 파이프라인 실패 전파
함정 2: || true가 실패를 완전히 삼킨다
CI나 배포 스크립트에서 “실패해도 넘어가야 하는 작업”을 만들려고 || true를 붙이는 순간, set -e의 의미가 크게 약화됩니다.
재현: 실패를 의도치 않게 숨김
#!/usr/bin/env bash
set -euo pipefail
# curl 실패해도 true 때문에 전체가 성공 처리됨
curl -fsS "https://example.invalid/health" || true
echo "deploy continues..."
이 패턴은 “헬스체크 실패를 무시하고 배포를 진행”하는 결과가 됩니다. 더 나쁜 점은, 로그를 꼼꼼히 보지 않으면 실패 자체를 놓치기 쉽다는 것입니다.
안전한 대안 1: 실패를 기록하고 의도적으로 분기
#!/usr/bin/env bash
set -euo pipefail
if ! curl -fsS "https://example.invalid/health"; then
echo "health check failed, but continuing by design" >&2
# 필요한 후속 처리(알림/메트릭 등)
fi
echo "deploy continues..."
이 방식은 “실패를 숨기지 않고, 의도적으로 처리”합니다.
안전한 대안 2: 예외 처리 범위를 최소화
정말로 특정 명령만 실패를 허용하고 싶다면, 그 명령의 영향 범위를 좁히는 게 좋습니다.
#!/usr/bin/env bash
set -euo pipefail
# 실패 허용이 필요한 구간만 명확히
set +e
optional_output=$(some_optional_command)
optional_rc=$?
set -e
if [ "$optional_rc" -ne 0 ]; then
echo "optional command failed (rc=$optional_rc), continuing" >&2
fi
다만 set +e는 이후 코드에 영향을 주기 쉬우니, 가능하면 if ! cmd; then ...; fi 형태가 더 읽기 좋습니다.
함정 3: if 조건식 안의 실패는 종료되지 않는다
set -e를 켜도 if의 조건으로 쓰인 명령이 실패하면, 그 자체로는 스크립트를 종료시키지 않습니다. 왜냐하면 조건식의 실패는 “분기 판단을 위한 정상적인 실패”로 간주되기 때문입니다.
#!/usr/bin/env bash
set -euo pipefail
if grep -q "needle" missing-file.txt; then
echo "found"
else
echo "not found (or file missing)"
fi
echo "still running"
위 예제에서 파일이 없으면 grep는 실패하지만, if 문맥이라 스크립트가 계속 진행될 수 있습니다. 특히 파일 없음과 패턴 미매칭을 동일하게 “not found”로 처리해버리는 버그가 생깁니다.
해결: 실패 원인을 분리해서 다루기
#!/usr/bin/env bash
set -euo pipefail
file="missing-file.txt"
if [ ! -f "$file" ]; then
echo "file does not exist: $file" >&2
exit 1
fi
if grep -q "needle" "$file"; then
echo "found"
else
echo "not found"
fi
함정 4: 명령 치환 $(...)에서 실패가 흐려진다
명령 치환은 실패가 바깥으로 잘 전파되지 않는다고 느끼게 만드는 경우가 많습니다. 특히 파이프라인과 결합되면 더 복잡해집니다.
#!/usr/bin/env bash
set -euo pipefail
# 내부에서 실패가 나도, 바깥에서 바로 티가 안 나는 형태가 종종 발생
value=$(echo "hello" | grep "world")
echo "value=$value"
이 케이스는 환경/버전에 따라 동작이 달라 혼란을 키웁니다. 실무에서는 명령 치환 안에 “실패 가능성이 있는 복잡한 로직”을 넣기보다, 중간 변수를 두고 종료 코드를 명시적으로 확인하는 편이 안전합니다.
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp)
echo "hello" | grep "world" >"$tmp"
value=$(cat "$tmp")
rm -f "$tmp"
echo "value=$value"
pipefail만으로 부족할 때: ERR trap로 실패 지점 로깅
set -euo pipefail을 켜도, 어디서 실패했는지 로그가 부족하면 디버깅이 어렵습니다. 이때 trap을 이용해 실패 지점을 출력하면 CI에서 원인 파악이 훨씬 빨라집니다.
#!/usr/bin/env bash
set -euo pipefail
trap 'rc=$?; echo "ERROR: line=$LINENO cmd=$BASH_COMMAND rc=$rc" >&2' ERR
step1() {
echo "running step1"
}
step2() {
echo "running step2"
false
}
step1
step2
BASH_COMMAND: 실패 직전에 실행된 명령LINENO: 라인 번호rc=$?: 종료 코드
GitHub Actions 같은 환경에서는 이 한 줄 로그가 원인 분석 시간을 크게 줄입니다. 인증/권한 실패처럼 원인이 다양한 문제를 다룰 때도 같은 접근이 유효합니다. 예를 들어 AWS 권한 오류를 파고들 때는 EKS IRSA인데 AccessDenied? OIDC·TrustPolicy·SA 점검처럼 “실패 지점을 특정하고 체크리스트로 좁혀가는” 방식이 중요합니다.
실무에서 추천하는 “안전한 Bash” 기본 템플릿
아래 템플릿은 운영/CI 스크립트에서 자주 쓰는 조합입니다.
#!/usr/bin/env bash
set -euo pipefail
trap 'rc=$?; echo "ERROR: line=$LINENO cmd=$BASH_COMMAND rc=$rc" >&2' ERR
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" >&2
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || { echo "missing command: $1" >&2; exit 127; }
}
require_cmd curl
require_cmd jq
log "start"
# 실패를 허용해야 한다면 if ! ... 로 의도를 드러낸다
if ! curl -fsS "https://example.invalid/health" >/dev/null; then
log "health check failed; continuing by design"
fi
log "done"
핵심은 두 가지입니다.
- 실패 전파를 강화:
pipefail과-u를 포함 - 실패를 숨기지 않기:
|| true남발 대신 의도적 분기와 로깅
체크리스트: set -e가 안 멈출 때 빠르게 보는 포인트
- 파이프라인이 있는가? 있다면
set -o pipefail이 켜져 있는가? || true,|| :가 붙어 있는가? 정말 의도된 예외인가?- 실패한 명령이
if/while조건식 안에 있는가? - 실패가 명령 치환
$(...)내부에서 발생하는가? - 서브셸
( ... )내부에서만 실패하고 바깥은 성공으로 끝나는가? - 실패 지점을 로그로 남기고 있는가? (
trap ... ERR)
마무리
set -e는 유용하지만, “Bash 스크립트를 안전하게 만든다”의 필요조건일 뿐 충분조건은 아닙니다. 특히 파이프라인과 조건/예외 처리 문법이 섞이는 순간, 실패가 어디선가 삼켜질 가능성이 커집니다.
권장하는 결론은 간단합니다.
- 기본은
set -euo pipefail - 예외는
|| true대신if ! cmd; then ...; fi로 의도를 드러내기 trap ... ERR로 실패 지점 로깅하기
이 3가지만 지켜도 “분명 실패했는데 CI는 성공” 같은 난감한 상황을 크게 줄일 수 있습니다.