셸 스크립트에서 파일 이름의 확장자를 떼거나 경로에서 디렉터리만 뽑을 때 습관적으로 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} | var에 word를 대입하고 돌려줌 | $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 | 변수를 재생성하는 declare 문 | 4.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##*/}"처럼 항상 큰따옴표 |
,,/^^/@U 등 | sh(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 ...)가 보이면 파라미터 확장으로 바꿀 수 있는지부터 확인하는 것이 좋다.