쉘 스크립트에서 $1, $2를 순서대로 읽는 방식은 옵션이 두세 개만 넘어가도 순서를 착각하기 쉽고, 필수 옵션 누락 같은 실수를 걸러낼 방법이 없다. 공백으로 이어붙인 문자열을 IFS로 잘라 쓰는 방식도 값 안에 공백이 섞이면 그 순간 깨진다. Bash는 이 두 문제를 각각 배열(array)과 getopts 내장 도구로 해결한다. 이 글에서는 인덱스/연관배열의 실제 동작과 ${arr[@]}/${arr[*]}의 word-splitting 차이, getopts로 옵션을 파싱하는 실전 패턴을 실행 결과와 함께 정리한다.
핵심 개념
Bash 배열은 인덱스 배열(indexed array)과 연관배열(associative array) 두 종류가 있다. 인덱스는 declare -a 없이 arr=(...)만으로도 생성되지만, 연관배열은 declare -A로 반드시 먼저 선언해야 한다.
| 문법 | 의미 |
|---|---|
arr=(a b c) | 인덱스 배열 선언 (0부터 시작) |
declare -A map | 연관배열 선언 (반드시 명시적으로 선언) |
${arr[i]} | i번 인덱스 값 조회 |
${arr[@]} / ${arr[*]} | 전체 원소 (따옴표·word splitting 여부에 따라 동작이 다름) |
${!arr[@]} | 전체 인덱스/키 목록 |
${#arr[@]} | 원소 개수 |
${arr[@]:start:len} | 슬라이싱 |
arr+=(x y) | 원소 추가 |
unset 'arr[i]' | 특정 인덱스 삭제 (인덱스에 구멍이 남음) |
getopts는 POSIX 스타일 단일 문자 옵션(-f value, -v 같은 플래그)을 파싱하는 Bash 내장 명령이다. 옵션 문자열에서 콜론(:) 위치가 동작을 결정한다.
| 옵션 문자열 예 | 의미 |
|---|---|
"vf:" | -v는 인자 없는 플래그, -f는 인자를 받는 옵션 |
":vf:" (맨 앞 콜론) | 에러를 getopts가 메시지로 출력하지 않고 $opt에 ?/:로 넘겨줘서 직접 처리 가능 (silent error mode) |
$OPTARG | 옵션이 받은 인자 값 (또는 : 케이스에서 문제된 옵션 문자) |
$OPTIND | 다음에 처리할 인자 위치. 파싱 후 shift $((OPTIND-1))로 위치 인자만 남긴다 |
실전 코드
배열 선언, 인덱싱, 슬라이싱
fruits=(apple banana cherry)
echo "fruits[0]=${fruits[0]}"
echo "fruits[@]=${fruits[@]}"
echo "length=${#fruits[@]}"
# 인덱스를 직접 지정하면 희소 배열이 된다
declare -a sparse
sparse[0]="a"
sparse[5]="f"
echo "indices: ${!sparse[@]}"
echo "length: ${#sparse[@]}"
# 슬라이싱: ${arr[@]:시작:길이}
nums=(0 1 2 3 4 5 6 7 8 9)
echo "nums[2:4]=${nums[@]:2:4}"
echo "nums[-3:]=${nums[@]: -3}"
# 추가/삭제
arr=(a b c)
arr+=(d e)
echo "after +=: ${arr[@]}"
unset 'arr[1]'
echo "after unset idx1: ${arr[@]}"
echo "indices after unset: ${!arr[@]}"fruits[0]=apple
fruits[@]=apple banana cherry
length=3
indices: 0 5
length: 2
nums[2:4]=2 3 4 5
nums[-3:]=7 8 9
after +=: a b c d e
after unset idx1: a c d e
indices after unset: 0 2 3 4
unset으로 중간 인덱스를 지우면 원소는 사라지지만 인덱스 번호는 재배치되지 않는다. ${!arr[@]}로 실제 존재하는 인덱스를 확인하지 않고 for i in $(seq 0 $((${#arr[@]}-1))) 식으로 순회하면 구멍 난 인덱스에서 빈 값을 읽게 되니 주의한다.
${arr[@]} vs ${arr[*]}: 따옴표에 따른 실제 차이
둘의 차이는 문서로만 보면 헷갈리기 쉬운데, 공백이 포함된 원소로 실제 실행해보면 명확하다.
words=("hello world" "foo bar" "baz")
# 따옴표 없이 @ -> word splitting 발생, 원소 경계가 무시됨
count=0
for w in ${words[@]}; do count=$((count+1)); done
echo "for \${words[@]} (unquoted) -> 반복 횟수: $count"
# 따옴표로 @ -> 원소 단위 보존 (거의 항상 이 형태를 써야 함)
count=0
for w in "${words[@]}"; do
count=$((count+1))
echo " element: [$w]"
done
echo "for \"\${words[@]}\" -> 반복 횟수: $count"
# 따옴표로 * -> IFS의 첫 문자로 이어붙인 하나의 문자열
joined="${words[*]}"
echo "\"\${words[*]}\" = [$joined]"
# IFS를 바꾸면 * 결과의 구분자도 바뀐다
( IFS=,; echo "\"\${words[*]}\" with IFS=, => [${words[*]}]" )
# 함수 인자로 넘길 때: @는 인자 개수 그대로, *는 통째로 하나
count_args() { echo " 받은 인자 개수: $#"; }
echo "words[@] 전달 (따옴표):"; count_args "${words[@]}"
echo "words[*] 전달 (따옴표):"; count_args "${words[*]}"for ${words[@]} (unquoted) -> 반복 횟수: 5
for "${words[@]}" -> 반복 횟수: 3
element: [hello world]
element: [foo bar]
element: [baz]
"${words[*]}" = [hello world foo bar baz]
"${words[*]}" with IFS=, => [hello world,foo bar,baz]
words[@] 전달 (따옴표):
받은 인자 개수: 3
words[*] 전달 (따옴표):
받은 인자 개수: 1
따옴표 없는 ${words[@]}는 공백 포함 원소 3개를 5개 단어로 쪼개지만, "${words[@]}"는 원소 경계를 지킨다. *는 따옴표를 붙여도 IFS의 첫 글자로 이어붙인 문자열 하나가 된다는 게 @와의 본질적 차이다. 함수에 배열을 넘길 때는 거의 항상 "${arr[@]}"를 쓴다.
연관배열
declare -A config
config[host]="localhost"
config[port]="8080"
config[env]="production"
echo "host=${config[host]}"
echo "keys: ${!config[@]}"
echo "values: ${config[@]}"
# 키 존재 여부는 -v로 확인 (값이 빈 문자열인 경우와 구분됨)
if [[ -v config[env] ]]; then
echo "env 키 존재: ${config[env]}"
fi
if [[ ! -v config[missing] ]]; then
echo "missing 키 없음"
fi
for key in "${!config[@]}"; do
echo " $key -> ${config[$key]}"
donehost=localhost
keys: env port host
values: production 8080 localhost
env 키 존재: production
missing 키 없음
env -> production
port -> 8080
host -> localhost
연관배열은 해시 기반이라 ${!config[@]} 순회 순서가 선언 순서(host, port, env)와 다르게(env, port, host) 나온다 — 순서가 중요하면 정렬된 키 배열을 따로 유지해야 한다. 키 존재 여부는 [[ -v config[key] ]]로 확인해야 “키 없음”과 “값이 빈 문자열”을 구분할 수 있다.
getopts로 옵션 파싱
-f value(인자 받는 옵션), -v(플래그), -f는 필수라는 실전 요구사항을 담은 스크립트다.
#!/bin/bash
set -e
usage() {
echo "Usage: $0 -f <파일> [-v] [-e <환경>]" >&2
exit 1
}
verbose=0
env="staging"
file=""
# 맨 앞 콜론(:)은 silent error mode: getopts가 직접 에러 메시지를 찍지 않고
# \?(알 수 없는 옵션), :(인자 누락)를 $opt로 넘겨줘서 우리가 처리한다
while getopts ":f:ve:" opt; do
case "$opt" in
f) file="$OPTARG" ;;
v) verbose=1 ;;
e) env="$OPTARG" ;;
\?) echo "알 수 없는 옵션: -$OPTARG" >&2; usage ;;
:) echo "옵션 -$OPTARG 에는 인자가 필요함" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[[ -z "$file" ]] && { echo "-f는 필수 옵션임" >&2; usage; }
echo "file=$file"
echo "env=$env"
echo "verbose=$verbose"
echo "남은 위치 인자: $*"네 가지 케이스를 실제로 실행한 결과다.
$ ./deploy.sh -f app.yaml -v -e production extra_arg
file=app.yaml
env=production
verbose=1
남은 위치 인자: extra_arg
$ ./deploy.sh -v
-f는 필수 옵션임
Usage: ./deploy.sh -f <파일> [-v] [-e <환경>]
(exit=1)
$ ./deploy.sh -f
옵션 -f 에는 인자가 필요함
Usage: ./deploy.sh -f <파일> [-v] [-e <환경>]
(exit=1)
$ ./deploy.sh -x
알 수 없는 옵션: -x
Usage: ./deploy.sh -f <파일> [-v] [-e <환경>]
(exit=1)
getopts는 위치 인자(extra_arg)를 옵션 뒤로 자동으로 몰아주지 않는다. shift $((OPTIND - 1))로 이미 처리된 옵션들을 $@에서 제거해야 남은 $1, $2…가 순수 위치 인자가 된다.
주의사항
getopts는--long-option형태의 GNU 스타일 롱 옵션을 지원하지 않는다. 롱 옵션이 필요하면 외부 명령인getopt(공백 없이, 별개 도구)를 쓰거나while [[ $# -gt 0 ]]; case "$1" in --file) ...식으로 직접 파싱해야 한다.- 같은 셸 세션에서
getopts를 두 번째로 호출하려면OPTIND=1로 초기화해야 한다. 초기화하지 않으면 이전 호출이 멈춘 위치부터 이어서 읽어 옵션을 놓친다. declare -A는 Bash 4.0 이상에서만 동작한다. macOS 기본/bin/bash는 여전히 3.2(라이선스 문제로 GPLv2에 머물러 있음)라 연관배열이 없다.bash --version으로 먼저 확인하거나 Homebrew로 설치한 최신 bash를 명시적으로 써야 한다.unset 'arr[i]'처럼 대괄호가 든 인자는 반드시 따옴표로 감싸야 한다. 따옴표 없이 쓰면 셸이 대괄호를 글롭(glob) 패턴으로 해석하려다 예기치 않게 동작할 수 있다.${arr[@]:idx}에서 음수 오프셋을 쓸 때는${arr[@]: -3}처럼 콜론과 마이너스 사이에 공백을 반드시 넣어야 한다. 붙여 쓰면:-가 변수 기본값 치환 연산자로 파싱돼 문법 에러가 난다.
마무리
배열과 getopts는 Bash 스크립트가 위치 인자 나열 수준을 벗어나 실제 CLI 도구처럼 동작하게 해주는 최소 장치다. 배열은 "${arr[@]}"를 기본값으로 삼고 ${arr[*]}는 문자열 하나가 필요할 때만 쓰면 word-splitting 버그 대부분을 피할 수 있으며, getopts는 옵션 문자열의 콜론 위치만 이해하면 롱 옵션이 필요 없는 대부분의 CLI에 별도 라이브러리 없이 충분하다.