Bash 변수 확장 문제를 효과적으로 해결하는 방법
Bash 스크립트는 미묘한 변수 확장 오류로 인해 자주 실패합니다. 이 종합 가이드는 잘못된 인용, 초기화되지 않은 값 처리, 서브쉘 및 함수 내 변수 범위 관리와 같은 일반적인 문제를 분석합니다. 필수 디버깅 기술(`set -u`, `set -x`)을 배우고 강력한 매개변수 확장 수정자(`${VAR:-default}` 등)를 마스터하여 견고하고 예측 가능하며 오류 없는 자동화 스크립트를 작성하세요. 신비로운 빈 문자열 디버깅을 중단하고 자신 있게 스크립팅을 시작하세요.
Bash 변수 확장 문제를 효과적으로 해결하는 방법
Bash 변수 확장 버그는 종종 무작위 동작처럼 보입니다: 공백이 있는 경로가 두 개의 경로가 되고, 파일 이름의 와일드카드가 디렉토리의 절반으로 확장되고, 루프 내부에서 설정된 변수가 사라지거나, 누락된 환경 변수가 조용히 빈 문자열로 변합니다. 쉘은 무작위가 아닙니다. 스크립트가 수행해야 하는 작업에 집중할 때 잊기 쉬운 확장 규칙을 따르고 있는 것입니다.
유용한 정신 모델은 이것입니다: Bash는 단순히 $name을 텍스트로 대체하고 명령을 실행하지 않습니다. 변수를 확장하고, 결과를 단어로 분할하고, 글로브를 확장한 다음, 결과 인수 목록으로 명령을 실행합니다. 대부분의 수정은 이러한 단계를 제어하는 데서 비롯됩니다.
설정되지 않은 변수는 막지 않으면 빈 값이 됩니다
기본적으로 이 스크립트는 빈 값을 출력하고 계속 진행합니다:
printf 'Deploying %s\n' "$APP_VERSION"
APP_VERSION이 필수였다면 버그입니다. 변수가 필수일 때 매개변수 확장을 사용하세요:
: "${APP_VERSION:?APP_VERSION must be set}"
printf 'Deploying %s\n' "$APP_VERSION"
앞의 :은 no-op 명령입니다. 확장이 검사를 수행합니다. 변수가 설정되지 않았거나 비어 있으면 Bash는 메시지를 출력하고 비대화형 쉘에서 종료됩니다.
선택적 값의 경우 기본값을 명확하게 만드세요:
log_level=${LOG_LEVEL:-INFO}
retry_count=${RETRY_COUNT:-3}
콜론이 중요합니다. ${VAR:-default}는 VAR이 설정되지 않았거나 비어 있을 때 기본값을 사용합니다. ${VAR-default}는 VAR이 설정되지 않았을 때만 기본값을 사용합니다. 빈 문자열이 유효한 구성 값인 경우 이 차이가 중요합니다.
set -u는 설정되지 않은 변수를 잡을 수도 있습니다:
set -u
많은 스크립트에서 유용하지만 명확한 검증을 대체하지는 않습니다. 선택적 위치 매개변수, 배열 또는 존재 여부를 의도적으로 확인하는 변수로 작업할 때 놀라움을 줄 수도 있습니다. 인수가 없을 수 있을 때 ${1:-}를 사용하세요:
mode=${1:-help}
분할과 글로빙을 원하지 않으면 변수를 인용하세요
이것은 가장 일반적인 확장 문제입니다:
file="Quarterly Report *.txt"
rm $file
인용되지 않으면 Bash는 먼저 $file을 확장한 다음 공백으로 분할하고 *를 와일드카드로 처리합니다. 명령은 의도하지 않은 여러 인수를 받을 수 있습니다. 인용하면 정확히 하나의 인수를 받습니다:
rm -- "$file"
--는 대시로 시작하는 값으로부터 명령을 보호합니다. -rf와 같은 파일 이름에 중요합니다.
변수, 명령 대체 및 대부분의 매개변수 확장에 큰따옴표를 사용하세요:
cp "$source_file" "$destination_dir/"
printf 'User: %s\n' "$user_name"
작은따옴표는 다릅니다. 확장을 완전히 방지합니다:
printf 'Home is $HOME\n' # 리터럴 텍스트 출력
printf "Home is $HOME\n" # 값 출력
'prefix-$value'와 같은 문자열을 만드는 스크립트를 본다면 버그일 가능성이 높습니다. 값이 확장되어야 할 때 큰따옴표를 사용하세요.
배열은 많은 인수 빌드 문제를 해결합니다
많은 깨진 Bash는 여러 명령 옵션을 하나의 문자열에 저장하는 데서 비롯됩니다:
opts="-a --delete --exclude *.tmp"
rsync $opts "$src/" "$dest/"
이는 단어 분할에 의존하며 옵션 인수에 공백이 포함되면 깨질 수 있습니다. 배열을 사용하세요:
opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$src/" "$dest/"
"${opts[@]}"는 각 배열 요소를 자체 인수로 확장합니다. 이것이 대부분의 명령 구성에 필요한 것입니다.
파일 이름을 수집할 때도 동일하게 적용됩니다:
files=("$report_dir"/*.txt)
for file in "${files[@]}"; do
[[ -e $file ]] || continue
process_report "$file"
done
[[ -e $file ]] || continue 가드는 일치하는 파일이 없고 글로브가 리터럴로 남아 있는 경우를 처리합니다(쉘 옵션에 따라 다름).
명령 대체는 후행 개행 문자를 제거합니다
$(command)는 stdout을 캡처하지만 Bash는 후행 개행 문자를 제거합니다. 이는 일반적으로 버전 문자열에는 괜찮지만 최종 개행이 중요한 데이터에는 잘못됩니다.
version=$(git describe --tags --always)
printf 'Version: %s\n' "$version"
라인 지향 출력의 경우 배열이 필요할 때 mapfile을 선호하세요:
mapfile -t names < <(find "$base_dir" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for name in "${names[@]}"; do
printf 'log=%s\n' "$name"
done
for item in $(ls)는 피하세요. 공백, 글로브 문자 및 특이한 파일 이름에서 깨집니다. 글로브를 반복하거나 신중한 구분 기호와 함께 find를 사용하세요.
파이프라인의 변수는 서브쉘에 있을 수 있습니다
루프가 올바르게 실행되는 것처럼 보이기 때문에 사람들을 잡는 경우입니다:
count=0
printf '%s\n' a b c | while IFS= read -r line; do
count=$((count + 1))
done
printf 'count=%s\n' "$count"
많은 Bash 구성에서 파이프라인의 while 루프는 서브쉘에서 실행됩니다. 증가는 발생하지만 부모 쉘의 count는 변경되지 않습니다.
대신 프로세스 대체를 사용하세요:
count=0
while IFS= read -r line; do
count=$((count + 1))
done < <(printf '%s\n' a b c)
printf 'count=%s\n' "$count"
또는 파이프라인이 필요한 값을 생성하고 해당 값을 직접 캡처하세요.
지역 변수는 우발적인 덮어쓰기를 방지합니다
Bash 함수의 변수는 local로 선언되지 않으면 전역입니다. 이는 도우미 함수를 이상한 확장 버그의 원인으로 만들 수 있습니다:
env=prod
load_config() {
env=dev
}
load_config
printf '%s\n' "$env" # dev
임시 값에는 local을 사용하세요:
load_config() {
local env=dev
printf 'loaded defaults for %s\n' "$env"
}
local은 Bash 기능입니다. Bash 스크립트에서는 괜찮지만 스크립트를 sh로 실행해서는 안 되는 또 다른 이유입니다.
이름이 다른 텍스트와 접촉할 때 중괄호를 사용하세요
$prefix_file은 $prefix 다음에 _file이 아닌 prefix_file이라는 변수를 의미합니다. 중괄호를 사용하여 경계를 명확히 하세요:
prefix=app
printf '%s\n' "${prefix}_file"
중괄호는 많은 매개변수 확장 작업에도 필요합니다:
path=/var/log/nginx/access.log
printf 'dir=%s\n' "${path%/*}"
printf 'file=%s\n' "${path##*/}"
${path%/*}는 가장 짧은 일치 접미사를 제거합니다. ${path##*/}는 가장 긴 일치 접두사를 제거합니다. 이는 유용하지만 dirname 또는 basename이 팀에게 스크립트를 더 명확하게 만들 때 과도하게 사용하지 마세요.
실제 인수를 출력하여 확장 디버깅
set -x는 확장 후 명령을 보여줍니다. 줄 번호로 추적을 개선하세요:
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $file $target_dir
set +x
추적은 명령이 mv Quarterly Report *.txt /tmp/out이 되었는지 아니면 mv 'Quarterly Report *.txt' /tmp/out이 되었는지 보여줍니다. xtrace를 비밀에서 멀리 유지하세요.
더 안전한 수동 확인을 위해 %q로 값을 출력하세요:
printf 'file=%q\n' "$file" >&2
printf 'target_dir=%q\n' "$target_dir" >&2
%q는 일반 echo보다 읽기 쉬운 방식으로 공백과 특수 문자를 표시합니다.
실용적인 체크리스트
Bash 변수가 잘못 확장될 때 순서대로 확인하세요:
- 스크립트가
sh가 아닌 Bash에서 실행되고 있습니까? - 변수가 실제로 설정되었습니까? 필수 값에는
${VAR:?message}를 사용하세요. - 분할이 의도적이지 않은 한 모든 확장이 인용되었습니까?
- 여러 인수에 배열을 사용하고 있습니까?
- 파이프라인이 루프를 서브쉘에 넣었습니까?
- 함수가
local누락으로 전역 변수를 덮어썼습니까? - 변수 이름을 주변 텍스트와 분리하기 위해 중괄호가 필요합니까?
이러한 확인은 가장 좋은 의미에서 지루합니다. 대부분의 확장 버그를 "Bash는 이상하다"에서 구체적이고 수정 가능한 규칙으로 바꿉니다.
간접 확장과 nameref는 추가 주의가 필요합니다
Bash는 다른 변수에 저장된 이름의 변수를 확장할 수 있습니다:
name=APP_ENV
printf '%s\n' "${!name}"
이는 APP_ENV의 값을 출력합니다. 강력하지만 스크립트를 읽기 어렵게 만들고 변수 이름이 사용자 입력에서 오는 경우 안전하지 않을 수 있습니다. 이름에서 값으로의 매핑만 필요한 경우 연관 배열이 더 명확합니다:
declare -A endpoints=(
[dev]='https://dev.example.test'
[prod]='https://api.example.com'
)
printf '%s\n' "${endpoints[$env]:?unknown environment}"
Bash는 또한 declare -n을 사용한 nameref를 가지고 있으며, 종종 도우미 함수에서 사용됩니다. 라이브러리 스타일 스크립트에서 유용하지만 놀라운 부작용을 만들 수 있습니다. 배열이나 변수를 참조로 전달하는 것이 실제로 코드를 단순화할 때만 사용하세요.
패턴 제거는 정규 표현식 일치가 아닙니다
${file%.log} 및 ${path##*/}와 같은 매개변수 확장 연산자는 정규 표현식이 아닌 쉘 패턴을 사용합니다. 이 차이가 중요합니다.
file='access.log'
printf '%s\n' "${file%.log}"
이는 .log 접미사를 제거합니다. "정규식과 일치하는 모든 것을 제거"를 의미하지 않습니다. 정규식 확인을 위해 [[ ... =~ ... ]]를 사용하세요:
if [[ $port =~ ^[0-9]+$ ]]; then
printf 'numeric\n'
fi
거기서도 조심스럽게 인용하세요. =~의 오른쪽은 일반적으로 정규식으로 처리되기를 원할 때 인용되지 않은 상태로 둡니다. 왼쪽 변수는 [[ ]] 내부에서 인용이 필요하지 않습니다. [[ ]]는 [ ]처럼 단어 분할을 수행하지 않기 때문입니다.
자식 프로세스가 필요한 것만 내보내기
Bash에서 변수를 설정한다고 해서 스크립트가 시작하는 명령에서 자동으로 사용 가능하지는 않습니다:
APP_ENV=prod
./run-app
run-app은 내보내거나 인라인으로 제공하지 않으면 APP_ENV를 볼 수 없습니다:
export APP_ENV=prod
./run-app
# 또는
APP_ENV=prod ./run-app
이는 스크립트가 올바른 값을 출력하지만 자식 프로세스가 값이 누락된 것처럼 동작할 때 일반적인 혼란의 원인입니다. 변수는 쉘에 존재합니다. 자식을 위해 환경에 배치되지 않았습니다.
반대도 마찬가지입니다: 자식 프로세스는 부모 쉘의 변수를 변경할 수 없습니다. 도우미 스크립트가 export TOKEN=...를 출력하면 정상적으로 실행해도 호출자를 업데이트하지 않습니다. 소스로 가져와야 하며, 소싱은 신뢰할 수 있는 쉘 코드에 예약되어야 합니다.
출시 전 실제 리뷰 패스
스크립트나 컨테이너 설정이 완료되었다고 부르기 전에, 다음 사람이 오전 2시에 디버깅해야 하는 사람인 것처럼 한 번 읽어보세요. 그러면 눈에 띄는 것이 달라집니다. 스크립트를 작성할 때 이해가 되었던 프롬프트가 CI 로그에 나타날 때 모호할 수 있습니다. 명백해 보였던 Docker 서비스 이름이 애플리케이션의 변수 이름과 일치하지 않을 수 있습니다. Bash 기본값이 개발에는 안전하고 프로덕션에는 위험할 수 있습니다.
의도적으로 어색한 값으로 짧은 드라이 런을 하는 것을 좋아합니다. 공백이 있는 경로를 사용하세요. 빈 선택적 값을 사용하세요. 대시로 시작하는 파일 이름을 시도하세요. 다른 작업 디렉토리에서 스크립트를 실행하세요. 예상된 환경 변수 없이 컨테이너를 시작하세요. 이러한 테스트는 화려하지 않지만 일반적으로 먼저 깨지는 가정을 잡습니다.
또한 실패 메시지를 확인하세요. 유일한 출력이 failed라면 기사의 조언이 구현에 반영되지 않은 것입니다. 유용한 실패는 어떤 값이 사용되었는지, 어떤 검사가 실패했는지, 운영자가 무엇을 변경할 수 있는지 알려줍니다. 이는 모든 환경 변수를 덤프하거나 비밀을 출력하는 것을 의미하지 않습니다. 구체성이 도움이 되는 곳에서 구체적이어야 함을 의미합니다: 구성 경로, 누락된 명령 이름, 네트워크 이름, 서비스 호스트 이름 또는 프로세스가 바인딩하려고 시도한 포트.
마지막 습관은 예제를 시스템이 실제로 실행되는 방식과 가깝게 유지하는 것입니다. 프로덕션이 Compose를 사용하면 Compose로 테스트하세요. 스크립트가 systemd에 의해 실행되면 systemd 또는 유사하게 최소한의 환경으로 테스트하세요. 명령이 복사하여 붙여넣기에 안전해야 하면 예제 자체에 인용, -- 구분 기호 및 검증을 포함하세요. 독자는 경고보다 작동하는 패턴을 더 자주 복사합니다.
그 리뷰 패스는 관료주의가 아닙니다. 작은 자동화가 지루하게 유지되는 방법입니다. 지루함은 쉘 프롬프트, 구성 로더, 변수 확장, 컨테이너 진단 및 Docker 네트워킹에서 원하는 것입니다. 동작이 덜 놀라울수록 다음 운영자가 신뢰하기 쉽습니다.
특히 변수 확장의 경우 해당 리뷰에 한 가지 습관을 더 추가하세요: 명령이 이상하게 동작할 때 인수 개수를 출력하세요. 작은 도우미가 보이지 않는 것을 보이게 만들 수 있습니다:
show_args() {
local i=1
for arg in "$@"; do
printf 'arg[%d]=%q\n' "$i" "$arg" >&2
i=$((i + 1))
done
}
show_args mv $file $target_dir
show_args mv "$file" "$target_dir"
첫 번째 호출은 깨진 명령이 받을 것을 보여줍니다; 두 번째는 수정된 버전을 보여줍니다. 인수 목록을 보면 인용 버그가 더 이상 신비롭게 느껴지지 않습니다.