Bash 파라미터 확장(Parameter Expansion) 정리 — 기본값, 패턴 제거, 치환, 대소문자 변환

셸 스크립트에서 파일 이름의 확장자를 떼거나 경로에서 디렉터리만 뽑을 때 습관적으로 basename, dirname, sed, tr을 호출하곤 한다. 한두 번이면 문제없지만 수천 개 파일을 도는 루프 안에서는 명령마다 프로세스가 새로 뜨면서 스크립트가 눈에 띄게 느려진다. 대부분의 문자열 가공은 Bash 내장 기능인 파라미터 확장(parameter expansion)만으로 처리된다.

이 글에서는 기본값 처리, 앞뒤 패턴 제거, 부분 문자열, 치환, 대소문자 변환, 간접 참조, 배열 일괄 적용, @ 변환 연산자를 예제로 정리하고 외부 명령과 속도를 비교한다. 예제는 모두 GNU Bash 5.2.21(Ubuntu 24.04)에서 실행했다.

기본값 처리 — :- := 😕 :+

#!/usr/bin/env bash
unset a          # 정의 안 됨
b=""             # 빈 문자열
c="value"        # 값 있음

echo "\${x:-def}  a=[${a:-def}]  b=[${b:-def}]  c=[${c:-def}]"
echo "\${x-def}   a=[${a-def}]  b=[${b-def}]  c=[${c-def}]"
echo "\${x:+alt}  a=[${a:+alt}]  b=[${b:+alt}]  c=[${c:+alt}]"
echo "\${x+alt}   a=[${a+alt}]  b=[${b+alt}]  c=[${c+alt}]"

: "${b:=assigned}"
echo "after \${b:=assigned}  b=[$b]"
$ bash defaults.sh
${x:-def}  a=[def]  b=[def]  c=[value]
${x-def}   a=[def]  b=[]  c=[value]
${x:+alt}  a=[]  b=[]  c=[alt]
${x+alt}   a=[]  b=[alt]  c=[alt]
after ${b:=assigned}  b=[assigned]

콜론(:)이 붙으면 '정의 안 됨'과 '빈 문자열'을 똑같이 취급하고, 콜론이 없으면 정의 안 된 경우만 본다. b=""에서 ${b-def}가 빈 값을 그대로 돌려준 것이 그 차이다.

문법변수가 비었거나 없을 때값이 있을 때
${var:-word}word를 돌려줌 (변수는 그대로)$var
${var:=word}varword대입하고 돌려줌$var
${var:?msg}msg를 stderr에 찍고 스크립트 종료$var
${var:+word}빈 문자열word

환경 변수로 받는 설정값은 이 연산자로 필수값 검사와 기본값 지정을 한 줄씩 끝낼 수 있다. : 명령은 아무것도 하지 않고 인자만 확장한다.

#!/usr/bin/env bash
: "${DB_HOST:?DB_HOST is required}"
: "${DB_PORT:=5432}"
LOG_LEVEL="${LOG_LEVEL:-info}"

echo "connect ${DB_HOST}:${DB_PORT} (log=${LOG_LEVEL})"
$ bash required.sh; echo "exit=$?"
required.sh: line 2: DB_HOST: DB_HOST is required
exit=1

$ DB_HOST=db01 bash required.sh
connect db01:5432 (log=info)

$ DB_HOST=db01 DB_PORT=6432 LOG_LEVEL=debug bash required.sh
connect db01:6432 (log=debug)

앞뒤 패턴 제거 — # ## % %%

#!/usr/bin/env bash
path="/var/log/nginx/access.log.2026-09-13.gz"

echo "#*/     ${path#*/}"
echo "##*/    ${path##*/}"      # basename
echo "%/*     ${path%/*}"       # dirname
echo "%.*     ${path%.*}"
echo "%%.*    ${path%%.*}"

file="${path##*/}"
echo "name=${file%%.*} ext=${file##*.}"
$ bash trim.sh
#*/     var/log/nginx/access.log.2026-09-13.gz
##*/    access.log.2026-09-13.gz
%/*     /var/log/nginx
%.*     /var/log/nginx/access.log.2026-09-13
%%.*    /var/log/nginx/access
name=access ext=gz
문법동작대표 용도
${var#pat}앞에서 가장 짧게 일치하는 부분 제거선행 / 하나 제거
${var##pat}앞에서 가장 길게 일치하는 부분 제거${path##*/} = basename, ${f##*.} = 확장자
${var%pat}뒤에서 가장 짧게 일치하는 부분 제거${path%/*} = dirname, ${f%.*} = 마지막 확장자 제거
${var%%pat}뒤에서 가장 길게 일치하는 부분 제거${f%%.*} = 첫 . 이후 전부 제거

패턴은 정규식이 아니라 glob(*, ?, [...])이다. 키보드에서 #$ 왼쪽, %는 오른쪽에 있다고 외우면 방향이 헷갈리지 않는다.

길이와 부분 문자열 — ${#var} ${var:offset:length}

#!/usr/bin/env bash
s="2026-09-13T23:40:15+09:00"

echo "length      ${#s}"
echo ":0:10       ${s:0:10}"
echo ":11:8       ${s:11:8}"
echo ": -6        ${s: -6}"
echo ":(-6)       ${s:(-6)}"
echo ":-6         ${s:-6}"
echo ":11:-6      ${s:11:-6}"
$ bash substr.sh
length      25
:0:10       2026-09-13
:11:8       23:40:15
: -6        +09:00
:(-6)       +09:00
:-6         2026-09-13T23:40:15+09:00
:11:-6      23:40:15

음수 오프셋은 반드시 : -6처럼 띄우거나 괄호로 감싸야 한다. 붙여 쓴 ${s:-6}은 기본값 연산자로 해석되어 원래 문자열이 그대로 나왔다.

치환 — / // /# /%

#!/usr/bin/env bash
csv="a,b,,c,d"
url="http://example.com/api/v1/users"
name="my-new-service"

echo "/,/;       ${csv/,/;}"
echo "//,/;      ${csv//,/;}"
echo "//,        ${csv//,}"
echo "/#http/https  ${url/#http:/https:}"
echo "/%users/groups ${url/%users/groups}"
echo "//[aeiou]/_   ${name//[aeiou]/_}"

shopt -s extglob
messy="  hello   world  "
trimmed="${messy##+([[:space:]])}"
trimmed="${trimmed%%+([[:space:]])}"
echo "trim         [${trimmed}]"
echo "squeeze      [${trimmed//+([[:space:]])/ }]"
$ bash replace.sh
/,/;       a;b,,c,d
//,/;      a;b;;c;d
//,        abcd
/#http/https  https://example.com/api/v1/users
/%users/groups http://example.com/api/v1/groups
//[aeiou]/_   my-n_w-s_rv_c_
trim         [hello   world]
squeeze      [hello world]
문법동작
${var/pat/rep}처음 일치하는 하나만 치환
${var//pat/rep}전부 치환 (rep 생략 시 삭제)
${var/#pat/rep}문자열 맨 앞에서 일치할 때만 치환
${var/%pat/rep}문자열 맨 끝에서 일치할 때만 치환

shopt -s extglob을 켜면 +(...)(1회 이상 반복) 같은 확장 glob을 쓸 수 있어, sed 없이 앞뒤 공백 제거와 연속 공백 압축까지 가능하다.

대소문자 변환 — ^ ^^ , ,,

#!/usr/bin/env bash
s="hello World"
echo "^    ${s^}"
echo "^^   ${s^^}"
echo ",,   ${s,,}"
echo "^^[lo] ${s^^[lo]}"

read -r -p "continue? [y/N] " answer
if [[ "${answer,,}" == y || "${answer,,}" == yes ]]; then
    echo "yes"
else
    echo "no"
fi
$ bash case.sh <<< YES
^    Hello World
^^   HELLO WORLD
,,   hello world
^^[lo] heLLO WOrLd
yes

$ bash case.sh <<< n | tail -1
no

${answer,,}로 소문자로 맞춰두면 Y/yes/YES를 따로 비교할 필요가 없다. ^^[lo]처럼 대괄호 패턴을 주면 해당 문자만 변환한다.

간접 참조 — ${!var} ${!prefix@}

#!/usr/bin/env bash
APP_ENV=prod
DB_URL_dev="postgres://localhost/dev"
DB_URL_prod="postgres://db01.internal/prod"

key="DB_URL_${APP_ENV}"
echo "${key} = ${!key}"

APP_NAME=web APP_PORT=8080 APP_WORKERS=4
for v in "${!APP_@}"; do
    printf '%-12s %s\n' "$v" "${!v}"
done
$ bash indirect.sh
DB_URL_prod = postgres://db01.internal/prod
APP_ENV      prod
APP_NAME     web
APP_PORT     8080
APP_WORKERS  4

${!key}key에 담긴 문자열을 변수 이름으로 삼아 값을 꺼내므로 eval 없이 환경별 설정을 고를 수 있다. ${!APP_@}는 이름이 APP_로 시작하는 변수 목록이다.

배열에 한꺼번에 적용하기

#!/usr/bin/env bash
files=(src/main.c src/util.c include/util.h README.md)

echo "count        ${#files[@]}"
echo "slice 1:2    ${files[@]:1:2}"
echo "basename    " "${files[@]##*/}"
echo "ext -> .o   " "${files[@]/%.c/.o}"
echo "prefix      " "${files[@]/#/./}"
echo "indexes      ${!files[@]}"

declare -A port=([http]=80 [https]=443 [ssh]=22)
for k in "${!port[@]}"; do echo "  $k=${port[$k]}"; done | sort
$ bash array.sh
count        4
slice 1:2    src/util.c include/util.h
basename     main.c util.c util.h README.md
ext -> .o    src/main.o src/util.o include/util.h README.md
prefix       ./src/main.c ./src/util.c ./include/util.h ./README.md
indexes      0 1 2 3
  http=80
  https=443
  ssh=22

${files[@]##*/}처럼 [@]에 확장을 붙이면 모든 원소에 각각 적용된다. /#에 빈 패턴을 주면 각 원소 앞에 접두사를 붙이는 용도로 쓸 수 있다.

@ 변환 연산자 — @Q @U @A @a

#!/usr/bin/env bash
msg="it's a \"test\" \$HOME"
arr=(one "two words" three)
declare -i count=3

echo "@Q  ${msg@Q}"
echo "@U  ${msg@U}"
echo "@A  ${count@A}"
echo "@a  ${arr@a}"
echo "arr[@]@Q ${arr[@]@Q}"
$ bash transform.sh
@Q  'it'\''s a "test" $HOME'
@U  IT'S A "TEST" $HOME
@A  declare -i count='3'
@a  a
arr[@]@Q 'one' 'two words' 'three'
연산자결과도입 버전
@Q셸에 다시 넣어도 안전하게 따옴표 처리한 문자열4.4
@U / @u / @L전체 대문자 / 첫 글자 대문자 / 전체 소문자5.1
@A변수를 재생성하는 declare4.4
@a변수 속성 플래그(a 배열, A 연관 배열, i 정수 등)4.4
@P프롬프트 문자열(\u, \h, \w)처럼 확장4.4

@Q는 공백이나 따옴표가 섞인 파일 이름을 로그에 남기거나 ssh 원격 명령에 넘길 때 특히 유용하다.

외부 명령과 속도 비교

같은 basename 작업을 5,000번 반복해 basename, sed, 파라미터 확장을 비교했다(WSL2 환경).

#!/usr/bin/env bash
path="/var/log/nginx/access.log.2026-09-13.gz"
N=5000

time for ((i = 0; i < N; i++)); do f=$(basename "$path"); done
time for ((i = 0; i < N; i++)); do f=$(echo "$path" | sed 's#.*/##'); done
time for ((i = 0; i < N; i++)); do f=${path##*/}; done
echo "$f"
$ bash bench.sh 2>&1

real	0m3.698s
user	0m0.220s
sys	0m0.923s

real	0m8.710s
user	0m1.968s
sys	0m1.325s

real	0m0.016s
user	0m0.016s
sys	0m0.000s
access.log.2026-09-13.gz

명령 치환 $(...)과 외부 명령은 매번 fork/exec가 일어나 수 초가 걸렸고, 파라미터 확장은 프로세스 생성 없이 수십 ms 안에 끝났다. 파이프로 sed를 붙이면 프로세스가 둘이 되어 더 느려진다.

실전 예제 — 확장자 일괄 변경 스크립트

.JPEG/.jpeg 파일을 소문자 .jpg로 바꾸고 공백은 _로 바꾸는 스크립트다. 인자를 안 주면 dry-run으로 결과만 보여준다.

#!/usr/bin/env bash
# 사용: rename_ext.sh <dir> [--apply]
set -euo pipefail

dir="${1:?usage: $0 <dir> [--apply]}"
dir="${dir%/}"
apply="${2:-}"
mode="${apply:+mv}"
mode="${mode:-dry-run}"

for src in "$dir"/*; do
    [[ -f "$src" ]] || continue
    ext="${src##*.}"
    [[ "${ext,,}" == jpeg ]] || continue

    base="${src##*/}"
    stem="${base%.*}"
    stem="${stem// /_}"
    dst="${dir}/${stem,,}.jpg"

    if [[ "$apply" == --apply ]]; then
        mv -n -- "$src" "$dst"
    fi
    printf '%-7s %s -> %s\n' "$mode" "${src@Q}" "${dst@Q}"
done
$ ls photos
IMG_0001.JPEG
IMG_0002.jpeg
notes.txt
trip day1.JPEG

$ ./rename_ext.sh photos/
dry-run 'photos/IMG_0001.JPEG' -> 'photos/img_0001.jpg'
dry-run 'photos/IMG_0002.jpeg' -> 'photos/img_0002.jpg'
dry-run 'photos/trip day1.JPEG' -> 'photos/trip_day1.jpg'

$ ./rename_ext.sh photos/ --apply
mv      'photos/IMG_0001.JPEG' -> 'photos/img_0001.jpg'
mv      'photos/IMG_0002.jpeg' -> 'photos/img_0002.jpg'
mv      'photos/trip day1.JPEG' -> 'photos/trip_day1.jpg'

$ ls photos
img_0001.jpg
img_0002.jpg
notes.txt
trip_day1.jpg

$ ./rename_ext.sh; echo "exit=$?"
./rename_ext.sh: line 5: 1: usage: ./rename_ext.sh <dir> [--apply]
exit=1

외부 명령은 mv 하나뿐이고, 이름 분해·소문자 변환·공백 치환·로그용 따옴표 처리는 전부 파라미터 확장으로 처리했다.

주의사항

#!/usr/bin/env bash
p1="notes.txt"
p2="/data/backup/"
echo "dirname  p1=[${p1%/*}]  p2=[${p2%/*}]"
echo "basename p1=[${p1##*/}] p2=[${p2##*/}]"

s="a*b*c"
pat="a*"
echo "unquoted \${s##\$pat}   [${s##$pat}]"
echo "quoted   \${s##\"\$pat\"} [${s##"$pat"}]"
$ bash pitfalls.sh
dirname  p1=[notes.txt]  p2=[/data/backup]
basename p1=[notes.txt] p2=[]
unquoted ${s##$pat}   []
quoted   ${s##"$pat"} [b*c]
함정증상대응
/가 없는 경로에 ${p%/*}dirname.을 주지만 확장은 원래 문자열을 그대로 돌려줌[[ $p == */* ]]로 먼저 확인
끝에 /가 붙은 경로에 ${p##*/}빈 문자열p=${p%/}로 끝 슬래시부터 제거
패턴 변수를 따옴표 없이 사용$pat 안의 */?가 glob으로 해석됨문자 그대로 매칭하려면 "$pat"로 감싼다
${s:-6}로 뒤에서 자르기기본값 연산자로 해석됨${s: -6} 또는 ${s:(-6)}
확장 결과를 따옴표 없이 사용공백이 들어간 값이 단어 분리됨"${var##*/}"처럼 항상 큰따옴표
,,/^^/@Ush(dash)에서는 Bad substitution#!/usr/bin/env bash로 실행, @U는 Bash 5.1 이상
#/% 패턴 제거POSIX 셸에도 있지만 /치환·대소문자·부분 문자열은 Bash 전용/bin/sh 스크립트에서는 # ## % %%:- := :? :+만 사용

마무리

하고 싶은 일파라미터 확장대체하는 외부 명령
파일 이름만${path##*/}basename
디렉터리만${path%/*}dirname
확장자 / 확장자 제거${f##*.} / ${f%.*}sed, cut
문자열 치환${s//old/new}sed 's/old/new/g'
대/소문자${s^^} / ${s,,}tr
기본값·필수값${v:-def} / ${v:?msg}if [ -z ] 블록
부분 문자열${s:off:len}cut -c, expr substr

루프 안에서 $(basename ...)이나 $(echo ... | sed ...)가 보이면 파라미터 확장으로 바꿀 수 있는지부터 확인하는 것이 좋다.

참고

답글 남기기