Published on

Bash set -e가 무시될 때 - pipefail·trap

Authors

서버 운영이나 CI에서 Bash 스크립트를 짤 때 set -e 하나만 켜두면 "실패하면 즉시 종료"가 보장될 것 같지만, 실제로는 조용히 실패를 삼키는 경우가 많습니다. 특히 파이프라인, 조건문, 함수, 서브셸이 섞이면 set -e의 동작은 직관과 다르게 흘러가고, 그 결과 배포 스크립트가 실패했는데도 다음 단계로 진행해 장애를 키우기도 합니다.

이 글에서는 set -e가 무시되는 대표 패턴을 재현하고, set -o pipefailtrap을 이용해 "어디서 왜 실패했는지"까지 남기는 견고한 템플릿을 제시합니다. (운영 환경에서 자주 만나는 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까지 포함하는 편이 안전합니다.

디버깅을 더 쉽게: PS4set -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는 시작일 뿐, pipefailtrap이 완성

set -e가 무시되는 것처럼 보이는 순간은 대부분 Bash의 "예외 규칙"을 밟았을 때입니다. 파이프라인에서는 pipefail이 사실상 필수이고, 운영에서 재현이 어려운 실패를 잡으려면 trap로 실패 지점과 명령을 남겨야 합니다.

이 조합을 스크립트 템플릿으로 표준화해두면, 배포/마이그레이션/점검 스크립트에서 "실패를 놓치는" 사고를 크게 줄일 수 있습니다. 특히 인증/권한 같은 문제는 스크립트가 조용히 넘어가면 뒤 단계에서 더 큰 장애로 번지므로, 초기에 정확히 실패시키고 로그를 남기는 것이 가장 값싼 안정성 투자입니다.