빌드노트
grep -q는 찾았는데 pipefail 조건은 왜 거짓일까
grep -q가 일치를 찾아 0으로 끝나도 생산자의 SIGPIPE 때문에 pipefail 조건은 거짓이 될 수 있다. PIPESTATUS를 즉시 저장해 원인을 구분하고, 입력을 끝까지 소비하는 방식과 조기 종료 정책을 비교한다.
Bash에서 producer | grep -q 'needle'을 if 조건으로 쓰면, 문자열이 있어도 else로 갈 수 있다. pipefail이 켜진 새 Bash에서 대량의 합성 출력을 검사하자 grep은 0으로 성공했지만 생산자는 141로 끝났고, 파이프 전체도 141이었다. grep -q가 일치를 찾고 먼저 종료하면서, 계속 출력하던 생산자가 SIGPIPE로 끝난 경우였다. 이때 else를 곧바로 “문자열 없음”으로 해석하면 판단이 틀린다.
생산자가 끝까지 작업을 마쳐야 한다면, 이 글에서 비교한 텍스트 입력에서는 -q를 빼고 grep의 출력만 /dev/null로 보내는 방법을 쓸 수 있었다. 빠른 검색 종료가 필요하다면 일치 여부와 생산자 종료를 따로 판정해야 한다. 어느 쪽이 필요한지 정하기 전에 두 단계의 상태부터 함께 확인한다.
grep의 성공과 파이프 전체의 성공을 나눠 본다
Linux 또는 WSL 터미널에서 bash --noprofile --norc로 실습용 새 Bash를 연다. 다음 명령은 파일이나 기존 로그를 읽지 않는다. 첫 줄에 needle, 그 뒤에 payload 10만 줄을 만드는 유한한 생산자를 정의하고, 파이프 직후의 상태를 저장한다. Bash의 배열을 쓰므로 sh로 실행하지 않는다. 옵션 변경도 이 새 셸 안에서만 한다.
set +e
set -o pipefail
export LC_ALL=C
producer() {
printf 'needle\n'
for ((i=0; i<100000; i++)); do printf 'payload\n'; done
}
producer | grep -q -- needle
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf 'quiet match: pipeline=%s producer=%s grep=%s\n' "${snapshot[@]}"
WSL2의 Ubuntu 24.04.4, Bash 5.2.21, GNU grep 3.11에서 다음을 관찰했다. 실제 서비스의 출력을 검사한 결과는 아니다.
quiet match: pipeline=141 producer=141 grep=0
snapshot의 첫 값은 파이프 전체의 $?, 뒤의 두 값은 왼쪽부터 생산자와 grep의 PIPESTATUS다. grep=0이므로 일치는 있었다. 같은 생산자를 if producer | grep -q -- needle; then ... else ... fi에 연결한 별도 비교에서도 else로 갔다.
GNU grep의 -q 설명 (새 탭에서 열림)에 따르면, -q는 화면 출력을 없애는 데서 끝나지 않는다. 일치를 찾으면 입력이 남아 있어도 0으로 바로 종료할 수 있다. Linux pipe(7) (새 탭에서 열림)은 파이프의 읽기 끝이 모두 닫힌 뒤 쓰기를 시도하면 SIGPIPE가 발생한다고 설명한다. 따라서 생산자가 실패했다고 grep도 검색에 실패한 것은 아니다.
Bash의 파이프 규칙 (새 탭에서 열림)은 pipefail이 켜져 있으면 오른쪽에 있는 0이 아닌 상태를 전체 상태로 고른다. 여기서는 141 0이므로 전체가 141이다. 이 Linux의 SIGPIPE 번호는 13이었고, Bash가 치명적 신호 종료를 128 + 신호 번호로 나타내므로 141이 됐다. 임의의 프로그램이 반환한 숫자 141만으로 같은 원인을 확정할 수는 없다.
상태 저장은 파이프 바로 다음 한 번의 대입으로 해야 한다. rc=$?를 먼저 실행하고 다음 줄에서 PIPESTATUS를 복사한 추가 비교에서는 원래의 141 0 대신 0 하나만 남았다. 대입도 새 명령이기 때문이다. PIPESTATUS 정의 (새 탭에서 열림)는 가장 최근 전경 파이프의 단계별 상태를 담으며, 단일 명령도 그 대상에 포함한다고 설명한다.
위 예제의 set +e는 실패 뒤에도 상태를 읽으려는 실습 설정이다. 실제 스크립트가 set -e를 쓴다면 파이프를 기존 if 조건으로 두고, 필요한 분기의 첫 명령으로 같은 snapshot 대입을 넣는다. 로그 출력이나 별도 대입을 먼저 하면 진단할 값을 잃는다.
생산자의 완료가 필요하면 입력을 끝까지 읽게 한다
같은 새 Bash에서 다음을 이어 실행한다. 먼저 없는 문자열을 -q로 검색하고, 이어서 -q 없이 출력만 버리는 방법으로 일치와 불일치를 비교한다. absent는 위 생산자의 출력에 없는 문자열이다.
producer | grep -q -- absent
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf 'quiet missing: pipeline=%s producer=%s grep=%s\n' "${snapshot[@]}"
producer | grep -- needle >/dev/null
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf 'redirect match: pipeline=%s producer=%s grep=%s\n' "${snapshot[@]}"
producer | grep -- absent >/dev/null
snapshot=( "$?" "${PIPESTATUS[@]}" )
printf 'redirect missing: pipeline=%s producer=%s grep=%s\n' "${snapshot[@]}"
quiet missing: pipeline=1 producer=0 grep=1
redirect match: pipeline=0 producer=0 grep=0
redirect missing: pipeline=1 producer=0 grep=1
일치가 없으면 grep -q도 입력 끝까지 확인해야 하므로 생산자가 정상 종료했다. -q를 뺀 일치 검색에서는 grep이 남은 입력까지 읽었고 두 단계 모두 0이었다. 생산자 끝에 별도 완료 표시를 넣은 비교에서도 -q를 쓴 경우에는 표시가 없었고, 출력 리다이렉션을 쓴 경우에는 마지막까지 도달했다.
기존 코드도 유한한 텍스트 출력의 생성 완료와 일치를 모두 요구한다면, 조건을 if producer | grep -- needle >/dev/null; then 형태로 바꿀 수 있다. 여기서 producer를 실제 출력 생성 명령으로, needle을 검색 패턴으로 바꾼다. >/dev/null은 검색 결과가 나갈 곳만 바꾸며, -q처럼 첫 일치에서 멈추도록 지시하지 않는다. 이 비교에는 -m, -l 같은 조기 종료 옵션이 없고 입력에도 NUL이나 인코딩 오류가 없다. 모든 옵션·입력에서 끝까지 읽는다는 보장으로 확대하지 않는다. 끝나지 않는 스트림이라면 이 방식은 검색 조건의 반환도 계속 기다리게 한다.
다만 실패 분기는 여전히 “없음”과 같지 않다. 모든 출력을 낸 뒤 7을 반환하도록 생산자를 바꾼 추가 실험에서, 출력 리다이렉션 검색은 일치할 때 pipeline=7 producer=7 grep=0이었다. 불일치할 때는 pipeline=1 producer=7 grep=1이었다. 오른쪽의 1이 전체 상태가 되므로 전체 상태가 1이어도 생산자 실패가 함께 있을 수 있다. PIPESTATUS에서 생산자가 0인지 먼저 확인한 뒤 grep의 0·1을 일치·불일치로 나눈다. GNU grep의 일반적인 오류 상태 2도 불일치와 구별해야 한다.
작은 입력에서 성공했다고 안전한 조건은 아니다
짧은 두 줄만 쓰는 별도 비교는 12회 모두 pipeline=0 producer=0 grep=0이었다. 생산자가 소비자의 종료보다 먼저 쓰기를 마칠 수 있기 때문이다. 큰 입력에서는 문제가 나고 작은 입력에서는 안 나는 현상은 이 차이로 설명할 수 있다.
쓰기 순서도 따로 비교했다. Python 3.12.3으로 파이프 용량보다 작은 동일한 합성 입력을 한 번에 쓴 경우에는 생산자가 0으로 끝났다. 첫 일치 줄을 쓴 뒤 50ms 기다렸다가 나머지를 쓰게 하자 141로 끝났다. 각 조건은 세 번 실행했고 같은 결과였다. 이 비교는 소비자가 먼저 닫은 뒤 추가 쓰기가 발생하는 조건을 보여 준다. 몇 바이트부터 항상 실패한다는 경계나 임의 실행의 실패 확률을 측정한 것은 아니다. 파이프 용량, 쓰기 단위, 검색 위치와 프로세스 실행 순서가 달라지면 결과도 달라질 수 있다.
빠른 종료를 유지하려면 먼저 생산자가 중간에 멈춰도 되는지 확인한다. snapshot[2]의 grep=0은 검색의 성공이고, snapshot[1]은 생산자의 종료 상태다. 이 둘을 따로 읽고 해당 생산자에서 확인한 조기 종료만 허용할지 정한다. 생산자 뒤쪽의 파일 저장이나 후처리가 필요하다면 SIGPIPE를 허용하는 것으로 작업 완료를 대신할 수 없다.
생산자마다 신호 처리도 다르다. 같은 지연 쓰기 생산자가 SIGPIPE를 무시하고 쓰기 오류 EPIPE를 잡아 74를 반환하도록 만든 비교에서는 pipeline=74 producer=74 grep=0이었다. 따라서 141만 예외로 삼는 공통 처리도 충분하지 않다. 반대로 pipefail을 끈 비교는 producer=141 grep=0인데 전체가 0이었다. 단순히 pipefail을 끄거나 뒤에 || true를 붙이면 생산자의 다른 실패까지 놓칠 수 있다.
이번에 실행한 것은 소유한 단기 프로세스와 유한한 ASCII 합성 입력뿐이다. GNU grep 3.11의 동봉 매뉴얼과 Bash 5.3 매뉴얼을 대조했으며, 실제 Bash 실행 버전은 5.2.21이다. 다른 버전·셸, 바이너리 입력, 실제 작업 명령의 신호 처리, 무한 스트림은 검증하지 않았다. 자기 생산자의 실패가 무엇을 뜻하는지 확인한 뒤, 전체 입력을 소비할지 조기 종료를 별도 정책으로 처리할지 결정한다. 실습을 마치면 exit로 새 Bash에서 나온다.