0Pricing
DevOps Bootcamp · 강의

스크립트 프로파일링 및 불필요한 서브셸 방지

스크립트 실행 시간을 측정하고 cat과 grep을 연이어 사용하는 것처럼 포크가 많은 패턴을 내장 기능으로 대체합니다.

스크립트 프로파일링 및 불필요한 서브셸 방지은(는) CoddyKit의 무료 DevOps Bootcamp 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DevOps Bootcamp 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DevOps Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

스크립트 성능이 중요한 이유

느리게 실행되는 Bash 스크립트는 CI 시간을 낭비하고, cron 작업을 막으며, 사용자를 불편하게 합니다. 대부분의 느려짐은 복잡한 로직에서 발생하지 않습니다. 불필요한 프로세스 분기가 원인인 경우가 많습니다. 호출하는 모든 외부 명령은 새로운 자식 프로세스를 시작하기 때문입니다.

이 레슨에서는 다음 방법을 배웁니다.

  • time과 bash -x로 실제로 시간이 어디에 쓰이는지 측정하기
  • cat의 쓸모없는 사용처럼 분기가 많은 안티패턴 식별하기
  • 외부 명령을 더 빠른 셸 내장 명령으로 바꾸기
  • 서브셸을 의도적으로 사용하고, 아무런 이점이 없을 때는 피하기

목표는 더 적은 자식 프로세스와 더 짧은 실제 경과 시간으로 동일한 작업을 수행하는 스크립트를 작성하는 것입니다.

time 내장 명령으로 스크립트 시간 측정하기

가장 간단한 성능 분석 도구는 셸 내장 명령인 time입니다. 명령이나 파이프라인 앞에 붙이면 다음 세 가지 측정값을 얻을 수 있습니다.

  • real — 실제 경과 시간(실제로 기다리는 시간)
  • user — 사용자 공간 코드에서 사용한 CPU 시간
  • sys — 커널에서 사용한 CPU 시간(시스템 호출, 입출력)

real과 user+sys 사이의 차이가 크다면 대개 스크립트가 입출력을 기다리거나 많은 자식 프로세스를 생성하고 있다는 뜻입니다. 먼저 전체 스크립트를 time으로 실행하여 문제가 있는지 확인한 다음 최적화하십시오.

#!/usr/bin/env bash
# Time a whole script block
time {
  for i in $(seq 1 1000); do
    echo "line $i"
  done | grep -c "5"
}
# Output example:
# 271
# real  0m0.045s
# user  0m0.038s
# sys   0m0.012s

bash -x와 PS4로 실행 추적하기

bash -x는 각 명령을 실행하기 전에 출력합니다. 이것이 바로 실행 추적입니다. 어떤 줄이 가장 자주 실행되는지, 외부 프로그램이 예상보다 많이 호출되는지를 확인할 수 있습니다.

기본적으로 추적된 각 줄에는 +가 접두사로 붙습니다. PS4를 사용하여 타임스탬프를 포함하도록 접두사를 확장하면 추적 기능을 간단한 성능 분석 도구로 활용할 수 있습니다.

  • PS4는 추적되는 각 명령 앞에서 확장됩니다.
  • $EPOCHREALTIME(bash 5 이상) 또는 $(date +%s%N)을 포함하면 나노초 단위의 해상도를 얻을 수 있습니다.
  • 표준 오류를 파일로 리디렉션한 다음 후처리하여 느린 부분을 찾습니다.
#!/usr/bin/env bash
# Run with:  bash -x ./myscript.sh  2>trace.log
# Or embed tracing inside the script:
export PS4='+ [${EPOCHREALTIME}] ${BASH_SOURCE}:${LINENO}: '
set -x

slow_function() {
  local result
  result=$(cat /etc/hostname)   # fork — slow
  echo "host: $result"
}

slow_function
set +x

# trace.log now contains timestamps so you can diff
# adjacent lines to find which step took longest.

쓸모없는 서브셸이란 무엇인가요

서브셸은 현재 셸 프로세스의 자식 복사본입니다. 다음과 같은 경우에 생성됩니다.

  • 명령 치환: $(command)
  • 괄호 그룹화: ( commands )
  • 셸 구문으로 파이프 연결: cmd | while read ...

격리나 파이프라인이 실제로 필요할 때 서브셸은 필요합니다. 셸 자체가 처리할 수 있는 외부 프로그램을 호출하기 위해서만 사용하거나, 아무 이유 없이 내장 명령을 추가 분기 계층으로 감쌀 때 서브셸은 쓸모없습니다.

최신 Linux 시스템에서 서브셸 하나를 분기하는 데 약 1~5ms가 걸립니다. 10,000번 실행되는 반복문에서는 쓸모없는 서브셸 1,000개가 순수한 오버헤드로 1~5초를 추가합니다.

고전적인 안티패턴: cat의 쓸모없는 사용

cat file | grep pattern은 분기가 많은 안티패턴으로 가장 유명합니다. grep만으로도 파일을 직접 읽을 수 있는데, 이 코드는 파이프로 연결된 두 개의 프로세스(cat과 grep)를 생성합니다.

해결 방법은 간단합니다. 파일을 이해하는 명령에 파일 이름을 직접 전달합니다. 도구가 파일 이름을 허용하지 않을 때는 이를 입력 리디렉션이라고 하며, 파일 이름을 허용할 때는 단순히 cat을 생략하면 됩니다.

  • 느림: cat file | grep pattern — 프로세스 2개, 파이프 1개
  • 빠름: grep pattern file — 프로세스 1개, 파이프 없음
  • 이 역시 빠름: grep pattern < file — 프로세스 1개, 표준 입력 리디렉션(파이프 버퍼 없음)
#!/usr/bin/env bash
# Create a sample file
seq 1 10000 > /tmp/numbers.txt

# --- Slow: useless cat ---
time cat /tmp/numbers.txt | grep -c "^5"

# --- Fast: grep reads the file directly ---
time grep -c "^5" /tmp/numbers.txt

# Both print the same count; the second is measurably faster
# because it skips the cat process and the inter-process pipe.

외부 명령을 셸 내장 명령으로 바꾸기

한 줄로 작성하는 많은 변환에는 분기를 완전히 피할 수 있는 내장 명령 대응 방법이 있습니다. 다음과 같은 일반적인 대체 방법을 비교해 보겠습니다.

  • echo ${#var}는 echo "$var" | wc -c 대신 사용할 수 있습니다 — 문자열 길이
  • ${var^^}와 ${var,,}는 echo "$var" | tr 'a-z' 'A-Z' 대신 사용할 수 있습니다 — 대소문자 변환(bash 4 이상)
  • ${var//search/replace}는 echo "$var" | sed 's/search/replace/' 대신 사용할 수 있습니다 — 단순 치환
  • [[ "$var" =~ pattern ]]은 echo "$var" | grep -q pattern 대신 사용할 수 있습니다 — 정규 표현식 일치
  • read -r line < file은 line=$(head -n1 file) 대신 사용할 수 있습니다 — 첫 번째 줄 읽기

이러한 내장 명령은 어느 것도 자식 프로세스를 분기하지 않습니다. 호출 한 번당 절약되는 시간은 작지만 반복문 안에서는 크게 누적됩니다.

#!/usr/bin/env bash
sentence="hello world from bash"

# --- Fork-heavy ---
upper_slow=$(echo "$sentence" | tr 'a-z' 'A-Z')
length_slow=$(echo "$sentence" | wc -c)

# --- Builtin equivalents (zero extra processes) ---
upper_fast=${sentence^^}
length_fast=${#sentence}

echo "Slow upper : $upper_slow"
echo "Fast upper : $upper_fast"
echo "Slow length: $length_slow"
echo "Fast length: $length_fast"

반복문 안에서 서브셸 피하기

반복문 안의 명령 치환은 반복 횟수만큼 분기 비용을 늘립니다. $(date) 호출 하나가 있는 500회 반복문은 타임스탬프만을 위해 자식 프로세스 500개를 생성합니다.

반복문 오버헤드를 줄이는 전략은 다음과 같습니다.

  • 변하지 않는 명령을 반복문 밖으로 옮깁니다(한 번 계산하고 재사용).
  • 산술 확장 $(( expr ))을 우선 사용합니다. 이는 분기가 아닌 내장 기능입니다.
  • 서식 지정만 필요할 때는 date를 호출하는 대신 printf를 사용합니다.
  • 외부 호출을 일괄 처리합니다. 먼저 데이터를 수집하고 반복문 밖에서 한 번 처리합니다.
#!/usr/bin/env bash
# Demonstrate: compute-once vs fork-per-iteration

# Bad: $(date) forks 1000 times
time (
  for i in $(seq 1 1000); do
    ts=$(date +%s)   # fork each iteration
    echo "$i $ts" > /dev/null
  done
)

# Good: capture once, reuse
time (
  ts=$(date +%s)   # fork exactly once
  for i in $(seq 1 1000); do
    echo "$i $ts" > /dev/null
  done
)

파이프 서브셸과 변수 범위의 함정

배시에서는 ksh/zsh와 달리 파이프라인의 각 명령이 자체 서브셸에서 실행됩니다. 따라서 파이프 안에서 설정한 변수는 파이프가 끝난 후 사라집니다.

이는 정확성 문제이면서 성능 문제이기도 합니다. 데이터를 수집하려고 while read로 파이프를 연결했지만, 나중에 변수가 비어 있는 상황을 발견할 수 있습니다.

해결 방법은 두 가지입니다.

  • 프로세스 치환 while read line; do ...; done < <(command)을 사용합니다. while 반복문이 서브셸이 아니라 현재 셸에서 실행됩니다.
  • lastpipe 옵션(shopt -s lastpipe)을 사용합니다. 파이프라인의 마지막 부분을 현재 셸에서 실행하도록 합니다(bash 4.2 이상).
#!/usr/bin/env bash
count=0

# --- Bug: count is always 0 after pipe (subshell) ---
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "After pipe   : count=$count"   # prints 0

# --- Fix 1: process substitution (no subshell for while) ---
count=0
while read -r n; do
  (( count++ ))
done < <(seq 1 5)
echo "Process sub  : count=$count"   # prints 5

# --- Fix 2: lastpipe option ---
shopt -s lastpipe
count=0
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "lastpipe     : count=$count"   # prints 5

마이크로벤치마크로 서브셸 비용 측정하기

간단한 벤치마크로 서브셸 오버헤드를 쉽게 증명할 수 있습니다. $(( ))(내장 기능)으로 수행한 산술 연산과 같은 연산을 expr(외부 프로세스)로 파이프 연결하여 수행한 결과를 비교해 보십시오.

일반적인 Linux 환경에서는 expr 호출 10,000번에 약 5초가 걸리는 반면, 동일한 수의 $(( )) 호출에는 0.1초 미만이 걸립니다. 출력은 동일하지만 50배 차이가 나는 것입니다.

이 벤치마크 패턴은 최적화를 측정할 때도 유용합니다. 두 버전을 반복문에서 N번 실행하고 time으로 비교하십시오.

#!/usr/bin/env bash
N=500

# External command (fork per call)
time (
  x=0
  for ((i=0; i<N; i++)); do
    x=$(expr $x + 1)   # forks expr each time
  done
  echo "expr result: $x"
)

# Arithmetic builtin (no fork)
time (
  x=0
  for ((i=0; i<N; i++)); do
    (( x++ ))          # pure builtin
  done
  echo "builtin result: $x"
)

here-string으로 echo 파이프 피하기

변수를 표준 입력으로 전달하기 위해 echo "$var" | command를 사용하는 것은 흔한 패턴입니다. 이 방식은 두 개의 프로세스(echo와 command)를 분기하고 파이프를 생성합니다. here-string(<<<)은 프로세스 하나만으로 같은 결과를 냅니다. 외부 명령이 커널이 관리하는 임시 버퍼에서 읽기 때문입니다.

  • grep pattern <<< "$var" — 프로세스 1개, 파이프 없음
  • read -r field1 field2 <<< "$line" — 외부 도구 없이 변수 분할
  • wc -w <<< "$sentence" — 변수에서 단어 수 세기

here-string은 모든 분기가 중요한 긴밀한 반복문 안에서 특히 유용합니다.

#!/usr/bin/env bash
data="The quick brown fox"

# --- Fork-heavy: echo spawns a child ---
word_count_slow=$(echo "$data" | wc -w)
echo "Slow word count: $word_count_slow"

# --- Fast: here-string, only wc spawns ---
word_count_fast=$(wc -w <<< "$data")
echo "Fast word count: $word_count_fast"

# --- Even better: use parameter expansion (zero forks) ---
# Split into array, count elements
read -ra words <<< "$data"
echo "Zero-fork count: ${#words[@]}"

실전 리팩터링: 전과 후

로그 파일을 처리하는 현실적인 스크립트를 살펴보면서 지금까지 배운 내용을 적용해 보겠습니다. 원래 버전은 cat, grep, awk, tr을 파이프로 연결합니다. 리팩터링한 버전은 프로세스 수를 8개에서 2개로 줄입니다.

주요 변경 사항:

  • cat을 제거했습니다. grep이 파일을 직접 읽습니다.
  • tr '[:lower:]' '[:upper:]'를 ${var^^}로 바꿨습니다.
  • echo "$line" | grep -q를 [[ $line =~ ]]로 바꿨습니다.
  • 파이프로 연결한 while 반복문 대신 프로세스 치환과 함께 read -r을 사용했습니다.

리팩터링한 후 time ./script.sh를 다시 실행하여 개선되었는지 확인하십시오. 항상 측정하고 추측에 의존하지 마십시오.

#!/usr/bin/env bash
# Create a sample log
printf 'ERROR: disk full\nINFO: started\nERROR: timeout\nINFO: done\n' \
  > /tmp/sample.log

# === BEFORE (fork-heavy) ===
time (
  cat /tmp/sample.log \
    | grep 'ERROR' \
    | while read -r line; do
        label=$(echo "$line" | tr '[:lower:]' '[:upper:]')
        echo "[ALERT] $label"
      done
)

# === AFTER (builtin-first) ===
time (
  while IFS= read -r line; do
    echo "[ALERT] ${line^^}"
  done < <(grep 'ERROR' /tmp/sample.log)
)

지식 점검: 서브셸 범위

파이프라인 서브셸과 파이프 내부에서 변경한 변수 값이 사라지는 문제를 방지하는 방법을 제대로 이해했는지 확인해 보십시오.

강의 요약: 먼저 프로파일링하고, 포크는 줄이기

이번 강의에서는 Bash 스크립트에서 불필요한 프로세스 생성을 일으키는 가장 일반적인 원인을 식별하고 제거하는 방법을 배웠습니다.

핵심 내용:

  • 최적화하기 전에 time과 PS4가 추가된 bash -x를 사용해 측정하십시오.
  • 쓸모없는 cat 사용은 가장 널리 퍼진 안티패턴입니다. 파일 이름을 직접 받을 수 있는 명령에 파일 이름을 직접 전달하십시오.
  • echo "$var" | command를 here-string(command <<< "$var") 또는 내장 명령으로 바꾸십시오.
  • 매개변수 확장(${var^^}, ${var//s/r}, ${#var})을 사용하면 많은 tr, sed, wc 호출을 대체할 수 있습니다.
  • 파이프라인 서브셸에서는 변수 변경이 사라지므로 프로세스 치환 또는 shopt -s lastpipe를 사용하십시오.
  • 변하지 않는 명령 호출은 반복문 밖으로 옮기고, expr보다 $(( )) 산술식을 우선 사용하십시오.

경험적으로 따를 규칙은 다음과 같습니다. 먼저 측정하고, 가능하면 외부 명령을 내장 명령으로 바꾼 다음, 두 번째 측정으로 개선 결과를 확인하십시오.

자주 묻는 질문

“스크립트 프로파일링 및 불필요한 서브셸 방지” 강의는 무료인가요?

네 — “스크립트 프로파일링 및 불필요한 서브셸 방지” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DevOps Bootcamp 강의 전체를 잠금 해제할 수 있습니다. DevOps Bootcamp 강의에는 총 4개의 강의가 포함되어 있습니다.

“스크립트 프로파일링 및 불필요한 서브셸 방지”에서 뭘 배우나요?

스크립트 실행 시간을 측정하고 cat과 grep을 연이어 사용하는 것처럼 포크가 많은 패턴을 내장 기능으로 대체합니다. 브라우저에서 직접 실행하는 실습 코드로 DevOps Bootcamp을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

DevOps Bootcamp을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 DevOps Bootcamp은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.

“스크립트 프로파일링 및 불필요한 서브셸 방지” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 DevOps Bootcamp 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 DevOps Bootcamp 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 스크립트 프로파일링 및 불필요한 서브셸 방지
  2. xargs -P와 백그라운드 작업을 활용한 병렬 처리
  3. GNU parallel을 활용한 작업 오케스트레이션
  4. 처리량을 높이는 스트리밍 파이프라인과 명명된 파이프
← DevOps Bootcamp(으)로 돌아가기