[Linux] Bash 스크립팅 기초

2026. 5. 22. 22:22·Linux
SMALL
시나리오

새벽 3시, 로그 디렉토리가 꽉 찼다는 알림이 옵니다. 접속해보니 30일 넘은 로그 파일이 수백 개 쌓여 있습니다. 그날 밤은 손으로 지웠지만, 다음 달에 또 같은 일이 생겼습니다. 명령어 서너 줄을 파일에 저장해 cron에 등록하는 것만으로 이 상황은 영구히 해결됩니다. Bash 스크립트는 반복 작업을 없애는 가장 직접적인 도구입니다.

Bash 스크립팅 기초

이번 챕터에서 배울 것
1Shebang(#!/bin/bash)과 chmod +x로 실행 가능한 스크립트를 만들 수 있다
2변수·특수변수($?, $#, $@)와 조건문(if/elif/else, [[ ]])을 작성할 수 있다
3for, while, until 반복문으로 서버 관리 작업을 자동화할 수 있다
4파이프(|)와 리다이렉션(>, 2>&1)으로 출력 흐름을 제어할 수 있다
5cron에 스크립트를 등록하고 CRLF 개행 문자 문제를 해결할 수 있다
실습 환경 준비
Bash 버전 확인
bash --version
스크립트 파일 생성 및 실행 권한 부여
touch script.sh && chmod +x script.sh
set -euo pipefail 옵션
모든 스크립트 상단에 추가해 오류 발생 시 즉시 종료되도록 설정합니다
cron 편집기 열기
crontab -e
개념
스크립트의 시작: Shebang과 실행 권한
shebang 선언과 chmod +x 실행 권한 부여 흐름

서버에서 수동으로 반복하던 작업(로그 정리, 백업 압축, 서비스 재시작 등)을 자동화하기로 했을 때, 명령어들을 파일에 모아두고 ./backup.sh를 실행하면 되겠다고 생각합니다. 그런데 막상 실행하면 Permission denied나 /usr/bin/env: bad interpreter 오류가 납니다. 스크립트 파일에는 실행 권한이 없고, 어떤 인터프리터로 실행할지 선언도 없기 때문입니다. Shebang과 실행 권한은 스크립트 자동화의 첫 관문이며, 이 두 가지를 올바르게 설정하지 않으면 스크립트는 동작하지 않습니다.

Bash 스크립트는 단순한 텍스트 파일입니다. 그런데 어떻게 터미널이 이 파일을 "실행 가능한 프로그램"으로 인식할까요? 두 가지 요소가 필요합니다.

Bash 스크립트 구조 해부 — Shebang, set 플래그, 파이프·리다이렉션, cron 등록 패턴

Shebang: 인터프리터 선언

파일의 첫 줄에 #!(shebang, "해시뱅")으로 시작하는 인터프리터 경로를 적습니다.

bash#!/bin/bash

커널은 파일을 실행할 때 첫 두 바이트가 #!이면 뒤에 오는 경로의 프로그램을 인터프리터로 사용합니다. 즉 #!/bin/bash는 "이 파일을 /bin/bash로 해석하라"는 지시입니다.

Shebang은 여러 변형이 있으며 어떤 것을 쓰느냐에 따라 이식성이 달라집니다. 실무에서 자주 보이는 형태를 정리하면 다음과 같습니다.

Shebang 설명
#!/bin/bash 절대 경로로 bash 지정. 가장 일반적
#!/usr/bin/env bash PATH에서 bash를 찾음. 다양한 시스템에서 이식성이 높음
#!/bin/sh POSIX sh 사용. bash 전용 기능([[ ]], 배열 등) 사용 불가
#!/usr/bin/python3 Python 스크립트에도 동일한 방식 적용

실무 팁: 팀 서버가 고정된 경우 #!/bin/bash, 다양한 배포판에 배포할 스크립트라면 #!/usr/bin/env bash를 권장합니다.

실행 권한 부여: chmod +x

파일을 생성하면 기본적으로 실행 권한이 없습니다. 스크립트를 ./script.sh 형태로 직접 실행하려면 x 권한을 추가해야 합니다.

bash# 스크립트 파일 생성
vim monitor.sh

# 실행 권한 부여 (소유자, 그룹, 기타 모두에게)
chmod +x monitor.sh

# 또는 소유자에게만
chmod u+x monitor.sh

# 실행
./monitor.sh

권한을 부여하지 않고 실행하면 다음 오류가 발생합니다.

bash: ./monitor.sh: Permission denied

이 경우 두 가지 대안이 있습니다.

bash# 대안 1: bash에 인자로 전달 (실행 권한 불필요)
bash monitor.sh

# 대안 2: chmod +x로 권한 추가 후 실행
chmod +x monitor.sh && ./monitor.sh

기본 스크립트 뼈대

bash#!/bin/bash
# ==============================================================
# 스크립트 이름: monitor.sh
# 목적: 서버 기본 상태를 점검하고 결과를 출력
# 작성자: ops-team
# 작성일: 2026-03-26
# ==============================================================

set -euo pipefail
# set -e : 명령 실패 시 즉시 종료
# set -u : 미정의 변수 사용 시 오류
# set -o pipefail : 파이프 중간 실패도 감지

echo "서버 상태 점검을 시작합니다."

set -euo pipefail은 스크립트 상단에 항상 추가하는 것이 좋습니다. 이 옵션 없이는 명령이 실패해도 스크립트가 계속 실행되어 예기치 않은 결과를 낳을 수 있습니다.

— — —

실습 전 디렉토리와 예제 파일을 먼저 준비합니다.

bash# 실습 디렉토리 준비
mkdir -p /tmp/linux/part2/exam_8 && cd /tmp/linux/part2/exam_8

# 실습용 샘플 데이터 파일 생성
cat > /tmp/linux/part2/exam_8/sample_data.txt << 'EOF'
server-01 online 85
server-02 online 23
server-03 offline 0
server-04 online 67
server-05 online 91
EOF

이제 실습을 진행합니다.

첫 번째 스크립트 작성과 실행

서버의 기본 정보를 출력하는 스크립트를 작성합니다.

1단계: 파일 생성

bashvim ~/first-script.sh

2단계: 내용 작성

bash#!/bin/bash
# 서버 기본 정보 출력 스크립트

echo "========================================="
echo " 서버 기본 정보"
echo "========================================="
echo "호스트명: $(hostname)"
echo "현재 날짜: $(date '+%Y-%m-%d %H:%M:%S')"
echo "업타임: $(uptime -p)"
echo "현재 사용자: $(whoami)"
echo "현재 디렉토리: $(pwd)"
echo ""
echo "--- CPU 정보 ---"
grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | xargs
echo ""
echo "--- 메모리 사용량 ---"
free -h
echo ""
echo "--- 디스크 사용량 ---"
df -h /
echo "========================================="

3단계: 실행 권한 부여 및 실행

bashchmod +x ~/first-script.sh
~/first-script.sh

예상 출력

=========================================
 서버 기본 정보
=========================================
호스트명: web-server-01
현재 날짜: 2026-03-26 14:32:11
업타임: up 3 days, 2 hours, 15 minutes
현재 사용자: deploy
현재 디렉토리: /home/deploy
...

$(명령어) 형태를 **명령어 치환(command substitution)**이라 합니다. 괄호 안의 명령을 실행한 결과를 문자열로 대입합니다.

— — —

개념
변수: 데이터를 저장하고 재사용하기
Bash 변수 — 선언·범위·특수 변수 한눈에 보기

스크립트를 짜다 보면 같은 값(서버 IP, 로그 경로, 파일 이름 등)이 여러 곳에 반복해서 등장합니다. 하드코딩된 값이 여기저기 흩어져 있으면 나중에 경로 하나 바꿀 때 놓친 곳에서 에러가 납니다. 변수로 뽑아두면 한 곳만 수정해도 전체에 반영됩니다. 또한 $1, $2 같은 위치 인자를 쓰면 같은 스크립트를 다른 서버나 다른 날짜에 재사용할 수 있습니다. Bash 변수는 Python이나 Java와 달리 타입 선언이 없고, 선언과 사용의 규칙도 독특한 부분이 있어서 처음엔 실수가 잦습니다.

변수 선언과 사용

Bash 변수는 타입 선언 없이 바로 대입합니다. 주의할 점은 = 앞뒤에 공백이 없어야 한다는 것입니다.

bash#!/bin/bash

# 올바른 선언
SERVER_NAME="web-01"
MAX_CONNECTIONS=100
LOG_DIR="/var/log/myapp"

# 잘못된 선언 (공백 때문에 오류 발생)
# SERVER_NAME = "web-01"   # 오류!

# 변수 사용: $ 접두어
echo "서버: $SERVER_NAME"
echo "최대 연결수: ${MAX_CONNECTIONS}"  # 중괄호로 감싸면 경계가 명확함

# 중괄호가 필요한 경우
PREFIX="log"
echo "${PREFIX}_file.txt"  # log_file.txt (올바름)
echo "$PREFIX_file.txt"    # 빈 문자열 (PREFIX_file 이라는 변수를 찾음)

변수 범위와 환경 변수

bash#!/bin/bash

# 일반 변수: 현재 스크립트에서만 유효
local_var="나만 보임"

# export: 자식 프로세스(서브쉘)에 전달
export APP_ENV="production"

# 읽기 전용 변수
readonly VERSION="1.0.0"
# VERSION="2.0.0"  # 오류: readonly 변수는 변경 불가

특수 변수

Bash는 스크립트 실행 정보를 담은 특수 변수를 자동으로 제공합니다.

변수 의미 예시
$0 스크립트 자체의 이름 ./deploy.sh
$1, $2, ... 위치 매개변수 (커맨드라인 인자) $1 = 첫 번째 인자
$@ 모든 인자를 개별 문자열로 "$1" "$2" "$3"
$* 모든 인자를 하나의 문자열로 "$1 $2 $3"
$# 인자의 개수 3
$? 직전 명령의 종료 코드 (0=성공) 0 또는 1
$$ 현재 스크립트의 PID 12345
$! 마지막으로 백그라운드 실행한 명령의 PID 12346
$_ 직전 명령의 마지막 인자 /var/log
bash#!/bin/bash
# 특수 변수 활용 예시

echo "스크립트 이름: $0"
echo "첫 번째 인자: $1"
echo "두 번째 인자: $2"
echo "모든 인자: $@"
echo "인자 개수: $#"
echo "현재 PID: $$"

# 종료 코드 확인
ls /tmp > /dev/null 2>&1
echo "ls 명령 종료 코드: $?"   # 0 (성공)

ls /존재하지않는디렉토리 > /dev/null 2>&1
echo "ls 실패 종료 코드: $?"   # 2 (오류)

문자열 조작

Bash는 외부 도구 없이도 문자열을 자르고, 치환하고, 기본값을 지정하는 연산자를 내장하고 있습니다. 로그 파일 이름에서 날짜 부분만 추출하거나, 확장자를 제거하는 작업이 대표적입니다.

bash#!/bin/bash

FILENAME="server-log-2026-03.tar.gz"

# 문자열 길이
echo "${#FILENAME}"                      # 26

# 부분 문자열: ${변수:시작:길이}
echo "${FILENAME:0:10}"                  # server-log

# 접미어 제거 (가장 짧은 매칭)
echo "${FILENAME%.tar.gz}"               # server-log-2026-03

# 접두어 제거
echo "${FILENAME#server-}"               # log-2026-03.tar.gz

# 치환: ${변수/찾을값/바꿀값}
echo "${FILENAME/2026/2025}"             # server-log-2025-03.tar.gz

# 기본값: 변수가 비어있으면 기본값 사용
APP_PORT="${APP_PORT:-8080}"
echo "포트: $APP_PORT"                   # 8080 (APP_PORT 미설정 시)

산술 연산

Bash의 기본 산술은 $(( )) 구문으로 처리합니다. 정수 연산만 지원하며, 소수점이 필요한 경우에는 bc를 사용합니다.

bash#!/bin/bash

A=10
B=3

# $(( )) 로 정수 산술
echo $((A + B))    # 13
echo $((A - B))    # 7
echo $((A * B))    # 30
echo $((A / B))    # 3  (정수 나눗셈)
echo $((A % B))    # 1  (나머지)
echo $((A ** B))   # 1000 (거듭제곱)

# 변수 업데이트
COUNT=0
((COUNT++))
echo $COUNT        # 1

# 소수점 계산은 bc 사용
echo "scale=2; $A / $B" | bc   # 3.33

— — —

변수와 커맨드라인 인자 활용

사용자로부터 인자를 받아 서버에 nginx 가상 호스트를 생성하는 스크립트를 작성합니다.

bash#!/bin/bash
# create-vhost.sh: nginx 가상 호스트 설정 파일 생성
# 사용법: ./create-vhost.sh <도메인명> <포트>

set -euo pipefail

# --- 인자 검증 ---
if [[ $# -lt 2 ]]; then
    echo "사용법: $0 <도메인명> <포트번호>"
    echo "예시:   $0 example.com 8080"
    exit 1
fi

DOMAIN="$1"
PORT="$2"
CONFIG_DIR="/etc/nginx/sites-available"
CONFIG_FILE="${CONFIG_DIR}/${DOMAIN}.conf"
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')

echo "[${TIMESTAMP}] 가상 호스트 생성 시작: ${DOMAIN}:${PORT}"

# 포트 번호 유효성 검사
if ! [[ "$PORT" =~ ^[0-9]+$ ]] || [[ "$PORT" -lt 1 ]] || [[ "$PORT" -gt 65535 ]]; then
    echo "오류: 유효하지 않은 포트 번호: $PORT"
    exit 1
fi

# 설정 파일 이미 존재하면 백업
if [[ -f "$CONFIG_FILE" ]]; then
    echo "기존 설정 파일 발견 → 백업: ${CONFIG_FILE}.bak"
    cp "$CONFIG_FILE" "${CONFIG_FILE}.bak"
fi

# 설정 파일 생성
cat > "$CONFIG_FILE" <<EOF
server {
    listen 80;
    server_name ${DOMAIN} www.${DOMAIN};

    location / {
        proxy_pass http://127.0.0.1:${PORT};
        proxy_set_header Host \$host;
        proxy_set_header X-Real-IP \$remote_addr;
        proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
    }

    access_log /var/log/nginx/${DOMAIN}_access.log;
    error_log  /var/log/nginx/${DOMAIN}_error.log;
}
EOF

echo "설정 파일 생성 완료: $CONFIG_FILE"
echo "nginx 설정 검증 중..."
nginx -t && echo "설정 유효성 검사 통과" || {
    echo "nginx 설정 오류 — 변경 사항을 롤백합니다."
    rm -f "$CONFIG_FILE"
    exit 1
}

echo "완료: $DOMAIN → localhost:$PORT 프록시 설정이 생성됐습니다."
echo "활성화하려면: ln -s $CONFIG_FILE /etc/nginx/sites-enabled/"

실행 테스트

bashchmod +x create-vhost.sh

# 정상 실행
./create-vhost.sh myapp.com 3000

# 인자 누락 테스트
./create-vhost.sh
# 출력: 사용법: ./create-vhost.sh <도메인명> <포트번호>

# 잘못된 포트 테스트
./create-vhost.sh myapp.com abc
# 출력: 오류: 유효하지 않은 포트 번호: abc

$#으로 인자 개수를 먼저 확인하고, $1, $2로 각 인자에 접근하는 패턴은 실무 스크립트의 기본 구조입니다.

— — —

조건문: if/elif/else, test, [ ], [[ ]]

기본 if 문 구조

bash#!/bin/bash

DISK_USAGE=85

if [[ $DISK_USAGE -ge 90 ]]; then
    echo "위험: 디스크 사용량 ${DISK_USAGE}% — 즉시 조치 필요"
elif [[ $DISK_USAGE -ge 80 ]]; then
    echo "경고: 디스크 사용량 ${DISK_USAGE}% — 모니터링 필요"
elif [[ $DISK_USAGE -ge 70 ]]; then
    echo "주의: 디스크 사용량 ${DISK_USAGE}%"
else
    echo "정상: 디스크 사용량 ${DISK_USAGE}%"
fi

[ ] vs [[ ]] 비교

구분 [ ] (test) [[ ]] (bash 확장)
표준 POSIX 표준 bash 전용
패턴 매칭 불가 =~ 정규식, * 글로브 가능
논리 연산 -a, -o &&, ||
단어 분리 변수 인용 필수 인용 없어도 안전
권장 sh 호환 필요 시 bash 스크립트 기본
bash#!/bin/bash

FILE="/var/log/nginx/access.log"
USER_INPUT="hello world"

# --- 파일 조건 검사 ---
if [[ -f "$FILE" ]]; then
    echo "파일 존재함"
fi

if [[ -d "/var/log" ]]; then
    echo "디렉토리 존재함"
fi

if [[ -r "$FILE" ]]; then
    echo "파일 읽기 가능"
fi

if [[ -s "$FILE" ]]; then
    echo "파일이 비어있지 않음"
fi

# --- 문자열 비교 ---
STATUS="running"
if [[ "$STATUS" == "running" ]]; then
    echo "서비스 실행 중"
fi

if [[ -z "$STATUS" ]]; then
    echo "STATUS가 빈 문자열"
fi

if [[ -n "$STATUS" ]]; then
    echo "STATUS가 비어있지 않음"
fi

# --- 정규식 매칭 ([[ ]] 전용) ---
IP="192.168.1.100"
if [[ "$IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
    echo "유효한 IP 형식"
fi

# --- 숫자 비교 ---
# -eq -ne -lt -le -gt -ge
COUNT=42
if [[ $COUNT -gt 10 ]]; then
    echo "$COUNT 는 10보다 큽니다"
fi

# --- 논리 연산자 ---
CPU_USAGE=75
MEM_USAGE=85
if [[ $CPU_USAGE -gt 70 && $MEM_USAGE -gt 80 ]]; then
    echo "CPU와 메모리 모두 높음 — 부하 상태"
fi

# --- case 문: 여러 패턴 매칭 ---
SERVICE="nginx"
case "$SERVICE" in
    nginx|apache)
        echo "웹 서버"
        ;;
    mysql|postgresql)
        echo "데이터베이스 서버"
        ;;
    redis|memcached)
        echo "캐시 서버"
        ;;
    *)
        echo "알 수 없는 서비스: $SERVICE"
        ;;
esac

파일 조건 테스트 옵션 요약

옵션 의미
-f 일반 파일이 존재함
-d 디렉토리가 존재함
-e 파일/디렉토리/링크 등 존재함
-r 읽기 권한 있음
-w 쓰기 권한 있음
-x 실행 권한 있음
-s 크기가 0보다 큼
-L 심볼릭 링크임

— — —

반복문: for, while, until

for 반복문

bash#!/bin/bash

# --- 목록 순회 ---
SERVERS=("web-01" "web-02" "web-03" "db-01")

for SERVER in "${SERVERS[@]}"; do
    echo "[$SERVER] 연결 확인 중..."
    if ping -c 1 -W 2 "$SERVER" > /dev/null 2>&1; then
        echo "[$SERVER] 온라인"
    else
        echo "[$SERVER] 응답 없음 — 점검 필요!"
    fi
done

# --- 숫자 범위 ---
echo "=== 카운트다운 ==="
for i in {10..1}; do
    echo -n "$i "
done
echo "발사!"

# --- C 스타일 for 문 ---
for ((i=0; i<5; i++)); do
    echo "반복 $i"
done

# --- 파일 글로브 ---
echo "=== /var/log/*.log 파일 목록 ==="
for LOGFILE in /var/log/*.log; do
    if [[ -f "$LOGFILE" ]]; then
        SIZE=$(du -sh "$LOGFILE" | cut -f1)
        echo "  $LOGFILE ($SIZE)"
    fi
done

# --- 커맨드 출력 순회 ---
echo "=== 실행 중인 서비스 ==="
for SERVICE in $(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}'); do
    echo "  실행 중: $SERVICE"
done

while 반복문

bash#!/bin/bash

# --- 기본 while ---
COUNT=1
while [[ $COUNT -le 5 ]]; do
    echo "시도 $COUNT/5"
    ((COUNT++))
done

# --- 파일을 한 줄씩 읽기 ---
while IFS= read -r LINE; do
    echo "처리 중: $LINE"
done < /etc/hosts

# --- 파이프로 받기 ---
ps aux | grep nginx | while IFS= read -r LINE; do
    PID=$(echo "$LINE" | awk '{print $2}')
    echo "nginx 프로세스 PID: $PID"
done

# --- 서비스 상태 대기 ---
MAX_WAIT=30
ELAPSED=0
echo "서비스 시작 대기 중..."
while ! systemctl is-active --quiet nginx; do
    if [[ $ELAPSED -ge $MAX_WAIT ]]; then
        echo "타임아웃: ${MAX_WAIT}초 내에 서비스가 시작되지 않음"
        exit 1
    fi
    sleep 1
    ((ELAPSED++))
    echo "  ${ELAPSED}초 경과..."
done
echo "nginx 시작 완료 (${ELAPSED}초 소요)"

until 반복문

until은 while의 반대입니다. 조건이 거짓인 동안 반복하고, 조건이 참이 되면 종료합니다.

bash#!/bin/bash

# until: 조건이 참이 될 때까지 반복
RETRY=0
MAX_RETRY=5

until curl -sf http://localhost:8080/health > /dev/null; do
    ((RETRY++))
    if [[ $RETRY -ge $MAX_RETRY ]]; then
        echo "최대 재시도 횟수($MAX_RETRY) 초과 — 서비스 점검 필요"
        exit 1
    fi
    echo "헬스체크 실패 (${RETRY}/${MAX_RETRY}) — 3초 후 재시도"
    sleep 3
done

echo "서비스 헬스체크 통과"

break와 continue

bash#!/bin/bash

# break: 반복문 완전 탈출
for FILE in /var/log/app/*.log; do
    SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
    if [[ $SIZE -gt 1073741824 ]]; then  # 1GB 초과
        echo "경고: $FILE 크기가 1GB 초과 — 즉시 처리 필요"
        break  # 즉시 반복 종료
    fi
done

# continue: 현재 반복만 건너뛰기
for USER in $(getent passwd | awk -F: '$3 >= 1000 {print $1}'); do
    if [[ "$USER" == "nobody" ]]; then
        continue  # nobody는 건너뜀
    fi
    echo "사용자 처리: $USER"
done

— — —

함수 정의와 호출

함수는 반복 사용하는 코드 블록을 이름으로 묶어 재사용성을 높입니다.

bash#!/bin/bash
# 함수를 활용한 서버 점검 스크립트

set -euo pipefail

# ── 로깅 함수 ───────────────────────────────────────────────
LOG_FILE="/var/log/server-check.log"

log_info() {
    local MESSAGE="$1"
    local TIMESTAMP
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[INFO]  ${TIMESTAMP} ${MESSAGE}" | tee -a "$LOG_FILE"
}

log_warn() {
    local MESSAGE="$1"
    local TIMESTAMP
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[WARN]  ${TIMESTAMP} ${MESSAGE}" | tee -a "$LOG_FILE" >&2
}

log_error() {
    local MESSAGE="$1"
    local TIMESTAMP
    TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
    echo "[ERROR] ${TIMESTAMP} ${MESSAGE}" | tee -a "$LOG_FILE" >&2
}

# ── 반환값 사용 함수 ─────────────────────────────────────────
# Bash 함수는 정수 종료 코드만 return할 수 있음
# 문자열 "반환"은 echo + 명령어 치환을 사용
get_disk_usage() {
    local MOUNT_POINT="${1:-/}"
    df -h "$MOUNT_POINT" | awk 'NR==2 {print $5}' | tr -d '%'
}

get_memory_usage() {
    free | awk '/^Mem:/ {printf "%.0f", ($3/$2)*100}'
}

# ── 서비스 점검 함수 ─────────────────────────────────────────
check_service() {
    local SERVICE_NAME="$1"
    local ALERT_RECIPIENT="${2:-root}"

    if systemctl is-active --quiet "$SERVICE_NAME"; then
        log_info "${SERVICE_NAME}: 정상 실행 중"
        return 0
    else
        log_error "${SERVICE_NAME}: 서비스 중단 감지!"
        log_info "${SERVICE_NAME}: 재시작 시도 중..."
        if systemctl restart "$SERVICE_NAME"; then
            log_info "${SERVICE_NAME}: 재시작 성공"
            # 담당자에게 알림 (mailutils 또는 sendmail 필요)
            echo "${SERVICE_NAME}이 중단되어 자동 재시작됐습니다." | \
                mail -s "[자동복구] ${SERVICE_NAME} 재시작 알림" "$ALERT_RECIPIENT" 2>/dev/null || true
            return 0
        else
            log_error "${SERVICE_NAME}: 재시작 실패 — 수동 점검 필요"
            return 1
        fi
    fi
}

# ── 종합 점검 함수 ───────────────────────────────────────────
run_health_check() {
    local FAILED=0

    log_info "=== 서버 헬스체크 시작 ==="

    # 디스크 점검
    local DISK_USAGE
    DISK_USAGE=$(get_disk_usage "/")
    if [[ $DISK_USAGE -ge 90 ]]; then
        log_error "디스크 사용량 ${DISK_USAGE}% — 위험 수준"
        ((FAILED++))
    elif [[ $DISK_USAGE -ge 80 ]]; then
        log_warn "디스크 사용량 ${DISK_USAGE}% — 경고 수준"
    else
        log_info "디스크 사용량 ${DISK_USAGE}% — 정상"
    fi

    # 메모리 점검
    local MEM_USAGE
    MEM_USAGE=$(get_memory_usage)
    if [[ $MEM_USAGE -ge 90 ]]; then
        log_error "메모리 사용량 ${MEM_USAGE}% — 위험 수준"
        ((FAILED++))
    else
        log_info "메모리 사용량 ${MEM_USAGE}% — 정상"
    fi

    # 서비스 점검
    for SVC in nginx mysql redis; do
        check_service "$SVC" "ops@company.com" || ((FAILED++))
    done

    log_info "=== 헬스체크 완료: 이상 항목 ${FAILED}개 ==="
    return $FAILED
}

# ── 메인 실행 ───────────────────────────────────────────────
main() {
    run_health_check
    local EXIT_CODE=$?
    if [[ $EXIT_CODE -ne 0 ]]; then
        log_error "점검 중 ${EXIT_CODE}개 항목 이상 감지"
        exit 1
    fi
    exit 0
}

main "$@"

함수의 핵심 규칙

  • local 키워드로 함수 내부 변수를 지역 변수로 선언하면 전역 변수와 충돌을 방지합니다
  • 함수는 호출하기 전에 정의되어야 합니다 (또는 main 패턴으로 구조화)
  • return 값은 0–255의 정수만 가능합니다. 문자열 반환은 echo + $(함수명) 패턴을 사용합니다

— — —

개념
파이프와 리다이렉션: 출력 흐름 제어
파이프와 리다이렉션 — fd 0/1/2 흐름 제어 패턴

스크립트에서 명령어 출력을 화면이 아닌 파일에 저장하거나, 에러 메시지는 따로 모으거나, 한 명령의 결과를 다른 명령의 입력으로 넘길 때 파이프와 리다이렉션을 씁니다. cron 작업에서 스크립트 출력을 로그 파일에 쌓거나(>> /var/log/backup.log 2>&1), grep 결과를 awk로 넘기는 패턴이 모두 이 두 개념을 기반으로 합니다. 특히 2>&1의 의미를 모르면 에러가 로그에 안 찍혀서 디버깅이 어려워지는 상황이 생깁니다.

Linux의 강력함은 작은 도구들을 파이프(|)로 연결하고, 리다이렉션으로 출력 목적지를 바꾸는 능력에서 나옵니다.

표준 스트림

모든 Linux 프로세스는 세 가지 표준 스트림을 가지고 태어납니다. 리다이렉션은 이 스트림의 목적지를 바꾸는 것이고, 파이프는 한 프로세스의 stdout을 다음 프로세스의 stdin으로 연결하는 것입니다.

번호 이름 기본 목적지 약어
0 stdin (표준 입력) 키보드 stdin
1 stdout (표준 출력) 터미널 화면 stdout
2 stderr (표준 오류) 터미널 화면 stderr

리다이렉션 연산자

각 연산자가 stdout/stderr를 어디로 보내는지 알면 로그 파일 분리, 오류 억제, 파일로부터 입력 읽기를 자유롭게 조합할 수 있습니다.

bash#!/bin/bash

# > : stdout을 파일로 (덮어쓰기)
ls /var/log > /tmp/log-list.txt

# >> : stdout을 파일로 (추가)
echo "$(date): 점검 완료" >> /var/log/cron-job.log

# 2> : stderr를 파일로
find /root -name "*.conf" 2> /tmp/find-errors.txt

# 2>&1 : stderr를 stdout으로 합치기
# (stdout이 향하는 곳으로 stderr도 보냄)
rsync -avz /data/ /backup/ > /tmp/rsync.log 2>&1

# &> : stdout과 stderr를 모두 같은 파일로 (bash 전용)
./deploy.sh &> /tmp/deploy-$(date +%Y%m%d).log

# /dev/null : 출력 완전 억제
ping -c 1 8.8.8.8 > /dev/null 2>&1 && echo "인터넷 연결 정상"

# < : 파일을 stdin으로
while IFS= read -r line; do
    echo "처리: $line"
done < /etc/hosts

# Here-document (<<EOF): 여러 줄을 stdin으로
cat <<'EOF' > /tmp/config.txt
server_name=web-01
max_conn=100
log_level=info
EOF

파이프: 명령 연결

파이프(|)는 앞 명령의 stdout을 뒤 명령의 stdin으로 연결합니다. 단순한 명령들을 연결해 복잡한 데이터 처리를 한 줄로 표현하는 것이 Linux 쉘의 핵심 능력입니다.

bash#!/bin/bash

# 기본 파이프
ps aux | grep nginx | grep -v grep

# 복잡한 파이프 체인
# "80% 이상 사용 중인 파티션 찾기"
df -h | awk 'NR>1 {print $5, $6}' | \
    sed 's/%//' | \
    awk '$1 >= 80 {print "경고: " $2 " 사용률 " $1 "%"}'

# tee: 파이프 중간에서 파일로도 저장
./backup.sh | tee /var/log/backup.log | grep -i "error\|warn"

# xargs: 파이프 결과를 다음 명령의 인자로
# 7일 이상 된 .log 파일 삭제
find /var/log/app -name "*.log" -mtime +7 | xargs rm -f

# 프로세스 치환: 두 명령의 출력을 비교
diff <(ls /backup/2026-03-25/) <(ls /backup/2026-03-26/)

종료 코드와 논리 연산자

모든 명령은 종료할 때 0(성공) 또는 1 이상(실패)의 종료 코드를 반환합니다. &&와 ||는 이 종료 코드를 조건으로 다음 명령 실행 여부를 결정합니다.

bash#!/bin/bash

# && : 앞 명령 성공 시에만 다음 실행
mkdir -p /var/app/logs && chown app:app /var/app/logs && chmod 755 /var/app/logs

# || : 앞 명령 실패 시에만 다음 실행
systemctl start nginx || {
    echo "nginx 시작 실패 — 로그를 확인하세요"
    journalctl -u nginx --since "5 minutes ago"
    exit 1
}

# ; : 결과와 무관하게 순서대로 실행
echo "백업 시작"; ./backup.sh; echo "백업 완료"

# 파이프라인의 종료 코드
set -o pipefail  # 파이프 중 하나라도 실패하면 전체 실패
cat /var/log/app.log | grep "ERROR" | wc -l
echo "파이프라인 종료 코드: $?"

— — —

입력 처리: read 명령과 커맨드라인 인자

read 명령으로 대화형 입력 받기

bash#!/bin/bash
# interactive-deploy.sh: 배포 전 확인을 요청하는 대화형 스크립트

set -euo pipefail

# --- 기본 read ---
echo -n "배포할 환경을 입력하세요 (staging/production): "
read -r ENVIRONMENT

echo -n "배포할 버전을 입력하세요 (예: v1.2.3): "
read -r VERSION

# --- 타임아웃 있는 read (-t) ---
echo ""
echo "======================================================="
echo "배포 정보 확인"
echo "  환경  : $ENVIRONMENT"
echo "  버전  : $VERSION"
echo "  시각  : $(date '+%Y-%m-%d %H:%M:%S')"
echo "======================================================="
echo ""
read -r -t 30 -p "위 내용으로 배포를 진행하겠습니까? [y/N] " CONFIRM

# --- 비밀번호 입력 (화면 출력 없음, -s) ---
read -r -s -p "배포 키 패스프레이즈를 입력하세요: " PASSPHRASE
echo ""  # read -s는 개행을 출력하지 않으므로 수동 추가

# --- 입력값 처리 ---
case "${CONFIRM,,}" in  # ,, : 소문자 변환 (bash 4.0+)
    y|yes)
        echo "배포를 시작합니다..."
        # 실제 배포 명령
        ;;
    *)
        echo "배포가 취소됐습니다."
        exit 0
        ;;
esac

# --- 배열로 여러 값 읽기 ---
echo "점검할 서버 목록을 공백으로 구분해 입력하세요:"
read -r -a SERVER_LIST
echo "총 ${#SERVER_LIST[@]}개 서버: ${SERVER_LIST[*]}"

for SERVER in "${SERVER_LIST[@]}"; do
    echo "[$SERVER] 점검 중..."
done

getopts로 옵션 파싱

실무 스크립트는 --environment production 같은 옵션 인자를 사용합니다.

bash#!/bin/bash
# deploy.sh: getopts를 활용한 옵션 파싱

usage() {
    echo "사용법: $0 [-e <환경>] [-v <버전>] [-d] [-h]"
    echo ""
    echo "옵션:"
    echo "  -e  배포 환경 (staging|production)"
    echo "  -v  배포 버전 (예: v1.2.3)"
    echo "  -d  드라이런 모드 (실제 배포 없이 확인만)"
    echo "  -h  도움말 출력"
    exit 1
}

ENVIRONMENT=""
VERSION=""
DRY_RUN=false

while getopts "e:v:dh" OPT; do
    case "$OPT" in
        e) ENVIRONMENT="$OPTARG" ;;
        v) VERSION="$OPTARG" ;;
        d) DRY_RUN=true ;;
        h) usage ;;
        *) usage ;;
    esac
done

# 필수 인자 검증
[[ -z "$ENVIRONMENT" ]] && { echo "오류: -e 옵션 필수"; usage; }
[[ -z "$VERSION" ]]     && { echo "오류: -v 옵션 필수"; usage; }

echo "환경: $ENVIRONMENT | 버전: $VERSION | 드라이런: $DRY_RUN"

if $DRY_RUN; then
    echo "[드라이런] 실제 배포를 실행하지 않습니다."
else
    echo "배포 실행..."
fi

실행 예시

bash./deploy.sh -e staging -v v1.2.3
./deploy.sh -e production -v v1.2.3 -d
./deploy.sh -h

— — —

실무 예제 1: 디스크 80% 초과 알림 스크립트

실무에서 가장 자주 마주치는 상황 중 하나입니다. 디스크가 가득 차면 서비스가 멈추기 때문에 미리 알림을 받아야 합니다.

bash#!/bin/bash
# disk-alert.sh: 디스크 사용량 모니터링 및 알림
# 크론 등록 예시: */15 * * * * /opt/scripts/disk-alert.sh
#
# 의존성: mailutils (또는 sendmail), 또는 슬랙 웹훅

set -euo pipefail

# ── 설정 값 ──────────────────────────────────────────────────
WARN_THRESHOLD=80      # 경고 임계값 (%)
CRITICAL_THRESHOLD=90  # 위험 임계값 (%)
ALERT_EMAIL="ops@company.com"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL:-}"  # 환경 변수로 주입
HOSTNAME=$(hostname -f)
SCRIPT_NAME=$(basename "$0")
LOG_FILE="/var/log/disk-alert.log"

# ── 로깅 ────────────────────────────────────────────────────
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" | tee -a "$LOG_FILE"
}

# ── 슬랙 알림 함수 ──────────────────────────────────────────
send_slack_alert() {
    local LEVEL="$1"    # WARNING | CRITICAL
    local MOUNT="$2"
    local USAGE="$3"
    local COLOR

    [[ "$LEVEL" == "CRITICAL" ]] && COLOR="danger" || COLOR="warning"

    if [[ -z "$SLACK_WEBHOOK" ]]; then
        log "INFO" "Slack 웹훅 미설정 — 슬랙 알림 건너뜀"
        return 0
    fi

    local PAYLOAD
    PAYLOAD=$(cat <<EOF
{
  "attachments": [{
    "color": "${COLOR}",
    "title": "[${LEVEL}] 디스크 사용량 경고 — ${HOSTNAME}",
    "text": "마운트 포인트 *${MOUNT}* 사용량이 *${USAGE}%* 에 도달했습니다.",
    "footer": "${SCRIPT_NAME}",
    "ts": $(date +%s)
  }]
}
EOF
)
    curl -s -X POST -H "Content-Type: application/json" \
        -d "$PAYLOAD" "$SLACK_WEBHOOK" > /dev/null
}

# ── 이메일 알림 함수 ─────────────────────────────────────────
send_email_alert() {
    local LEVEL="$1"
    local MOUNT="$2"
    local USAGE="$3"
    local SUBJECT="[${LEVEL}] ${HOSTNAME} 디스크 ${MOUNT} ${USAGE}% 사용"

    if ! command -v mail &> /dev/null; then
        log "WARN" "mail 명령을 찾을 수 없음 — 이메일 알림 건너뜀"
        return 0
    fi

    {
        echo "서버: ${HOSTNAME}"
        echo "마운트: ${MOUNT}"
        echo "사용률: ${USAGE}%"
        echo "시각: $(date '+%Y-%m-%d %H:%M:%S')"
        echo ""
        echo "--- 현재 디스크 상태 ---"
        df -h
    } | mail -s "$SUBJECT" "$ALERT_EMAIL"

    log "INFO" "이메일 알림 발송: $SUBJECT → $ALERT_EMAIL"
}

# ── 디스크 점검 함수 ─────────────────────────────────────────
check_disk_usage() {
    local ALERT_SENT=0

    # df 출력에서 헤더 제외, 사용률 파싱
    # 출력 형식: 사용률(%) 마운트포인트
    while IFS= read -r LINE; do
        # 숫자%로 시작하는 필드 추출
        USAGE=$(echo "$LINE" | awk '{print $5}' | tr -d '%')
        MOUNT=$(echo "$LINE" | awk '{print $6}')

        # 숫자가 아니면 건너뜀 (tmpfs, devtmpfs 등 제외 가능)
        if ! [[ "$USAGE" =~ ^[0-9]+$ ]]; then
            continue
        fi

        if [[ $USAGE -ge $CRITICAL_THRESHOLD ]]; then
            log "CRITICAL" "${MOUNT} 사용량 ${USAGE}% — 즉각 조치 필요"
            send_slack_alert "CRITICAL" "$MOUNT" "$USAGE"
            send_email_alert "CRITICAL" "$MOUNT" "$USAGE"
            ((ALERT_SENT++))
        elif [[ $USAGE -ge $WARN_THRESHOLD ]]; then
            log "WARNING" "${MOUNT} 사용량 ${USAGE}% — 경고"
            send_slack_alert "WARNING" "$MOUNT" "$USAGE"
            ((ALERT_SENT++))
        else
            log "INFO" "${MOUNT} 사용량 ${USAGE}% — 정상"
        fi
    done < <(df -h --output=pcent,target | tail -n +2)

    return $ALERT_SENT
}

# ── 메인 ────────────────────────────────────────────────────
main() {
    log "INFO" "디스크 사용량 점검 시작 (경고: ${WARN_THRESHOLD}%, 위험: ${CRITICAL_THRESHOLD}%)"
    check_disk_usage
    local RESULT=$?
    if [[ $RESULT -gt 0 ]]; then
        log "WARN" "총 ${RESULT}개 파티션에서 임계값 초과"
        exit 1
    fi
    log "INFO" "모든 파티션 정상"
    exit 0
}

main

스크립트 설치 및 테스트

bash# 스크립트 저장
sudo cp disk-alert.sh /opt/scripts/disk-alert.sh
sudo chmod +x /opt/scripts/disk-alert.sh

# 임계값을 낮춰 테스트 (현재 디스크 사용량보다 낮게)
WARN_THRESHOLD=10 /opt/scripts/disk-alert.sh

# 로그 확인
tail -f /var/log/disk-alert.log

— — —

실무 예제 2: 로그 파일 정리 스크립트

장기간 운영하는 서버에서 로그 파일이 쌓여 디스크를 가득 채우는 문제가 자주 발생합니다.

bash#!/bin/bash
# log-cleanup.sh: 오래된 로그 파일 정리 및 압축
# 크론 등록: 0 3 * * * /opt/scripts/log-cleanup.sh
#
# 동작:
#   - 30일 이상 된 .log 파일 삭제
#   - 7일 이상 된 .log 파일은 gzip 압축
#   - 압축된 .gz 파일은 90일 후 삭제
#   - 실행 결과를 로그에 기록

set -euo pipefail

# ── 설정 ────────────────────────────────────────────────────
LOG_DIRS=(
    "/var/log/nginx"
    "/var/log/app"
    "/var/log/myservice"
)
COMPRESS_AFTER_DAYS=7     # N일 이상 된 로그 압축
DELETE_LOG_AFTER_DAYS=30  # N일 이상 된 원본 로그 삭제
DELETE_GZ_AFTER_DAYS=90   # N일 이상 된 압축 로그 삭제
SCRIPT_LOG="/var/log/log-cleanup.log"
DRY_RUN="${DRY_RUN:-false}"  # DRY_RUN=true 로 실행하면 실제 삭제 안 함

# ── 유틸리티 ────────────────────────────────────────────────
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $*" | tee -a "$SCRIPT_LOG"; }

run_cmd() {
    if [[ "$DRY_RUN" == "true" ]]; then
        log "[DRY-RUN] $*"
    else
        log "[EXEC] $*"
        "$@"
    fi
}

bytes_to_human() {
    local BYTES="$1"
    if [[ $BYTES -ge 1073741824 ]]; then
        echo "$(echo "scale=1; $BYTES/1073741824" | bc)GB"
    elif [[ $BYTES -ge 1048576 ]]; then
        echo "$(echo "scale=1; $BYTES/1048576" | bc)MB"
    elif [[ $BYTES -ge 1024 ]]; then
        echo "$(echo "scale=1; $BYTES/1024" | bc)KB"
    else
        echo "${BYTES}B"
    fi
}

# ── 디렉토리별 정리 ──────────────────────────────────────────
cleanup_directory() {
    local DIR="$1"

    if [[ ! -d "$DIR" ]]; then
        log "경고: 디렉토리 없음 — 건너뜀: $DIR"
        return 0
    fi

    log "--- 정리 시작: $DIR ---"

    local DELETED_COUNT=0
    local DELETED_BYTES=0
    local COMPRESSED_COUNT=0

    # 1단계: 오래된 원본 로그 삭제
    while IFS= read -r FILE; do
        local SIZE
        SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
        run_cmd rm -f "$FILE"
        ((DELETED_COUNT++)) || true
        ((DELETED_BYTES += SIZE)) || true
        log "  삭제: $FILE ($(bytes_to_human $SIZE))"
    done < <(find "$DIR" -maxdepth 2 -name "*.log" -type f -mtime "+${DELETE_LOG_AFTER_DAYS}" 2>/dev/null)

    # 2단계: 오래된 압축 로그 삭제
    while IFS= read -r FILE; do
        local SIZE
        SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
        run_cmd rm -f "$FILE"
        ((DELETED_COUNT++)) || true
        ((DELETED_BYTES += SIZE)) || true
        log "  삭제(압축): $FILE ($(bytes_to_human $SIZE))"
    done < <(find "$DIR" -maxdepth 2 -name "*.log.gz" -type f -mtime "+${DELETE_GZ_AFTER_DAYS}" 2>/dev/null)

    # 3단계: 압축 대상 로그 gzip 압축
    while IFS= read -r FILE; do
        local ORIG_SIZE
        ORIG_SIZE=$(stat -c%s "$FILE" 2>/dev/null || echo 0)
        run_cmd gzip -9 "$FILE"
        ((COMPRESSED_COUNT++)) || true
        log "  압축: $FILE ($(bytes_to_human $ORIG_SIZE))"
    done < <(find "$DIR" -maxdepth 2 -name "*.log" -type f -mtime "+${COMPRESS_AFTER_DAYS}" ! -mtime "+${DELETE_LOG_AFTER_DAYS}" 2>/dev/null)

    log "  완료: 삭제 ${DELETED_COUNT}개 ($(bytes_to_human $DELETED_BYTES)), 압축 ${COMPRESSED_COUNT}개"
}

# ── 메인 ────────────────────────────────────────────────────
main() {
    log "========================================"
    log "로그 정리 시작 (DRY_RUN=${DRY_RUN})"
    log "  압축 기준: ${COMPRESS_AFTER_DAYS}일 이상"
    log "  삭제 기준: ${DELETE_LOG_AFTER_DAYS}일 이상 (원본)"
    log "  삭제 기준: ${DELETE_GZ_AFTER_DAYS}일 이상 (압축)"
    log "========================================"

    for DIR in "${LOG_DIRS[@]}"; do
        cleanup_directory "$DIR"
    done

    log "========================================"
    log "로그 정리 완료"
    log "========================================"
}

main

사용 방법

bash# 실제 실행
sudo /opt/scripts/log-cleanup.sh

# 드라이런 (실제로 삭제하지 않고 무엇을 할지만 출력)
sudo DRY_RUN=true /opt/scripts/log-cleanup.sh

# 결과 확인
tail -50 /var/log/log-cleanup.log

— — —

트러블슈팅
bash: ./deploy.sh: /bin/bash^M: bad interpreter: No such file or directory

상황: Windows에서 작성한 스크립트를 Linux 서버에 올려 실행했을 때 위 오류가 발생합니다. 또는 스크립트는 실행되지만 변수 비교가 항상 실패하거나, 예기치 않은 출력이 보입니다.

원인: Windows는 줄 끝에 CR+LF(\r\n, 0x0D 0x0A) 두 바이트를 사용합니다. Linux는 LF(\n, 0x0A) 한 바이트만 사용합니다. Windows에서 저장한 스크립트를 그대로 Linux로 옮기면 각 줄 끝에 보이지 않는 \r(Carriage Return, ^M)이 포함됩니다. Shebang 줄이 #!/bin/bash\r이 되면 커널이 /bin/bash\r이라는 인터프리터를 찾으려 하고, 존재하지 않으므로 오류가 납니다.

진단

bash# 방법 1: cat -A 로 줄 끝 확인 (^M$는 CRLF를 의미)
cat -A deploy.sh | head -5
# #!/bin/bash^M$    ← ^M이 보이면 CRLF

# 방법 2: file 명령
file deploy.sh
# deploy.sh: Bourne-Again shell script, ASCII text, with CRLF line terminators

# 방법 3: xxd로 바이트 레벨 확인
xxd deploy.sh | head -3
# 0x0d 0x0a 가 보이면 CRLF

해결 방법

bash# 방법 1: dos2unix 도구 (가장 간단)
sudo apt install dos2unix    # Ubuntu/Debian
sudo yum install dos2unix    # RHEL/CentOS

dos2unix deploy.sh           # 파일을 LF로 변환
unix2dos deploy.sh           # 반대 방향 (참고용)

# 여러 파일 일괄 변환
find /opt/scripts -name "*.sh" | xargs dos2unix

# 방법 2: sed로 변환 (dos2unix 없을 때)
sed -i 's/\r//' deploy.sh

# 방법 3: tr로 변환
tr -d '\r' < deploy.sh > deploy-fixed.sh
mv deploy-fixed.sh deploy.sh

# 방법 4: vim에서 직접 변환
vim deploy.sh
# vim 내부에서:
# :set fileformat=unix
# :wq

예방 방법

Windows 환경에서 개발하는 경우, .gitattributes 파일을 프로젝트 루트에 추가합니다.

# .gitattributes
# 쉘 스크립트는 항상 LF로 저장
*.sh    text eol=lf
*.bash  text eol=lf

# Windows 파일은 CRLF 허용
*.bat   text eol=crlf
*.ps1   text eol=crlf

# 자동 감지
*       text=auto

VS Code를 사용한다면 우측 하단의 CRLF 표시를 클릭해 LF로 변경하거나, .editorconfig를 설정합니다.

ini# .editorconfig
[*.sh]
end_of_line = lf
charset = utf-8

— — —

트러블슈팅
bash: ./deploy.sh: Permission denied

상황: 스크립트 파일이 분명히 존재하는데 실행 시 위 오류가 발생합니다. 또는 sudo로 실행 시 스크립트 내부에서 특정 명령이 실패합니다.

원인: 파일에 실행 권한(x) 비트가 설정되지 않았거나, 다른 사용자 소유이거나, /tmp 등 noexec 옵션으로 마운트된 디렉토리에서 실행을 시도하는 경우입니다.

진단:

bashls -la deploy.sh
# -rw-r--r-- 1 deploy deploy 1234 Mar 26 14:00 deploy.sh
# ↑ x 권한 없음

mount | grep noexec    # noexec 마운트 확인

해결:

케이스 1: 실행 권한 없음

bashls -la deploy.sh
# -rw-r--r-- 1 deploy deploy 1234 Mar 26 14:00 deploy.sh
# ↑ x 권한 없음

# 해결
chmod +x deploy.sh
# 또는 소유자에게만
chmod u+x deploy.sh

케이스 2: 다른 사용자 소유의 파일

bashls -la /opt/scripts/deploy.sh
# -rwxr-xr-x 1 root root 1234 Mar 26 14:00 /opt/scripts/deploy.sh

# 현재 사용자가 deploy인 경우, 소유자가 root라도 실행 가능
# (others의 x 비트가 설정되어 있으면)

# 소유자 변경이 필요한 경우
sudo chown deploy:deploy /opt/scripts/deploy.sh

케이스 3: noexec 마운트 옵션

스크립트를 /tmp나 NFS 마운트된 디렉토리에서 실행하려 할 때 발생합니다.

bash# 마운트 옵션 확인
mount | grep noexec
# /tmp on tmpfs type tmpfs (rw,nosuid,nodev,noexec,...)

# 해결: 실행 가능한 위치로 복사
cp deploy.sh /opt/scripts/
chmod +x /opt/scripts/deploy.sh
/opt/scripts/deploy.sh

케이스 4: sudo 환경의 PATH 문제

bash# 스크립트 내에서 sudo로 실행되는 명령이 없다고 나올 때
sudo ./deploy.sh
# deploy.sh: line 10: some-tool: command not found

# 원인: sudo는 기본적으로 PATH를 제한함
# 해결 1: 전체 경로 사용
/usr/local/bin/some-tool

# 해결 2: sudo -E (환경 변수 유지)
sudo -E ./deploy.sh

# 해결 3: sudo bash -c (현재 환경 전달)
sudo bash -c "source /etc/profile && ./deploy.sh"

— — —

트러블슈팅
set -e 환경에서 스크립트가 예상치 않은 위치에서 갑자기 종료됨 (grep 종료 코드 1)

상황: set -e를 설정했는데 예상치 못한 위치에서 스크립트가 갑자기 종료됩니다. 반대로 파이프라인 중간 실패가 무시되고 스크립트가 계속 실행되기도 합니다.

원인: grep은 매칭 없으면 종료 코드 1을 반환합니다. set -e 환경에서 이것이 스크립트를 종료시킵니다. 반대로 set -o pipefail 없이는 파이프 중간 실패가 무시됩니다.

진단:

bash# grep이 매칭 없으면 exit 1
set -e
ERROR_COUNT=$(grep "ERROR" /var/log/app.log | wc -l)
# → 로그에 ERROR 없으면 스크립트 즉시 종료!

해결:

케이스 1: grep의 종료 코드

grep은 매칭되는 줄이 없으면 종료 코드 1을 반환합니다. set -e 환경에서 이것이 스크립트를 종료시킵니다.

bash# 문제 있는 코드
set -e
ERROR_COUNT=$(grep "ERROR" /var/log/app.log | wc -l)
# grep이 매칭 없으면 exit 1 → 스크립트 종료!

# 해결 1: || true로 실패를 무시
ERROR_COUNT=$(grep "ERROR" /var/log/app.log | wc -l || true)

# 해결 2: grep -c 대신 조건부 처리
if grep -q "ERROR" /var/log/app.log; then
    ERROR_COUNT=$(grep -c "ERROR" /var/log/app.log)
else
    ERROR_COUNT=0
fi

케이스 2: pipefail 없는 파이프라인

bash#!/bin/bash
set -e
# set -o pipefail 없음!

# false는 실패를 반환하지만, 파이프 다음의 cat이 성공하므로
# 전체 파이프라인 종료 코드는 0 (성공)
false | cat  # 스크립트 계속 실행됨!

# 해결: pipefail 추가
set -euo pipefail
false | cat  # 이제 스크립트 종료

케이스 3: 함수 내부의 set -e 동작

bash#!/bin/bash
set -e

is_file_exists() {
    local FILE="$1"
    # test 실패 = 종료 코드 1
    # 함수 내부에서 set -e는 함수를 호출한 맥락에 따라 다르게 동작함
    [[ -f "$FILE" ]]
}

# if 조건에서 호출 시: 실패해도 스크립트 종료 안 됨 (의도적)
if is_file_exists "/tmp/test.txt"; then
    echo "파일 존재"
fi

# 단독 호출 시: 실패하면 set -e 에 의해 스크립트 종료
is_file_exists "/tmp/test.txt"  # 파일 없으면 종료!

# 해결: 명시적으로 처리
is_file_exists "/tmp/test.txt" || echo "파일 없음"

— — —

실무 맥락
스크립트를 cron에 등록해 반복 작업을 자동화하기 — 디스크 정리, 로그 로테이션, 백업 스케줄링

cron은 지정한 시간에 명령을 자동으로 실행하는 Linux의 내장 작업 스케줄러입니다. 디스크 점검, 로그 정리, 백업, 리포트 생성 등 반복 작업을 수동으로 실행할 필요가 없어집니다.

crontab 기본 사용법

bash# 현재 사용자의 crontab 편집
crontab -e

# 현재 crontab 조회
crontab -l

# 특정 사용자의 crontab 조회 (root만 가능)
sudo crontab -l -u deploy

# crontab 삭제 (주의: 전체 삭제)
crontab -r

crontab 형식

# ┌── 분 (0-59)
# │  ┌── 시 (0-23)
# │  │  ┌── 일 (1-31)
# │  │  │  ┌── 월 (1-12)
# │  │  │  │  ┌── 요일 (0-7, 0과 7 모두 일요일)
# │  │  │  │  │
# *  *  *  *  *  실행할 명령

자주 사용하는 cron 표현식

표현식 의미
* * * * * 매 분마다
*/15 * * * * 15분마다
0 * * * * 매 시 정각
0 3 * * * 매일 새벽 3시
0 3 * * 0 매주 일요일 새벽 3시
0 3 1 * * 매월 1일 새벽 3시
0 3 1 1 * 매년 1월 1일 새벽 3시
@reboot 시스템 재부팅 시 1회
@daily 매일 자정 (= 0 0 * * *)
@weekly 매주 일요일 자정
@monthly 매월 1일 자정

실무 crontab 예시

bash# /etc/crontab 또는 crontab -e 로 편집

# 환경 변수 설정 (cron은 최소한의 환경만 제공)
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@company.com
HOME=/root

# 디스크 사용량 점검: 15분마다
*/15 * * * *   root   /opt/scripts/disk-alert.sh >> /var/log/disk-alert-cron.log 2>&1

# 로그 정리: 매일 새벽 3시
0 3 * * *      root   /opt/scripts/log-cleanup.sh >> /var/log/log-cleanup-cron.log 2>&1

# 데이터베이스 백업: 매일 새벽 2시
0 2 * * *      deploy /opt/scripts/db-backup.sh

# 주간 보고서: 매주 월요일 오전 9시
0 9 * * 1      deploy /opt/scripts/weekly-report.sh

# 서버 재부팅 후 초기화 스크립트
@reboot        root   /opt/scripts/on-boot-setup.sh >> /var/log/boot-setup.log 2>&1

cron 실행 환경의 함정과 해결

cron은 로그인 쉘이 아닌 최소 환경에서 실행됩니다. 터미널에서는 잘 되는데 cron에서 실패하는 이유의 80%는 환경 변수 문제입니다.

bash#!/bin/bash
# cron 친화적 스크립트 작성 체크리스트

# 1. 명령 전체 경로 사용 (PATH가 제한될 수 있음)
/usr/bin/find /var/log -name "*.log" -mtime +30

# 2. 스크립트 내에서 PATH 직접 설정
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

# 3. 환경 변수가 필요한 경우 명시적으로 로드
source /etc/profile
source /home/deploy/.bashrc

# 4. cron이 메일로 출력을 보내지 않게 하려면 리다이렉션
0 3 * * * /opt/scripts/cleanup.sh >> /var/log/cleanup.log 2>&1

# 5. 잠금 파일로 중복 실행 방지
LOCKFILE="/var/run/myscript.lock"
if [[ -f "$LOCKFILE" ]]; then
    echo "이미 실행 중 — 종료합니다." >&2
    exit 1
fi
touch "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT  # 스크립트 종료 시 잠금 파일 자동 삭제

systemd timer: cron의 현대적 대안

RHEL 8+, Ubuntu 20.04+ 환경에서는 systemd timer가 cron보다 강력한 기능을 제공합니다.

bash# /etc/systemd/system/disk-alert.service
[Unit]
Description=디스크 사용량 모니터링

[Service]
Type=oneshot
ExecStart=/opt/scripts/disk-alert.sh
StandardOutput=journal
StandardError=journal

# /etc/systemd/system/disk-alert.timer
[Unit]
Description=디스크 사용량 15분마다 점검

[Timer]
OnCalendar=*:0/15      # 15분마다
Persistent=true         # 놓친 실행 복구

[Install]
WantedBy=timers.target
bash# timer 활성화
sudo systemctl enable --now disk-alert.timer

# 상태 확인
sudo systemctl status disk-alert.timer
sudo systemctl list-timers

# 로그 확인
sudo journalctl -u disk-alert.service --since "1 hour ago"

— — —

실무 맥락
실무에서 Bash 스크립트를 Git으로 관리하고 팀 공유 라이브러리로 발전시키는 방법

버전 관리와 코드 리뷰

서버 관리 스크립트는 코드입니다. 인프라 코드도 Git으로 관리해야 합니다.

bash# 스크립트 저장소 구조 예시
/opt/scripts/
├── README.md
├── .gitignore
├── lib/                  # 공통 함수 라이브러리
│   ├── logging.sh
│   ├── alerts.sh
│   └── utils.sh
├── monitoring/
│   ├── disk-alert.sh
│   └── service-check.sh
├── maintenance/
│   ├── log-cleanup.sh
│   └── db-backup.sh
└── deployment/
    └── deploy.sh

공통 라이브러리 활용

bash#!/bin/bash
# lib/logging.sh — 모든 스크립트에서 공유

LOG_FILE="${LOG_FILE:-/var/log/scripts.log}"

log_info()  { echo "$(date '+%Y-%m-%d %H:%M:%S') [INFO]  $*" | tee -a "$LOG_FILE"; }
log_warn()  { echo "$(date '+%Y-%m-%d %H:%M:%S') [WARN]  $*" | tee -a "$LOG_FILE" >&2; }
log_error() { echo "$(date '+%Y-%m-%d %H:%M:%S') [ERROR] $*" | tee -a "$LOG_FILE" >&2; }
bash#!/bin/bash
# monitoring/disk-alert.sh — 라이브러리 로드

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../lib/logging.sh"
source "${SCRIPT_DIR}/../lib/alerts.sh"

# 이후 log_info, send_alert 등을 바로 사용 가능
log_info "디스크 점검 시작"

스크립트 품질 도구

bash# shellcheck: Bash 스크립트 정적 분석 도구
sudo apt install shellcheck    # Ubuntu
sudo yum install shellcheck    # RHEL

# 스크립트 검사
shellcheck disk-alert.sh

# 전체 디렉토리 검사
find /opt/scripts -name "*.sh" -exec shellcheck {} \;

shellcheck가 잡아주는 대표적인 실수들

bash# 잘못된 예 (shellcheck 경고)
for f in $(ls *.txt); do  # ls 대신 글로브 사용 권장
    ...

if [ $COUNT == 0 ]; then  # 숫자 비교는 -eq 사용

VAR="hello world"
echo $VAR  # 인용 필요: echo "$VAR"

비밀값(시크릿) 관리

스크립트에 패스워드, API 키, 웹훅 URL을 하드코딩하지 마세요.

bash#!/bin/bash
# 잘못된 방법 (절대 사용 금지)
DB_PASSWORD="mypassword123"           # Git 히스토리에 영구 노출
SLACK_WEBHOOK="https://hooks.slack.com/..."

# 올바른 방법 1: 환경 변수
DB_PASSWORD="${DB_PASSWORD:?'DB_PASSWORD 환경 변수가 설정되지 않았습니다'}"

# 올바른 방법 2: 별도 시크릿 파일 (권한 600, .gitignore에 추가)
if [[ -f "/etc/myapp/secrets.env" ]]; then
    source "/etc/myapp/secrets.env"
fi

# 올바른 방법 3: AWS Secrets Manager, Vault 등 시크릿 관리 도구
DB_PASSWORD=$(aws secretsmanager get-secret-value \
    --secret-id "myapp/db-password" \
    --query SecretString \
    --output text)

다음 모듈에서는 디스크와 스토리지 관리 — df, du, lsblk, mount로 파일시스템을 관리하는 방법을 다룹니다.

반응형
LIST

'Linux' 카테고리의 다른 글

[Linux] LVM & 볼륨 관리  (0) 2026.05.22
[Linux] 디스크와 스토리지 관리  (0) 2026.05.22
[Linux] 텍스트 처리 (grep/awk/sed)  (0) 2026.05.22
[Linux ] systemd 서비스 관리  (0) 2026.05.22
[Linux ] 시그널 & 프로세스 종료  (0) 2026.05.22
'Linux' 카테고리의 다른 글
  • [Linux] LVM & 볼륨 관리
  • [Linux] 디스크와 스토리지 관리
  • [Linux] 텍스트 처리 (grep/awk/sed)
  • [Linux ] systemd 서비스 관리
cumo
cumo
  • cumo
    이것저것
    cumo
    • 분류 전체보기 (147) N
      • 이것저것 (1)
      • 보안뉴스 (15)
      • Project (12)
      • wargame (1)
      • Cloud (25)
      • DevOps (21)
      • Linux (43)
      • 네트워크 (23)
      • AWS Developer BootCamp (1)
      • WEB&WAS (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 도구모음 사이트
    • 참고 기술 블로그
  • 공지사항

  • 인기 글

  • 태그

    부팅서비스
    Volume Group
    스토리지확장
    논리볼륨
    포트진단
    인프라
    서버관리
    디렉토리구조
    데몬
    눅스네트워킹
    vi
    ubuntu
    nano
    Linux
    리눅스
    bash입문
    텍스트편집기
    vim
    터미널멀티플렉서
    서버편집
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
cumo
[Linux] Bash 스크립팅 기초
상단으로

티스토리툴바