set -e 스크립트가 에러 없이 중간에 끝날 때: 변수에 담은 grep 한 줄 찾기
set -e 스크립트가 에러 메시지 없이 멈추는 독자가 멈춘 줄과 원인을 찾고, 진짜 오류는 남기면서 grep의 매치 없음만 통과시키게 돕는다.
set -e 아래에서 grep 결과를 변수에 담는 줄은 매치가 없으면 종료 코드 1로 스크립트를 끝낸다. bash -x로 마지막 줄을 찾고, || [ $? -eq 1 ]처럼 1만 허용하거나 검사기의 종료 코드로 판정한다.
멈춘 스크립트를 bash -x로 다시 돌려 마지막에 찍힌 줄이 grep이 들어간 변수 대입인지 확인한다.
목차
한 줄 요약: set -e 아래에서 grep을 변수에 담으면 매치가 없는 순간 스크립트가 에러 없이 끝나니, bash -x로 마지막 줄을 찾아 종료 코드 1만 허용하라.
파일 검사 스크립트가 “검사 시작”까지만 찍고 멈췄다. 에러 메시지도, 통과 표시도 없다. 이 글은 그 한 줄을 찾는 방법과, 고치다가 새로 만들기 쉬운 구멍 두 개를 다룬다. 구멍 두 개는 || true가 진짜 오류까지 삼키는 것, if로 감싸다가 실패 상태에서 죽는 것이다. 확인한 환경은 macOS 26.6.2 arm64의 bash 3.2.57, zsh 5.9, sh(이 맥의 sh는 bash 3.2.57의 POSIX 모드다)다.
먼저 용어 몇 개. set -e는 명령 하나가 실패하면 스크립트를 바로 끝내는 bash 옵션이다(예외는 아래 매뉴얼 인용에 있다). 종료 코드는 명령이 끝날 때 남기는 숫자로, 0이 성공이고 0이 아니면 실패로 취급한다(직전 명령의 값은 $?로 읽는다). $(명령)은 명령 치환이라 부르고, 명령의 출력을 변수나 문자열에 넣는다. shasum -c는 파일의 체크섬이 목록과 같은지 검사하는 명령이다.
통과해야 할 때만 멈추는 검사기
체크섬 목록(MANIFEST.sha256)과 파일이 모두 일치하는지 보는 스크립트다. 일치하지 않는 줄만 모아 보여 주려고 grep -v를 붙였다.
#!/bin/bash
set -e
echo "검사 시작"
bad=$(shasum -a 256 -c MANIFEST.sha256 2>&1 | grep -v ': OK$')
if [ -n "$bad" ]; then
echo "실패 항목:"; echo "$bad"; exit 1
fi
echo "PASS: 전부 일치"
파일 두 개(a.txt, b.txt)가 목록과 모두 같을 때 실행하면 이렇게 끝난다. 아래 [종료 코드 N] 줄은 스크립트가 찍은 것이 아니라 시험 실행기가 붙인 표시다.
검사 시작
[종료 코드 1]
PASS도 실패 항목도 없다. 반대로 b.txt를 일부러 바꾸면 스크립트는 b.txt: FAILED를 정상적으로 출력한다. 즉 이 검사기는 실패를 잡는 쪽만 시험해 보면 멀쩡해 보이고, 통과해야 하는 경로에서만 죽는다. 정상 상태에서는 bash 3.2, zsh, sh 모두 코드 1로 멈췄고, 변조 상태에서는 모두 실패 항목을 출력했다. 이 맥의 sh는 bash 3.2의 POSIX 모드라 독립된 셸의 증거는 아니다.
범인은 매치가 없다는 종료 코드
이 맥의 man grep은 종료 코드를 이렇게 적는다.
0 One or more lines were selected.
1 No lines were selected.
>1 An error occurred.
모든 줄이 : OK로 끝나면 grep -v ': OK$'가 고를 줄이 없으니 종료 코드 1이다. 오류가 아니라 정상적인 “없음”이다. 문제는 set -e가 이것을 구분하지 않는다는 점이다. bash 매뉴얼의 -e 항목은 이렇게 쓴다.
The shell does not exit if the command that fails is part of the command list immediately following a while or until reserved word, part of the test in an if statement, part of any command executed in a && or || list except the command following the final && or ||, any command in a pipeline but the last (subject to the state of the pipefail shell option), or if the command’s return status is being inverted with !.
예외 목록에 “변수 대입”은 없다. 대입문의 종료 코드는 대입 안에 든 명령 치환의 종료 코드다.
If one of the expansions contained a command substitution, the exit status of the command is the exit status of the last command substitution performed.
여기에 파이프가 하나 더 얽힌다. 위 스크립트의 치환 안은 shasum ... | grep ... 파이프이고, pipefail이 꺼져 있으면 파이프 전체의 종료 코드는 마지막 명령인 grep의 것이다. 매뉴얼은 pipefail을 “기본으로 꺼져 있다”고 적는다. 그래서 shasum이 실패해도 신호는 grep에 가려지고, grep이 아무 줄도 못 고른 순간 대입문 전체가 1이 되어 스크립트가 끝난다.
멈춘 줄 찾기
에러가 없으니 실행 흔적으로 찾는다. bash -x로 다시 돌리면 마지막에 찍힌 줄이 멈춘 곳이다.
+ set -e
+ echo '검사 시작'
검사 시작
++ shasum -a 256 -c MANIFEST.sha256
++ grep -v ': OK$'
+ bad=
+ bad=가 마지막이고 그 뒤에 if가 없다. bad=에 값이 비어 있는 채로 대입이 끝났고, 바로 그 줄에서 종료됐다는 뜻이다. 스크립트 안에 ERR 트랩(명령이 실패할 때 실행할 명령)을 걸어 두는 방법도 있다. 아래처럼 검사기와 별도로 만든 짧은 시험 파일(a.txt에는 alpha만 들어 있다)은 ERR: 종료 코드 1 / 줄 5를 찍었고, 5는 대입문이 있는 줄이다.
#!/bin/bash
trap 'echo "ERR: 종료 코드 $? / 줄 $LINENO" >&2' ERR
set -e
echo "시작"
hit=$(grep gamma a.txt)
echo "unreached"
고치는 세 가지와 각각이 삼키는 것
세 방법을 같은 검사기에 적용해 정상 상태(A)와 파일 하나를 바꾼 상태(B)로 돌렸다.
| 방법 | 고친 줄 | A: 전부 일치 | B: 변조 |
|---|---|---|---|
| 기존 | bad=$(… | grep -v ': OK$') |
검사 시작에서 멈춤, 코드 1 |
실패 항목 출력, 코드 1 |
|| true |
bad=$(… | grep -v ': OK$' || true) |
PASS, 코드 0 | 실패 항목 출력, 코드 1 |
| 1만 허용 | bad=$(… | grep -v ': OK$' || [ $? -eq 1 ]) |
PASS, 코드 0 | 실패 항목 출력, 코드 1 |
| 검사기 코드로 판정 | 아래 스크립트 | PASS, 코드 0 | 실패 항목 출력, 코드 1 |
|| true는 가장 짧지만 종료 코드 2(진짜 오류)까지 지운다. 존재하지 않는 파일을 grep하는 줄로 비교했다.
|| true : grep: no-such-file.txt: No such file or directory
grep 뒤에도 계속 진행: [] [종료 코드 0]
|| [ $? -eq 1 ] : grep: no-such-file.txt: No such file or directory
[종료 코드 1]
|| true는 오류 메시지를 한 줄 남기고 스크립트를 0으로 계속 달리게 한다. || [ $? -eq 1 ]는 || 바로 앞 명령(파이프라면 마지막 명령인 grep)의 종료 코드가 1일 때만 통과시키고 2는 스크립트를 멈춘다. 멈출 때의 종료 코드는 2가 아니라 [가 낸 1이므로, 오류의 종류는 위의 grep 메시지로 구분한다. 같은 처방은 Linux 커널 merge_config.sh의 패치 논의에도 나온다. set -e가 추가된 뒤 grep이 설정 항목을 찾지 못하면 스크립트가 중간에 끝나서, ACTUAL_VAL=$(grep -w -e "$CFG" "$KCONFIG_CONFIG" || echo)로 고친 패치다. 답신에서 Jon Hunter는 echo 없이 true면 충분하고 이런 경우에는 항상 grep에 true를 쓴다고 썼다. GNU grep 메일링 리스트에는 set -eo pipefail 아래 파이프 속 grep이 매치를 못 찾아 스크립트가 끝나는 경우를 들며 grep ... || [ $? = 1 ]가 “읽기 어렵다”며 별도 옵션을 제안한 글이 있다. 이 글의 || [ $? -eq 1 ]가 바로 그 관용구다.
세 번째 방법은 grep의 종료 코드에 기대지 않고 검사기 자체의 종료 코드로 판정한다.
#!/bin/bash
set -e
echo "검사 시작"
status=0
report=$(shasum -a 256 -c MANIFEST.sha256 2>&1) || status=$?
if [ "$status" -ne 0 ]; then
echo "실패 항목:"; printf '%s\n' "$report" | grep -v ': OK$' || true; exit 1
fi
echo "PASS: 전부 일치"
|| status=$?로 실패 코드를 받아 두므로 set -e가 건드리지 않는다. 마지막 || true는 실패 줄을 보여 주는 용도뿐이라 안전하다. 처음에는 if로 감싸면 된다고 생각해 report=$(shasum …)을 그대로 두고 grep만 if 안에 넣었다. 정상 상태는 통과했지만 변조 상태에서 검사 시작 뒤 아무것도 찍지 않고 코드 1로 끝났다. shasum -c가 불일치에서 직접 종료 코드 1을 내기 때문이다. 정상 상태만 돌렸다면 이 판본도 고쳐진 것처럼 보였을 것이다. 고친 뒤에는 정상과 실패를 둘 다 돌려야 한다.
같은 뿌리의 함정 두 개
grep -c는 매치가 0개일 때 0을 출력하고도 종료 코드 1을 낸다. set -e 아래에서는 0이 찍힌 직후 스크립트가 끝난다.
시작
0
[종료 코드 1]
반대로 함수 안의 local hit=$(grep gamma a.txt)는 멈추지 않는다. bash 매뉴얼은 local의 반환 상태를 “name이 읽기 전용이거나 이름이 잘못됐거나 함수 밖에서 쓴 경우가 아니면 0”이라고 적는다. 대입에 든 grep의 코드가 local 명령의 코드로 덮인다. 같은 내용을 local 없이 hit=$(…)로 쓴 함수는 멈췄다. 한쪽은 죽고 한쪽은 안 죽으니, 대입을 local에서 떼어 내면 숨어 있던 실패가 갑자기 드러날 수 있다.
종료 코드가 0으로 보이는 경우
종료 코드 1로 끝난 스크립트도 호출 방식에 따라 성공으로 보인다. 정상 상태에서 bash verify-bad.sh | tail -n 5를 돌리면 tail의 종료 코드 0이 보이고 스크립트의 1은 PIPESTATUS(파이프의 각 명령이 낸 종료 코드를 담는 bash 배열)에만 남는다. 호출하는 쪽에 set -o pipefail을 걸면 파이프 전체가 1이 된다. 또 bash verify-bad.sh; echo 다음 단계처럼 종료 코드를 보지 않고 이어 가면 “다음 단계”가 그대로 실행된다. 로그에 검사 시작만 남은 채 이어지는 작업은 이렇게 생긴다.
필자가 기록해 둔 용량 정리 저장소의 함정 노트에는 같은 일이 적혀 있다. 새로 만든 verify가 grep -v ': OK$' 대입 한 줄에서 죽어 “통과”와 구분이 안 됐다는 메모다. 노트에는 종료 코드 0이라고 적혀 있지만, 이번에 새로 만든 재현에서 스크립트 단독 실행의 종료 코드는 1이었다. 당시 그 스크립트를 어떻게 호출했는지는 이 글에서 복원하지 못했다.
확인 순서와 시험 범위
- 멈춘 스크립트를
bash -x 스크립트로 돌려 마지막 줄이x=$(… grep …)대입인지 본다. - 그 줄에서 매치 없음이 정상인지 정한다. 정상이면
|| [ $? -eq 1 ], 검사기가 따로 판정을 내려 주면 그 종료 코드를 쓴다. - 고친 뒤에는 “매치 없음”, “매치 있음”, “파일 없음” 세 경우를 모두 실행해 종료 코드를 본다.
- 위 모든 시험은 macOS 26.6.2의 BSD grep 2.6.0-FreeBSD와 bash 3.2.57에서만 했다. bash 4·5, dash, Linux의 GNU grep에서의 동작은 시험하지 않았다. 매뉴얼의
-e문구는 위 링크의 현재 버전이다.
세 줄 요약
- set -e 스크립트가 에러 없이 멈추면 bash -x로 마지막에 찍힌 줄을 본다. grep이 든 변수 대입이 흔한 범인이다.
- grep의 종료 코드 1은 “매치 없음”이다. || true는 종료 코드 2까지 지우고, || [ $? -eq 1 ]는 1만 통과시킨다.
- 고친 뒤에는 통과·실패·오류 세 경우를 모두 실행하고, 호출하는 쪽이 종료 코드를 가리지 않는지 확인한다.