infra
Platform

모듈 맵

[Linux] 백엔드 개발자를 위한 실전 Bash 쉘 스크립트 핵심 가이드

0 / 37 완료

펼치기
0 / 37 완료0%

리눅스 서버 운영 · 14 / 37

[Linux] 백엔드 개발자를 위한 실전 Bash 쉘 스크립트 핵심 가이드

변수, 조건문, 반복문, 함수로 서버 관리 작업을 자동화합니다

🚨INCIDENT ALERT
HIGH

새벽 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 실행 흐름 — 스크립트 첫 줄 shebang(#!/bin/bash)이 어느 인터프리터로 실행할지 지정하고, chmod +x로 실행 권한을 줘야 ./script.sh로 직접 실행 가능. 권한이 없으면 Permission denied, shebang이 없으면 현재 셸로 실행돼 호환 문제 발생확대

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

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

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

Shebang: 인터프리터 선언

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

로컬 터미널
# 실습 디렉토리 준비
mkdir -p /tmp/linux/part2/exam_13 && cd /tmp/linux/part2/exam_13

#!/bin/bash

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

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

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

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

실행 권한 부여: chmod +x

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

로컬 터미널
# 스크립트 파일 생성
vim monitor.sh

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

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

# 실행
./monitor.sh

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

bash: ./monitor.sh: Permission denied

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

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

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

기본 스크립트 뼈대

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

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

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

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


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

로컬 터미널
# 실습 디렉토리 준비
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단계: 파일 생성

로컬 터미널
vim ~/first-script.sh

2단계: 내용 작성

로컬 터미널
#!/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단계: 실행 권한 부여 및 실행

로컬 터미널
chmod +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)**이라 합니다. 괄호 안의 명령을 실행한 결과를 문자열로 대입합니다.

🔍실행 후 확인할 것
  • 먼저 ls -l ~/first-script.sh 에서 권한 열 맨 앞(-rwxr-xr-x)을 확인한다 — x 비트가 3자리(소유자·그룹·기타)에 있어야 ./first-script.sh 로 실행 가능하다. x가 없으면 'Permission denied'가 난다
  • $? 값이 0이면 성공, 1 이상이면 실패다 — set -e 없이 스크립트를 작성하면 중간 명령이 실패해도 $? 를 명시 확인하지 않는 한 스크립트가 계속 진행된다. 에러가 조용히 묻히는 위험을 막으려면 shebang 바로 아래에 set -euo pipefail 을 추가한다
  • uptime 출력의 load average 3개 숫자는 1분/5분/15분 평균이다 — 1분 값이 15분 값보다 크면 부하 증가 추세, 반대면 회복 중. CPU 코어 수보다 클 때 처리 지연이 시작된다

💡개념

변수: 데이터를 저장하고 재사용하기

Bash 변수 — 선언·범위·특수 변수 한눈에 보기확대

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

변수 선언과 사용

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 이라는 변수를 찾음)

변수 범위와 환경 변수

로컬 터미널
#!/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
$$현재 스크립트의 PID12345
$!마지막으로 백그라운드 실행한 명령의 PID12346
$_직전 명령의 마지막 인자/var/log
로컬 터미널
#!/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는 외부 도구 없이도 문자열을 자르고, 치환하고, 기본값을 지정하는 연산자를 내장하고 있습니다. 로그 파일 이름에서 날짜 부분만 추출하거나, 확장자를 제거하는 작업이 대표적입니다.

로컬 터미널
#!/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를 사용합니다.

로컬 터미널
#!/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 가상 호스트를 생성하는 스크립트를 작성합니다.

로컬 터미널
#!/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/"

실행 테스트

로컬 터미널
chmod +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 문 구조

로컬 터미널
#!/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 스크립트 기본
로컬 터미널
#!/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 반복문

로컬 터미널
#!/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 반복문

로컬 터미널
#!/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 반복문

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

로컬 터미널
#!/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

로컬 터미널
#!/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

함수 정의와 호출

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

로컬 터미널
#!/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으로 연결하는 것입니다.

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

리다이렉션 연산자

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

로컬 터미널
#!/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 쉘의 핵심 능력입니다.

로컬 터미널
#!/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 이상(실패)의 종료 코드를 반환합니다. &&||는 이 종료 코드를 조건으로 다음 명령 실행 여부를 결정합니다.

로컬 터미널
#!/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 명령으로 대화형 입력 받기

로컬 터미널
#!/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 같은 옵션 인자를 사용합니다.

로컬 터미널
#!/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

실행 예시

로컬 터미널
./deploy.sh -e staging -v v1.2.3
./deploy.sh -e production -v v1.2.3 -d
./deploy.sh -h
언제 어떤 구조를 쓸까 — 스크립트 설계 판단
[ ] vs [[ ]] 어느 test를Bash 스크립트면 [[ ]] 기본 — 따옴표 누락·빈 변수에 안전하고 &&·정규식·패턴매칭 지원. [ ]는 POSIX sh 호환이 꼭 필요할 때만. '왜 빈 값에서 에러가 나지'의 단골 원인이 [ ]'
고정 횟수·목록 반복 vs 조건 만족까지대상이 정해졌으면 for(파일·서버 목록), 조건이 풀릴 때까지면 while(로그 tail·재시도). '몇 번 도는지 아는가'로 가른다
같은 코드가 2번 이상 반복함수로 추출 — 한 곳만 고치면 됨. 인자는 $1 $2, 반환은 echo+명령치환 또는 return 코드. '복붙 3번째면 함수'
외부 명령 결과로 분기if command; then — 종료코드(0=성공) 기준. $(command) 출력 비교보다 종료코드가 견고. grep -q 처럼 조용한 판정 활용
인자 개수·필수값 검증스크립트 맨 앞에서 [[ $# -lt 2 ]] && { echo usage; exit 1; }. 잘못된 입력으로 망가지기 전에 차단 — '입력 검증은 맨 앞'
스크립트를 견고하게set -euo pipefail + trap으로 정리. 에러 시 즉시 중단·미정의 변수 차단. 운영 스크립트에서 이게 없으면 실패를 무시하고 계속 달려 사고

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

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

로컬 터미널
#!/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

스크립트 설치 및 테스트

로컬 터미널
# 스크립트 저장
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: 로그 파일 정리 스크립트

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

로컬 터미널
#!/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

사용 방법

로컬 터미널
# 실제 실행
sudo /opt/scripts/log-cleanup.sh

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

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

상황: 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이라는 인터프리터를 찾으려 하고, 존재하지 않으므로 오류가 납니다.

진단

로컬 터미널
# 방법 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

해결 방법

로컬 터미널
# 방법 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

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

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

진단:

로컬 터미널
ls -la deploy.sh
# -rw-r--r-- 1 deploy deploy 1234 Mar 26 14:00 deploy.sh
# ↑ x 권한 없음

mount | grep noexec    # noexec 마운트 확인

해결:

케이스 1: 실행 권한 없음

로컬 터미널
ls -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: 다른 사용자 소유의 파일

로컬 터미널
ls -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 마운트된 디렉토리에서 실행하려 할 때 발생합니다.

로컬 터미널
# 마운트 옵션 확인
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 문제

로컬 터미널
# 스크립트 내에서 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 환경에서 이것이 스크립트를 종료시킵니다. 반대로 set -o pipefail 없이는 파이프 중간 실패가 무시됩니다.

진단:

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

해결:

케이스 1: grep의 종료 코드

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

로컬 터미널
# 문제 있는 코드
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 없는 파이프라인

로컬 터미널
#!/bin/bash
set -e
# set -o pipefail 없음!

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

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

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

로컬 터미널
#!/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 "파일 없음"

심화 — 파이프라인 속에서 사라지는 상태

💡개념

심화: 파이프라인의 각 단계는 별도 서브셸이다

앞의 실무 예제들이 done < <(...) 같은 낯선 형태를 쓰고 ... | while을 피한 데는 이유가 있습니다. 파이프라인이 프로세스 수준에서 어떻게 쪼개지는지 알면, "루프 안에서 바꾼 변수가 왜 밖에서 사라지는가" 같은 헷갈리는 버그를 원리로 설명할 수 있습니다.

  • 파이프라인 a | b | c는 서브셸의 나열입니다. 셸은 각 단계를 별도 프로세스(서브셸)로 fork하고 stdout을 파이프로 잇습니다. 각 서브셸은 부모의 변수 값을 복사해 시작하지만, 자신이 바꾼 값은 부모로 돌아오지 않습니다. 상태는 아래(자식)로만 흐릅니다.
  • 그래서 ... | while read x; do ((count++)); done 의 count는 루프가 끝나면 원래 값 그대로입니다. 증가는 파이프 오른쪽 서브셸 안에서만 일어났고, 그 서브셸은 파이프가 끝나며 사라졌기 때문입니다. cd로 디렉토리를 바꾸거나 배열을 채우는 것도 전부 같은 함정을 겪습니다.
  • 처방은 파이프로 while에 넘기지 않는 것입니다. 프로세스 치환 while read x; do ...; done < <(cmd) 을 쓰면 while이 서브셸이 아니라 현재 셸에서 돌아 상태가 보존됩니다. 본문 실무 예제들이 정확히 이 패턴을 쓴 이유입니다.
  • bash 4.2+에는 shopt -s lastpipe 가 있습니다. 비대화형이고 job control이 꺼진 조건에서 파이프라인의 마지막 단계를 현재 셸에서 실행해 줍니다. 다만 조건이 까다로워, 이식성 있는 코드는 프로세스 치환을 기본으로 씁니다.
  • 같은 원리로 export한 값이 자식에게만 전달되고 부모로 못 돌아오는 것, ( ... ) 서브셸 안의 변경이 밖에서 안 보이는 것도 모두 "상태는 위로 흐르지 않는다"는 하나의 규칙입니다. 파이프·서브셸·자식 프로세스를 볼 때마다 이 경계를 먼저 떠올리면 됩니다.

상황: 백업 스크립트가 대상 파일을 find ... | while read -r f 로 돌며 하나씩 처리하고, 매 반복에서 ((PROCESSED++)) 로 카운트를 올립니다. 파일별 처리: ... 로그는 정상적으로 쌓이는데, 루프 뒤에 찍는 총 PROCESSED개 처리는 언제나 0입니다. DRY_RUN 모드도 아니고 파일도 실제로 존재합니다.

원인: find | while 구조라 while 루프 전체가 파이프 오른쪽의 서브셸에서 실행됐습니다. PROCESSED 증가는 그 서브셸 안에서만 일어났고, 파이프가 끝나며 서브셸이 종료되자 값이 부모 셸로 돌아오지 못했습니다. 부모의 PROCESSED는 루프 시작 전 값(0) 그대로입니다. 문법은 완벽히 옳기 때문에 set -xset -o pipefail로도 잡히지 않습니다 — 이건 문법이 아니라 프로세스 구조 문제입니다.

진단: 루프 마지막 줄에 echo "내부 $PROCESSED", 루프 바깥에 echo "외부 $PROCESSED"를 나란히 넣어 실행합니다. 내부 값은 1, 2, 3... 으로 오르는데 외부 값만 0으로 갈리면, 그 경계가 곧 서브셸 경계라는 증거입니다.

해결: 파이프를 없애고 프로세스 치환으로 바꿉니다 — while read -r f; do ...; ((PROCESSED++)); done < <(find ...). 이러면 while이 현재 셸에서 돌아 PROCESSED가 보존됩니다. 임시로는 카운트를 임시 파일에 적어 루프 밖에서 다시 읽는 방법도 있고, bash 4.2+에 비대화형이라면 shopt -s lastpipe도 가능하지만, 이식성과 가독성 때문에 프로세스 치환이 표준 처방입니다.


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

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

crontab 기본 사용법

로컬 터미널
# 현재 사용자의 crontab 편집
crontab -e

# 현재 crontab 조회
crontab -l

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

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

crontab 형식

crontab 한 줄 * * * * * 실행할 명령의 다섯 자리는 각각 다음을 뜻합니다.

위치필드범위
1번째0-59
2번째0-23
3번째1-31
4번째1-12
5번째요일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 예시

로컬 터미널
# /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%는 환경 변수 문제입니다.

로컬 터미널
#!/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보다 강력한 기능을 제공합니다.

로컬 터미널
# /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
서버 터미널
# 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으로 관리해야 합니다.

스크립트 저장소(/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

공통 라이브러리 활용

로컬 터미널
#!/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; }
로컬 터미널
#!/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 "디스크 점검 시작"

스크립트 품질 도구

로컬 터미널
# shellcheck: Bash 스크립트 정적 분석 도구
sudo apt install shellcheck    # Ubuntu
sudo yum install shellcheck    # RHEL

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

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

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

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

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

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

비밀값(시크릿) 관리

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

로컬 터미널
#!/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)

명령어·단축키 빠른 참조

이 모듈에서 다룬 Bash 스크립트 문법·명령을 실전 옵션과 함께 모았습니다. "예" 열의 조합을 그대로 응용해도 됩니다.

명령어/단축키용도자주 쓰는 예
#!/bin/bash셰뱅 — 인터프리터 지정첫 줄 고정, 이식성은 #!/usr/bin/env bash
chmod +x스크립트 실행 권한 부여chmod +x deploy.sh && ./deploy.sh
set -euo pipefail실패 즉시 중단·미정의 변수 차단셰뱅 바로 아래 항상 추가
$? / $# / $@종료코드·인자 개수·전체 인자if [[ $? -ne 0 ]]; then …; fi
[[ ]] / [ ]조건 검사(test)[[ -f "$F" && "$p" =~ ^[0-9]+$ ]]
for / while / until반복문for f in *.log; do …; done, until curl -sf "$URL"; do …
$(( ))정수 산술echo $((A % B)), ((COUNT++))
${var%…} / ${var:-기본}문자열 조작·기본값${FILE%.tar.gz}, PORT="${PORT:-8080}"
함수(){ } / local / return함수·지역변수·종료코드log(){ local m="$1"; echo "$m"; }
read입력 받기read -r -s -p "pw: " PW, read -r -a ARR
getopts옵션 파싱while getopts "e:v:d" o; do …; done
> / >> / 2>&1리다이렉션(덮기·추가·에러 합침)cmd >> app.log 2>&1
| / && / ||파이프·조건 연쇄ps aux | grep nginx, mkdir d && cd d
tee / xargs / <(…)분기 저장·인자 전달·프로세스 치환find … | xargs rm -f, diff <(ls a) <(ls b)

관련 모듈로 더 깊이:

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

지식 확인

퀴즈 — 10문제

Q1

배포 스크립트에서 `rsync` 명령 실행 후 성공 여부에 따라 알림을 다르게 보내려 합니다. 다음 중 올바른 패턴은?

Q2

헬스체크 스크립트에서 설정 파일이 존재할 때만 nginx를 재시작하려 합니다. `if [ ? /etc/nginx/nginx.conf ]` 조건의 올바른 플래그는?

Q3

for i in $(seq 1 5); do echo $i; done 을 실행하면 어떤 결과가 출력되나요?

Q4

cron 작업에서 에러 로그도 함께 파일로 저장하고 싶습니다. `backup.sh > /var/log/backup.log` 만으로는 에러 메시지가 누락됩니다. 수정 방법은?

Q5

CI 파이프라인에서 `./deploy.sh` 를 실행하면 'Permission denied' 오류가 납니다. 스크립트 문법은 정상입니다. 원인과 해결책은?

Q6

스크립트에서 FILE="/var/log/my app.log" 처럼 공백이 든 경로 변수를 rm $FILE 로 지웠더니 엉뚱한 파일들이 삭제됐다. 원인과 올바른 작성은?

Q7

[심화] 명령의 출력을 `cmd | while read -r x; do ((COUNT++)); done` 로 세었더니, 루프가 끝난 뒤 COUNT가 늘 0입니다. 근본 원인은?

Q8

[심화] find | while 루프로 파일을 처리하는 스크립트에서 파일별 로그는 정상인데 마지막 '총 처리 건수'만 항상 0으로 찍힙니다. 가장 확실한 수정은?

Q9

[1급] 변수 FILE=/var/log/app/access.log.2024 일 때, 뒤쪽 확장자 .2024 만 잘라 /var/log/app/access.log 를 얻는 파라미터 확장은?

Q10

[1급] 큰따옴표로 감싼 특수 파라미터에서 $@ 와 $* 의 차이로 옳은 것은? (위치 인자가 3개일 때)

0 / 10 답변

🧪 실습으로 확인하기

cron — 자동화 작업 스케줄링

초급

매일 새벽 2시에 DB 백업 스크립트를 자동 실행해야 하는 실무 상황에서 crontab 5자리 문법을 익히고, 첫 cron job을 등록하며, syslog로 실행 기록을 확인하고, 표준 출력 리다이렉트까지 포함한 실전 운영 패턴을 완성합니다.

30📋 4단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요