Bash 배열과 getopts로 커맨드라인 인자 제대로 다루기

쉘 스크립트에서 $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]}"
done
host=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에 별도 라이브러리 없이 충분하다.

참고

답글 남기기