- Authors
- Name
들어가며
서버에 SSH 접속하고, CI/CD 파이프라인을 작성하고, 로그를 분석하고, 배포 스크립트를 돌린다. 엔지니어의 하루는 Shell 위에서 시작되고 Shell 위에서 끝난다. 하지만 의외로 많은 개발자가 Shell의 기본 동작 원리를 깊이 이해하지 못한 채 "되는 명령어"만 반복한다.
이 글에서는 Bash/Zsh 기본 문법에서 출발하여 파이프라인, 프로세스 치환, 시그널 핸들링, 성능 최적화까지 엔지니어가 알아야 할 Shell 기법을 실전 예제 중심으로 다룬다.
1. Shell 선택: Bash vs Zsh vs Fish
| 항목 | Bash | Zsh | Fish |
|---|---|---|---|
| 기본 탑재 | 대부분 Linux 배포판 | macOS (Catalina+) | 별도 설치 |
| POSIX 호환 | 거의 완전 | 거의 완전 | 비호환 |
| 자동완성 | 기본 수준 | 플러그인으로 강력 | 기본 내장 최강 |
| 스크립트 호환 | 표준 | Bash 호환 모드 지원 | 독자 문법 |
| 프롬프트 커스텀 | PS1 직접 수정 | Oh My Zsh / Powerlevel10k | 내장 설정 |
| 추천 용도 | 서버 스크립트, CI/CD | 로컬 개발 환경 | 개인 터미널 |
실전 원칙: 서버 스크립트는
#!/usr/bin/env bash로 작성하고, 로컬 인터랙티브 셸은 Zsh를 쓴다.
2. 기본기: 변수·조건·반복
2.1 변수 선언과 스코프
# 로컬 변수 (현재 셸에서만)
APP_NAME="my-service"
# 환경 변수 (자식 프로세스에 전달)
export DB_HOST="db.prod.internal"
# readonly - 실수로 덮어쓰기 방지
readonly CONFIG_PATH="/etc/app/config.yaml"
# 변수 기본값 패턴
: "${LOG_LEVEL:=info}" # 미설정 시 info 할당
: "${TIMEOUT:?TIMEOUT 환경변수 필수}" # 미설정 시 에러 종료
echo "${USER:-unknown}" # 미설정 시 unknown 출력 (할당 안 함)
2.2 조건문 패턴
# 문자열 비교 - [[ ]] 사용 (Bash/Zsh 확장)
if [[ "$ENV" == "production" ]]; then
echo "프로덕션 모드"
elif [[ "$ENV" =~ ^(staging|dev)$ ]]; then
echo "비프로덕션 환경: $ENV"
else
echo "알 수 없는 환경"
fi
# 파일 테스트
[[ -f /etc/hosts ]] # 파일 존재
[[ -d /var/log ]] # 디렉터리 존재
[[ -r "$file" ]] # 읽기 권한
[[ -s "$file" ]] # 파일 크기 > 0
[[ "$f1" -nt "$f2" ]] # f1이 f2보다 최신
# 산술 비교 - (( )) 사용
if (( retries > 3 )); then
echo "재시도 한도 초과"
fi
2.3 반복문 패턴
# 파일 목록 순회 - glob 사용 (ls 파싱 금지!)
for f in /var/log/*.log; do
[[ -f "$f" ]] || continue
echo "처리 중: $f ($(wc -l < "$f") 줄)"
done
# C-style for
for (( i=0; i<10; i++ )); do
curl -s "http://api.local/health" > /dev/null && break
sleep 1
done
# while + read - 파일/명령 출력 한 줄씩 처리
while IFS=',' read -r name email role; do
echo "사용자 생성: $name ($role)"
done < users.csv
# 무한 루프 + 탈출 조건
while true; do
status=$(curl -s -o /dev/null -w '%{http_code}' http://api/health)
[[ "$status" == "200" ]] && break
sleep 5
done
3. 파이프라인 심화
3.1 파이프라인 기본 원리
파이프(|)는 앞 명령의 stdout을 뒤 명령의 stdin에 연결한다. 각 명령은 별도 서브셸에서 동시 실행된다.
# 접속 IP Top 10
awk '{print $1}' /var/log/nginx/access.log \
| sort \
| uniq -c \
| sort -rn \
| head -10
# pipefail - 파이프라인 중간 실패 감지
set -o pipefail
curl -s "$URL" | jq '.items[]' | wc -l
# curl 실패 시 전체 파이프라인 종료 코드 ≠ 0
3.2 프로세스 치환 (Process Substitution)
두 명령의 출력을 파일처럼 다른 명령에 전달한다.
# 두 서버의 패키지 목록 비교
diff <(ssh server1 'rpm -qa | sort') <(ssh server2 'rpm -qa | sort')
# 두 API 응답 비교
diff <(curl -s api-v1/users | jq -S .) <(curl -s api-v2/users | jq -S .)
# tee + 프로세스 치환: 한 스트림을 여러 곳에 동시 전달
cat access.log \
| tee >(grep 'ERROR' > errors.log) \
| tee >(awk '{print $1}' | sort -u > unique_ips.txt) \
| wc -l
3.3 리다이렉션 고급 패턴
# stderr만 캡처
errors=$(command 2>&1 1>/dev/null)
# stdout + stderr 모두 파일로
command &> output.log # Bash 4+
command > output.log 2>&1 # POSIX 호환
# Here String
grep "pattern" <<< "$variable"
# File Descriptor 활용
exec 3>/tmp/audit.log # FD 3 열기
echo "작업 시작: $(date)" >&3
do_something
echo "작업 완료: $(date)" >&3
exec 3>&- # FD 3 닫기
4. 함수와 에러 처리
4.1 함수 정의 패턴
# 방어적 함수 구조
log() {
local level="${1:?level 필수 (INFO|WARN|ERROR)}"
local message="${2:?message 필수}"
printf '[%s] [%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$message" >&2
}
retry() {
local max_attempts="${1:?}"
local delay="${2:?}"
shift 2
local attempt=1
until "$@"; do
if (( attempt >= max_attempts )); then
log ERROR "명령 실패 ($max_attempts회 시도): $*"
return 1
fi
log WARN "재시도 $attempt/$max_attempts (${delay}s 후): $*"
sleep "$delay"
(( attempt++ ))
done
}
# 사용
retry 5 3 curl -sf http://api.internal/health
4.2 안전한 스크립트 헤더
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# set -e: 명령 실패 시 즉시 종료
# set -u: 미정의 변수 사용 시 에러
# set -o pipefail: 파이프라인 중간 실패 감지
# IFS: 단어 분리 기준을 줄바꿈·탭으로 제한
# 클린업 트랩
cleanup() {
local exit_code=$?
rm -f "$TMPFILE"
log INFO "종료 (exit code: $exit_code)"
exit "$exit_code"
}
trap cleanup EXIT
trap 'log ERROR "라인 $LINENO에서 에러 발생"; exit 1' ERR
TMPFILE=$(mktemp)
5. 텍스트 처리 파이프라인
5.1 도구 비교표
| 도구 | 용도 | 속도 | 복잡성 |
|---|---|---|---|
grep | 패턴 매칭·필터링 | 매우 빠름 | 낮음 |
sed | 스트림 편집·치환 | 빠름 | 중간 |
awk | 필드 기반 처리·집계 | 빠름 | 높음 |
jq | JSON 처리 | 빠름 | 중간 |
yq | YAML 처리 | 보통 | 중간 |
cut/paste | 단순 필드 추출·병합 | 매우 빠름 | 낮음 |
xargs | 표준입력 → 인수 변환 | 빠름 | 중간 |
5.2 실전 예제
# 1. 로그에서 5xx 에러 요청 경로 Top 10
awk '$9 ~ /^5[0-9]{2}$/ {print $7}' access.log \
| sort | uniq -c | sort -rn | head -10
# 2. JSON API 응답에서 특정 필드 추출 + CSV 변환
curl -s https://api.example.com/users \
| jq -r '.[] | [.id, .name, .email] | @csv'
# 3. YAML 설정에서 이미지 태그 일괄 변경
yq -i '.spec.template.spec.containers[].image |= sub("v1\\.2\\.3", "v1.2.4")' \
k8s/deployment.yaml
# 4. 대용량 로그 병렬 검색 (xargs + grep)
find /var/log -name '*.log' -mtime -1 -print0 \
| xargs -0 -P4 grep -l 'OutOfMemoryError'
# 5. CSV 3번째 컬럼 합계
awk -F',' '{sum += $3} END {printf "합계: %.2f\n", sum}' sales.csv
6. 시그널 핸들링과 프로세스 관리
6.1 주요 시그널
| 시그널 | 번호 | 기본 동작 | 용도 |
|---|---|---|---|
SIGHUP | 1 | 종료 | 데몬 설정 리로드 |
SIGINT | 2 | 종료 | Ctrl+C |
SIGQUIT | 3 | 코어 덤프 | Ctrl+\ |
SIGKILL | 9 | 강제 종료 | 트랩 불가 |
SIGTERM | 15 | 종료 | 정상 종료 요청 |
SIGUSR1 | 10 | 사용자 정의 | 로그 레벨 변경 등 |
SIGSTOP | 19 | 일시정지 | 트랩 불가 |
6.2 Graceful Shutdown 패턴
#!/usr/bin/env bash
set -euo pipefail
RUNNING=true
CHILD_PID=""
shutdown() {
log INFO "종료 시그널 수신, graceful shutdown 시작"
RUNNING=false
if [[ -n "$CHILD_PID" ]]; then
kill -TERM "$CHILD_PID" 2>/dev/null || true
wait "$CHILD_PID" 2>/dev/null || true
fi
}
trap shutdown SIGTERM SIGINT
while $RUNNING; do
process_job &
CHILD_PID=$!
wait "$CHILD_PID" || true
CHILD_PID=""
sleep 5
done
log INFO "정상 종료"
6.3 Job Control
# 백그라운드 실행 + 완료 대기
build_frontend &
pid1=$!
build_backend &
pid2=$!
wait "$pid1" "$pid2"
echo "빌드 완료"
# nohup - 세션 종료 후에도 실행 유지
nohup long_task.sh > /var/log/task.log 2>&1 &
disown
# timeout - 명령 실행 시간 제한
timeout 30s curl -s http://slow-api.com/data
7. 배열과 연관 배열
# 인덱스 배열
servers=("web01" "web02" "web03" "db01")
echo "서버 수: ${#servers[@]}"
echo "첫 번째: ${servers[0]}"
echo "전체: ${servers[@]}"
# 배열 슬라이스
web_servers=("${servers[@]:0:3}")
# 배열에 추가
servers+=("cache01")
# 연관 배열 (Bash 4+)
declare -A service_ports
service_ports=(
[nginx]=80
[api]=8080
[redis]=6379
[postgres]=5432
)
for svc in "${!service_ports[@]}"; do
echo "$svc → ${service_ports[$svc]}"
done
# 배열로 안전한 명령 구성
curl_opts=(
-s
--max-time 10
--retry 3
-H "Authorization: Bearer $TOKEN"
-H "Content-Type: application/json"
)
curl "${curl_opts[@]}" "$API_URL"
8. 고급 패턴
8.1 Subshell vs Command Group
# Subshell () - 별도 프로세스, 부모 변수 변경 없음
(cd /tmp && tar czf backup.tar.gz /var/data)
# 현재 디렉터리 변경 없음
# Command Group {} - 현재 셸에서 실행
{
echo "=== 시스템 정보 ==="
uname -a
free -h
df -h
} > system_report.txt
8.2 동적 변수명 (nameref)
# Bash 4.3+ nameref
setup_db() {
local -n result=$1 # nameref
result="postgresql://localhost:5432/app"
}
setup_db DB_URL
echo "$DB_URL" # postgresql://localhost:5432/app
8.3 병렬 실행 패턴
# GNU parallel을 이용한 병렬 처리
cat server_list.txt | parallel -j10 'ssh {} "df -h / | tail -1"'
# xargs 병렬
find . -name '*.png' -print0 \
| xargs -0 -P$(nproc) -I{} convert {} -resize 50% resized/{}
# wait + 배열로 병렬 제어
pids=()
for host in web0{1..5}; do
deploy.sh "$host" &
pids+=($!)
done
failed=0
for pid in "${pids[@]}"; do
wait "$pid" || (( failed++ ))
done
echo "배포 완료: 실패 $failed건"
9. 성능 최적화 체크리스트
| 항목 | 느린 패턴 | 빠른 패턴 |
|---|---|---|
| 루프 내 외부 명령 | for f in ...; do cat "$f" | grep ...; done | grep -r ... /path/ |
| 서브셸 남발 | result=$(echo "$var" | sed ...) | result="${var//old/new}" |
| 불필요한 파이프 | cat file | grep pattern | grep pattern file |
| 정렬 후 유니크 | sort | uniq | sort -u |
| 큰 파일 행 수 | cat file | wc -l | wc -l < file |
| 파일 존재 확인 | ls /path/file 2>/dev/null | [[ -f /path/file ]] |
| 문자열에서 추출 | echo "$s" | cut -d. -f1 | "${s%%.*}" (Parameter Expansion) |
Parameter Expansion 주요 패턴
file="/var/log/nginx/access.log"
echo "${file##*/}" # access.log (경로 제거)
echo "${file%.*}" # /var/log/nginx/access (확장자 제거)
echo "${file%%/*}" # (빈 문자열, 첫 / 이전)
echo "${file%.log}.bak" # /var/log/nginx/access.bak
version="v1.2.3-rc1"
echo "${version#v}" # 1.2.3-rc1
echo "${version%-*}" # v1.2.3
echo "${version^^}" # V1.2.3-RC1 (대문자)
echo "${version,,}" # v1.2.3-rc1 (소문자)
echo "${#version}" # 10 (문자열 길이)
10. 실전 스크립트 템플릿
배포 스크립트
#!/usr/bin/env bash
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="${1:?사용법: $0 <app-name> <version>}"
readonly VERSION="${2:?사용법: $0 <app-name> <version>}"
readonly DEPLOY_ENV="${DEPLOY_ENV:-staging}"
readonly LOG_FILE="/var/log/deploy/${APP_NAME}-$(date +%Y%m%d-%H%M%S).log"
# --- 로깅 ---
log() { printf '[%s] [%-5s] %s\n' "$(date +%T)" "$1" "$2" | tee -a "$LOG_FILE" >&2; }
info() { log INFO "$1"; }
warn() { log WARN "$1"; }
die() { log ERROR "$1"; exit 1; }
# --- 사전 점검 ---
preflight() {
info "사전 점검 시작"
command -v docker >/dev/null || die "docker가 설치되어 있지 않습니다"
command -v kubectl >/dev/null || die "kubectl이 설치되어 있지 않습니다"
local context
context=$(kubectl config current-context)
[[ "$context" == *"$DEPLOY_ENV"* ]] || die "kubectl context($context)가 $DEPLOY_ENV 와 일치하지 않습니다"
info "사전 점검 통과 (context: $context)"
}
# --- 배포 ---
deploy() {
info "$APP_NAME:$VERSION → $DEPLOY_ENV 배포 시작"
kubectl set image "deployment/$APP_NAME" \
"$APP_NAME=registry.internal/$APP_NAME:$VERSION" \
--record
info "롤아웃 대기 중..."
if ! kubectl rollout status "deployment/$APP_NAME" --timeout=300s; then
warn "롤아웃 실패, 롤백 실행"
kubectl rollout undo "deployment/$APP_NAME"
die "배포 실패 → 롤백 완료"
fi
info "배포 성공"
}
# --- 메인 ---
main() {
mkdir -p "$(dirname "$LOG_FILE")"
info "=== $APP_NAME $VERSION 배포 ($DEPLOY_ENV) ==="
preflight
deploy
info "=== 배포 완료 ==="
}
main "$@"
마무리 체크리스트
- 스크립트 상단에
set -euo pipefail선언했는가? - 모든 변수를 큰따옴표(
"$var")로 감쌌는가? - 외부 입력(사용자, 파일명)을 그대로 명령에 넣지 않았는가?
-
trap으로 임시 파일·프로세스 정리를 보장했는가? - 루프 안에서 불필요한 외부 명령 호출을 줄였는가?
- ShellCheck(
shellcheck script.sh)로 정적 분석을 통과했는가? - POSIX 호환이 필요한 환경이면 Bash 확장 문법을 피했는가?
Shell은 "알면 빠르고, 모르면 위험한" 도구다. 기본기를 탄탄히 다지고 안전한 패턴을 습관화하면, 어떤 서버 환경에서도 자신 있게 문제를 해결할 수 있다.