Bash 명령 치환: $()와 백틱 차이점 및 사용법
Bash에서 $()와 백틱을 사용할 때 발생하는 서브셸 동작 원리와 변수 소멸 문제를 설명합니다. 백틱의 중첩 오류를 해결하고 $()를 권장하는 이유와 상태 유지를 위한 프로세스 치환 활용법을 상세히 정리했습니다.
Bash 명령 치환의 동작 원리
Bash 명령 치환은 $(command)를 해당 명령이 표준 출력으로 내보낸 텍스트로 대체합니다. 백틱을 사용하는 구식 표기법도 동일한 역할을 수행합니다. 사용자가 당황하는 모든 현상은 두 가지 사실에서 비롯됩니다. 명령은 서브셸(subshell)이라는 별도의 프로세스에서 실행되며, 출력 끝에 있는 모든 개행 문자는 제거된다는 점입니다.
mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"3이것이 기능의 전부입니다. wc -l는 3과 그 뒤의 개행 문자를 출력했지만, 개행 문자는 제거되었고 count은 사용자가 원했던 두 글자만을 담게 됩니다. wc -l < three.txt은 파일 이름을 출력할 수 없는 표준 입력을 읽는 GNU wc의 특성상 숫자만 출력한다는 점에 유의하십시오. 대신 wc -l three.txt을 작성하면 3 three.txt을 캡처하게 되는데, 이는 서로 다른 문자열이며 나중에 산술 연산이 실패하는 흔한 원인이 됩니다.
이 가이드의 나머지 부분은 아무도 예상하지 못하는 동작들을 다룹니다. 서브셸은 별도의 프로세스이므로, 별도의 프로세스는 현재 사용 중인 셸의 상태를 변경할 수 없기 때문입니다.
$()를 사용하고 백틱 사용을 중단하십시오
두 형식 모두 유효합니다. $()는 POSIX 표준에 포함되어 있으므로 dash, ash, busybox sh 모두 이를 지원합니다. 백틱을 작성해야 할 이식성 관련 이유는 더 이상 없으며, 사용하지 말아야 할 구체적인 이유가 두 가지 있습니다.
백틱은 중첩되지 않습니다
echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"hi
echo hi두 번째 줄은 echo hi이라는 문자열을 그대로 출력했습니다. 셸은 이스케이프되지 않은 다음 백틱을 찾을 때까지 앞으로 스캔하므로, 입력한 두 번째 백틱이 첫 번째 백틱을 닫아버립니다. 실제로 실행된 명령어는 인자가 없는 echo이었으며, 이는 빈 줄을 출력한 뒤 개행 문자를 제거했습니다. echo hi이라는 단어는 일반 텍스트로 남았고, 마지막 백틱 쌍은 빈 명령어를 실행했습니다.
백틱을 중첩하려면 내부의 모든 백틱을 이스케이프해야 합니다.
echo "`echo \`echo hi\``"hi중첩 단계가 깊어질수록 이스케이프 처리가 배로 늘어납니다. $()는 파서가 구분자를 찾는 대신 괄호의 짝을 맞추기 때문에 이러한 복잡한 처리가 필요 없습니다.
백틱은 명령어 실행 전 백슬래시를 변경합니다
echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"a\\b
a\b동일한 내부 명령어가 서로 다른 결과를 출력했습니다. 백틱 내부에서는 내부 텍스트가 파싱되기 전에 셸이 백슬래시 이스케이프를 한 단계 제거하므로, 작은따옴표가 아무것도 보호하지 못합니다. $() 내부에서는 괄호 사이의 텍스트가 일반적인 스크립트로 파싱되므로 작은따옴표가 예상대로 동작합니다. 이는 sed 및 awk 원라이너에서 가장 치명적인데, 백슬래시가 유실되면 정상 작동하던 패턴이 조용히 다른 패턴으로 변하기 때문입니다.
$() 내부에서는 따옴표 처리가 새로 시작되므로, 이스케이프 없이 큰따옴표를 중첩할 수 있습니다.
path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"/etc/nginx백틱을 사용하는 경우 $path 주위에 \"가 필요합니다. 모든 이스케이프는 실수를 유발할 수 있는 지점입니다.
$() 내부의 cd가 셸을 변경하지 않는 이유
$(...)은 새로운 프로세스를 생성(fork)하기 때문입니다. 서브셸은 현재 셸의 변수와 작업 디렉터리를 복사하여 가져갑니다. 서브셸은 자신의 복사본 내에서 디렉터리를 변경하고, 결과를 출력한 뒤 종료됩니다. 이 복사본은 서브셸이 종료될 때 함께 사라집니다.
cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd/tmp/subst-demo
/etc
/tmp/subst-demo실패한 것은 없습니다. cd은 정상적으로 작동했으며, 서브셸 내부의 pwd은 실제로 /etc를 출력했습니다. 다만 서브셸이 부모 프로세스에게 전달하는 것은 표준 출력과 종료 상태 코드뿐이므로, 디렉터리 변경 사항이 부모 셸로 돌아올 방법이 없습니다.
변수 할당도 동일하게 동작합니다.
count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"inside: 99
outside: 0이와 동일한 원리로, 사용자들이 훨씬 더 자주 겪는 파이프라인 관련 문제도 설명할 수 있습니다.
n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"0파이프라인의 각 단계는 각각 별도의 서브셸에서 실행됩니다. 따라서 while 루프는 n의 복사본을 증가시킨 후 종료된 것입니다. 파이프 대신 리다이렉션을 사용하면 루프가 현재 셸에서 실행됩니다.
n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"3Bash는 shopt -s lastpipe 옵션을 사용하면 파이프라인의 마지막 단계를 현재 셸에서 실행할 수 있습니다. 하지만 이는 작업 제어(job control)가 꺼져 있을 때만 가능하며, 대화형 셸에서는 항상 작업 제어가 켜져 있으므로 이 방법은 사용할 수 없습니다. 리다이렉션을 사용하십시오.
프롬프트는 어디로 갔는가? $() 내부의 대화형 명령어
명령어 치환(command substitution)은 표준 출력을 파이프(pipe)로 리다이렉트하지만 표준 입력은 그대로 둡니다. 프로그램이 표준 출력으로 질문을 출력한 뒤 답변을 기다리는 경우, 질문은 사라지고 대기 상태만 남습니다. 터미널은 멈춘 것처럼 보입니다.
ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
askUsername: deploy
deploy첫 번째 줄의 deploy은 사용자가 입력한 내용입니다. 이제 동일한 함수를 치환 내부에서 실행해 봅니다.
v=$(ask)deploy프로그램이 아닌 터미널 드라이버가 에코(echo)한 사용자의 키 입력만 나타납니다. 프롬프트는 다른 곳으로 이동했습니다.
echo "$v"Username: deploy프롬프트는 이제 변수 내부에 답변과 함께 붙어 있습니다. $()가 함수가 표준 출력으로 보낸 모든 내용을 캡처했기 때문입니다. 표준 입력은 건드리지 않았으므로 사용자의 입력은 여전히 read에 도달합니다. 이것이 바로 "스크립트가 멈추고 아무것도 출력하지 않는다"는 버그 리포트의 전형적인 특징입니다.
일부 도구는 프롬프트를 표준 에러(standard error)로 보내거나 직접 /dev/tty으로 출력하여 이 문제를 피합니다. 하지만 그렇지 않은 도구도 많습니다. 함수가 반드시 치환 내부에 있어야 한다면, 프롬프트를 직접 표준 에러로 보내야 합니다.
ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"Username: deploy
deploy표준 에러는 캡처되지 않으므로 프롬프트는 터미널에 도달하고, 답변만 v에 저장됩니다.
왜 $() 안에서 ls의 결과가 다르게 보입니까?
ls이 파일 디스크립터 1에 대해 isatty를 호출하고 그 결과에 따라 출력 형식을 변경하기 때문입니다. 셸 프롬프트에서는 해당 디스크립터가 터미널이므로 ls이 파일 이름을 여러 열로 나누어 표시합니다. 반면 명령 치환 내부에서는 파이프가 연결되므로 ls이 한 줄에 하나의 이름만 출력하는 방식으로 전환됩니다.
mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"alpha
beta
delta
gamma이와 동일한 확인 절차를 통해 grep --color=auto에서는 색상이 제거되고 git에서는 페이저(pager) 기능이 비활성화됩니다. 이는 의도된 기능입니다. 스크립트가 별도의 요청 없이도 기계가 읽기 적합한 안정적인 출력을 얻을 수 있도록 하기 위함입니다.
이는 파이프라인에 관해 자주 묻는 질문에 대한 답이기도 합니다. 프롬프트에서 ls | sort를 실행할 때와 $(ls | sort)를 실행할 때 동일한 결과가 출력되는 이유는, 두 경우 모두 ls의 출력에 파이프가 연결되어 있었기 때문입니다. 명령 치환 내부에서 바뀌는 것은 파이프라인의 마지막 단계입니다. sort은 터미널 여부를 확인하지 않으므로 출력 결과가 절대 변하지 않습니다. 파이프라인의 마지막에 터미널을 인식하는 명령어를 배치하면 출력이 달라질 수 있으며, 이것이 바로 눈으로 직접 테스트한 파이프라인이 $()로 감싸는 순간 다르게 동작하는 이유입니다.
이와 관련하여 한 가지 주의할 점은, 편리해 보인다는 이유로 스크립트에서 ls의 출력을 파싱하지 말라는 것입니다. 파일 이름에는 공백이나 줄바꿈 문자가 포함될 수 있습니다. 대신 glob을 사용하거나 read -d '' 옵션과 함께 find -print0을 사용하십시오.
조용히 사라지는 후행 개행 문자
명령어 치환(command substitution)은 출력 끝에 있는 모든 개행 문자를 제거합니다. 마지막 하나만 제거하는 것이 아니라, 전부 제거합니다.
cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c8
5파일에는 hello와 개행 문자 3개가 포함되어 총 8바이트입니다. 변수에는 hello이 저장되어 5바이트가 됩니다. 경고 없이 3바이트가 사라졌습니다.
이러한 제거 작업은 의도된 것이며 대개 유용합니다. stamp=$(date -u +%Y%m%dT%H%M%SZ)가 줄 바꿈을 포함한 이름 대신 사용 가능한 파일 이름 조각을 생성하게 만드는 원리이며, 이것이 예약된 restic 백업 스크립트와 같은 곳에서 이 패턴이 안전한 이유입니다.
stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"backup-20260804T031500Z.tar.gz타임스탬프는 다를 수 있습니다. 중요한 점은 파일 이름이 한 줄로 구성된다는 것입니다.
단점은 파일의 정확한 바이트를 옮기기 위해 $()를 사용할 수 없다는 점입니다. 후행 개행 문자가 필요하다면 치환 내부에 감시자(sentinel) 문자를 추가한 뒤 나중에 제거하십시오.
v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c8x이 개행 문자 뒤에 위치하므로 제거될 후행 개행 문자가 남지 않습니다. 그 후 ${v%x}이 감시자 문자를 제거하여 원래의 바이트만 남깁니다.
관련된 두 가지 세부 사항이 있습니다. 전체 파일을 읽을 때는 cat를 실행하지 않고도 v=$(<blanks.txt)이 동일한 작업을 수행합니다. bash가 직접 파일을 열기 때문입니다. 이때도 후행 개행 문자는 동일하게 제거됩니다. 반대로 here-string은 반대 방향으로 작동하며, 작성하지 않은 개행 문자를 추가합니다.
wc -c <<< 'abc'4결과를 따옴표로 감싸십시오. 그렇지 않으면 bash가 단어를 분할하고 globbing을 수행합니다.
따옴표로 감싸지 않은 치환은 단어 분할(word splitting)과 경로명 확장(pathname expansion) 과정을 거칩니다. 따옴표로 감싼 치환은 두 과정 모두 거치지 않습니다.
printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"a b c d
a b c
d따옴표를 사용하지 않으면 bash는 IFS에 포함된 문자(기본값은 공백, 탭, 줄바꿈)를 기준으로 출력을 분할하며, echo은 분할된 네 조각을 단일 공백으로 다시 결합합니다. 따옴표를 사용하면 텍스트는 탭과 내부 줄바꿈이 그대로 유지된 하나의 단어로 전달됩니다.
Globbing은 더 위험한 요소입니다.
mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"one.txt pattern.txt two.txt
*대입 자체는 안전합니다. 대입 과정에서는 단어 분할이나 globbing이 발생하지 않기 때문입니다. 문제는 echo $p에서 발생하며, 여기서 *은 현재 디렉터리를 기준으로 확장됩니다. 설정 파일에서 패턴을 읽어오는 스크립트에서 따옴표를 누락하면, 스크립트는 눈에 보이는 모든 파일에 대해 의도치 않은 동작을 수행하게 됩니다. 모든 확장을 따옴표로 감싸면 이러한 유형의 버그를 완전히 방지할 수 있습니다. 단어 분할이 반드시 필요한 드문 경우에만 따옴표를 생략하십시오.
왜 local x=$(cmd)는 항상 0을 반환합니까?
local 자체가 하나의 명령이기 때문입니다. $?는 명령 치환 내부의 상태가 아니라 local의 실행 상태를 보고합니다.
check_bad() { local out=$(false); echo "status: $?"; }
check_badstatus: 0false이 1을 반환하며 종료되었더라도, local은 변수 선언을 성공적으로 수행했으므로 1은 무시됩니다. declare, export, typeset, readonly 모두 동일하게 동작합니다. 셸의 관점에서는 실패한 것이 없으므로 set -e을 사용해도 오류를 잡아낼 수 없습니다.
선언과 할당을 분리하십시오:
check_good() { local out; out=$(false); echo "status: $?"; }
check_goodstatus: 1최상위 수준에서 일반적인 할당을 수행하면 마지막 명령 치환의 상태를 그대로 보고합니다:
out=$(exit 3)
echo $?3이 문제는 systemd 서비스 및 타이머에서 실행되는 상태 점검(health check)에서 가장 중요합니다. 종료 코드가 가려지면 실제 작업이 실패했음에도 불구하고 유닛은 매번 성공한 것으로 보고하기 때문입니다.
실제로 유용한 셸 내장 대안
대부분의 사용자는 변수에 데이터를 담으려고 $(...)를 사용합니다. 하지만 종종 필요한 것은 캡처가 아니라 입력 그 자체인 경우가 많습니다. 다음 네 가지 방식은 현재 셸의 상태를 유지합니다.
루프에 리다이렉션 사용
cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txtgot: alpha
got: beta
got: gamma입력을 위해 별도의 프로세스가 생성되지 않으므로, 루프 본문에서 설정한 모든 내용은 루프가 끝난 뒤에도 유지됩니다.
프로세스 치환
while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)got: gamma
got: beta
got: alpha<(command)는 명령의 출력을 읽을 수 있는 /dev/fd/63과 같은 경로를 제공합니다. 명령은 여전히 자체 프로세스에서 실행됩니다. 하지만 while 루프는 그렇지 않으며, 이것이 핵심입니다. < <(의 공백은 필수입니다. <<(는 히어 도큐먼트(here-document)의 시작으로 간주되어 구문 분석에 실패합니다. 프로세스 치환은 bash 기능이므로, Ubuntu나 Debian에서 #!/bin/sh으로 작성된 스크립트는 dash에서 실행되며 실패하게 됩니다. #!/bin/bash를 사용하십시오.
히어 스트링(Here-strings)
read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"alpha
beta gamma<<<은 하나의 문자열을 명령의 표준 입력으로 전달합니다. read는 현재 셸에서 실행되므로, 두 변수 모두 이후에 사용할 수 있도록 설정됩니다. read -r a b <<< "$(some-command)"는 한 줄의 출력에서 두 개의 필드를 추출하는 일반적인 방법입니다.
전체 파일을 위한 mapfile
mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"3
betamapfile(readarray로도 표기)은 파일을 현재 셸의 배열로 읽어 들입니다. -t은 각 요소에서 마지막 줄바꿈 문자를 제거합니다. 이 기능은 bash 4 이상이 필요하며, Ubuntu 24.04는 bash 5.2를 탑재하고 있으므로 현재의 모든 서버 이미지에서 사용할 수 있습니다.
스크립트를 커밋하기 전 확인 목록
$(command)를 작성하고, 특별히 분할이 필요한 경우가 아니라면"$(command)"으로 인용하십시오.- 후행 개행 문자가 제거된다고 가정하십시오. 개행 문자가 다시 필요하면 센티넬 문자를 추가하십시오.
- 대화형 명령은 치환에서 제외하거나, 해당 프롬프트를 표준 오류(standard error)로 보내십시오.
out=$(command)의 종료 상태가 중요할 때는local out을 별도의 줄에 작성하십시오.- 입력값으로 변수를 설정할 때는 파이프 대신 리다이렉트나 프로세스 치환을 사용하십시오.
이러한 형태는 사용자가 새 VPS를 설정할 때 처음 작성하는 작은 스크립트에서 나타나며, 실패가 발생해도 조용히 넘어갑니다. 프롬프트를 파일 이름으로 캡처하는 백업 스크립트나 종료 코드를 가리는 상태 점검 스크립트는 계속해서 성공을 보고합니다. 여러 서버에서 동일한 스크립트를 실행하기 시작하면 비용은 커집니다. 읽지 않은 출력값이 이제는 20대의 서버에서 읽지 않은 출력값이 되기 때문입니다.
FAQ
Why does cd inside $() not change my current directory?
$(...) runs its command in a subshell, which is a separate process holding a copy of your working directory and your variables. The cd changes that copy, then the process exits and the copy is discarded. A subshell can only return its standard output and an exit status, so there is no mechanism for the directory change to reach the parent shell. If you want the directory itself, capture it with target=$(cd /etc && pwd) and use "$target". If you want your shell to move, run cd directly, without a substitution around it.
What is the difference between $() and backticks in bash?
They produce the same result for simple commands, and differ in two ways that matter. $() nests directly, because the parser matches parentheses, while backticks need an escaped backtick for every level of nesting. Backticks also strip one layer of backslash escaping before the inner command is parsed, so ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`, so there is no portability argument for backticks.
Why does my script hang with no prompt when a command asks a question?
Command substitution redirects standard output into a pipe but leaves standard input connected to your terminal. A program that prints its prompt to standard output has that prompt captured into the variable, while the read behind it still waits for you. The terminal shows only the characters you type, echoed by the terminal driver. Move the question outside the substitution, or make the prompt write to standard error with printf 'Username: ' >&2 so it is not captured.
Why did the blank lines at the end of my variable disappear?
Command substitution removes every trailing newline, not just the last one. printf 'hello\n\n\n' > f; v=$(cat f) leaves v holding five bytes while the file holds eight. To keep them, append a sentinel inside the substitution and strip it afterwards with v=$(cat f; printf x) followed by v=${v%x}. The sentinel sits after the newlines, so there is nothing at the end for bash to remove.
Why does local out=$(cmd) always report success?
local is a command in its own right, and $? after that line reports whether local succeeded at declaring the variable. The exit status of the substitution is consumed and thrown away, which also means set -e will not stop the script. declare, export, typeset and readonly behave the same way. Write local out on one line and out=$(cmd) on the next, and $? then reports the real status.