Loop With Globs, Return With Exit Codes
한국어 원문으로 표시합니다.
한 줄 요약
셸 함수는 값을 반환하지 않는다. 종료 코드로 참·거짓을 알리고, 값은 표준출력으로 내보낸다. 그리고 파일 목록을 얻으려고 ls 를 파싱하면 언젠가 반드시 깨진다.
왜 이게 필요했나
for f in $(ls *.log) 는 배우기 쉽고, 그래서 많이 쓰인다. 그런데 이 한 줄에는 함정이 세 개 겹쳐 있다.
ls의 출력을 명령 치환으로 받으면 공백 기준으로 단어 분리된다.access 2026.log는 두 개가 된다.- 매칭되는 파일이 하나도 없으면
ls가 오류를 내고, 그 오류 메시지가 루프의 입력이 된다. - 파일 이름에 개행이나 글로브 문자가 들어 있으면 예측할 수 없게 동작한다.
정답은 셸에게 직접 시키는 것이다.
for f in /var/log/*.log; do
[ -f "$f" ] || continue
...
done
여기서 [ -f "$f" ] || continue 가 핵심이다. 매칭되는 파일이 없으면 글로브 패턴이 그대로 문자열로 남기 때문에 이 방어선이 없으면 /var/log/*.log 라는 존재하지 않는 파일을 처리하려 든다. 실제 실습에서도 이 함정이 나온다.
어떻게 동작하나
반복 세 가지 형태를 상황에 맞게 쓴다.
for x in a b c- 정해진 목록for f in 경로/*.log- 파일 순회. 셸이 직접 확장한다while IFS=, read -r name role; do ... done < users.csv- 줄 단위 입력
세 번째 형태에서 IFS=, 를 read 앞에 붙이면 그 명령에만 구분자가 적용된다. -r 은 백슬래시를 이스케이프로 해석하지 말라는 뜻으로, 사실상 항상 붙인다.
함수의 인터페이스는 다음 세 가지로 이루어진다.
| 무엇 | 어디로 |
|---|---|
| 계산한 값 | 표준출력 (echo) |
| 성공/실패, 참/거짓 | 종료 코드 (return 0 / return 1) |
| 사람에게 보낼 메시지 | 표준에러 |
is_number 123 같은 판정 함수가 결과를 echo yes 로 출력하면 if is_number "$x" 처럼 쓸 수 없다. 판정은 반드시 종료 코드로 알린다.
인자 다루기에서 가장 중요한 것은 "$@" 와 $* 의 차이다. "$@" 는 인자 하나하나를 각각 따옴표로 감싼 것처럼 펼치고, $* 는 전부 하나의 문자열로 합친다. 명령을 그대로 다시 실행하는 재시도 함수 같은 곳에서는 반드시 "$@" 여야 한다.
함수 안의 변수는 기본적으로 전역이다. local 을 붙이지 않으면 호출한 쪽의 같은 이름 변수를 조용히 덮어쓴다. 함수 안에서 만든 변수는 예외 없이 local 로 선언하는 습관을 들이는 게 좋다.
현장에서 만나는 모습
라이브러리 분리. 여러 스크립트가 같은 함수를 복붙하고 있으면 고칠 곳이 여러 곳이 된다. lib.sh 에 함수를 모으고 . /root/bin/lib.sh 로 불러오면 한 곳만 고치면 된다. source 와 . 는 같은 명령이고, 새 프로세스를 만들지 않고 현재 셸에서 실행하기 때문에 함수 정의가 남는다.
루프 안의 외부 명령이 성능을 갉아먹는다. 1000줄을 도는 루프 안에서 $(echo "$v" | sed ...) 를 부르면 프로세스가 2000개 생긴다. 같은 일을 ${v//old/new} 로 하면 프로세스가 0개다. 셸 스크립트가 느리다는 인상의 절반은 여기서 온다.
병렬화가 필요할 때는 xargs -P 나 & + wait 를 쓴다. 다만 병렬로 돌린 작업의 실패를 놓치지 않으려면 각 PID 를 모아 두었다가 wait "$pid" 로 하나씩 상태를 확인해야 한다.
스크립트가 조용히 틀리는 자리
셸은 오류를 만나도 다음 줄로 넘어간다. 그래서 스크립트가 성공한 것처럼 끝나면서 아무것도 안 한 상태가 흔하다. 이것을 막는 장치가 몇 가지 있고, 각각 무엇을 막는지 알고 써야 한다.
set -e 는 명령이 실패하면 멈춘다. 다만 예외가 많다. 조건문 안, && 나
|| 의 왼쪽, ! 뒤에서는 실패해도 멈추지 않는다. 그리고 함수 안에서 실패한
것이 조건문의 일부로 불리면 그 함수 전체가 멈추지 않는다. 그래서 set -e
하나에 기대면 안 되고, 중요한 자리에는 명시적으로 확인을 붙인다.
set -u 는 정의되지 않은 변수를 쓰면 멈춘다. 오타 하나로 빈 문자열이 되어
rm -rf "$PREFX/" 가 rm -rf / 가 되는 사고를 막는다. 다만 인자가 없을 수도
있는 자리에서는 ${1:-} 처럼 기본값을 적어 주어야 한다.
set -o pipefail 은 파이프라인의 종료 코드를 실패한 것 중 마지막으로
바꾼다. 이것이 없으면 curl ... | jq ... 에서 curl 이 실패해도 jq 가 성공하면
전체가 성공이다. 앞 코스에서 본 그 함정이다.
세 개를 함께 켜는 것이 관례이지만, 켠 뒤에 한 번 돌려 보고 의도치 않게
멈추는 자리가 없는지 확인해야 한다. 특히 set -u 는 기존 스크립트에 넣으면
평소에 안 쓰이던 분기에서 갑자기 죽는다.
그리고 마지막에 정리를 보장한다. trap 'rm -rf "$TMP"' EXIT 처럼 걸어
두면 중간에 실패해도 임시 파일이 남지 않는다. 실패했을 때만 남기고 싶다면
성공 경로에서 명시적으로 지우는 편이 낫고, 어느 쪽이든 디버깅을 위해 남길
것과 지울 것을 정해 두는 것이 나중에 디스크를 채우지 않는 방법이다.
다음 실습에서 할 것
숫자 출력 루프에서 시작해 글로브 순회, CSV 읽기, 함수 분리와 source, 종료 코드 기반 판정, 공백이 든 인자 안전 처리, 가변 인자 합산을 거쳐, 마지막에는 디렉터리를 받아 요약을 내는 스크립트를 만든다.