Bash 파라미터 확장(Parameter Expansion) 완전 정복

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.pngphoto로 바꿔주고, 거기에 .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

/가 하나면 첫 번째 매칭만 바꾸고, //면 전부 바꿉니다. seds/패턴/대체/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^^} — 대소문자 변환

이 문법들을 알고 나면 sedawk로 우회하던 코드를 훨씬 간결하게 바꿀 수 있습니다. 쉘 스크립트를 읽을 때도 더 이상 ${...} 안의 기호들이 두렵지 않을 거예요.

GNU Bash 매뉴얼의 Shell Parameter Expansion 섹션에서 더 다양한 확장 문법을 확인할 수 있습니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord