- Published on
bash set -euo pipefail 함정 7가지와 안전패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
배포 스크립트, CI 잡, 운영 자동화에서 set -euo pipefail은 사실상 표준처럼 쓰입니다. 실패를 조기에 감지하고, 파이프라인 오류를 놓치지 않게 해주며, 정의되지 않은 변수를 강제하는 등 “안전장치” 역할을 하죠.
하지만 이 조합은 bash의 문법/평가 규칙과 결합되면서 예상과 다른 종료, 반대로 예상과 달리 통과하는 상황을 만들기도 합니다. 특히 CI에서는 “어제까지 되던 스크립트가 오늘부터 랜덤하게 죽는” 형태로 나타나 원인 추적이 어렵습니다.
이 글에서는 실무에서 자주 밟는 함정 7가지를 재현 가능한 코드와 함께 정리하고, 팀에서 재사용 가능한 안전 패턴을 제시합니다. (CI 맥락에서는 GitHub Actions 캐시 미스 원인 7가지와 해결 같은 글과 함께 보면, 실패 지점을 더 빨리 좁힐 수 있습니다.)
기본 전제: 각 옵션이 의미하는 것
보통 아래처럼 시작합니다.
#!/usr/bin/env bash
set -euo pipefail
-e: 단순 명령이 실패(비-0)하면 스크립트를 종료하려고 시도-u: 정의되지 않은 변수를 참조하면 오류로 종료pipefail: 파이프라인의 종료코드를 “마지막 명령”이 아니라 “실패한 명령 중 하나”로 설정
핵심은 -e가 “항상 실패 시 종료”가 아니라는 점입니다. bash는 특정 문맥에서는 실패를 “기대되는 분기”로 간주하고 종료하지 않습니다. 이게 함정의 대부분을 만듭니다.
함정 1) if/while 조건에서 실패가 무시된다
set -e가 있어도 조건식 위치에서는 실패가 종료로 이어지지 않는 경우가 많습니다.
#!/usr/bin/env bash
set -euo pipefail
if grep -q "needle" /path/to/missing-file; then
echo "found"
else
echo "not found or error"
fi
echo "script continues"
위에서 grep는 파일이 없으면 보통 종료코드 2를 내지만, if 조건 문맥이라 스크립트가 계속 진행될 수 있습니다. “파일이 없으면 즉시 죽어야 한다”는 기대와 달라집니다.
안전 패턴
조건에서 “없음(1)”과 “오류(2)”를 구분해 처리합니다.
if grep -q "needle" "$file"; then
echo "found"
else
rc=$?
if [ "$rc" -eq 1 ]; then
echo "not found"
else
echo "grep error" >&2
exit "$rc"
fi
fi
함정 2) cmd || true가 실제 오류를 영구히 숨긴다
set -e와 함께 가장 흔히 등장하는 해제 패턴이 || true입니다.
set -euo pipefail
rm -f /tmp/somefile || true
문제는 이것이 “없으면 무시”만이 아니라, 권한 오류, I/O 오류 같은 진짜 장애도 전부 삼켜버린다는 점입니다.
안전 패턴
무시하고 싶은 오류를 명확히 제한합니다.
if ! rm /tmp/somefile 2>/dev/null; then
rc=$?
# 파일이 없어서 실패한 경우만 무시하고 싶다면, rm은 보통 이 케이스를 1로 내기도 하고 메시지로만 판단이 어려움
# 차라리 존재 여부를 먼저 체크
if [ -e /tmp/somefile ]; then
echo "rm failed" >&2
exit "$rc"
fi
fi
또는 “정말로 무시해도 되는 작업”만 || true를 허용하고, 그 주변에는 반드시 로그를 남깁니다.
some_optional_step || echo "optional step failed (ignored)" >&2
함정 3) 커맨드 치환 $(...) 내부 실패가 늦게 터지거나 다르게 전파된다
커맨드 치환은 평가 시점과 실패 전파가 직관과 다를 수 있습니다.
set -euo pipefail
value=$(cat /path/to/missing)
echo "value=$value"
환경/버전/문맥에 따라 여기서 즉시 종료하거나, 예상치 못한 시점에 set -e가 작동하지 않는 것처럼 보일 수 있습니다. 특히 치환 결과를 다른 문맥에서 사용하면 디버깅이 어려워집니다.
안전 패턴
실패 가능성이 있는 커맨드 치환은 “명시적으로” 처리합니다.
set -euo pipefail
if ! value=$(cat "$file"); then
echo "failed to read file: $file" >&2
exit 1
fi
또는 아예 임시 파일/리다이렉션으로 분리해 실패 지점을 명확히 합니다.
tmp=$(mktemp)
cat "$file" >"$tmp"
value=$(<"$tmp")
rm -f "$tmp"
함정 4) -u로 인해 “정상적인 빈 값”까지 폭발한다
-u는 오타를 잡아주는 최고의 옵션이지만, “옵션 인자/환경변수는 없을 수도 있다” 같은 현실과 충돌합니다.
set -euo pipefail
echo "TOKEN=$TOKEN" # TOKEN이 unset이면 즉시 종료
안전 패턴
기본값을 명시하고, 필수 값은 에러 메시지를 커스터마이즈합니다.
: "${TOKEN:?TOKEN is required}"
: "${REGION:=ap-northeast-2}"
echo "REGION=$REGION"
:${VAR:?msg}는 unset 또는 빈 값이면 종료:${VAR:=default}는 unset이면 default를 대입
CI에서 환경변수 누락으로 터지는 경우가 많아, GitHub Actions를 쓴다면 권한/인증 이슈 글인 GitHub Actions OIDC로 AWS 권한 오류 해결하기 같은 체크리스트와 함께 “필수 env 검증”을 스크립트 상단에 두는 것이 효과적입니다.
함정 5) pipefail은 좋아 보이지만, 의도된 grep 실패까지 장애로 만든다
pipefail을 켜면 파이프라인 중간의 실패를 잡을 수 있어 좋습니다. 하지만 grep의 “미매치(1)”는 흔히 정상 흐름입니다.
set -euo pipefail
echo "hello" | grep -q "world" | sed 's/x/y/'
# grep -q는 미매치면 1, pipefail로 파이프라인 전체가 실패로 간주
안전 패턴
grep가 “미매치면 정상”인 경우를 명시적으로 처리합니다.
set -euo pipefail
if echo "hello" | grep -q "world"; then
echo "matched"
else
rc=$?
if [ "$rc" -eq 1 ]; then
echo "not matched (ok)"
else
echo "grep error" >&2
exit "$rc"
fi
fi
또는 파이프라인을 분리해 실패의 의미를 분명히 합니다.
set -euo pipefail
text="$(echo "hello")"
if grep -q "world" <<<"$text"; then
echo "matched"
fi
주의: 본문에 <<< 같은 토큰을 그대로 쓰면 MDX에서 오해될 수 있으니, 여기처럼 코드 블록 안에서만 사용합니다.
함정 6) trap이 없으면 어디서 죽었는지 로그가 빈약하다
set -e로 “빨리 죽는 것”은 좋은데, “어디서 왜 죽었는지”가 없으면 운영에서 의미가 없습니다. 특히 CI에서는 로그가 짧을수록 재현 비용이 커집니다.
안전 패턴: 에러 트랩 + 라인/명령 로깅
#!/usr/bin/env bash
set -euo pipefail
on_error() {
local exit_code=$?
local line_no=${BASH_LINENO[0]}
local cmd=${BASH_COMMAND}
echo "ERROR: exit_code=$exit_code line=$line_no cmd=$cmd" >&2
}
trap on_error ERR
# 예시
false
추가로 디버깅이 필요하면 set -x를 항상 켜기보다 “필요할 때만” 활성화하는 패턴이 안전합니다.
if [ "${DEBUG:-0}" = "1" ]; then
set -x
fi
이런 식의 “재시도/관측성”은 API 호출에서도 중요합니다. 외부 호출을 포함한 자동화라면 Anthropic Claude 429 Rate Limit 실무 재시도 패턴처럼 실패를 분류하고 재시도 정책을 명확히 두는 접근이 셸 스크립트에도 그대로 적용됩니다.
함정 7) 서브셸과 함수 경계에서 -e 기대가 깨진다
괄호 (...)는 서브셸을 만들고, 일부 설정/상태가 다르게 전파됩니다. 또한 함수 내부에서 실패를 “잡아먹는” 구조가 생기면 -e가 기대대로 동작하지 않는 것처럼 보일 수 있습니다.
set -euo pipefail
fail_in_subshell() (
echo "in subshell"
false
echo "unreachable"
)
fail_in_subshell
echo "maybe unreachable depending on context"
또 다른 흔한 케이스는 함수에서 실패를 반환으로 바꿔버리는 패턴입니다.
set -euo pipefail
try() {
some_cmd
echo "done"
}
# 호출부에서 조건문으로 감싸면 실패가 종료로 이어지지 않을 수 있음
if try; then
echo ok
fi
안전 패턴
- 실패는 “반환값으로 제어”할지, “즉시 종료”할지 정책을 통일합니다.
- 함수는 성공/실패를 명확히 반환하고, 호출부에서 처리합니다.
set -euo pipefail
run_step() {
local name=$1
shift
echo "==> $name"
"$@"
}
run_step "check" bash -c 'exit 0'
run_step "build" bash -c 'exit 1'
실패 시 즉시 중단이 목적이라면, 애매한 if func; then 패턴을 남발하기보다 “실패를 그대로 전파”하는 형태로 구성하는 것이 낫습니다.
안전 패턴 모음: 실무에서 바로 쓰는 템플릿
아래는 운영/CI에서 무난한 베이스 템플릿입니다.
#!/usr/bin/env bash
set -euo pipefail
on_error() {
local exit_code=$?
local line_no=${BASH_LINENO[0]}
local cmd=${BASH_COMMAND}
echo "ERROR: exit_code=$exit_code line=$line_no cmd=$cmd" >&2
}
trap on_error ERR
# 필수 환경변수 검증
: "${ENV:?ENV is required}"
: "${APP:?APP is required}"
# 옵션 환경변수 기본값
: "${DEBUG:=0}"
if [ "$DEBUG" = "1" ]; then
set -x
fi
log() { echo "[$(date +%Y-%m-%dT%H:%M:%S%z)] $*"; }
die() {
echo "FATAL: $*" >&2
exit 1
}
# 재시도 예시 (네트워크/레이트리밋 류)
retry() {
local max=$1
local sleep_s=$2
shift 2
local attempt=1
while true; do
if "$@"; then
return 0
fi
if [ "$attempt" -ge "$max" ]; then
return 1
fi
log "retry $attempt/$max failed; sleeping ${sleep_s}s"
sleep "$sleep_s"
attempt=$((attempt + 1))
done
}
main() {
log "start env=$ENV app=$APP"
# 의도적으로 실패를 허용해야 하는 경우는 분기에서 명시적으로
if grep -q "something" ./optional.txt; then
log "matched"
else
rc=$?
if [ "$rc" -ne 1 ]; then
die "grep failed with rc=$rc"
fi
fi
retry 3 2 curl -fsS "https://example.com/health" >/dev/null
log "done"
}
main "$@"
이 템플릿의 포인트는 다음입니다.
trap ERR로 실패 지점의 최소 정보를 확보-u를 쓰되,:${VAR:?}와:${VAR:=}로 의도를 문서화- “실패를 무시하는 곳”은
|| true대신 분기/반환코드 처리로 제한 - 네트워크 호출은 재시도 래퍼로 감싸 일시 오류에 강하게
마무리: set -euo pipefail은 만능이 아니라 “규칙이 있는 도구”
set -euo pipefail은 스크립트를 더 안전하게 만들지만, bash의 예외 규칙(특히 조건문/파이프라인/치환/서브셸) 때문에 의도와 다른 종료/통과가 발생할 수 있습니다.
팀에서 권장하는 방식은 다음 한 줄로 요약됩니다.
- “실패는 빨리”는
set -euo pipefail로 - “왜 실패했는지”는
trap ERR로 - “실패를 무시해야 하는 곳”은
|| true가 아니라 명시적 분기로
이 3가지만 지켜도, CI와 운영 자동화에서의 셸 스크립트 신뢰도가 눈에 띄게 올라갑니다.