- Published on
Bash set -e가 무시될 때 - pipefail·trap
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 운영이나 CI에서 Bash 스크립트를 짤 때 set -e 하나만 켜두면 "실패하면 즉시 종료"가 보장될 것 같지만, 실제로는 조용히 실패를 삼키는 경우가 많습니다. 특히 파이프라인, 조건문, 함수, 서브셸이 섞이면 set -e의 동작은 직관과 다르게 흘러가고, 그 결과 배포 스크립트가 실패했는데도 다음 단계로 진행해 장애를 키우기도 합니다.
이 글에서는 set -e가 무시되는 대표 패턴을 재현하고, set -o pipefail과 trap을 이용해 "어디서 왜 실패했는지"까지 남기는 견고한 템플릿을 제시합니다. (운영 환경에서 자주 만나는 403/권한 문제는 Bash 스크립트에서 특히 치명적이므로, 필요하다면 AWS IAM AssumeRole AccessDenied 원인 10가지 같은 글과 함께 원인 분석을 병행하는 것도 좋습니다.)
set -e의 기대와 현실
set -e는 간단히 말해 "어떤 커맨드가 0이 아닌 종료 코드를 반환하면 스크립트를 종료"하려는 옵션입니다.
하지만 Bash에는 예외가 많습니다.
- 파이프라인에서 기본적으로는 "마지막 커맨드"의 종료 코드만 반영됨
if,while,until,&&,||같은 조건 평가 컨텍스트에서는-e가 다르게 적용됨$(...)커맨드 치환, 서브셸, 함수 호출 등에서 전파가 직관적이지 않음- 어떤 실패는 "실패로 취급되지 않도록" 작성된 코드 패턴 때문에 묻힘
따라서 set -e는 "안전벨트"라기보다 "기본값"에 가깝고, 파이프라인과 종료 처리까지 갖춰야 운영에서 믿을 수 있습니다.
set -e가 무시되는 대표 케이스 1: 파이프라인
가장 흔한 함정은 파이프라인입니다. 기본 설정에서는 파이프라인의 종료 코드는 마지막 명령의 종료 코드입니다.
#!/usr/bin/env bash
set -e
# grep이 매칭을 못 찾으면 종료 코드 1
# 하지만 뒤의 wc는 정상(0)로 끝날 수 있음
printf '%s\n' "hello" | grep "nomatch" | wc -l
echo "still running"
위 스크립트는 많은 사람이 "grep이 실패했으니 종료"를 기대하지만, 실제로는 wc -l이 성공해서 파이프라인 전체가 성공으로 간주될 수 있습니다. 결과적으로 echo가 실행됩니다.
해결: set -o pipefail
pipefail을 켜면 파이프라인 중 하나라도 실패하면 파이프라인이 실패로 간주됩니다.
#!/usr/bin/env bash
set -e
set -o pipefail
printf '%s\n' "hello" | grep "nomatch" | wc -l
echo "never printed"
이제 grep의 실패가 파이프라인 실패로 전파되어 스크립트가 종료됩니다.
참고: PIPESTATUS로 어느 단계가 실패했는지 보기
디버깅 시에는 PIPESTATUS 배열이 유용합니다.
#!/usr/bin/env bash
set -o pipefail
printf '%s\n' "hello" | grep "nomatch" | wc -l
status=$?
# Bash에서만 동작
printf 'pipeline exit=%s\n' "$status"
printf 'PIPESTATUS: %s %s %s\n' "${PIPESTATUS[0]}" "${PIPESTATUS[1]}" "${PIPESTATUS[2]}"
운영 스크립트에선 PIPESTATUS를 직접 출력하기보다, 아래에서 소개할 trap 기반 로깅으로 라인/명령을 남기는 방식이 더 유지보수에 좋습니다.
set -e가 무시되는 대표 케이스 2: 조건문과 논리 연산자
Bash는 조건을 평가하는 자리에서 실패 코드를 "정상적인 분기"로 취급합니다. 그래서 set -e가 기대처럼 동작하지 않는 경우가 생깁니다.
if에서의 실패는 종료를 트리거하지 않을 수 있음
#!/usr/bin/env bash
set -e
if grep -q "nomatch" /etc/hosts; then
echo "found"
else
echo "not found"
fi
echo "still running"
여기서 grep이 실패해도, 그 실패는 if 조건 평가의 일부이므로 "스크립트를 죽일 실패"로 보지 않습니다.
이건 버그가 아니라 설계입니다. if는 원래 "성공/실패"로 분기하는 문법이기 때문에, 조건식 실패를 치명 오류로 취급하면 if 자체가 쓸 수 없게 됩니다.
cmd || fallback 패턴이 실패를 숨김
#!/usr/bin/env bash
set -e
curl -fsS "https://example.invalid" || echo "download failed, continue"
echo "still running"
이 패턴은 의도적으로 실패를 무시하고 계속 진행합니다. 문제는 CI나 배포 스크립트에서 이런 코드가 섞이면, 실제로는 실패했는데도 성공처럼 흘러가며 다음 단계에서 더 큰 문제를 만들 수 있습니다.
운영 스크립트에서는 "무시"를 명시적으로
실패를 무시할 필요가 있다면, 최소한 로그와 의도를 남기는 편이 좋습니다.
curl -fsS "$url" \
|| { echo "WARN: curl failed for $url" >&2; true; }
이렇게 하면 "실패를 의도적으로 무시했다"는 사실이 코드로 드러납니다.
set -e가 무시되는 대표 케이스 3: 커맨드 치환 $(...)
커맨드 치환은 실패를 놓치기 쉬운 구간입니다. 특히 여러 명령을 조합하거나, 실패한 명령의 출력이 비어도 다음 로직이 진행되는 경우가 많습니다.
#!/usr/bin/env bash
set -e
# 실패할 수 있는 명령을 커맨드 치환으로 감싸면,
# 스크립트 흐름상 어디서 실패했는지 추적이 어려워짐
value=$(grep "nomatch" /etc/hosts)
echo "value=$value"
환경과 Bash 버전에 따라 동작이 미묘하게 달라질 수 있고, 무엇보다 "실패했을 때 어떤 라인에서 어떤 명령이 실패했는지"를 남기기 어렵습니다.
해결: 치환 내부를 명시적으로 처리하거나, trap로 로깅
커맨드 치환 내부에서 실패가 가능한 경우, 아예 분리해서 다루는 게 안전합니다.
#!/usr/bin/env bash
set -euo pipefail
if grep -q "nomatch" /etc/hosts; then
value=$(grep "nomatch" /etc/hosts)
else
echo "required value not found" >&2
exit 1
fi
echo "value=$value"
다만 이런 분리는 코드가 길어지므로, 실무에서는 아래의 trap 템플릿으로 "실패 위치"를 자동으로 남기는 방식을 많이 씁니다.
pipefail만으로 부족한 이유: 실패 지점을 남겨야 함
set -euo pipefail을 켜도, 실제 장애 대응에선 다음이 필요합니다.
- 어느 라인에서 실패했는지
- 어떤 명령이 실행되다 실패했는지
- 실패 코드가 무엇인지
- 종료 전에 임시파일/락/세션을 정리했는지
이걸 해결하는 핵심이 trap입니다.
실전 템플릿: set -euo pipefail + trap ERR + trap EXIT
아래는 운영/CI에서 많이 쓰는 형태의 "견고한 Bash 스크립트" 기본 골격입니다.
#!/usr/bin/env bash
set -euo pipefail
# 에러 발생 시 어떤 명령이, 몇 번째 라인에서 실패했는지 출력
on_err() {
local exit_code=$?
# BASH_COMMAND: 실패를 유발한 "현재" 명령
# LINENO: 현재 라인
echo "ERROR: exit_code=${exit_code} line=${LINENO} cmd=${BASH_COMMAND}" >&2
}
# 스크립트 종료 시 공통 정리 로직
cleanup() {
local exit_code=$?
# 필요 시 tmp 파일 삭제, 락 해제, 세션 종료 등을 수행
# 예: rm -f "${TMP_FILE:-}" 2>/dev/null || true
if [ "$exit_code" -ne 0 ]; then
echo "cleanup: script failed with exit_code=${exit_code}" >&2
fi
}
trap on_err ERR
trap cleanup EXIT
# 예시: 파이프라인 실패를 확실히 잡음
printf '%s\n' "hello" | grep "nomatch" | wc -l
echo "never printed"
trap ERR를 쓸 때 알아둘 점
ERR트랩은 "에러가 발생했을 때" 실행되지만, 위에서 본 것처럼if조건식 같은 컨텍스트에서는 에러로 취급되지 않을 수 있습니다.- 함수/서브셸로 에러 트랩을 전파하려면
set -E(또는set -o errtrace)가 필요할 때가 많습니다.
다음처럼 확장하는 경우가 많습니다.
#!/usr/bin/env bash
set -eEuo pipefail
on_err() {
local exit_code=$?
echo "ERROR: exit_code=${exit_code} line=${LINENO} cmd=${BASH_COMMAND}" >&2
}
trap on_err ERR
fail_in_func() {
false
}
fail_in_func
여기서 set -E가 없으면 함수 내부 실패에서 ERR 트랩이 기대대로 동작하지 않는 환경이 있을 수 있어, 팀 표준 템플릿에는 -E까지 포함하는 편이 안전합니다.
디버깅을 더 쉽게: PS4와 set -x 조합
장애 상황에서만 추적 로그가 필요하다면, 평소에는 조용히 두고 필요 시에만 -x를 켜는 패턴이 좋습니다.
#!/usr/bin/env bash
set -eEuo pipefail
# xtrace 출력에 파일/라인을 포함
export PS4='+ ${BASH_SOURCE}:${LINENO} '
if [ "${DEBUG:-0}" = "1" ]; then
set -x
fi
trap 'echo "ERROR line=${LINENO} cmd=${BASH_COMMAND}" >&2' ERR
# 예시
aws sts get-caller-identity >/dev/null
권한/인증 문제로 aws sts가 실패하면, 어떤 라인에서 실패했는지 바로 확인할 수 있습니다. 특히 GitHub Actions나 GitLab CI에서 OIDC나 AssumeRole이 섞일 때 이 형태가 큰 도움이 됩니다. 필요하면 GitHub Actions OIDC로 AWS STS AssumeRole 실패 해결도 함께 참고하세요.
자주 하는 실수: set -e를 "부분적으로" 끄고 켜기
일부 구간에서 실패를 허용하려고 set +e를 켰다가 다시 set -e로 복구하는 패턴이 있는데, 복구를 빼먹기 쉽고 코드 리뷰에서도 놓치기 쉽습니다.
대신 "실패를 허용하는 구간"을 함수로 감싸고, 그 함수에서만 명시적으로 처리하는 편이 안전합니다.
#!/usr/bin/env bash
set -eEuo pipefail
try_download() {
local url=$1
if ! curl -fsS "$url" -o /tmp/out; then
echo "WARN: download failed: $url" >&2
return 0
fi
}
trap 'echo "ERROR line=${LINENO} cmd=${BASH_COMMAND}" >&2' ERR
try_download "https://example.invalid"
echo "continue safely"
체크리스트: 운영/CI 스크립트 기본 옵션 추천
대부분의 배포/운영 자동화 스크립트에서 아래 조합이 출발점으로 좋습니다.
set -e: 실패 시 종료set -u: 정의되지 않은 변수 사용 시 실패set -o pipefail: 파이프라인 중간 실패 전파set -E: 함수/서브셸로ERR트랩 전파 강화trap ERR: 실패 라인/명령 로깅trap EXIT: 임시 리소스 정리
최소 템플릿은 다음처럼 정리할 수 있습니다.
#!/usr/bin/env bash
set -eEuo pipefail
trap 'echo "ERROR: line=${LINENO} cmd=${BASH_COMMAND} exit_code=$?" >&2' ERR
trap 'code=$?; [ "$code" -ne 0 ] && echo "EXIT: failed exit_code=$code" >&2' EXIT
# your script here
마무리: set -e는 시작일 뿐, pipefail과 trap이 완성
set -e가 무시되는 것처럼 보이는 순간은 대부분 Bash의 "예외 규칙"을 밟았을 때입니다. 파이프라인에서는 pipefail이 사실상 필수이고, 운영에서 재현이 어려운 실패를 잡으려면 trap로 실패 지점과 명령을 남겨야 합니다.
이 조합을 스크립트 템플릿으로 표준화해두면, 배포/마이그레이션/점검 스크립트에서 "실패를 놓치는" 사고를 크게 줄일 수 있습니다. 특히 인증/권한 같은 문제는 스크립트가 조용히 넘어가면 뒤 단계에서 더 큰 장애로 번지므로, 초기에 정확히 실패시키고 로그를 남기는 것이 가장 값싼 안정성 투자입니다.