Bash 파라미터 확장(Parameter Expansion) 완전 정복
쉘 스크립트를 읽다 보면 ${tag#v}나 ${file%%.*} 같은 독특한 문법을 마주치게 됩니다. 중괄호 안에 #이나 % 같은 기호가 붙어 있어서 처음 보면 꽤 당황스러운데요. 이게 바로 Bash의 파라미터 확장(Parameter Expansion)입니다.
파라미터 확장을 알면 sed, awk, cut 같은 외부 명령어 없이도 변수값을 자유자재로 다룰 수 있습니다. 문자열에서 특정 부분을 잘라내거나, 패턴을 치환하거나, 변수가 비어 있을 때 기본값을 넣어주거나… 이런 작업을 Bash 내장 기능만으로 처리할 수 있죠. 외부 프로세스를 실행하지 않으니 스크립트 성능도 좋아집니다.
이 글에서는 파라미터 확장의 주요 문법을 하나씩 살펴보면서, 실제 스크립트에서 어떻게 활용하는지 알아보겠습니다.
기본 변수 확장
가장 기본적인 형태는 ${변수명}입니다. 사실 $변수명과 같은 결과를 주지만, 중괄호가 있으면 변수명의 경계를 명확하게 구분할 수 있습니다.
name="world"
echo "Hello $name!" # Hello world!
echo "Hello ${name}!" # Hello world! (동일)
echo "Hello ${name}abc" # Hello worldabc
echo "Hello $nameabc" # Hello (nameabc라는 변수를 찾음)
$name과 ${name}은 단독으로 쓸 때는 차이가 없지만, 뒤에 문자가 바로 이어지면 중괄호 없이는 변수명 경계를 구분할 수 없습니다. $nameabc라고 쓰면 Bash는 nameabc라는 변수를 찾으려 하니까요. 그래서 파라미터 확장의 모든 문법은 ${...} 중괄호 안에서 동작합니다.
기본값 지정
변수가 비어 있거나 설정되지 않았을 때 기본값을 지정하는 문법입니다. 환경 변수에 의존하는 스크립트를 작성할 때 정말 많이 쓰입니다.
# ${변수:-기본값} - 변수가 비어 있으면 기본값을 "사용"
echo "${NAME:-anonymous}" # anonymous (NAME이 비어 있으므로)
echo "$NAME" # (여전히 빈 문자열)
# ${변수:=기본값} - 변수가 비어 있으면 기본값을 "할당"
echo "${NAME:=anonymous}" # anonymous
echo "$NAME" # anonymous (변수에 값이 할당됨)
:-는 기본값을 임시로 사용하기만 하고, :=는 변수에 실제로 값을 할당한다는 차이가 있습니다.
실전에서는 스크립트 상단에서 설정값의 기본값을 지정할 때 많이 씁니다.
PORT="${PORT:-3000}"
HOST="${HOST:-localhost}"
LOG_LEVEL="${LOG_LEVEL:-info}"
echo "서버를 ${HOST}:${PORT}에서 시작합니다 (로그 레벨: ${LOG_LEVEL})"
이렇게 하면 환경 변수로 값을 덮어쓸 수 있으면서도, 아무것도 설정하지 않았을 때 합리적인 기본값이 사용됩니다.
비슷한 문법이 두 개 더 있습니다.
# ${변수:+대체값} - 변수가 설정되어 있으면 대체값을 사용
TOKEN="abc123"
echo "${TOKEN:+Bearer ${TOKEN}}" # Bearer abc123
unset TOKEN
echo "${TOKEN:+Bearer ${TOKEN}}" # (빈 문자열)
# ${변수:?에러메시지} - 변수가 비어 있으면 에러를 발생시키고 스크립트 종료
# ${DATABASE_URL:?DATABASE_URL 환경 변수를 설정해주세요}
${변수:+대체값}은 변수가 있을 때만 특정 형식으로 출력하고 싶을 때, ${변수:?에러메시지}는 필수 환경 변수가 빠졌을 때 스크립트를 즉시 중단시키고 싶을 때 유용합니다.
앞에서 패턴 제거: #과 ##
파라미터 확장에서 가장 자주 쓰이는 문법 중 하나입니다. 변수값의 앞쪽(왼쪽)에서 패턴과 일치하는 부분을 제거합니다.
# ${변수#패턴} - 앞에서 가장 짧게 매칭되는 부분 제거
# ${변수##패턴} - 앞에서 가장 길게 매칭되는 부분 제거
가장 흔한 사용처인 Git 태그 예제로 시작해볼까요?
tag="v1.2.3"
echo "${tag#v}" # 1.2.3
Git 태그는 보통 v1.2.3 형식인데, package.json의 버전은 1.2.3입니다. CI/CD 스크립트에서 이 둘을 비교하려면 v 접두사를 제거해야 하는데, ${tag#v}면 충분합니다. sed 's/^v//' 같은 외부 명령어를 파이프로 연결할 필요가 없죠.
# 하나와 ## 두 개의 차이는 최단 매칭과 최장 매칭입니다. 파일 경로를 다룰 때 그 차이가 확실히 드러납니다.
path="/home/user/documents/report.txt"
echo "${path#*/}" # home/user/documents/report.txt
echo "${path##*/}" # report.txt
#*/는 첫 번째 /까지만 잘라내고(최단 매칭), ##*/는 마지막 /까지 모두 잘라냅니다(최장 매칭). 그래서 ##*/를 쓰면 전체 경로에서 파일명만 깔끔하게 추출할 수 있습니다. basename 명령어와 같은 결과인데 외부 프로세스 실행 없이 처리되죠.
한 가지 더 예를 들어보면, URL에서 프로토콜을 제거할 때도 유용합니다.
url="https://example.com/api/users"
echo "${url#*://}" # example.com/api/users
뒤에서 패턴 제거: %와 %%
#이 앞에서 잘라낸다면, %는 뒤쪽(오른쪽)에서 패턴을 제거합니다.
# ${변수%패턴} - 뒤에서 가장 짧게 매칭되는 부분 제거
# ${변수%%패턴} - 뒤에서 가장 길게 매칭되는 부분 제거
파일 확장자를 다룰 때 %와 %%의 차이를 확인해보겠습니다.
file="archive.tar.gz"
echo "${file%.*}" # archive.tar
echo "${file%%.*}" # archive
%.*는 마지막 .부터 끝까지만 제거하고(최단 매칭), %%.*는 첫 번째 .부터 끝까지 전부 제거합니다(최장 매칭). 확장자가 여러 개인 파일을 다룰 때 이 차이를 알고 있으면 정확하게 원하는 결과를 얻을 수 있습니다.
경로에서 디렉터리 부분만 추출하는 것도 가능합니다.
path="/home/user/documents/report.txt"
echo "${path%/*}" # /home/user/documents
dirname 명령어와 같은 결과입니다. ##*/로 파일명을, %/*로 디렉터리를 추출하는 조합은 쉘 스크립트에서 정말 자주 등장합니다.
실전에서 이 문법이 빛나는 순간 하나를 더 보여드릴게요. 이미지 파일의 확장자를 일괄 변환하는 스크립트입니다.
for f in *.png; do
convert "$f" "${f%.png}.webp"
done
각 .png 파일에서 확장자를 제거한 뒤 .webp를 붙여서 새 파일명을 만듭니다. ${f%.png}이 photo.png을 photo로 바꿔주고, 거기에 .webp가 붙어서 photo.webp가 되는 거죠.
#과 % 기억하는 법
#은 앞에서, %는 뒤에서 제거한다고 했는데, 어느 쪽이 어느 쪽인지 헷갈리기 쉽습니다. 키보드를 보면 힌트가 있습니다.
미국식 키보드 레이아웃에서 $ 기호의 왼쪽에 #이, 오른쪽에 %가 있습니다.
... # $ % ...
↑ ↑
앞 뒤
#이 $의 왼쪽(앞쪽)이니 앞에서 제거하고, %가 $의 오른쪽(뒤쪽)이니 뒤에서 제거한다고 기억하면 됩니다. 한 번 이 배치를 떠올리면 잊어버릴 일이 없을 거예요.
문자열 치환
패턴을 제거하는 것만이 아니라 다른 문자열로 바꿀 수도 있습니다.
# ${변수/패턴/대체값} - 첫 번째 매칭만 치환
# ${변수//패턴/대체값} - 모든 매칭을 치환
msg="hello world"
echo "${msg/world/Bash}" # hello Bash
text="apple banana apple cherry apple"
echo "${text/apple/orange}" # orange banana apple cherry apple
echo "${text//apple/orange}" # orange banana orange cherry orange
/가 하나면 첫 번째 매칭만 바꾸고, //면 전부 바꿉니다. sed의 s/패턴/대체/와 s/패턴/대체/g의 차이와 같다고 생각하면 됩니다.
대체값을 비워두면 패턴을 삭제하는 효과도 있습니다.
phone="010-1234-5678"
echo "${phone//-/}" # 01012345678
하이픈을 모두 제거해서 숫자만 남긴 모습입니다.
#과 %를 조합하면 치환 위치도 제한할 수 있습니다.
file="test_main_test.py"
echo "${file/#test/spec}" # spec_main_test.py (앞에서만 치환)
echo "${file/%test.py/spec.py}" # test_main_spec.py (뒤에서만 치환)
/#은 문자열의 시작 부분에서만, /%는 끝 부분에서만 치환합니다. 정규표현식의 ^와 $에 해당하는 기능이라고 보시면 됩니다.
부분 문자열 추출
변수값에서 특정 위치의 문자열을 잘라낼 수 있습니다.
# ${변수:시작위치} - 시작 위치부터 끝까지
# ${변수:시작위치:길이} - 시작 위치부터 지정한 길이만큼
text="Hello, World!"
echo "${text:7}" # World!
echo "${text:7:5}" # World
echo "${text:0:5}" # Hello
위치는 0부터 시작합니다. 음수 값을 사용하면 뒤에서부터 셀 수도 있는데, 이때 콜론과 마이너스 사이에 공백이 필요합니다.
text="Hello, World!"
echo "${text: -6}" # orld! (뒤에서 6글자)
echo "${text: -6:3}" # orl (뒤에서 6글자 위치부터 3글자)
공백 없이 ${text:-6}이라고 쓰면 기본값 지정 문법으로 해석되니 주의하세요.
날짜 문자열에서 연/월/일을 분리하는 예제입니다.
date="2026-03-28"
year="${date:0:4}" # 2026
month="${date:5:2}" # 03
day="${date:8:2}" # 28
문자열 길이
변수 앞에 #을 붙이면 값이 아니라 길이를 알 수 있습니다.
# ${#변수} - 문자열 길이
text="Hello"
echo "${#text}" # 5
empty=""
echo "${#empty}" # 0
입력값 검증에 쓸 수 있겠죠.
password="abc"
if [ "${#password}" -lt 8 ]; then
echo "비밀번호는 8자 이상이어야 합니다"
fi
대소문자 변환
Bash 4.0부터 대소문자 변환도 파라미터 확장으로 할 수 있습니다.
# ${변수,,} - 전부 소문자로
# ${변수^^} - 전부 대문자로
# ${변수^} - 첫 글자만 대문자로
text="Hello World"
echo "${text,,}" # hello world
echo "${text^^}" # HELLO WORLD
echo "${text^}" # Hello World (이미 대문자라 변화 없음)
lower="hello"
echo "${lower^}" # Hello
환경 변수 이름을 정규화하거나, 사용자 입력을 비교할 때 유용합니다.
read -p "계속하시겠습니까? (y/n) " answer
if [ "${answer,,}" = "y" ]; then
echo "계속 진행합니다"
fi
Y, y, Yes, YES 등 어떤 형태로 입력하든 소문자로 변환해서 비교하니 모든 경우를 처리할 수 있습니다.
실전 활용 예제
지금까지 배운 문법들을 조합하면 꽤 복잡한 문자열 처리도 외부 명령어 없이 해결할 수 있습니다.
CI/CD에서 버전 비교
Git 태그의 버전과 package.json의 버전이 일치하는지 확인하는 스크립트입니다.
git_tag=$(git describe --tags --abbrev=0)
pkg_version=$(node -p "require('./package.json').version")
# v 접두사 제거
tag_version="${git_tag#v}"
if [ "$tag_version" != "$pkg_version" ]; then
echo "버전 불일치: 태그=$tag_version, package.json=$pkg_version"
exit 1
fi
echo "버전 일치: $tag_version"
파일 일괄 처리
디렉터리 안의 파일들을 확장자별로 분류하거나, 이름 패턴을 변환하는 작업입니다.
for file in src/*.test.ts; do
# src/utils.test.ts → utils.test.ts → utils → src/utils.ts
basename="${file##*/}"
name="${basename%.test.ts}"
source="src/${name}.ts"
if [ ! -f "$source" ]; then
echo "경고: ${file}의 소스 파일 ${source}를 찾을 수 없습니다"
fi
done
로그 파싱
로그 메시지에서 타임스탬프, 레벨, 본문을 분리하는 예제입니다.
log="2026-03-28T10:30:45 [ERROR] Connection refused: database timeout"
timestamp="${log%% *}" # 2026-03-28T10:30:45
rest="${log#* }" # [ERROR] Connection refused: database timeout
level="${rest%% *}" # [ERROR]
level="${level//[\[\]]/}" # ERROR
message="${rest#* }" # Connection refused: database timeout
echo "시간: $timestamp"
echo "레벨: $level"
echo "메시지: $message"
외부 명령어를 하나도 쓰지 않고 파라미터 확장만으로 로그를 파싱했습니다.
Docker 이미지 태그 파싱
Docker 이미지 이름에서 레지스트리, 이미지명, 태그를 분리합니다.
image="ghcr.io/my-org/my-app:v2.1.0"
tag="${image##*:}" # v2.1.0
name_with_registry="${image%:*}" # ghcr.io/my-org/my-app
registry="${name_with_registry%%/*}" # ghcr.io
name="${name_with_registry#*/}" # my-org/my-app
echo "레지스트리: $registry"
echo "이미지: $name"
echo "태그: $tag"
마치며
파라미터 확장은 처음에는 #, ##, %, %% 같은 기호들이 낯설게 느껴지지만, 패턴이 보이기 시작하면 금방 익숙해집니다. #은 앞에서, %는 뒤에서 자른다는 것만 기억하면 나머지는 응용이니까요.
정리하면 이렇습니다.
${var:-default}— 기본값 사용${var:=default}— 기본값 할당${var#pattern},${var##pattern}— 앞에서 패턴 제거 (최단/최장)${var%pattern},${var%%pattern}— 뒤에서 패턴 제거 (최단/최장)${var/old/new},${var//old/new}— 문자열 치환 (첫 번째/전체)${var:offset:length}— 부분 문자열 추출${#var}— 문자열 길이${var,,},${var^^}— 대소문자 변환
이 문법들을 알고 나면 sed나 awk로 우회하던 코드를 훨씬 간결하게 바꿀 수 있습니다. 쉘 스크립트를 읽을 때도 더 이상 ${...} 안의 기호들이 두렵지 않을 거예요.
GNU Bash 매뉴얼의 Shell Parameter Expansion 섹션에서 더 다양한 확장 문법을 확인할 수 있습니다.
This work is licensed under
CC BY 4.0