Claude Code에게 테스트 스크립트를 | tail을 통해 실행하게 했더니, 실패하고 있는 테스트를 '통과'로 잘못 해석했습니다. 같은 날 두 번이나 이런 일이 있었습니다. 이 글에서는 무슨 일이 있었는지, 앞으로 어떻게 요청해야 하는지, 그리고 테스트의 성패를 정확히 읽기 위한 명령어들을 작성합니다.
저는 iPhone 앱 개발을 하면서, 테스트를 한꺼번에 실행하는 셸 스크립트(run.sh)를 사용하고 있습니다. 테스트는 '세션(節)'이라는 단위로 나뉘어 있으며, 세션마다 OK 또는 NG를 출력하며 진행되고, 마지막에는 === 실행된 검사: N세션 ===과 같이 실행된 세션의 개수를 보여주는 줄을 출력하며 끝납니다. NG가 있으면 종료 코드(exit code) 1로 끝나도록 만들어져 있습니다.
여기서 발생한 두 가지 사건은 제가 AI의 실수를 기록하는 노트와, Claude Code의 세션이 다음 세션으로 남기는 인계 메모를 바탕으로 작성했습니다.
2026년 9월 어느 날, Claude Code가 이 스크립트를 ./run.sh 2>&1 | tail -60로 실행했습니다. 출력의 마지막 60줄만 보는 방식입니다. 끝부분의 '실행된 검사: 69세션'이라는 줄과 종료 코드 0을 보고 'NG 없음'이라고 판단했습니다. 하지만 실제로는 run.sh가 1로 끝나 있었고, NG가 2건 있었습니다. 이 2건은 수정 예정에 있던 NG였기 때문에 당시에는 실질적인 피해는 없었습니다. 다만, NG가 있음에도 불구하고 'NG 없음'으로 보고된 것 자체는 변함이 없습니다.
같은 날 오후, 자리를 비운 사이에 작업을 Claude Code에게 맡겼을 때도 같은 오해를 겪었습니다. ./run.sh 2>&1 | tail -80로 실행하여 끝부분의 '80세션'과 종료 코드 0을 보고 통과했다고 판단했습니다. 나중에 다시 실행해보니 종료 코드는 1이었고, 중간 세션에 NG가 있었습니다. 이 NG는 앱이 AI API로 보내는 문구에 삽입되는 값이 허용된 값 목록에 포함되어 있는지 확인하는 테스트였습니다. 그날 자동 작업 과정에서 Claude Code 자신이 앱에 추가한 코드가 목록에 없는 값을 삽입하고 있었던 것입니다. 이것은 수정 예정이었던 NG가 아니라, 새로 발생한 NG였습니다.
무슨 일이 있었나
이유는 두 가지입니다.
첫 번째는 종료 코드(exit code)입니다. 파이프(|)로 연결된 명령어의 종료 코드는 마지막 명령어의 것입니다. ./run.sh 2>&1 | tail -60이라면 tail의 종료 코드가 됩니다. tail은 보통 0으로 끝납니다. run.sh가 1로 끝나더라도, $?에는 표시되지 않습니다. Claude Code의 Bash 도구가 반환하는 종료 코드 역시 마찬가지로 마지막 명령어의 것입니다.
제 컴퓨터 Mac에서 직접 테스트해보면 다음과 같습니다.
false | tail -1; echo $?
# 0
false는 반드시 1로 끝나는 명령어이지만, 표시되는 것은 tail의 0입니다. bash에서도 zsh에서도 마찬가지입니다.
두 번째는, tail -60이 '마지막 60줄'이라는 점입니다. NG는 세션마다 중간에 발생하기 때문에, 마지막 60줄 안에 들어올 것이라는 보장이 없습니다. 끝부분에 나오는 것은 실행된 세션의 개수일 뿐, 모든 것이 OK였다는 것을 의미하지 않습니다. 종료 코드도, 마지막 줄도 성패를 나타내지 않았습니다.
이 함정은 스크립트 내부에 8월부터 주석으로 적혀 있었습니다('파이프로 연결하면 exit code가 tail의 것이 되므로, 한 번 파일에 받는다'). 하지만 그것은 스크립트 내부의 다른 부분을 위한 주석이었을 뿐, 스크립트를 실행할 때의 규칙이 되지는 않았습니다.
앞으로는 이렇게 요청하겠습니다
앞으로는 테스트를 실행해 달라고 할 때, 다음과 같이 덧붙여서 요청하기로 했습니다.
테스트는 파이프에 연결하지 말고 실행해 주세요. 출력은 파일에 저장하고, 종료 코드를 그 자리에서 보여주세요. 성패는 종료 코드와 로그 내부의 NG 줄을 보고 알려주세요. 마지막 몇 줄만 보고 통과했다고 판단하지 말아 주세요.
실행 방법은 다음과 같습니다.
./run.sh > /tmp/run.log 2>&1; echo "EXIT=$?"
grep -nw NG /tmp/run.log
EXIT=0이고, grep이 아무것도 출력하지 않으면 통과한 것입니다. grep은 NG 줄을 행 번호와 함께 출력해주기 때문에, NG가 로그의 어디에서 발생했는지도 알 수 있습니다(-w는 'NG'라는 단어 자체에만 일치시키고, 'WARNING' 같은 단어 일부에는 일치시키지 않는 지정입니다. '테스트NG'
와 같이 앞 단어에 붙어서 나오는 작성 방식이라면 -w는 작동하지 않으므로, 그럴 때는 -w를 제거합니다. NG 부분은 사용하고 있는 테스트의 출력 작성 방식에 맞게 조정해야 합니다).
종료 코드는 EXIT= 줄에서 읽습니다. 이 두 줄을 한 번에 실행하면 (Claude Code의 Bash 도구 호출도 마찬가지입니다), 마지막으로 반환되는 종료 코드는 grep의 것이며, NG가 없으면 1이 됩니다. 모든 것이 통과했더라도, 도구 화면에는 실패로 표시됩니다. 여기에서도 마지막 명령어의 종료 코드를 성공/실패 여부로 판단해서는 안 됩니다.
어쩔 수 없이 파이프(pipe)를 통해 보고 싶을 때는, 파이프 내부의 각 명령어의 종료 코드를 읽어야 합니다. 파이프 바로 다음에, 다음 명령어를 실행하기 전에 읽습니다. 변수 이름이나 배열 인덱스도 셸마다 다르며, 잘못된 구문을 사용하면 오류 없이 빈 값만 출력됩니다. Claude Code의 Bash 도구는 이름은 Bash라도 일반적인 셸에서 작동합니다 (Mac의 기본값은 zsh입니다. 도구 내에서 ps -p $$ -o comm=을 실행하면 현재 동작 중인 셸이 무엇인지 알 수 있습니다).
# bash
./run.sh 2>&1 | tail -60; echo "EXIT=${PIPESTATUS[0]}"
# zsh
...
어떤 셸에서도 사용할 수 있는 것은 pipefail입니다. 파이프 앞에 설정해 두면, 파이프의 어느 부분이 실패했을 때 그 종료 코드가 $?에 저장됩니다. Claude Code의 Bash 도구는 호출마다 셸 상태를 유지하지 않기 때문에, 이를 하나의 명령어 안에 포함시켜야 합니다.
set -o pipefail; ./run.sh 2>&1 | tail -60; echo "EXIT=$?"
하지만 이렇게 해도 종료 코드는 올바르게 읽을 수 있지만, tail이 중간의 NG를 숨기는 것은 변함이 없습니다. 성공/실패 여부를 보고서에 사용하려면 파일로 저장하는 방식을 이용해야 합니다.
Claude Code가 "테스트는 통과했습니다"라고 말한다면, 다음 두 가지를 질문합니다.
그 종료 코드는 어느 명령어의 것인가?
NG 라인을 로그 전체에서 어디서 찾았는가?
이러한 AI의 실수와 이후 필요한 요청 사항들을 모아서, 다른 AI가 검색하고 읽을 수 있도록 만든 MCP 서버 "わだち"를 만들었습니다 (커넥터 목록에서의 이름은 Imashime). claude.ai 및 Claude Desktop에서는 커넥터 목록에서, Claude Code에서는 claude mcp add --transport http imashime https://imashime.waitlab.workers.dev/mcp 명령어로 추가할 수 있습니다.
Discussion