셸 while read 루프가 한 건만 처리하고 멈추는 이유
셸 while read 루프가 큐를 쌓아도 한 건만 돌고 정상 종료하는 stdin 잠식 버그의 원인과 </dev/null·ssh -n·exec 3< 해법을 실측 사례로 정리합니다.
지난 7월 20일에 짧게 적었던 “루프가 한 건만 돌고 끝나는 버그” 이야기를 조금 더 깊게 풀어 보려 합니다. 그때는 증상과 한 줄 해법만 남겼는데, 막상 다시 겪어 보니 이 버그는 원인을 정확히 이해하지 못하면 계속 재발하는 종류였습니다. 저는 cron으로 도는 워커에서 이 문제를 만났고, 반나절 가까이 엉뚱한 데를 뒤지다가 결국 한 글자대 수정으로 끝냈습니다.
증상: 에러 0, 한 건만, 정상 종료
가장 헷갈렸던 건 이 버그가 아무 소리도 내지 않는다는 점이었습니다. 저는 큐에 처리할 항목을 여러 건 쌓아 두고 워커를 돌렸는데, 로그를 보면 항상 첫 번째 항목만 처리되어 있었습니다.
정리하면 증상은 이렇게 세 가지였습니다.
- 에러가 하나도 없습니다. exit code도 0입니다.
- 큐에 열 건을 넣든 스무 건을 넣든 딱 한 건만 처리됩니다.
- 루프가 중간에 멈추는 게 아니라 “정상 종료”합니다.
에러가 났다면 스택 트레이스라도 봤을 텐데, 루프가 할 일을 다 했다는 얼굴로 조용히 끝나 버리니 어디를 봐야 할지 감이 안 잡혔습니다. 저는 처음에 큐를 쌓는 쪽 코드를 의심했습니다. 정작 범인은 루프 본문에서 부르는 명령이었습니다.
원인: 루프 입력을 명령이 잠식한다
핵심은 while read 루프의 stdin과, 루프 안에서 부른 명령의 stdin이 같다는 사실입니다.
while read line; do
ssh some-host "do-something $line"
done < queue.txt
이 루프에서 read는 queue.txt를 stdin으로 받아 한 줄씩 읽습니다. 그런데 루프 본문의 ssh도 별도로 stdin을 지정하지 않으면 같은 stdin, 즉 queue.txt를 그대로 물려받습니다. ssh는 원격 명령에 표준 입력을 넘겨주려고 stdin을 끝까지 읽어 버리는 습성이 있습니다.
그래서 실제로 벌어지는 일은 이렇습니다. 첫 반복에서 read가 첫 줄을 가져갑니다. 이어서 ssh가 실행되며 queue.txt에 남은 줄을 전부 빨아들입니다. 두 번째 반복으로 넘어가 read가 다시 읽으려 하지만, 입력이 이미 바닥났으니 read는 실패하고 루프는 종료합니다. 에러도 아닙니다. 그냥 “더 읽을 게 없어서” 끝난 것입니다.
이걸 셸 밖에서 흔히 “stdin 잠식(입력 잠식)“이라고 부릅니다. ssh뿐 아니라 대화형 CLI, 요즘 자주 쓰는 AI CLI 같은 것들이 다 후보입니다. 표준 입력을 읽을 수 있는 명령이면 무엇이든 루프의 입력을 삼킬 수 있습니다.
최소 재현: cat 한 줄로 확인하기
ssh 없이도 cat 하나로 똑같이 재현됩니다.
printf 'a\nb\nc\n' > queue.txt
while read line; do
echo "처리 시작: $line"
cat > /dev/null # stdin을 통째로 읽어 버림
done < queue.txt
이 코드를 돌리면 “처리 시작: a”만 한 번 찍히고 끝납니다. cat이 b와 c를 다 읽어 버렸기 때문입니다. 여기서 cat 자리에 ssh나 AI CLI를 넣으면 제가 겪은 상황과 정확히 같아집니다.
반대로 cat > /dev/null 대신 cat </dev/null > /dev/null로 바꾸면 a, b, c가 모두 찍힙니다. 이 한 줄 차이가 버그와 정상의 경계였습니다.
해법 세 가지 비교
제가 정리한 해법은 세 가지입니다. 상황에 따라 고르면 됩니다.
1) 명령에 </dev/null 붙이기 (가장 단순)
while read line; do
ssh some-host "do-something $line" </dev/null
done < queue.txt
문제를 일으키는 명령의 stdin을 빈 입력으로 막아 버리는 방식입니다. 루프 안에서 입력을 삼키는 명령이 하나뿐이면 이게 제일 명확합니다. 제가 실제로 쓴 방법도 이것이었습니다.
2) ssh -n 쓰기 (ssh 전용)
while read line; do
ssh -n some-host "do-something $line"
done < queue.txt
ssh -n은 stdin을 /dev/null로 돌리는 전용 옵션입니다. 명령이 ssh라면 의도가 코드에 그대로 드러나서 좋습니다. 다만 ssh 전용이라 다른 명령에는 못 씁니다.
3) 루프 입력을 별도 FD로 격리 (exec 3< + read -u 3)
exec 3< queue.txt
while read -u 3 line; do
ssh some-host "do-something $line"
done
exec 3<&-
루프 입력을 파일 디스크립터 3에 실어 두고 read -u 3으로 읽습니다. 이러면 루프의 입력과 명령의 stdin(기본 0번)이 아예 분리되어, 본문에서 무슨 명령을 부르든 루프 입력이 안전합니다. 어떤 명령이 범인인지 특정하기 어렵거나, 여러 명령이 얽혀 있을 때 근본적으로 막는 방법입니다.
진단 요령과 제 실측
진단 기준은 하나로 요약됩니다. 루프가 꼭 한 바퀴만 돌고 에러 없이 정상 종료하면 stdin을 의심하라는 것입니다. 이 조합은 다른 원인으로는 잘 나오지 않는 독특한 지문이라서, 알고 나면 다음부터는 몇 초 만에 알아챌 수 있습니다.
저는 cron으로 도는 워커가 codex를 호출하는 부분에서 </dev/null이 빠져 있었습니다. 큐를 쌓아도 매번 한 건만 처리되던 게 이 한 줄 때문이었고, 명령 뒤에 </dev/null을 붙이자 그날로 끝났습니다. 코드 변경량으로 보면 한 글자대 수정입니다. 원인을 몰랐을 때는 반나절을 헤맸는데, 알고 나니 허무할 만큼 작았습니다.
이런 버그는 대개 실력 문제가 아니라 “셸의 stdin이 어디까지 공유되는가”라는 모델을 안 갖고 있어서 생깁니다. 그래서 저는 이제 루프 안에서 외부 명령을, 특히 ssh나 CLI 도구를 부를 일이 있으면 반사적으로 stdin을 어떻게 처리할지부터 정합니다. 삼킬 만한 명령이 하나면 </dev/null, 여럿이면 exec 3<. 판단 기준은 그 정도로 충분했습니다.
자주 묻는 질문
while read 루프가 왜 첫 번째 항목만 처리하고 끝나나요?
루프 안에서 실행한 명령이 stdin을 상속받아 큐의 나머지 줄을 통째로 읽어버리기 때문입니다. read는 첫 줄만 가져가고, 다음 반복에서 읽을 입력이 남아 있지 않아 루프가 정상 종료합니다. 에러 없이 조용히 한 건만 도는 것이 특징입니다.
ssh나 CLI 도구가 stdin을 삼키지 않게 하려면 어떻게 하나요?
가장 간단한 방법은 명령 뒤에 </dev/null을 붙여 stdin을 빈 입력으로 막는 것입니다. ssh라면 전용 옵션인 ssh -n을 쓸 수 있고, 여러 명령에 반복된다면 루프 입력 자체를 exec 3< 파일과 read -u 3으로 별도 파일 디스크립터에 실어 격리하는 방법도 있습니다.
루프가 정확히 한 바퀴만 도는 버그는 어디부터 의심해야 하나요?
에러가 전혀 없이 딱 한 건만 처리하고 정상 종료한다면 stdin 잠식을 먼저 의심하세요. 루프 본문에서 ssh, 대화형 CLI, AI CLI처럼 표준 입력을 읽을 수 있는 명령을 호출하는지 확인하고, 그 명령에 </dev/null을 붙여 증상이 사라지는지 보면 진단이 끝납니다.
</dev/null과 exec 3< 방식 중 무엇을 써야 하나요?
루프 안에서 stdin을 삼키는 명령이 하나뿐이면 그 명령에 </dev/null을 붙이는 게 가장 단순하고 명확합니다. 여러 명령이 얽혀 있거나 어떤 명령이 범인인지 특정하기 어렵다면, 루프 입력을 exec 3<로 파일 디스크립터 3에 실어 두고 read -u 3으로 읽어 아예 잠식 가능성을 차단하는 편이 안전합니다.