Published on

bash set -euo pipefail 함정과 안전한 예외처리

Authors

서버 프로비저닝, CI/CD, 배포 스크립트에서 set -euo pipefail은 거의 관용구처럼 붙습니다. 실패를 조기에 감지하고(`-e`), 미정의 변수를 막고(`-u`), 파이프라인 실패를 전파(`pipefail`)해 “조용히 망하는” 상황을 줄여주기 때문입니다.

하지만 이 조합은 안전장치이면서 동시에 함정입니다. 특히 Bash의 에러 처리 규칙은 직관적이지 않은 부분이 많아서, “예외적으로 실패를 허용하려던 코드”가 전체 스크립트를 종료시키거나, 반대로 “실패를 잡았다고 생각했는데” 실제로는 실패를 놓치는 일이 발생합니다.

이 글에서는 set -euo pipefail이 왜 위험해질 수 있는지, 그리고 **안전한 예외처리(의도된 실패 허용)**를 어떻게 설계해야 하는지 실전 패턴 위주로 정리합니다. 운영 환경에서 이런 스크립트 실패가 서비스 재시작 루프나 장애로 이어질 수 있는데, 증상이 비슷한 케이스를 다룬 글로는 systemd 서비스가 무한 재시작되는 원인 7가지도 함께 참고하면 좋습니다.

set -euo pipefail의 의미와 “기대”

보통 다음처럼 시작합니다.

#!/usr/bin/env bash
set -euo pipefail

# -e: 명령이 실패하면 즉시 종료 (단, 예외 규칙이 많음)
# -u: 정의되지 않은 변수 사용 시 오류
# -o pipefail: 파이프라인 중간 실패도 전체 실패로 간주

기대하는 효과는 명확합니다.

  • 실패를 빨리 표면화: 중간 명령 실패를 무시한 채 다음 단계로 진행하지 않음
  • 변수 오타/누락 방지: 빈 값이 조용히 들어가서 파괴적 명령이 실행되는 사고 방지
  • 파이프라인 실패 탐지: cmd1 | cmd2에서 cmd1이 실패해도 cmd2가 성공하면 성공으로 보이는 문제 방지

문제는, 이 “기대”가 Bash의 실제 규칙과 자주 어긋난다는 점입니다.

함정 1) -e는 ‘모든 실패’에 반응하지 않는다

set -e는 “어떤 명령이 0이 아닌 종료 코드를 내면 종료”로 알려져 있지만, 실제로는 종료하지 않는 경우가 많습니다. 대표적으로 다음 상황에서는 -e가 무시되거나 다르게 동작합니다.

  • if, while, until의 조건식
  • &&, || 체인의 일부
  • ! cmd 형태
  • 일부 서브셸/커맨드 치환 상황

예를 들어 다음 코드는 직관과 다르게 동작할 수 있습니다.

set -euo pipefail

if grep -q "needle" file.txt; then
  echo "found"
else
  echo "not found"
fi

echo "script continues"

grep -q가 못 찾으면 종료코드 1인데, 이는 “오류”가 아니라 “조건 불일치”로 흔히 사용되므로 if 컨텍스트에서는 -e가 스크립트를 죽이지 않습니다. 이건 오히려 바람직하죠.

반대로, 개발자가 “이건 실패하면 바로 죽어야 한다”고 믿고 조건문 밖에서 쓰면 예상치 못한 종료가 발생합니다.

안전한 규칙: ‘실패가 정상인 명령’을 명시적으로 예외 처리

  • grep의 미매치는 정상 흐름일 수 있음
  • test/[의 false도 정상 흐름일 수 있음
  • curl로 health check를 할 때 404/503을 분기 처리할 수 있음

이럴 때는 의도를 코드로 박아두는 편이 안전합니다.

set -euo pipefail

if ! grep -q "needle" file.txt; then
  echo "needle not found (expected in some cases)" >&2
fi

또는 명시적으로 “실패 허용”을 표시합니다.

set -euo pipefail

grep -q "needle" file.txt || true

단, || true는 남발하면 실제 오류도 덮어버리므로(관측 불가), 아래에서 소개할 예외처리 래퍼 함수를 권합니다.

함정 2) pipefail이 켜지면 grep/head 조합이 지뢰가 된다

pipefail은 좋은 옵션이지만, 파이프라인에서 “중간 명령이 SIGPIPE로 죽는” 상황을 자주 만듭니다.

대표 예:

set -euo pipefail

# 첫 줄만 필요해서 head로 자름
first_line=$(cat big.log | head -n 1)

head는 1줄 읽고 종료합니다. 그러면 cat은 파이프가 끊겨 SIGPIPE로 종료(보통 141)할 수 있고, pipefail이 켜져 있으면 파이프라인 전체가 실패로 간주되어 스크립트가 종료될 수 있습니다.

해결: 불필요한 cat 제거 + 파이프라인 최소화

set -euo pipefail

first_line=$(head -n 1 big.log)

또 다른 흔한 예:

set -euo pipefail

# 매칭이 없으면 grep이 1로 종료 -> 파이프라인 실패
count=$(grep -E "ERROR" app.log | wc -l)

매칭이 없는 건 정상일 수 있는데, pipefail 때문에 실패로 처리되어 종료될 수 있습니다.

해결: grep의 실패를 “정상 0건”으로 변환

set -euo pipefail

count=$( (grep -E "ERROR" app.log || true) | wc -l )

하지만 이 패턴은 “진짜 grep 에러(파일 없음 등)”까지 덮을 수 있습니다. 그래서 더 안전한 방식은 파일 존재/권한을 먼저 확인하거나, grep 에러와 “미매치”를 분리하는 것입니다.

set -euo pipefail

if [[ ! -r app.log ]]; then
  echo "app.log not readable" >&2
  exit 1
fi

# 미매치(1)는 허용, 그 외는 실패
count=$( { grep -E "ERROR" app.log; rc=$?; if (( rc == 1 )); then exit 0; else exit $rc; fi; } | wc -l )

함정 3) -u는 ‘옵션 파싱’에서 특히 자주 터진다

set -u는 미정의 변수 접근을 막아주지만, 다음 같은 코드에서 흔히 폭발합니다.

set -euo pipefail

# $1이 없으면 -u로 즉시 종료
env="$1"

해결: 기본값/필수값을 파라미터 확장으로 강제

set -euo pipefail

env="${1:?usage: script.sh <env>}"
region="${2:-ap-northeast-2}"

echo "env=$env region=$region"
  • ${var:?msg}: 비어있거나 미정의면 msg 출력 후 종료
  • ${var:-default}: 미정의/빈 값이면 default 사용

또한 배열/연관배열에서도 -u는 예상치 못한 종료를 만들 수 있습니다.

set -euo pipefail

declare -A m

# 키가 없으면 -u로 죽을 수 있음
val="${m[missing]}"

안전하게는 다음처럼 기본값을 둡니다.

val="${m[missing]-}"  # 없으면 빈 문자열

함정 4) trap cleanup에서 실패하면 원인 분석이 더 어려워진다

set -e 상태에서 trap 'cleanup' EXIT를 걸어두면, cleanup 내부에서 실패가 나면서 원래 실패 원인을 가리는 경우가 있습니다.

set -euo pipefail

tmpdir=$(mktemp -d)
cleanup() {
  rm -rf "$tmpdir"   # 여기서 권한/경로 문제로 실패하면?
}
trap cleanup EXIT

false  # 원래 원인

이때 로그가 cleanup 실패로 덮여 “왜 실패했는지”가 흐려질 수 있습니다.

해결: cleanup은 실패해도 스크립트를 망치지 않게 설계

  • cleanup 내부는 set +e로 보호
  • 원래 exit code를 보존
set -euo pipefail

tmpdir=$(mktemp -d)

cleanup() {
  local ec=$?
  set +e
  rm -rf "$tmpdir" >/dev/null 2>&1
  set -e
  exit $ec
}
trap cleanup EXIT

# ...
false

또는 cleanup은 “최선을 다하되 실패는 로깅만” 하는 형태가 운영에 더 안전합니다.

함정 5) command substitution 안의 실패가 기대대로 전파되지 않거나 과하게 전파됨

$(...) 안에서 실패가 나면 -e가 어떻게 반응할지 Bash 버전/문맥에 따라 헷갈리는 케이스가 많습니다. 특히 다음은 위험합니다.

set -euo pipefail

token=$(curl -fsS https://example.com/token)

curl -f는 400/500에서 실패로 처리합니다. 토큰이 일시적으로 실패할 수 있는 시스템이라면 “재시도/대체 경로”가 필요합니다. 이런 네트워크/인증 실패가 배포 파이프라인을 끊어 장애로 이어지는 패턴은 클라우드에서도 흔하고, 예를 들어 OIDC/STS 계열 문제는 GitHub Actions OIDC로 AWS AssumeRoleAccessDenied 해결 같은 글에서 다루는 것처럼 원인 분리가 중요합니다.

해결: 실패를 ‘잡아서’ 메시지+재시도 정책으로 승격

set -euo pipefail

retry() {
  local -r max=${1:?max}
  local -r delay=${2:?delay}
  shift 2

  local i=1
  while true; do
    if "$@"; then
      return 0
    fi
    if (( i >= max )); then
      return 1
    fi
    sleep "$delay"
    ((i++))
  done
}

get_token() {
  curl -fsS https://example.com/token
}

if ! token=$(retry 5 2 get_token); then
  echo "failed to get token after retries" >&2
  exit 1
fi

이렇게 하면 set -e에 운명을 맡기지 않고, 실패를 정책적으로 처리할 수 있습니다.

안전한 예외처리의 핵심: “무시”가 아니라 “분류”

|| true는 가장 쉬운 예외처리지만, 실제 운영에서는 다음 문제가 생깁니다.

  • 실패가 발생했는지 로그로 남지 않음
  • 어떤 실패는 허용하면 안 되는데 같이 덮임
  • 이후 단계에서 더 큰 파괴적 명령이 실행될 수 있음

따라서 예외처리는 “무시”가 아니라 (1) 실패를 분류하고 (2) 관측 가능하게 만들고 (3) 의도된 경우만 계속 진행해야 합니다.

추천 패턴 1) try/warn/die 래퍼로 의도를 코드화

#!/usr/bin/env bash
set -euo pipefail

log()  { printf '%s\n' "$*" >&2; }
die()  { log "ERROR: $*"; exit 1; }
warn() { log "WARN:  $*"; }

# 실패하면 즉시 종료 (set -e에 의존하지 않고 메시지 보장)
run() {
  "$@"
}

# 실패를 허용하되, 실패 사실은 남김
try() {
  if "$@"; then
    return 0
  else
    local rc=$?
    warn "command failed (rc=$rc): $*"
    return $rc
  fi
}

# 예: optional cleanup
try rm -f /tmp/nonexistent

# 예: 필수 명령
run mkdir -p /var/myapp

이 패턴의 장점은:

  • set -e의 예외 규칙을 덜 신경 써도 됨
  • 실패 메시지 표준화
  • “이건 실패해도 됨/안 됨”이 코드에서 한눈에 보임

추천 패턴 2) ERR 트랩으로 실패 지점과 콜스택 남기기

Bash는 기본적으로 어디서 실패했는지 정보가 빈약합니다. trap '...' ERRBASH_COMMAND, BASH_LINENO를 활용하면 디버깅이 급격히 쉬워집니다.

#!/usr/bin/env bash
set -euo pipefail

on_err() {
  local rc=$?
  echo "ERROR rc=$rc at ${BASH_SOURCE[1]}:${BASH_LINENO[0]}: ${BASH_COMMAND}" >&2
}
trap on_err ERR

# 예시
cp /no/such/file /tmp/

주의할 점:

  • 함수/서브셸로 에러가 전파되도록 set -E(errtrace)를 추가하는 경우가 많습니다.
set -Eeuo pipefail
trap on_err ERR

추천 패턴 3) “의도적 실패”는 블록으로 감싸고 모드 전환

특정 구간에서는 실패가 정상(탐색/프로빙)일 수 있습니다. 그럴 땐 잠깐 -e를 끄고, 결과를 직접 해석하는 편이 가장 명확합니다.

set -euo pipefail

set +e
curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1
rc=$?
set -e

if (( rc != 0 )); then
  echo "health check failed (rc=$rc), continue with fallback" >&2
fi

이 방식은 “여긴 예외 구간”이라는 의도가 분명하고, || true처럼 광범위하게 실패를 덮지 않습니다.

추천 패턴 4) 파이프라인은 PIPESTATUS로 원인 분해

파이프라인이 실패했을 때 어느 단계가 실패했는지 알아야 예외처리가 가능합니다.

set -euo pipefail

set +e
output=$(grep -E "ERROR" app.log | head -n 10)
rc=$?
ps=("${PIPESTATUS[@]}")
set -e

# rc: pipefail 기준 파이프라인 종료코드
# ps: 각 단계의 종료코드 배열

if (( rc != 0 )); then
  echo "pipeline failed rc=$rc pipe=${ps[*]}" >&2
fi

이걸 기반으로 “grep 미매치(1)는 허용, 파일 없음(2)은 실패” 같은 정책을 구현할 수 있습니다.

운영에서 자주 겪는 시나리오: 스크립트 실패가 재시작 루프를 만든다

set -euo pipefail로 엄격하게 만든 스크립트가 systemd 서비스의 ExecStartPre=/ExecStart=에 걸려 있으면, 사소한 조건 실패도 서비스 전체를 죽이고 재시작을 유발할 수 있습니다. 이때는 스크립트 자체의 예외처리뿐 아니라, 서비스 레벨에서 재시작 정책/백오프/로그를 함께 봐야 합니다.

또한 컨테이너 환경에서는 스크립트의 즉시 종료가 곧바로 CrashLoopBackOff로 보이기도 합니다. 이 경우 ERR 트랩으로 실패 지점을 남기면 원인 파악 시간이 크게 줄어듭니다.

체크리스트: set -euo pipefail을 “안전하게” 쓰는 법

  • set -e에만 의존하지 말고, 필수/선택 명령을 run/try 같은 래퍼로 구분한다.
  • pipefail을 켰다면 head, grep, xargs 조합에서 SIGPIPE/미매치(1)로 종료될 수 있음을 전제로 한다.
  • -u를 켰다면 ${1:?}, ${var:-} 같은 파라미터 확장을 기본 습관으로 한다.
  • trap EXIT cleanup은 실패해도 원인 은폐가 되지 않게 set +e/exit code 보존을 적용한다.
  • trap ERR(+ set -E)로 실패 지점/명령을 로깅해 관측 가능성을 확보한다.
  • “실패를 허용해야 하는 구간”은 set +e로 모드를 전환하고, rc를 직접 해석한다.

결론

set -euo pipefail은 좋은 기본값이지만, Bash의 에러 전파 규칙이 복잡하기 때문에 “엄격 모드”만으로 안전성이 완성되진 않습니다. 핵심은 실패를 무조건 막는 게 아니라, 실패를 정책적으로 분류하고(허용/불허), 관측 가능하게 만들고(로그/트랩), 예외 구간을 명시적으로 격리하는 것입니다.

위에서 소개한 run/try/die 패턴과 ERR/EXIT 트랩, 그리고 파이프라인/변수 처리의 기본기를 적용하면, set -euo pipefail이 ‘지뢰’가 아니라 ‘안전장치’로 기능하는 스크립트를 만들 수 있습니다.