macOS에서 mapfile: command not found가 뜰 때: 스크립트가 오류 뒤에도 빈 배열로 계속 도는 경우까지 bash 3.2에서 직접 시험한 결과
리눅스나 새 bash에서 쓰던 mapfile -t 스크립트를 macOS에서 돌려 command not found를 만났거나 오류 없이 결과가 빈 독자가 원인을 가려내고, 매뉴얼 설명과 같은 줄 단위 배열을 만드는 대체를 고르게 돕는다(새 bash의 mapfile은 못 돌려 봄).
이 맥의 /bin/bash 3.2.57에는 mapfile과 readarray가 없고, set -e·set -u 가 없고 뒤에 다른 명령이 이어지면 스크립트는 오류 한 줄 뒤에도 빈 배열로 계속 돈다. while IFS= read -r 와 += 는 매뉴얼 설명과 같은 줄 단위 배열을 만들지만 파이프, 줄바꿈 없는 마지막 줄, 입력 명령의 실패는 따로 막아야 한다.
스크립트 맨 위에 mapfile 유무를 확인하는 가드를 넣고, mapfile 줄을 while read 로 바꾼 뒤 빈 줄과 공백이 있는 목록으로 항목 수를 비교한다.
목차
한 줄 요약: 이 맥(26.6.2)의 bash 3.2.57에는 mapfile 이 없어, -e·-u 없이 뒤에 명령이 이어지면 오류 한 줄 뒤 빈 배열로 달리므로 맨 위에서 막거나 while read 로 바꾼다.
리눅스나 새 bash에서 잘 돌던 mapfile -t files < <(명령) 을 맥에서 돌리면 아래 한 줄이 나온다. 이 글은 무엇으로 바꾸면 되는지, 그리고 이 오류가 스크립트를 멈추지 않고 “0개를 찾았다”로 이어지는 경우와, 흔한 대체가 같은 배열을 만들지 않는 경우를 이 맥에서 직접 만들어 보인다.
bash: mapfile: command not found
스크립트 파일로 돌리면 앞의 bash: 자리가 스크립트이름: line 2: 꼴로, 실행 경로로 부르면 /bin/bash: 로 바뀐다.
같은 문구로 맥 사용자가 묻는 글이 공개 질문 사이트에 있다. 다만 본문 출력이 2020년 2월에 설치한 zsh 5.8(darwin18)이어서 최근 맥의 사례는 아니고, 질문의 초점은 PATH 에 있다. 일본어 Q&A 사이트 teratail 의 질문은 zsh 스크립트에서 mapfile 이 안 되고, Homebrew 로 bash 를 설치했는데도 readarray 가 안 된다고 적는다. 제목은 zsh 쪽 문구인 command not found: mapfile 이다. 확인한 환경은 macOS 26.6.2 arm64 의 /bin/bash 3.2.57, zsh 5.9, 시간대 Asia/Seoul(KST)이고, 이 맥에는 Homebrew 의 새 bash 가 없다.
용어 몇 개. 배열은 값을 번호 붙여 여러 개 담는 변수다. 종료 코드는 0이 정상이고 그 밖은 실패다. 표준 출력은 명령의 결과가 나가는 통로이고, 표준 오류는 오류 메시지가 나가는 별도의 통로이며, $(...) 는 표준 출력만 변수에 담는다. < <(명령) 은 명령의 출력을 파일처럼 읽게 하는 프로세스 치환이다. 하위 셸은 본체와 따로 도는 별도의 셸로, 예를 들어 파이프 뒤쪽이 그렇고 거기서 바꾼 변수는 밖에 남지 않는다. 글롭은 *.txt 처럼 파일 이름으로 풀리는 패턴이고, NUL 은 값이 0인 글자로 줄바꿈이 든 파일 이름의 구분자로 쓴다. IFS 는 셸이 단어를 쪼개는 기준 글자를 담은 변수다.
이 맥의 bash 에는 mapfile 이 없다
GNU bash 매뉴얼은 mapfile 을 표준 입력의 줄을 인덱스 배열에 읽어 넣는 내장 명령으로 설명하고 readarray 를 같은 뜻의 별칭이라 적는다. bash 의 변경 이력(NEWS)은 4.0 절에 새 mapfile 내장 명령이 생겼고 readarray 가 동의어라고 적고, 구분 문자를 바꾸는 -d 옵션은 4.4 절에 있다. 그래서 4.0 보다 오래된 bash 에는 두 이름이 모두 없다. 이 맥의 /bin/bash 는 3.2.57 이고 type mapfile readarray 는 둘 다 not found 였다. 애플은 macOS 10.15 부터 새 계정의 기본 셸을 zsh 로 바꿨다고 안내하지만, #!/bin/bash 로 시작하는 스크립트는 항상 /bin/bash 를 쓰고, #!/usr/bin/env bash 는 PATH 에서 찾은 bash 를 쓰는데 이 맥에서는 PATH 에 다른 bash 가 없어 같은 /bin/bash 였다. 이 맥에서 env bash 도 /bin/bash 3.2.57 을 골랐다.
실패 모습은 셸마다 문구가 다르다. 같은 mapfile -t a < /dev/null 을 셸 -c 로 돌린 결과다.
| 실행한 셸 | 출력 | 종료 코드 |
|---|---|---|
/bin/bash 3.2.57 |
/bin/bash: mapfile: command not found |
127 |
/bin/zsh 5.9 |
zsh:1: command not found: mapfile |
127 |
/bin/dash |
/bin/dash: 1: mapfile: not found |
127 |
/bin/ksh |
/bin/ksh: mapfile: not found |
127 |
문제는 오류 자체보다 그 뒤다. 아래 스크립트는 mapfile 줄이 실패해도 멈추지 않는다.
#!/bin/bash
mapfile -t files < <(printf 'one.txt\ntwo.txt\n')
echo "found ${#files[@]} files"
for f in "${files[@]}"; do echo "processing $f"; done
echo "done"
돌리면 mapfile: command not found 가 표준 오류에 한 줄 찍힌 다음 found 0 files, done 이 나오고 종료 코드는 0이다. 이것은 set -e 도 set -u 도 없고 mapfile 줄 뒤에 다른 명령이 이어질 때의 모습이다. 배포나 백업 스크립트라면 “처리할 파일이 없음”으로 정상 종료한 것처럼 보인다. bash -e 로 돌리면 첫 오류에서 종료 코드 127로 끝난다. 반대로 set -u 를 건 스크립트는 mapfile 이 실패해 배열이 아예 만들어지지 않으므로 ${#files[@]} 줄에서 files: unbound variable 로 종료 코드 1로 죽는다. 개수를 세지 않고 "${files[@]}" 를 펼치면 메시지는 files[@]: unbound variable 이다. 이 메시지는 mapfile 이 아니라 3.2 가 값이 없는 배열을 펼칠 때 내는 것이라 원인을 오해하기 쉽다. 뒤쪽 절에서 선언한 빈 배열 z=() 를 펼칠 때 같은 꼴의 메시지가 나는 것을 보인다.
가장 가까운 대체와 그 함정 셋
mapfile -t 는 줄 끝의 줄바꿈을 떼고 한 줄을 배열 한 칸에 담는다. 3.2 에서 같은 일을 하는 가장 단순한 형태는 이렇다. 새 bash 의 mapfile 을 돌려 비교한 것은 아니고 매뉴얼 설명과 맞춘 것이다.
a=()
while IFS= read -r line; do a+=("$line"); done < list.txt
시험 파일은 5줄이다. alpha, beta gamma, 빈 줄, 앞에 공백 두 칸이 있는 indented, 글롭 문자가 든 *.txt. 결과는 5칸이고 칸마다 줄 그대로였다. 빈 줄은 빈 칸이 됐고 앞 공백도 남았고 *.txt 도 파일 이름으로 풀리지 않았다. 이 모양이 mapfile -t 가 만들 배열과 같은 것으로 매뉴얼 설명(줄 단위, 끝 줄바꿈 제거)과 맞지만, 새 bash 에서 mapfile 자체를 돌려 비교하지는 못했다.
이 대체가 틀어지는 경우를 셋 만들었다.
- 파이프에 물리면 배열이 비는 것처럼 보인다.
cat list.txt | while …; do a+=…; done은 루프가 하위 셸에서 돌아 밖의a가 0칸이었다. 같은 루프를done < <(cat list.txt)로 바꾸면 4칸이 담겼다. 이 시험의 목록은 글롭 줄이 없는 4줄이었다. - 마지막 줄에 줄바꿈이 없으면 그 줄이 빠진다.
p,q두 줄이고 끝에 줄바꿈이 없는 파일을 위 루프로 읽으면 1칸(p)뿐이었다.while IFS= read -r line || [ -n "$line" ]로 바꾸면 2칸(p,q)이 됐다. IFS=와-r를 빼면 내용이 변한다. 앞 공백 두 칸과 역슬래시가 든 한 줄을read x로 읽으면 공백이 사라지고\t의 역슬래시가 없어져leadtab이 됐고,IFS= read -r y는lead\tab그대로였다.
윈도우에서 온 목록의 줄 끝 \r 도 이 루프에서는 칸에 남았다. a, b 두 줄이 \r\n 으로 끝나면 첫 칸의 길이가 2였고, l=${l%$'\r'} 로 떼면 1이었다.
다른 흔한 대체는 같은 배열이 아니다
한 줄로 끝나 보이는 다른 대체도 떠올리기 쉽다. 같은 5줄 목록(디렉터리에 list.txt, zzz.txt 가 있는 상태)으로 세 가지를 견줬다.
| 방법 | 칸 수 | 달라진 점 |
|---|---|---|
while IFS= read -r + += |
5 | 줄 그대로 |
IFS=$'\n'; b=($(cat list.txt)) |
5 | 빈 줄이 사라지고 *.txt 가 list.txt, zzz.txt 두 칸으로 풀림 |
IFS=$'\n' read -d '' -r -a c < list.txt |
4 | 빈 줄이 사라지고 종료 코드 1 |
d=($(cat list.txt)) |
6 | 공백마다 쪼개지고 글롭도 풀림 |
둘째 줄은 칸 수가 5로 같아서 더 위험하다. 빈 줄 한 칸이 빠지고 *.txt 한 칸이 두 칸으로 풀려 우연히 합이 맞았을 뿐 내용은 다르다. 셋째 줄은 값이 담겼는데도 read 의 종료 코드가 1이었다(입력 끝에서 구분 문자를 못 찾은 경우로 보이며, set -e 아래의 동작은 시험하지 않았다). 빈 줄이 중요한 목록(설정 파일의 구분 줄 등)이나 줄바꿈이 없는 파일 이름 목록에는 첫째 줄만 같은 배열을 만들었다.
입력을 만드는 명령이 실패해도 안 보인다
while … done < <(명령) 은 파이프의 하위 셸 문제를 피하지만, 입력을 만드는 명령의 종료 코드를 잃는다. ls /없는_폴더 에 해당하는 실패 명령을 넣고 루프를 돌리면 배열이 0칸이고 $? 도 0이었다. 임시 파일로 받아 if 명령 > "$tmp"; then 으로 감싸면 종료 코드 1을 잡을 수 있었다. 목록이 비어도 정상인 스크립트라면 이 둘을 구분해야 한다.
파일 이름처럼 줄바꿈이 들어갈 수 있는 값에는 NUL 구분이 대응한다. mapfile -d '' 에 해당하는 3.2 의 형태는 while IFS= read -r -d '' l; do f+=("$l"); done < <(find … -print0 | sort -z) 였고, a b, c, 줄바꿈이 든 이름, 공백 둘이 든 이름까지 4칸이 그대로 담겼다. 입력의 마지막 항목 뒤에 NUL 이 없으면 줄바꿈 때와 같은 문제가 생겨 || [ -n "$l" ] 를 붙여야 p, q 2칸이 됐다.
마지막으로 3.2 의 빈 배열이다. set -u 아래에서 선언한 빈 배열 z=() 의 ${#z[@]} 는 0을 냈지만 "${z[@]}" 를 펼치는 순간 z[@]: unbound variable 로 죽었다. ${z[@]+"${z[@]}"} 로 감싸면 빈 문자열로 종료 코드 0이었다. 앞 절의 set -u 오류와 같은 꼴의 메시지이니, 이 문구를 보면 mapfile 부재와 빈 배열 펼침을 따로 의심해야 한다. 선언하지 않은 배열은 ${#w[@]} 도 w: unbound variable 로 죽었다.
먼저 멈추게 하거나 함수로 감싸기
대체보다 먼저, 새 bash 가 필요한 스크립트라면 맨 위에서 막는 편이 낫다. 아래 가드를 넣은 스크립트는 이 맥에서 메시지와 함께 종료 코드 1로 끝났다.
if ! type mapfile >/dev/null 2>&1; then
echo "이 스크립트는 mapfile 이 있는 bash 4 이상이 필요합니다 (지금 $BASH_VERSION)" >&2
exit 1
fi
[ "${BASH_VERSINFO[0]}" -lt 4 ] 로 버전 숫자를 보는 형태도 bash 4+ 필요: 3.2 를 찍고 종료 코드 1로 끝났다. 어느 쪽이든 mapfile 줄 앞에 있어야 한다.
새 bash 를 요구하고 싶지 않으면 위의 안전한 루프를 함수로 감싼다. 배열 이름을 받는 3.2 방식이고, 이름은 호출하는 쪽이 정하는 신뢰할 수 있는 값이라는 전제로 eval 을 쓴다.
read_lines() { # $1=배열 이름, 표준 입력=줄
local _n=$1 _l; eval "$_n=()"
while IFS= read -r _l || [ -n "$_l" ]; do eval "$_n+=(\"\$_l\")"; done
}
read_lines files < list.txt
5줄 목록은 5칸으로, 끝 줄바꿈 없는 p, q 는 2칸으로 담겼다. 앞 절의 파이프 함정은 여기에도 그대로여서 cat list.txt | read_lines pp 는 밖에서 0칸이었고, read_lines pp < list.txt 형태로 불러야 했다.
시험하지 못한 것
mapfile 자체가 도는 bash 4 이상은 이 맥에 없어서 돌려 보지 못했다. 위에서 “같은 배열”이라고 한 것은 매뉴얼의 설명과 3.2 에서의 결과를 견준 것이다. Homebrew bash 를 설치해 PATH 앞에 두는 방법, 리눅스, BusyBox, dash·ksh 의 배열 문법은 시험하지 않았다. zsh 는 한 가지만 확인했다. zmodload zsh/mapfile 로 불러온 mapfile 은 명령이 아니라 파일 내용을 담은 연상 배열(zsh 문서의 설명이고 이 글은 확인하지 않았다)이어서 ${(f)mapfile[list.txt]} 로 줄을 나눴을 때 2줄 파일이 3칸이었고(끝 줄바꿈 뒤의 빈 원소 때문으로 보이며 확인은 하지 않았다), ${(@f)$(<list.txt)} 는 2칸이었다. 지금은 맥의 기본 셸이 zsh 인 계정이 많지만 #!/bin/bash 로 시작하는 스크립트는 그대로 3.2 가 읽는다.
세 줄 요약
- 이 맥의 /bin/bash 3.2.57 에는 mapfile 과 readarray 가 없고, set -e·set -u 가 없고 뒤에 다른 명령이 이어지면 오류 한 줄 뒤 종료 코드 0까지 달린다.
- while IFS= read -r 와 += 는 5줄 목록에서 줄 그대로 5칸을 만들었지만(새 bash 의 mapfile 은 못 돌려 봄), 파이프·줄바꿈 없는 마지막 줄·실패한 입력 명령은 따로 막아야 한다.
- IFS=$‘\n’ 한 줄 대체는 빈 줄을 지우거나 글롭을 풀어 다른 배열을 만들므로, 새 bash 가 필요하면 맨 위에 type mapfile 가드를 둔다.