본문 바로가기
빌드노트 검색

빌드노트

Bash에서 set -e를 켰는데 함수 안 실패 뒤 명령이 계속 실행된다

Bash 함수를 if 조건이나 || 왼쪽에서 호출하면 set -e가 함수 내부에서도 무시될 수 있다. 단독·조건·OR 호출의 실제 출력과 종료 상태를 비교하고, 명시적 return과 호출자의 실패 종료를 나누어 고친다.

Bash 스크립트에서 set -e를 켰는데도 함수 안의 실패 뒤에 다음 작업이 실행될 수 있다. 그 함수를 if work; then ...의 조건이나 work || ...의 왼쪽에서 호출하는지 먼저 본다. 이 문맥에서는 함수 내부 명령에도 errexit의 자동 종료가 적용되지 않는다. 중요한 단계가 실패하면 함수에서 즉시 return하도록 만들어야 호출 위치에 기대지 않고 후속 실행을 막을 수 있다.

함수 정의만 읽으면 이 차이를 놓치기 쉽다. 같은 함수를 단독으로 호출하던 코드를 오류 메시지를 붙이려고 work || ...로 바꾸는 것만으로도 내부 실패 뒤의 실행 여부가 달라진다. 함수 끝의 성공한 명령이 반환 상태를 0으로 만들면, 바깥의 오류 처리문도 실행되지 않는다.

호출 위치만 바꿔 비교한다

Linux나 WSL에서 실습용 bash --noprofile --norc를 새로 열고 아래 입력을 실행한다. 첫 set +e는 결과를 읽을 부모 셸이 중간에 끝나지 않게 한다. 안쪽의 새 Bash에는 따로 set -e를 켠다. 실제 작업 대신 실패 상태 1을 내는 false와 출력 마커만 사용한다.

이 입력으로 확인할 것은 false 뒤의 함수 내부 출력과 함수 호출 뒤의 출력이 각각 실행되는지, 그리고 자식 Bash가 어떤 상태로 끝나는지다.

set +e
bash --noprofile --norc -c '
set -e
work() {
    false
    printf "after-failure\n"
}
work
printf "caller-after\n"
'
printf 'status=%s\n' "$?"

Ubuntu 24.04.4의 WSL2, Bash 5.2.21(1)-release에서 출력은 status=1 한 줄이었다. after-failure도 caller-after도 나오지 않았다. 단독 호출에서는 false에서 자식 Bash가 종료됐다.

다음에는 위 입력의 함수 정의를 그대로 두고, 정의 아래의 호출 한 줄 work만 다음 조건문으로 바꿔 전체 입력을 다시 실행한다. 호출 문맥을 바꿨을 때 실패가 성공 분기로 이어지는지 확인하는 입력이다.

if work; then printf "then\n"; else printf "else\n"; fi

이번에는 다음 네 줄이 나왔다.

after-failure
then
caller-after
status=0

false가 성공으로 바뀐 것은 아니다. 실패 뒤의 printf가 실행됐고, 그 성공 상태가 함수의 반환값이 되어 then으로 들어간 것이다.

같은 호출 한 줄을 work || printf "handler\n"로 바꿔 실행해도 after-failure, caller-after, status=0이 차례로 나왔다. handler는 출력되지 않았다. 함수가 내부 실패를 지나 0을 반환했으므로 || 오른쪽을 실행할 이유가 없어진 것이다.

같은 입력에서 결과가 다르면 bash --version과 교체한 호출 한 줄부터 확인한다. 다른 셸로 실행했거나 함수 본문에도 별도 제어문이 있으면 이 비교와 조건이 다르다.

옵션은 켜져 있어도 이 호출에서는 무시된다

Debian이 제공하는 GNU Bash 5.2 매뉴얼 (새 탭에서 열림)의 set -e 설명은 if의 검사와 &&·|| 목록의 마지막 명령을 제외한 위치 등을 자동 종료의 예외로 둔다. 그런 문맥에서 함수를 실행하면 함수 본문의 명령도 -e의 영향을 받지 않는다고 설명한다. 이 글의 OR 예제에서는 왼쪽의 work가 그 위치다. || 오른쪽 마지막 명령까지 언제나 예외라는 뜻은 아니다.

따라서 함수 안에 set -e를 한 번 더 넣는 것으로 해결되지 않는다. 같은 if 실험에서 함수 첫 줄에 set -e를 추가해도 after-failure와 then이 출력됐고 최종 상태는 0이었다. 옵션 설정을 반복하기보다 호출부와 실패 처리 코드를 함께 확인해야 한다.

실패한 지점에서 함수를 반환시킨다

다음 정의로 앞 입력의 work를 교체하면, 첫 명령의 실패를 즉시 반환해 후속 출력을 막는다. 실제 함수에서는 false 자리에 실패 시 중단해야 할 작업 명령을 둔다.

work() {
    false || return "$?"
    printf "after-failure\n"
}

return은 함수를 끝내며, 여기의 $?는 바로 앞 false의 상태 1이다. 이 값은 다른 명령을 실행하기 전에 사용해야 한다. 중요한 단계가 여러 개라면 각 단계의 실패를 같은 방식으로 검사한다. 작업 명령 자체가 내부 오류를 0으로 숨겨 반환한다면 이 조건으로는 검출할 수 없다.

고친 함수를 같은 세 위치에서 실행하자 after-failure는 모두 사라졌다. 단독 호출은 상태 1로 끝났고, if는 else로 들어갔으며, OR 목록은 handler를 출력했다. 다만 뒤의 두 실험은 여전히 caller-after를 출력하고 자식 Bash의 최종 상태는 0이었다. 함수가 실패를 반환하는 것과 호출자가 스크립트를 실패로 종료하는 것은 따로 처리해야 한다.

실패 시 스크립트 전체도 끝내려면 앞 입력의 호출 한 줄을 아래처럼 바꾼다. 실패 분기에서 상태를 먼저 저장하고 그 값으로 종료하도록 하는 수정이다.

if work; then
    printf "then\n"
else
    rc=$?
    printf "failed:%s\n" "$rc" >&2
    exit "$rc"
fi

고친 함수와 이 호출부를 함께 실행한 결과는 표준 오류의 failed:1과 부모 셸이 출력한 status=1이었다. 두 후속 마커는 나오지 않았다. 별도 합성 명령이 7을 반환하도록 바꾼 조건에서도 failed:7과 종료 상태 7이 유지됐다. 첫 명령을 성공하는 true로 바꾼 대조에서는 함수의 후속 출력과 성공 분기가 실행되고 상태 0으로 끝났다.

여기서 확인한 범위는 새 비대화형 Bash의 기본 모드에서 단독 호출, if 조건, OR 목록 왼쪽 호출이다. 자기 스크립트에서는 먼저 실제 실행 명령이 bash인지와 함수의 호출 위치를 확인한다. 다른 셸의 sh 실행, POSIX 모드, 명령 치환·트랩을 섞은 흐름, 실제 배포 작업은 이 실험으로 검증하지 않았다. 실습용 부모 Bash는 exit로 나올 수 있다.

← 전체 글 보기