set -e를 걸었는데 스크립트가 실패를 지나칠 때, 파이프와 함수부터 확인하는 순서
환경 Bash Reference Manual 5.3 기준 · 2026-08-04 공식 문서 확인
배포 스크립트 맨 위에 set -e 를 걸어 뒀습니다. 그런데 중간 단계가 실패한 날에도 스크립트는 끝까지 실행되고 종료 코드 0으로 끝났습니다. CI 는 초록불이고, 문제는 한참 뒤에 빈 백업 파일이나 반쯤 올라간 정적 파일로 드러납니다. -e 가 걸리지 않은 게 아니라, 그 줄이 -e 가 적용되지 않는 자리에 있었던 것입니다.
-e 가 적용되지 않는 자리는 문서에 나열돼 있다
Bash 공식 문서의 set 빌트인 항목은 -e 를 파이프라인이 0이 아닌 상태로 끝나면 즉시 종료하는 옵션으로 설명합니다. 그런데 같은 항목이 곧바로 예외를 나열합니다. while 이나 until 예약어 바로 뒤에 오는 명령 목록, if 문의 조건 부분, && 또는 || 목록에서 마지막 && 나 || 뒤의 명령을 제외한 모든 명령, 파이프라인에서 마지막을 제외한 모든 명령, 그리고 ! 로 반환 상태를 뒤집는 경우입니다.
이 목록을 한 줄로 줄이면, 셸이 실패를 스크립트가 직접 보고 판단하는 자리라고 여기는 곳에서는 -e 가 개입하지 않는다는 뜻입니다. 조건문이나 && 로 이어 붙인 줄은 실패 여부 자체가 흐름을 결정하는 재료이므로 셸이 대신 종료해 버리면 곤란하기 때문입니다.
문제는 배포 스크립트에서 자주 쓰는 모양이 대부분 이 목록 안에 들어 있다는 점입니다. 로그를 남기려고 파이프로 넘기고, 조건을 확인하려고 if 안에서 부르고, 여러 단계를 && 로 이어 붙입니다. set -e 한 줄을 믿고 그 위에 스크립트를 쌓아 올렸는데, 정작 실패가 나는 자리마다 -e 가 비켜서 있는 상황이 됩니다.
#!/bin/bash
set -e
# 실패하는 명령을 파이프로 넘기면 -e 가 걸리지 않는다
false | cat > /dev/null
echo "여기까지 실행됨" # 그대로 출력된다
exit 0이 스크립트는 중간에 실패가 있었는데도 종료 코드 0으로 끝납니다. CI 는 성공으로 기록합니다.
-e 는 실패하면 멈춘다가 아니라, 문서가 나열한 자리를 뺀 나머지에서 멈춘다는 뜻입니다.
파이프라인은 마지막 명령의 종료 코드만 남긴다
Pipelines 문서는 파이프라인의 종료 상태를 이렇게 정의합니다. pipefail 옵션이 켜져 있지 않다면 파이프라인의 종료 상태는 마지막 명령의 종료 상태라는 것입니다. 앞쪽 명령이 무엇으로 끝났는지는 파이프라인 바깥으로 전달되지 않습니다.
실무에서 이 규칙이 가장 자주 물리는 모양은 로그를 남기려고 tee 를 붙인 줄입니다. tee 는 파일에 쓰기만 성공하면 0으로 끝나므로, 앞의 빌드나 마이그레이션이 어떤 상태로 끝났든 파이프라인 전체는 성공으로 보고됩니다. 덤프를 압축하는 mysqldump 와 gzip 조합도 구조가 같습니다. 덤프가 실패해도 gzip 은 받은 것이 없는 대로 성공하고, 크기가 작은 압축 파일이 하나 남습니다.
각 단계가 실제로 어떻게 끝났는지는 PIPESTATUS 배열에 남습니다. 직전 파이프라인의 종료 코드가 순서대로 들어 있어서, 어느 단계에서 끊겼는지 확인할 때 쓸 수 있습니다. 다만 이 배열은 다음 명령을 실행하면 덮어써지므로, 확인하거나 로그에 남길 값은 파이프라인 바로 다음 줄에서 꺼내야 합니다.
# 파이프라인 자체의 종료 코드는 마지막 명령만 본다
false | cat > /dev/null
echo $? # 0 ← 앞의 실패가 사라졌다
# 각 단계의 종료 코드는 PIPESTATUS 에 순서대로 남는다
false | true | false
echo "${PIPESTATUS[@]}" # 1 0 1 ← 첫 번째와 세 번째가 실패PIPESTATUS 는 직전 파이프라인에만 유효합니다. 사이에 다른 명령이 들어가면 값이 덮어써집니다.
명령 | tee 로그 는 tee 의 성공을 파이프라인 전체의 성공으로 보고합니다.
pipefail 을 켜면 파이프 중간의 실패가 다시 보인다
set 빌트인 문서의 pipefail 설명은 이렇습니다. 켜져 있으면 파이프라인의 반환값은 0이 아닌 상태로 끝난 마지막 명령의 값이 되고, 모든 명령이 성공했으면 0이 됩니다. 그리고 이 옵션은 기본적으로 꺼져 있다고 명시돼 있습니다. 앞 절의 동작은 버그가 아니라 기본값입니다.
그래서 스크립트 맨 위 한 줄이 달라집니다. set -e 대신 set -euo pipefail 을 쓰면 파이프 중간의 실패도 파이프라인의 종료 코드에 반영되고, 함께 켜는 -u 는 정의되지 않은 변수를 쓸 때 종료시킵니다. 경로 변수를 빈 문자열로 확장한 채 rm 이나 rsync 가 도는 사고를 이 옵션이 막아 줍니다.
옵션을 켤 때 두 가지를 같이 봐야 합니다. 먼저 pipefail 은 Bash 의 set 옵션이라 쉬뱅을 #!/bin/sh 로 둔 스크립트는 실행 환경에 따라 다른 셸에서 돌 수 있습니다. pipefail 에 기대려면 쉬뱅을 #!/bin/bash 로 명시하는 편이 확실합니다. 다음으로, 지금까지 통과하던 스크립트에 pipefail 을 켜면 전에는 조용히 지나가던 자리에서 멈추기 시작합니다. 그동안 가려져 있던 실패가 드러나는 것이므로 정상이지만, 배포 도중에 켤 일은 아닙니다.
#!/bin/bash
# -e: 실패하면 종료 -u: 정의 안 된 변수 사용 시 종료 -o pipefail: 파이프 중간 실패도 반영
set -euo pipefail
mysqldump mydb | gzip > dump.sql.gz
echo "덤프 완료" # mysqldump 가 실패하면 이 줄에 도달하지 않는다
# 옵션이 실제로 켜졌는지는 이렇게 확인한다
set -o | grep -E 'errexit|pipefail|nounset'pipefail 이 없으면 gzip 이 성공하는 한 거의 빈 dump.sql.gz 를 만들고도 덤프 완료가 찍힙니다.
pipefail 은 기본적으로 꺼져 있습니다. 켜지 않았다면 파이프 앞쪽의 실패는 처음부터 보이지 않았던 것입니다.
함수와 local 에 남는 구멍
pipefail 을 켜도 -e 가 통째로 비켜서는 자리가 남습니다. 문서는 -e 가 무시되는 문맥에서 복합 명령이나 셸 함수가 실행되면 그 안에서 실행되는 어떤 명령도 -e 설정의 영향을 받지 않는다고 적습니다. 함수를 if 조건이나 && 뒤에서 호출하는 순간, 함수 본문 전체가 -e 밖으로 나간다는 뜻입니다.
배포 스크립트에서 흔한 모양입니다. 단계별로 함수를 만들어 두고 성공 여부에 따라 메시지를 다르게 찍으려고 if 로 감싸는데, 그 순간 함수 안의 실패는 전부 무시되고 함수는 마지막 줄의 종료 코드만 돌려줍니다. 이런 함수는 안에서 실패를 직접 검사하고 return 으로 상태를 넘기도록 바꿔야 합니다.
종료 코드가 덮이는 자리가 하나 더 있습니다. local 빌트인 문서는 반환 상태가 local 을 함수 밖에서 쓰거나 잘못된 이름을 주거나 읽기 전용 변수일 때를 빼면 0이라고 적습니다. local v=$(명령) 처럼 선언과 대입을 한 줄에 쓰면 셸이 보는 것은 local 의 종료 코드라서, 명령 치환 안에서 무엇이 실패했든 0이 남습니다. 선언과 대입을 두 줄로 나누면 대입문의 종료 코드가 그대로 남아 -e 가 걸립니다.
set -e
# local 이 종료 코드를 덮는다 — 실패해도 그냥 지나간다
f() { local v=$(false); echo "여기 도달함"; }
f # 출력되고 스크립트도 계속된다
# 선언과 대입을 나누면 대입문의 종료 코드가 살아남는다
g() { local v; v=$(false); echo "여기 도달하지 않음"; }
g # v=$(false) 에서 종료된다
# 함수를 if 조건에서 부르면 함수 본문 전체가 -e 밖으로 나간다
h() { false; echo "함수 안도 계속 진행됨"; }
if h; then :; fi같은 false 인데 호출하는 자리에 따라 결과가 갈립니다. 함수 본문이 아니라 호출 문맥이 -e 적용 여부를 정합니다.
함수를 if 조건이나 && 뒤에서 호출하는 순간, 그 함수 본문에서는 -e 가 통째로 꺼집니다.
조치 후 확인할 것
- 스크립트 첫 줄의 쉬뱅이 #!/bin/bash 인지, 그다음 줄에 set -euo pipefail 이 있는지 확인합니다.
- 실패를 일부러 하나 만들어 넣고 스크립트를 돌린 뒤 echo $? 가 0이 아닌지 봅니다. 성공 경로만 통과했다고 확인이 끝난 것은 아닙니다.
- tee, grep, jq 처럼 파이프로 넘기는 줄을 찾아 앞 명령의 실패가 반영되는지 봅니다. 필요하면 ${PIPESTATUS[@]} 를 로그에 남깁니다.
- if 조건이나 && 뒤에서 호출하는 함수가 있는지 보고, 그 함수 안에서는 실패를 직접 검사해 return 으로 넘기도록 바꿉니다.
- local 과 대입을 한 줄에 쓴 곳을 찾아 선언과 대입을 나눕니다.
확인한 문서
- Bash Reference Manual — The Set Builtin (-e 예외 목록, pipefail) (2026-08-04 확인)
- Bash Reference Manual — Pipelines (파이프라인 종료 상태) (2026-08-04 확인)
- Bash Reference Manual — Bash Builtins (local 의 반환 상태) (2026-08-04 확인)
정리하면 순서는 하나입니다. -e 가 적용되지 않는 자리를 문서에서 확인하고, pipefail 을 켜서 파이프 중간의 실패를 되살리고, 함수와 local 처럼 종료 코드가 덮이는 자리를 손으로 막습니다. 그리고 실패를 일부러 한 번 만들어 돌려 봅니다. 그 전까지는 스크립트가 실패를 잡는지 아닌지 확인된 것이 아닙니다.