Table of contents
- 왜 스크립트를 쓰나
- 첫 번째 스크립트 — 뼈대 잡기
- 변수와 인용 — 세 가지 큰 함정
- 조건문 —
[[ ... ]]를 쓴다 - 반복문 — for / while
- 함수 — 재사용의 최소 단위
- exit code — 성공과 실패를 숫자로
- set 옵션의 실제 동작
- 실전 예제 1 — 백업 스크립트
- 실전 예제 2 — 디스크·메모리 모니터링
- 디버깅 — bash -x와 trap
- ShellCheck — 린터를 곁에 둔다
- 쉘을 넘어서는 지점 — 어디서 멈춰야 할까
- Linux 기초 시리즈를 마무리하며
왜 스크립트를 쓰나
여기까지 시리즈를 따라온 독자라면 이미 손이 기억하는 명령이 꽤 쌓였을 것이다. apt update && apt upgrade, systemctl restart nginx, journalctl -u myapp -f, curl -v http://.... 매일 아침 이 명령들을 순서대로 쳐야 한다면 어느 날부터 누가 한 줄을 빼먹는다. 그래서 스크립트를 쓴다.
Bash 스크립트는 “터미널에 친 것과 똑같은 명령을 파일로 모은 것”이 출발점이다. 거기에 변수·조건문·반복문·함수 같은 요소가 더해지고, 실패 처리와 디버깅 옵션까지 붙으면 운영 자동화의 기본이 된다.
이 글은 “스크립트를 한 번도 안 짜봤다”는 사람이 안전하게 돌아가는 스크립트를 짤 수 있을 정도까지 끌어올리는 걸 목표로 삼는다. 문법 레퍼런스라기보다 실전에서 자주 밟는 지뢰와 그걸 피하는 관용구 모음에 가깝다.
flowchart TB
A["매일 치는 명령들"] --> B["파일로 묶기 (.sh)"]
B --> C["변수·인용으로 유연하게"]
C --> D["조건문·반복문으로 로직 추가"]
D --> E["함수로 재사용"]
E --> F["exit code · set -e 로 안전하게"]
F --> G["bash -x 로 디버깅"]
첫 번째 스크립트 — 뼈대 잡기
어느 스크립트든 시작은 비슷하다.
#!/usr/bin/env bash
set -euo pipefail
echo "Hello, scripting."
한 줄씩 뜯어보자.
#!/usr/bin/env bash: shebang. 이 파일을 실행하면env가$PATH에서bash를 찾아 인터프리터로 쓴다.#!/bin/bash로도 쓸 수 있는데, macOS나 일부 minimal 컨테이너에서는 경로가 달라env방식이 더 포터블하다set -e: 명령 하나라도 실패하면 즉시 스크립트를 종료set -u: 정의되지 않은 변수를 쓰면 에러set -o pipefail: 파이프라인 중 어떤 단계가 실패하면 전체를 실패로 처리
set -euo pipefail은 거의 모든 “실무용” 스크립트의 첫 줄로 들어가는 관용구다. 자세한 이유는 아래에서 하나씩 살펴본다. 이 세 줄만 있어도 초심자가 짜는 스크립트의 대형 사고 대부분이 차단된다.
파일을 만든다.
cat > hello.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
echo "Hello, scripting."
EOF
chmod +x hello.sh
./hello.sh
chmod +x로 실행 권한을 줘야 ./hello.sh가 돈다. 권한 없이도 bash hello.sh로 명시적으로 실행할 수는 있지만, 스크립트로 배포할 거라면 실행 권한을 붙이는 게 관례다.
변수와 인용 — 세 가지 큰 함정
Bash의 가장 흔한 버그는 인용 처리다. 변수를 제대로 안 감싸면 공백과 특수문자가 들어오는 순간 스크립트가 부서진다. 이 절 하나만 챙겨도 버그의 절반이 사라진다.
name="ioob"
greeting="Hello, $name" # 변수 전개
echo "$greeting" # 변수는 항상 큰따옴표로 감싼다
세 가지 인용 규칙을 먼저 잡는다.
- 큰따옴표
"..."— 변수 전개 허용, 공백 보존. 대부분 여기를 쓴다 - 작은따옴표
'...'— 내부 전개 없이 그대로. 고정 문자열에 - 인용 없음 — 공백으로 쪼개지고 와일드카드 확장. 대부분 버그의 원흉
차이를 실제로 느껴보는 데는 공백이 들어간 경로가 효과적이다.
dir="/tmp/my data"
# 나쁜 예 — 공백에서 쪼개져서 두 인자로 전달된다
rm -rf $dir # rm -rf /tmp/my data — "data" 파일을 지우려 함
# 좋은 예
rm -rf "$dir" # 하나의 경로로 전달
이 차이가 남의 시스템에서 파일을 날려먹는 사고로 직결되기 때문에, 변수는 무조건 큰따옴표로 감싼다는 습관이 좋다.
변수 확장 관용구
Bash는 변수 확장에서 쓸 만한 단축 문법을 몇 개 갖고 있다. 자주 쓰는 것만 정리해두면 편하다.
# 기본값 — 변수가 비어 있으면 "default"
echo "${NAME:-default}"
# 기본값 대입 — 변수가 비어 있으면 default 대입
: "${NAME:=default}"
echo "$NAME" # default
# 필수 — 변수가 비어 있으면 에러 후 종료
echo "${NAME:?NAME is required}"
# 길이
s="hello"; echo "${#s}" # 5
# 접두/접미 제거
path="/var/log/app.log"
echo "${path%.log}" # /var/log/app
echo "${path##*/}" # app.log (basename처럼)
echo "${path%/*}" # /var/log (dirname처럼)
${VAR:-default}는 “환경변수가 있으면 그걸 쓰고, 없으면 기본값”이라는 의미라 환경 분기에서 자주 등장한다. ${VAR:?}는 “없으면 즉시 죽어라”로, 필수 인자 검증에 유용하다.
명령 치환
명령의 출력을 변수로 받아올 때는 $(...)를 쓴다.
today=$(date +%Y-%m-%d)
echo "Today is $today"
# 중첩도 자연스럽게
count=$(wc -l < "$(ls -t log/*.log | head -1)")
예전 문법인 백틱 `...`은 중첩이 불가능하고 읽기 어렵다. $()를 기본으로 쓴다고 정해두면 된다.
조건문 — [[ ... ]]를 쓴다
Bash의 조건문은 if와 테스트 블록으로 이뤄진다. 현대 Bash에서는 [[ ... ]]가 표준이다. 예전 [ ... ](test 명령 별칭)보다 안전하고 기능이 많다.
name="ioob"
if [[ "$name" == "ioob" ]]; then
echo "hello, ioob"
elif [[ "$name" == "admin" ]]; then
echo "welcome, admin"
else
echo "who are you?"
fi
자주 쓰는 비교 표현을 정리하면 이렇다.
| 표현 | 의미 |
|---|---|
[[ "$a" == "$b" ]] | 문자열 같음 |
[[ "$a" != "$b" ]] | 문자열 다름 |
[[ "$a" =~ ^[0-9]+$ ]] | 정규식 매칭 |
[[ -z "$a" ]] | 빈 문자열 |
[[ -n "$a" ]] | 비어있지 않음 |
(( a == b )) | 숫자 비교 |
(( a < b )) | 숫자 미만 |
[[ -f "$f" ]] | 파일 존재 (파일 타입) |
[[ -d "$d" ]] | 디렉토리 존재 |
[[ -e "$p" ]] | 경로 존재 (아무 타입이든) |
[[ -r "$f" ]] | 읽기 권한 |
&&, ||, ! | 논리 AND/OR/NOT |
숫자 비교를 문자열 비교로 쓰면 "10" < "9"가 true로 나오는 함정이 있다. 숫자 비교에는 (( ... ))라는 점을 분명히 기억한다.
count=10
if (( count > 5 )); then
echo "big"
fi
반복문 — for / while
Bash의 for는 두 가지 스타일이 있다. 목록 순회와 C 스타일이다.
# 목록 순회
for name in alice bob carol; do
echo "hi, $name"
done
# 파일 순회 — 글롭(glob)이 목록으로 전개된다
for f in ./*.log; do
echo "$f"
done
# 숫자 범위 (brace expansion)
for i in {1..5}; do
echo "round $i"
done
# C 스타일
for (( i=0; i<5; i++ )); do
echo "i=$i"
done
파일 목록을 순회할 때 ls의 출력을 쓰지 않는다. for f in $(ls ...)는 공백 있는 파일명에서 부서진다. 위처럼 글롭 패턴을 직접 쓰는 쪽이 안전하다.
while은 조건이 참인 동안 반복한다. 자주 쓰는 패턴은 한 줄씩 파일을 읽는 경우다.
while IFS= read -r line; do
echo "line: $line"
done < /etc/hosts
IFS=와 -r를 짝지어 쓰는 게 관용구다. 공백을 보존하고 백슬래시 해석을 막는다. 이 조합 없이 라인을 읽으면 들여쓰기나 특수문자가 섞인 파일에서 오동작한다.
함수 — 재사용의 최소 단위
함수는 이름 있는 명령 블록이다. 반복되는 로직을 묶어둔다.
log() {
local level="$1"
shift
echo "[$(date +%Y-%m-%dT%H:%M:%S%z)] [$level] $*"
}
require() {
local cmd="$1"
if ! command -v "$cmd" >/dev/null 2>&1; then
log ERROR "required command not found: $cmd"
exit 1
fi
}
log INFO "script started"
require rsync
require jq
log INFO "all requirements satisfied"
몇 가지 실전 팁이다.
local로 지역 변수를 선언한다. 안 쓰면 전역이 된다. 모르는 사이 바깥 변수를 덮어쓴다$1,$2…는 함수 인자.$@는 인자 전체(보존 배열),$*는 공백으로 합쳐진 문자열- 함수의 성공·실패는 return code로 전달한다.
return 0이 성공, 다른 숫자는 실패. 아무것도 쓰지 않으면 마지막 명령의 종료 코드가 리턴값
exit code — 성공과 실패를 숫자로
모든 명령은 종료 시 0~255 범위의 숫자를 반환한다. 0은 성공, 그 외는 실패라는 관례가 리눅스 전체에서 통한다. 이 숫자를 $?로 받을 수 있다.
grep "ERROR" /var/log/syslog
echo "exit code: $?"
# 못 찾으면 1, 찾으면 0, I/O 오류면 2
스크립트의 마지막 줄에서 반환하는 exit code는 “이 스크립트 전체의 성공 여부”로 이어진다. CI 파이프라인, systemd Restart=on-failure, &&/|| 체인 모두 이 숫자에 의존한다. 그래서 실패 경로에서 0을 리턴하면 안 된다.
# 나쁜 예 — 실패를 삼킨다
grep "ERROR" file.log || true # 안 잡혀도 계속 진행
# 좋은 예 — 실패를 명시적으로 처리
if ! grep "ERROR" file.log >/dev/null; then
log INFO "no errors today"
fi
“일부러 실패를 무시”하고 싶을 때만 || true를 쓴다. 습관적으로 달면 set -e의 효과가 무력화된다.
set 옵션의 실제 동작
set -euo pipefail을 첫 줄에 썼다면 스크립트가 구체적으로 어떻게 바뀌는지 간단히 시연해둔다.
set -e — 실패하면 즉시 종료
#!/usr/bin/env bash
set -e
cp nonexistent.txt /tmp/
echo "이 줄은 실행되지 않는다"
cp가 실패하는 순간 스크립트가 멈춘다. -e 없이 짠 스크립트는 “에러가 났는데도 다음 줄을 계속 실행”해서 엉뚱한 상태가 되기 쉽다.
예외: set -e는 명시적으로 조건에 들어간 명령에는 적용되지 않는다. if grep ..., while grep ..., grep ... && ..., grep ... || true 같은 상황이다. 그래서 실패를 조건으로 다루고 싶을 땐 if로 감싸면 된다.
set -u — 미정의 변수 사용 시 에러
#!/usr/bin/env bash
set -u
echo "hello, $USERNAME" # 변수가 정의되지 않았으면 에러
오타가 나서 $USRENAME으로 쳤다면 보통은 빈 문자열로 처리되어 조용히 지나간다. -u가 있으면 즉시 에러로 잡힌다. :- 기본값 문법과 같이 써서 “선택적 변수”를 처리한다.
set -o pipefail — 파이프 중간 실패도 잡는다
#!/usr/bin/env bash
set -e
set -o pipefail
cat nonexistent.txt | wc -l
# pipefail 없으면 wc -l은 0을 출력하고 성공으로 끝난다
# pipefail 있으면 cat의 실패가 전체 실패로 전파된다
파이프 라인은 마지막 명령의 exit code만 본다는 게 기본이라, 중간 단계의 에러가 가려진다. pipefail이 이걸 막는다.
trap — 실패·종료 시 정리 훅
trap으로 “스크립트가 죽을 때 정리”를 달 수 있다. 임시 파일을 지우는 용도로 자주 쓴다.
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
echo "work in $tmpdir"
# 어떻게 끝나든(정상·실패·Ctrl+C) tmpdir이 지워진다
EXIT는 “스크립트가 종료되면 무조건”, ERR은 “에러가 나는 순간”이다. 둘을 같이 쓰는 스크립트도 많다.
실전 예제 1 — 백업 스크립트
지금까지의 요소를 엮어 실제 써먹을 만한 스크립트를 짜본다. 첫 번째 예는 데이터베이스 덤프를 압축해서 원격 스토리지에 올리고, 오래된 파일은 정리하는 흔한 백업 작업이다.
#!/usr/bin/env bash
set -euo pipefail
# --- 설정 ---
DB_NAME="${DB_NAME:-myapp}"
BACKUP_ROOT="${BACKUP_ROOT:-/var/backups/db}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
S3_BUCKET="${S3_BUCKET:-s3://backups/myapp}"
# --- 유틸 ---
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
}
require() {
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 \
|| { log "required command missing: $cmd"; exit 1; }
done
}
# --- 준비 ---
require pg_dump gzip aws
mkdir -p "$BACKUP_ROOT"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
file="$BACKUP_ROOT/${DB_NAME}_${stamp}.sql.gz"
# 에러/정상 어느 쪽이든 로컬 임시 파일은 정리
trap 'log "backup script exiting with code $?"' EXIT
# --- 덤프 ---
log "dumping $DB_NAME to $file"
pg_dump "$DB_NAME" | gzip -c > "$file"
# --- 업로드 ---
log "uploading to $S3_BUCKET"
aws s3 cp "$file" "$S3_BUCKET/"
# --- 로컬 보관 기간 정리 ---
log "cleaning local backups older than $RETENTION_DAYS days"
find "$BACKUP_ROOT" -name "${DB_NAME}_*.sql.gz" -mtime +"$RETENTION_DAYS" -print -delete
log "done"
이 스크립트의 장점을 짚으면 이렇다.
- 환경변수 override:
DB_NAME,BACKUP_ROOT같은 값은 기본값이 있지만 env로 덮을 수 있다. 다른 환경에서도 재사용 가능 - require 헬퍼: 필요한 명령이 환경에 없으면 조기에 실패. “전부 다 돈 다음에 마지막에 실패”를 방지
- trap: 종료 시 로그 한 줄을 남긴다. systemd와 연결했을 때
journalctl에서 추적이 쉽다 set -euo pipefail:pg_dump | gzip중 어느 쪽이 실패해도 즉시 잡힌다
systemd 타이머와 묶으면 cron 대신 쓸 수 있다(6편 참고).
실전 예제 2 — 디스크·메모리 모니터링
두 번째 예는 임계치를 넘으면 알림을 보내는 간단한 모니터링 스크립트다. 실전에서는 Prometheus·Datadog 같은 도구를 쓰겠지만, 곁에 두고 쓰는 간이 도구로도 가치가 있다.
#!/usr/bin/env bash
set -euo pipefail
DISK_THRESHOLD="${DISK_THRESHOLD:-90}" # %
MEM_THRESHOLD="${MEM_THRESHOLD:-85}" # %
WEBHOOK="${WEBHOOK:-}" # 비어있으면 알림 생략
notify() {
local msg="$1"
echo "$msg"
if [[ -n "$WEBHOOK" ]]; then
curl -fsS -X POST -H "Content-Type: application/json" \
-d "{\"text\":\"$msg\"}" "$WEBHOOK" || true
fi
}
# 디스크 — / 마운트 기준
disk_used=$(df -P / | awk 'NR==2 {gsub("%",""); print $5}')
if (( disk_used >= DISK_THRESHOLD )); then
notify "[WARN] disk usage on / is ${disk_used}% (threshold ${DISK_THRESHOLD}%)"
fi
# 메모리 — 사용률
read -r total used <<<"$(free -m | awk '/^Mem:/ {print $2, $3}')"
mem_used=$(( used * 100 / total ))
if (( mem_used >= MEM_THRESHOLD )); then
notify "[WARN] memory usage is ${mem_used}% (threshold ${MEM_THRESHOLD}%)"
fi
짚어볼 포인트가 몇 있다.
df -P: POSIX 출력. 컬럼 정렬이 깨지지 않아서awk로 파싱하기 좋다awk 'NR==2 {gsub("%",""); print $5}': 두 번째 줄에서 다섯 번째 열(사용률)의%를 떼고 출력. 파이프라인으로 이런 변환을 거는 건 매우 흔한 관용구read -r total used <<<"...": 명령 출력을 두 변수에 한 번에 나눠 담는 here-string 패턴curl ... || true: 알림 실패가 스크립트 전체를 실패시키지 않도록. 경보가 늦는 것보단 놓치는 쪽이 덜 나쁘다고 판단하면 이렇게 둔다
임계값과 웹훅은 환경변수로 바뀌므로, 같은 스크립트를 여러 서버에서 다른 설정으로 돌릴 수 있다.
디버깅 — bash -x와 trap
스크립트가 예상대로 동작하지 않을 때 가장 빠른 디버깅 도구는 bash -x다. 각 명령이 전개된 최종 형태로 찍혀 나온다.
bash -x ./myscript.sh
# 또는 스크립트 상단에
set -x
출력이 시끄럽다면 특정 구간만 켜고 끌 수 있다.
set -x
cp -r "$src" "$dst"
set +x
더 정교한 디버깅으로 trap ... DEBUG / trap ... ERR를 붙여 라인 번호나 스택을 기록할 수도 있다. 규모가 큰 스크립트에선 아래 관용구가 유용하다.
trap 'echo "[err] line $LINENO: $BASH_COMMAND" >&2' ERR
에러가 난 순간 어느 줄의 어떤 명령이 실패했는지를 stderr로 찍는다. set -e와 조합하면 실패 지점 추적이 간단해진다.
ShellCheck — 린터를 곁에 둔다
Bash는 함정이 많아서 눈으로만 검토하면 놓치기 쉽다. ShellCheck이라는 린터가 이 문제를 잘 잡아준다. 인용 누락, 변수 오용, 쓰이지 않은 할당, set -e와 충돌하는 패턴까지 경고해준다.
# 로컬 설치
sudo apt install shellcheck # Debian/Ubuntu
sudo dnf install ShellCheck # RHEL 계열
# 실행
shellcheck myscript.sh
CI에 shellcheck 단계를 넣어두면, 미처 잡지 못한 인용 버그나 $ 실수를 코드 리뷰 전에 걸러낸다. Bash 스크립트가 프로덕션에 들어간다면 거의 필수에 가깝다.
쉘을 넘어서는 지점 — 어디서 멈춰야 할까
마지막으로 현실적인 이야기 하나. Bash는 짧은 작업을 이어붙이는 데 탁월하지만, 로직이 복잡해지면 금방 한계가 온다.
- 여러 단계의 JSON 처리 →
jq로 커버가 되지만 체이닝이 길어지면 파이썬이 낫다 - 자료구조가 필요한 처리 → Bash의 배열·연관배열은 있긴 하지만 불편하다
- 수백 줄을 넘어가는 스크립트 → Python/Go로 옮기는 편이 유지보수가 쉽다
“어디까지 Bash로, 어디부터 다른 언어로”를 판단하는 감이 붙는 것도 경험이다. 경험칙으로는 100줄을 넘어가면 일단 고민, 300줄을 넘어가면 옮긴다. Bash는 글루 언어로 쓸 때 가장 빛난다.
Linux 기초 시리즈를 마무리하며
5편부터 8편까지, 실무에서 리눅스 서버를 쓸 때 손에 붙어야 하는 네 가지 영역을 다뤘다. 네트워크 도구로 통신 상태를 진단하고, systemd로 프로세스를 선언적으로 관리하고, 패키지 매니저로 도구를 일관되게 설치하고, Bash 스크립트로 반복 작업을 자동화한다.
이 네 축을 놓고 보면 서버 한 대를 처음부터 세팅하는 과정이 머릿속에 그려진다.
flowchart LR
A["서버 접속 (SSH)"] --> B["패키지 설치<br/>(apt / dnf)"]
B --> C["서비스 등록<br/>(systemd unit)"]
C --> D["서비스 기동<br/>(systemctl enable --now)"]
D --> E["로그 확인<br/>(journalctl)"]
E --> F["운영 자동화<br/>(Bash + systemd timer)"]
F --> G["트러블슈팅<br/>(curl / ss / dig)"]
G -.-> A
여기서 한 걸음 더 나아가면 컨테이너와 오케스트레이션의 세계다. 이 시리즈에서 다룬 프로세스·네트워크·파일시스템의 개념은 Docker와 Kubernetes의 거의 모든 동작에 그대로 깔려 있다. “리눅스 프로세스를 namespace로 격리하면 컨테이너가 된다”는 Docker의 정의부터, “systemd 대신 Kubelet이 Pod 생명주기를 관리한다”는 쿠버네티스의 구도까지. 지금까지 쌓은 기초가 그대로 이어진다.
다음 단계의 기술 스택을 이어서 읽고 싶다면 아래 시리즈를 권한다.
- Docker 입문 1편 — Docker란 — 리눅스 프로세스가 어떻게 컨테이너가 되는지
- Kubernetes 입문 1편 — Kubernetes란 — 컨테이너를 여러 노드에 걸쳐 운영하는 시스템
리눅스 위에서 벌어지는 거의 모든 개발·운영 문제는 결국 이 기초들의 조합으로 풀린다. 시리즈가 서버 앞에서 쓸 수 있는 감각으로 남기를 바란다.

Loading comments...