- Published on
bash set -euo pipefail 함정 9가지와 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 스크립트에서 set -euo pipefail은 사실상 표준처럼 쓰입니다. 실패를 조기에 감지하고, 파이프라인의 중간 실패를 놓치지 않게 해주며, 오타나 누락된 변수를 빠르게 드러내기 때문입니다.
하지만 이 3종 세트는 Bash의 미묘한 평가 규칙과 만나면 “왜 여기서 죽지?” 같은 상황을 꽤 자주 만듭니다. 특히 set -e는 문맥에 따라 동작이 달라지고, set -u는 편리하지만 방어 코드를 요구하며, pipefail은 로그 필터링 패턴까지 뒤집어놓습니다.
이 글은 실무에서 자주 겪는 함정 9가지를 재현 가능한 최소 예제로 보여주고, 각 케이스의 안전한 대안과 디버깅 방법을 함께 정리합니다.
기본 세팅과 권장 템플릿
먼저 논의의 기준이 되는 기본 템플릿입니다.
#!/usr/bin/env bash
set -euo pipefail
# 디버깅 시에만 켜기
# set -x
# 에러 위치를 더 잘 보여주기 위한 트랩
trap 'rc=$?; echo "[ERR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2' ERR
trap ... ERR는 실패한 커맨드와 라인 번호를 남겨주기 때문에, set -e로 조용히 종료되는 스크립트를 추적할 때 도움이 큽니다.
함정 1) set -e는 if 조건에서 실패해도 죽지 않는다
많은 사람이 set -e를 “어떤 명령이든 실패하면 즉시 종료”로 이해하지만, Bash는 조건식 컨텍스트에서는 실패를 에러로 취급하지 않는 경우가 많습니다.
#!/usr/bin/env bash
set -euo pipefail
if grep -q "needle" /tmp/missing-file; then
echo "found"
fi
echo "still running"
위에서 grep는 파일이 없어서 실패하지만, if의 조건식 안이므로 스크립트가 계속 진행될 수 있습니다. 이 동작은 “조건 평가를 위한 실패는 허용”이라는 Bash의 규칙 때문입니다.
대안
- 정말 파일이 없으면 실패로 처리하고 싶다면, 먼저 파일 존재를 검사하거나 실패를 명시적으로 핸들링합니다.
[[ -f /tmp/missing-file ]] || { echo "file missing" >&2; exit 1; }
if grep -q "needle" /tmp/missing-file; then
echo "found"
fi
함정 2) set -e는 && ||와 섞이면 종료 타이밍이 직관적이지 않다
cmd || fallback 패턴은 흔하지만, set -e가 켜진 상태에서는 “어떤 실패는 무시되고, 어떤 실패는 죽는다”로 느껴질 수 있습니다.
#!/usr/bin/env bash
set -euo pipefail
false || echo "recover"
echo "alive"
위는 정상입니다. 실패가 ||의 왼쪽에 있을 때는 “의도된 실패”로 간주되어 종료하지 않습니다.
문제는 다음처럼 복잡해질 때입니다.
#!/usr/bin/env bash
set -euo pipefail
# 의도: 실패하면 로그 남기고 계속
some_command || { echo "warn" >&2; }
# 그런데 some_command가 파이프라인/서브셸/함수 내부에서 실패하면
# 기대와 다르게 스크립트가 종료하는 케이스가 생깁니다.
대안
- 실패를 허용하려는 블록은 “여기서는 실패를 삼킨다”를 명확히 표현합니다.
set +e
some_command
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "warn rc=$rc" >&2
fi
이 패턴은 번거롭지만, “정말로 여기만 예외”라는 의도를 가장 확실히 전달합니다.
함정 3) 커맨드 치환 $(...) 안의 실패가 조용히 스크립트를 죽인다
set -e가 켜진 상태에서 커맨드 치환은 자주 폭발합니다. 특히 로그를 변수에 담는 과정에서 실패가 발생하면, 변수 할당 줄에서 바로 종료되어 원인을 놓치기 쉽습니다.
#!/usr/bin/env bash
set -euo pipefail
out=$(grep -q "x" /tmp/nope; echo "done")
# 여기까지 못 옴
echo "$out"
대안
- 실패 가능성이 있는 커맨드 치환은 분리하고, 종료 여부를 직접 판단합니다.
set +e
out=$(grep -q "x" /tmp/nope; echo "done")
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "grep failed rc=$rc" >&2
fi
- 또는 치환 내부에서 실패를 흡수할지 명시합니다.
out=$(grep -q "x" /tmp/nope 2>/dev/null || true; echo "done")
함정 4) pipefail로 인해 “로그 필터링” 파이프라인이 실패 처리된다
pipefail은 파이프라인의 마지막 명령이 아니라, 중간 명령이 실패해도 파이프라인 전체를 실패로 만듭니다. 좋은 기능이지만, 로그를 grep으로 거르는 패턴에서는 “매칭이 없어서 grep이 1을 반환”하는 것을 실패로 보게 됩니다.
#!/usr/bin/env bash
set -euo pipefail
echo "hello" | grep -q "world"
# grep은 매칭이 없으면 exit 1
# set -e + pipefail이면 여기서 종료 가능
echo "alive?"
대안
- 매칭 실패를 정상 흐름으로 취급한다면 명시적으로 처리합니다.
echo "hello" | grep -q "world" || true
- 혹은 조건식으로 감싸 의도를 분명히 합니다.
if echo "hello" | grep -q "world"; then
echo "match"
else
echo "no match"
fi
함정 5) while read 파이프는 서브셸에서 돌아 변수 변경이 사라진다
pipefail과 직접 관련은 없지만, set -euo pipefail 템플릿을 쓰는 스크립트에서 매우 자주 같이 터집니다.
#!/usr/bin/env bash
set -euo pipefail
count=0
echo -e "a\nb" | while read -r line; do
count=$((count+1))
done
echo "count=$count" # 기대: 2, 실제: 0
파이프 오른쪽의 while은 서브셸에서 실행될 수 있어, 루프 내부에서 변경한 변수가 밖으로 나오지 않습니다.
대안
- 프로세스 치환을 사용해 현재 셸에서 루프를 실행합니다.
count=0
while read -r line; do
count=$((count+1))
done < <(echo -e "a\nb")
echo "count=$count"
함정 6) set -u는 배열/파라미터 확장에서 예상치 못한 즉시 종료를 만든다
set -u는 미정의 변수를 참조하면 즉시 종료합니다. 문제는 “의도적으로 비어있을 수 있는 변수”까지 죽여버린다는 점입니다.
#!/usr/bin/env bash
set -euo pipefail
echo "token=$TOKEN" # TOKEN이 없으면 즉시 종료
대안
- 기본값을 제공하는 확장을 습관화합니다.
echo "token=${TOKEN:-}"
- 필수 변수를 강제하려면 더 친절한 에러를 만듭니다.
: "${TOKEN:?TOKEN is required}"
- 배열도 마찬가지입니다. 빈 배열을 허용하려면 초기화를 명시합니다.
declare -a items=()
# 또는: items=()
함정 7) trap ERR는 상속/함수/서브셸에서 기대와 다르게 동작한다
trap '...' ERR는 유용하지만, 함수나 서브셸에서 동작 범위가 헷갈릴 수 있습니다. 또한 어떤 실패는 set -e 예외 규칙 때문에 ERR 트랩이 호출되지 않는 것처럼 보일 수 있습니다.
대안: 디버깅 전용 옵션을 함께 사용
set -E를 켜서ERR트랩이 함수/서브셸에 더 잘 전파되게 합니다.set -o errtrace는set -E와 동일 계열입니다.
#!/usr/bin/env bash
set -eEuo pipefail
trap 'rc=$?; echo "[ERR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2' ERR
fail_in_func() {
false
}
fail_in_func
또한 라인 추적이 필요하면 PS4를 설정하고 set -x를 켭니다.
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
함정 8) read/mapfile/IFS 조합에서 마지막 줄 처리와 종료코드가 꼬인다
read는 EOF에서 실패(비0)를 반환합니다. set -e가 켜진 상태에서 루프 바깥에서 read를 단독으로 쓰면 EOF를 “에러”로 간주해 종료할 수 있습니다.
#!/usr/bin/env bash
set -euo pipefail
# 입력이 비면 read는 실패
read -r line < /dev/null
echo "alive" # 여기 못 옴
대안
read실패를 정상 흐름으로 인정하는 구문으로 감쌉니다.
if read -r line < /dev/null; then
echo "line=$line"
else
echo "no input"
fi
- 여러 줄 입력은
mapfile이 더 안전한 경우가 많습니다.
mapfile -t lines < <(printf '%s\n' a b c)
printf '%s\n' "${lines[@]}"
함정 9) “정리(cleanup)”가 실패하면 원래 에러 원인이 가려진다
set -e 환경에서 rm이나 umount 같은 정리 단계가 실패하면, 원래 실패 원인보다 cleanup 실패가 최종 원인처럼 보일 수 있습니다.
#!/usr/bin/env bash
set -euo pipefail
trap 'rm -rf /tmp/some-dir' EXIT
mkdir -p /tmp/some-dir
false # 실제 원인
여기서 rm이 어떤 이유로 실패하면(권한, busy 등) 종료 로그는 cleanup 실패로 덮일 수 있습니다.
대안
- cleanup은 실패해도 스크립트의 원인을 덮지 않게 방어합니다.
cleanup() {
rm -rf /tmp/some-dir || true
}
trap cleanup EXIT
- 운영에서 디스크/파일 핸들 이슈로 삭제가 안 되는 경우도 많습니다. 삭제했는데 용량이 안 줄어드는 케이스는 열린 파일 핸들이 원인일 수 있으니, 필요하면
lsof로 점검하는 흐름을 잡아두면 좋습니다: 리눅스 디스크 100%인데 삭제해도 용량이 안 줄 때(lsof)
디버깅 체크리스트: “왜 set -e가 여기서 죽지?”를 푸는 순서
1) -x만 켜지 말고 PS4를 함께 세팅
기본 set -x는 정보가 부족합니다. 파일/라인/함수명을 포함시키면 체감 난이도가 크게 내려갑니다.
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
2) trap ERR로 실패 지점을 강제 기록
set -eEuo pipefail
trap 'rc=$?; echo "[ERR] rc=$rc line=$LINENO cmd=$BASH_COMMAND" >&2' ERR
3) “실패를 허용하는 구간”을 명확히 분리
cmd || true는 편하지만 남용하면 진짜 에러도 삼킵니다. 대신 아래처럼 구간을 분리하면 디버깅이 쉬워집니다.
set +e
cmd
rc=$?
set -e
if [[ $rc -ne 0 ]]; then
echo "cmd failed rc=$rc" >&2
fi
4) 파이프라인은 단계별로 쪼개서 종료코드 확인
pipefail이 켜져 있으면 “어느 단계가 실패했는지”를 보는 게 핵심입니다.
set -o pipefail
step1() { echo "hello"; }
step2() { grep -q "world"; }
if step1 | step2; then
echo "ok"
else
echo "failed pipeline" >&2
fi
더 깊게 보려면 PIPESTATUS를 확인합니다.
set +e
step1 | step2
rc=$?
statuses=("${PIPESTATUS[@]}")
set -e
echo "pipeline_rc=$rc statuses=${statuses[*]}" >&2
실전 권장 규칙 요약
set -euo pipefail은 “기본값”으로 좋지만, 예외 구간은 반드시 분리하라.grep매칭 실패(1)는 에러가 아닐 수 있으니,if로 감싸거나|| true로 의도를 표현하라.- 커맨드 치환
$(...)안에서 실패가 나면 원인 추적이 어려우니, 실패 가능 로직은 분해하라. while read는 파이프 대신 프로세스 치환으로 현재 셸에서 돌려 변수 스코프를 지켜라.- cleanup 트랩은 실패를 삼켜 원래 에러를 보존하라.
운영 환경에서 스크립트가 systemd 서비스나 배치 잡으로 돌아가면, set -e로 인한 조용한 종료가 “계속 재시작” 같은 증상으로 나타나기도 합니다. 그런 경우에는 서비스 레벨에서 로그와 재시작 원인을 추적하는 방법도 함께 알아두면 좋습니다: systemd 서비스가 계속 재시작될 때 원인 추적법
필요하면 다음 단계로, 위 9가지 함정을 실제 CI 스크립트(예: GitHub Actions)나 배포 스크립트에 적용할 때의 “안전한 패턴 템플릿”도 함께 정리해드릴 수 있습니다.