셸 while read 루프가 한 건만 돌고 끝날 때 stdin을 막아 고치는 법
루프 안에서 부른 ssh나 CLI가 남은 입력을 읽어 버려서 생기는 버그입니다. 에러 없이 한 바퀴만 도는 증상으로 알아보는 방법과 </dev/null, ssh -n, 별도 파일 디스크립터 세 가지 해법을 정리했습니다.
cron으로 도는 워커가 큐에 일감을 몇 건 쌓아 두든 늘 한 건만 처리하고 끝났습니다. 에러는 한 줄도 없었고 종료 코드도 0이었습니다. 밤새 돌라고 둔 워커가 아침에 일감 하나만 끝내 둔 걸 보고서야 알았는데, 원인은 루프 안에서 codex를 부르는 줄에 </dev/null이 빠져 있던 것이었습니다. 그 리디렉션 하나를 붙이자 큐에 있던 일감이 전부 돌았습니다.
while read 루프 안에서 표준 입력(stdin)을 읽는 명령을 부르면 그 명령이 루프가 읽을 나머지 줄까지 가져가 버립니다. 그 명령에 </dev/null을 붙이면 고쳐집니다.
while read 루프가 한 건만 처리하는 증상
이 버그는 겉으로 드러나는 모양이 독특합니다.
- 에러 메시지가 없고 종료 코드가 0입니다.
- 큐에 몇 건을 넣든 정확히 1건만 처리됩니다.
- 루프가 중간에 죽은 게 아니라 끝까지 돌고 정상 종료한 것처럼 보입니다.
로그만 보면 할 일을 다 한 것처럼 보이니 처음에는 큐를 쌓는 쪽을 먼저 의심하기 쉬운데, 실제 원인은 루프 본문에서 부르는 명령에 있습니다. 반대로 말하면 루프가 꼭 한 바퀴만 돌고 에러 없이 끝나는 조합은 다른 원인으로는 잘 나오지 않아서, 이 모양을 보면 루프 안에서 부른 명령이 stdin을 읽는지부터 확인하면 됩니다.
원인: 루프 안의 명령이 stdin을 물려받아 다 읽는다
while read line; do
ssh some-host "do-something $line"
done < queue.txt
done < queue.txt는 루프 전체의 stdin을 queue.txt로 바꿉니다. read는 여기서 한 줄씩 꺼내 쓰는데, 루프 안의 ssh도 따로 지정하지 않으면 같은 stdin을 물려받습니다. ssh는 원격 명령에 입력을 넘겨주려고 stdin을 끝까지 읽습니다.
그래서 이런 순서로 흘러갑니다.
- 첫 바퀴에서
read가 첫 줄을 가져갑니다. ssh가 실행되면서queue.txt에 남은 줄을 전부 읽습니다.- 두 번째 바퀴에서
read가 다시 읽으려 하지만 남은 입력이 없습니다. read가 실패를 돌려주고 루프는 그냥 끝납니다. 에러가 아니라 “읽을 게 없어서” 끝난 것이라 아무것도 남지 않습니다.
ssh만 그런 게 아닙니다. 대화형 CLI, AI CLI(제 경우 codex)처럼 입력을 받을 수 있는 명령이면 무엇이든 같은 일을 할 수 있습니다. cmd | while read line 처럼 파이프로 넣는 경우도 루프의 stdin이 파이프라는 점만 다르고 똑같이 생깁니다.

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 </dev/null > /dev/null로 바꾸면 a, b, c가 모두 찍힙니다. 내 스크립트에서 어떤 명령이 범인인지 모를 때는 루프 본문의 명령에 하나씩 </dev/null을 붙여 보면서 이 차이가 나는지 보면 됩니다.
해법 1: 명령 뒤에 </dev/null 붙이기
while read line; do
ssh some-host "do-something $line" </dev/null
done < queue.txt
입력을 읽어 가는 명령의 stdin만 빈 입력으로 막습니다. 루프 안에 그런 명령이 하나뿐이면 이게 제일 간단하고, 제가 워커에 실제로 넣은 것도 이 방법입니다.
해법 2: ssh는 -n 옵션 쓰기
while read line; do
ssh -n some-host "do-something $line"
done < queue.txt
OpenSSH 매뉴얼은 -n을 “stdin을 /dev/null에서 읽도록 돌린다(실제로는 stdin 읽기를 막는다)“고 설명합니다(ssh(1) 매뉴얼). </dev/null과 하는 일은 같지만 코드만 보고도 의도가 읽힙니다. ssh 전용이라 다른 명령에는 쓸 수 없습니다. 같은 매뉴얼은 -n을 붙여 ssh를 백그라운드로 돌리는 경우, ssh가 비밀번호나 패스프레이즈를 물어야 하면 동작하지 않는다고 적어 둡니다(2026년 10월 확인). cron처럼 사람이 없는 곳에서 돌린다면 키 인증이 되어 있는지 먼저 확인해 두는 편이 낫습니다.
해법 3: 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으로 그 3번에서만 읽습니다. 루프 안의 명령은 기본 stdin(0번)을 쓰니 루프 입력에는 손을 못 댑니다. 루프 안에서 부르는 명령이 여러 개이거나 어느 명령이 입력을 읽는지 하나하나 확인하기 어려울 때 이 방법이 확실합니다. 마지막 exec 3<&-는 3번을 닫는 줄입니다.
read -u는 bash와 zsh에 있는 옵션이고 POSIX sh(예: dash)에는 없습니다. #!/bin/sh로 도는 스크립트라면 read line <&3처럼 리디렉션으로 3번을 지정하면 같은 효과가 납니다.
exec 3< queue.txt
while read line <&3; do
ssh some-host "do-something $line"
done
exec 3<&-
세 가지 해법 비교
| 방법 | 쓰는 곳 | 주의할 점 |
|---|---|---|
</dev/null | 범인 명령이 하나일 때 | 명령마다 붙여야 함 |
ssh -n | ssh 한정 | 다른 명령엔 못 씀 |
exec 3< | 명령이 여럿일 때 | read -u는 bash·zsh 전용 |
cron 워커에서 확인할 것
cron이 스크립트를 실행할 때 붙는 stdin과, 스크립트 안에서 done < queue.txt로 루프에 붙인 stdin은 별개입니다. cron으로 돌린다고 이 버그가 피해지지 않습니다. 오히려 사람이 터미널에서 지켜보지 않으니 아침에 결과를 볼 때까지 모르고 지나가기 쉽습니다. 제 경우가 그랬습니다.
그래서 루프로 큐를 처리하는 워커라면 다음을 남겨 두면 좋습니다.
- 루프가 끝날 때 처리한 건수를 로그에 남깁니다. 큐에 넣은 건수와 처리한 건수가 다르면 바로 보입니다.
- 루프 본문에서
ssh, AI CLI, 대화형 도구를 부르는 줄에는 처음부터</dev/null을 붙여 둡니다.
에러 없이 조용히 덜 도는 자동화를 찾는 방법은 조용한 실패가 제일 비싸다에 따로 정리했습니다. ssh로 원격 명령을 던질 때 생기는 다른 함정은 SSH로 명령을 던지면 command not found가 나는 이유에 있습니다.
자주 묻는 질문
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으로 읽어 아예 잠식 가능성을 차단하는 편이 안전합니다.