새벽 3시, 로그 디렉토리가 꽉 찼다는 알림이 옵니다. 접속해보니 30일 넘은 로그 파일이 수백 개 쌓여 있습니다. 그날 밤은 손으로 지웠지만, 다음 달에 또 같은 일이 생겼습니다. 명령어 서너 줄을 파일에 저장해 cron에 등록하는 것만으로 이 상황은 영구히 해결됩니다. Bash 스크립트는 반복 작업을 없애는 가장 직접적인 도구입니다.
Bash 스크립팅 기초
bash --version
touch script.sh && chmod +x script.sh
crontab -e
서버에서 수동으로 반복하던 작업(로그 정리, 백업 압축, 서비스 재시작 등)을 자동화하기로 했을 때, 명령어들을 파일에 모아두고 ./backup.sh를 실행하면 되겠다고 생각합니다. 그런데 막상 실행하면 Permission denied나 /usr/bin/env: bad interpreter 오류가 납니다. 스크립트 파일에는 실행 권한이 없고, 어떤 인터프리터로 실행할지 선언도 없기 때문입니다. Shebang과 실행 권한은 스크립트 자동화의 첫 관문이며, 이 두 가지를 올바르게 설정하지 않으면 스크립트는 동작하지 않습니다.
Bash 스크립트는 단순한 텍스트 파일입니다. 그런데 어떻게 터미널이 이 파일을 "실행 가능한 프로그램"으로 인식할까요? 두 가지 요소가 필요합니다.
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)**이라 합니다. 괄호 안의 명령을 실행한 결과를 문자열로 대입합니다.
— — —
스크립트를 짜다 보면 같은 값(서버 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 문 구조
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 반복문
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+$(함수명)패턴을 사용합니다
— — —
스크립트에서 명령어 출력을 화면이 아닌 파일에 저장하거나, 에러 메시지는 따로 모으거나, 한 명령의 결과를 다른 명령의 입력으로 넘길 때 파이프와 리다이렉션을 씁니다. 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 명령으로 대화형 입력 받기
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
— — —
실무에서 가장 자주 마주치는 상황 중 하나입니다. 디스크가 가득 차면 서비스가 멈추기 때문에 미리 알림을 받아야 합니다.
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
— — —
장기간 운영하는 서버에서 로그 파일이 쌓여 디스크를 가득 채우는 문제가 자주 발생합니다.
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
— — —
상황: 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
— — —
상황: 스크립트 파일이 분명히 존재하는데 실행 시 위 오류가 발생합니다. 또는 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 환경에서 이것이 스크립트를 종료시킵니다. 반대로 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은 지정한 시간에 명령을 자동으로 실행하는 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"
— — —
버전 관리와 코드 리뷰
서버 관리 스크립트는 코드입니다. 인프라 코드도 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로 파일시스템을 관리하는 방법을 다룹니다.
'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 |