Shell 스크립팅 실전 가이드 | Bash, Zsh, PowerShell 비교

이 글의 핵심

같은 반복 작업이라도 리눅스 서버에서는 Bash, macOS 터미널에서는 Zsh, 윈도우에서는 PowerShell을 쓰게 되는 경우가 많습니다. 텍스트를 넘기는 Unix 파이프와 객체를 넘기는 PowerShell 파이프라인의 차이에서 출발해, 각 셸로 실무 자동화 스크립트를 작성하고 어떤 상황에서 어떤 셸을 고를지 정리합니다.

같은 명령이 Bash에서는 되는데 PowerShell에서는 안 되거나, 로컬 Zsh에서 잘 돌던 스크립트가 CI의 Bash에서 깨지는 일은 셸마다 문법과 동작이 다르기 때문에 생깁니다. 이 글은 Bash, Zsh, PowerShell의 차이를 비교한 뒤 파이프와 리다이렉션, 스크립트 작성과 에러 처리, 백업·배포·로그 분석 자동화 예제, 디버깅과 트러블슈팅까지 차례로 다룹니다.


사전 지식 (초보자를 위한 기초)

Shell이란?

Shell은 사용자와 운영체제를 연결하는 명령어 해석기입니다. 사용자가 입력한 명령어를 운영체제가 이해할 수 있는 형태로 변환하고, 결과를 다시 사용자에게 보여 줍니다. 예를 들어 ls 명령어를 입력하면, Shell이 이를 해석해서 운영체제에 파일 목록 조회를 요청하고, 결과를 터미널에 출력합니다.

GUI vs CLI:

GUI (Graphical User Interface):
- 마우스 클릭
- 아이콘, 창
- 직관적, 느림
CLI (Command Line Interface):
- 키보드 입력
- 텍스트 명령어
- 빠름, 자동화 가능

터미널 vs Shell

터미널 (Terminal)

  • Shell을 실행하는 프로그램
  • 예: iTerm2, Windows Terminal, GNOME Terminal

Shell

  • 명령어를 해석하는 프로그램
  • 예: Bash, Zsh, PowerShell
┌─────────────────────────────────┐
│      Terminal (창)              │
│  ┌───────────────────────────┐  │
│  │   Shell (명령어 해석기)    │  │
│  │   $ ls                    │  │
│  │   file1.txt file2.txt     │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

기본 명령어

# 현재 디렉토리 확인
pwd
# /home/user
# 파일 목록
ls
# file1.txt  file2.txt  folder/
# 디렉토리 이동
cd folder
# 파일 내용 보기
cat file1.txt
# 파일 복사
cp file1.txt file2.txt
# 파일 이동/이름 변경
mv file1.txt renamed.txt
# 파일 삭제
rm file1.txt
# 디렉토리 생성
mkdir new_folder
# 명령어 도움말
man ls

Shell이란?

Shell의 역할

1. 명령어 해석
   $ echo "Hello"
   → echo 프로그램 실행
2. 파이프라인
   $ cat file.txt | grep "error" | wc -l
   → 3개 명령어 연결
3. 리다이렉션
   $ ls > output.txt
   → 출력을 파일로 저장
4. 변수 및 제어 구조
   $ for i in 1 2 3; do echo $i; done
   → 반복문
5. 스크립트 실행
   $ ./script.sh
   → 여러 명령어를 한 번에

Shell의 종류

Unix Shell 계열:
- sh (Bourne Shell, 1979)
- bash (Bourne Again Shell, 1989) ← 가장 보편적
- zsh (Z Shell, 1990) ← 최신 기능
- fish (Friendly Interactive Shell, 2005)
Windows:
- cmd.exe (Command Prompt, 1981)
- PowerShell (2006) ← 현대적, 강력함
기타:
- dash (Debian Almquist Shell) ← 빠름
- ksh (Korn Shell)
- tcsh (TENEX C Shell)

Bash 기초

Bash란?

Bash (Bourne Again Shell)는 Linux·macOS에서 흔히 쓰이는 기본 셸입니다.

# Bash 버전 확인
bash --version
# GNU bash, version 5.2.15
# 현재 Shell 확인
echo $SHELL
# /bin/bash

기본 문법

변수 - Shell의 핵심 메커니즘:

# 변수 선언 (공백 없이!)
name="Alice"
age=25

# ⚠️ 흔한 실수: 공백 넣기
name = "Alice"  # ❌ 에러: 'name' 명령어를 '=' 인자와 함께 실행
# Shell은 공백을 구분자로 사용:
#   name = "Alice"
#   ↑     ↑  ↑
#   명령어  인자1 인자2

# ✅ 올바른 방법
name="Alice"  # 공백 없이

변수 참조의 다양한 방식:

# 기본 참조
echo "Name: $name, Age: $age"
# Name: Alice, Age: 25

# 중괄호 사용 (권장)
echo "Name: ${name}, Age: ${age}"
# 왜 중괄호?
file="report"
echo "$file_v2.txt"     # ❌ ".txt" (변수 file_v2를 찾음 → 비어 있음)
echo "${file}_v2.txt"   # ✅ report_v2.txt (변수: file)
# 참고: "$file.txt"는 괜찮음 — '.'은 변수 이름에 쓸 수 없는 문자라 여기서 끊김

# 기본값 설정
echo "${undefined:-default}"  # undefined가 없으면 "default"
echo "${undefined:=default}"  # undefined에 "default" 할당
echo "${undefined:?error}"    # undefined가 없으면 에러 발생

# 변수 존재 확인
echo "${name:+exists}"  # name이 있으면 "exists", 없으면 빈 문자열

Bash에서 가장 많은 버그를 만드는 규칙은 따옴표 없는 변수 확장입니다. $var를 따옴표 없이 쓰면 셸은 값을 공백·탭·줄바꿈 기준으로 쪼개고(word splitting), 쪼갠 조각에 *가 있으면 파일 이름으로 펼칩니다(globbing). 그래서 rm $file은 파일 이름이 my report.txt일 때 my와 report.txt 두 파일을 지우려 하고, 변수가 비어 있으면 [ $x = "yes" ]가 [ = "yes" ]가 되어 unary operator expected 에러를 냅니다. 규칙은 단순합니다. 쪼개지기를 의도한 경우가 아니라면 변수와 명령 치환은 항상 "$var", "$(cmd)"처럼 큰따옴표로 감쌉니다. ShellCheck(19장)가 경고하는 항목의 상당수가 이 규칙 위반(SC2086)입니다.

명령어 치환 (Command Substitution):

# 명령어 결과 저장 - 최신 문법
current_date=$(date)
echo "Today: $current_date"
# Today: Fri Apr 17 10:30:00 KST 2026

# 레거시 문법 (백틱)
current_date=`date`  # ❌ 가독성 떨어짐, 중첩 어려움

# 명령어 치환의 내부 동작:
# 1. Shell이 $(...) 내부 명령어 실행
# 2. 새로운 서브셸(subshell) 생성
# 3. 명령어 출력을 문자열로 캡처
# 4. 변수에 할당

# 서브셸 예제
count=1
(count=2; echo "Inside subshell: $count")  # 2
echo "Outside: $count"  # 1 (원본 유지)
# () 안의 변수 변경은 부모 셸에 영향 없음

서브셸 규칙은 파이프에서 자주 사람을 괴롭힙니다. Bash에서 파이프의 각 단계는 별도 서브셸에서 실행되므로, count=0; cat list.txt | while read line; do count=$((count+1)); done; echo $count는 항상 0을 출력합니다. 루프 안에서 늘린 count는 서브셸이 끝나면서 사라지기 때문입니다. while read line; do ...; done < list.txt처럼 리다이렉션으로 입력을 주거나, 명령 출력이라면 done < <(cmd)(프로세스 치환)를 쓰면 루프가 현재 셸에서 돌아 값이 유지됩니다. 참고로 Zsh는 파이프의 마지막 단계를 현재 셸에서 실행하므로 같은 코드가 Zsh에서는 기대대로 동작하는데, 이 차이 때문에 로컬 Zsh에서 확인한 스크립트가 CI의 Bash에서 다른 결과를 내기도 합니다.

환경 변수 vs 셸 변수:

# 셸 변수 (현재 셸에만)
local_var="value"
bash -c 'echo $local_var'  # 빈 출력 (자식 프로세스는 모름)

# 환경 변수 (자식 프로세스에 전달)
export PATH="/usr/local/bin:$PATH"
bash -c 'echo $PATH'  # ✅ 출력됨

# 환경 변수의 내부 동작:
# 1. export로 표시된 변수는 환경 배열에 추가
# 2. fork() 시 자식 프로세스에 복사
# 3. 자식은 부모의 환경 변수를 읽기만 가능 (수정 불가)

# PATH 환경 변수 이해
echo $PATH
# /usr/local/bin:/usr/bin:/bin
# Shell이 명령어를 찾는 순서:
# 1. /usr/local/bin/
# 2. /usr/bin/
# 3. /bin/

# PATH 추가 (앞에)
export PATH="/my/custom/bin:$PATH"
# /my/custom/bin이 우선 검색됨

# PATH 추가 (뒤에)
export PATH="$PATH:/my/custom/bin"
# 기존 경로 우선, /my/custom/bin은 마지막

특수 변수:

# $0: 스크립트 이름
echo $0  # /bin/bash 또는 스크립트 파일명

# $1, $2, ...: 위치 매개변수
# script.sh arg1 arg2
echo $1  # arg1
echo $2  # arg2

# $#: 인자 개수
echo $#  # 2

# $@: 모든 인자 (각각 개별 단어)
for arg in "$@"; do
    echo $arg
done

# $*: 모든 인자 (하나의 문자열)
echo "$*"  # arg1 arg2

# $?: 마지막 명령어의 종료 상태
ls /tmp
echo $?  # 0 (성공)
ls /nonexistent
echo $?  # 2 (실패)

# $$: 현재 셸의 PID
echo $$  # 12345

# $!: 백그라운드 프로세스의 PID
sleep 100 &
echo $!  # 12346

변수 타입과 배열:

# Bash는 기본적으로 문자열
number="123"
echo $number  # 123 (문자열)

# 산술 연산 (정수만 가능)
result=$((10 + 20))
echo $result  # 30

result=$((10 / 3))
echo $result  # 3 (정수 나눗셈)

# 배열
fruits=("apple" "banana" "cherry")
echo ${fruits[0]}  # apple
echo ${fruits[@]}  # 모든 요소
echo ${#fruits[@]}  # 배열 크기: 3

# 배열 순회
for fruit in "${fruits[@]}"; do
    echo $fruit
done

# 연관 배열 (Bash 4.0+)
declare -A colors
colors[red]="#FF0000"
colors[green]="#00FF00"
echo ${colors[red]}  # #FF0000

조건문:

# if 문
if [ "$age" -gt 18 ]; then
    echo "Adult"
else
    echo "Minor"
fi
# 파일 존재 확인
if [ -f "file.txt" ]; then
    echo "File exists"
fi
# 디렉토리 존재 확인
if [ -d "folder" ]; then
    echo "Directory exists"
fi
# 문자열 비교
if [ "$name" = "Alice" ]; then
    echo "Hello Alice"
fi
# 숫자 비교
# -eq (equal), -ne (not equal)
# -gt (greater than), -lt (less than)
# -ge (greater or equal), -le (less or equal)

반복문:

# for 문
for i in 1 2 3 4 5; do
    echo "Number: $i"
done
# 범위
for i in {1..10}; do
    echo $i
done
# 파일 순회
for file in *.txt; do
    echo "Processing $file"
    cat "$file"
done
# while 문
count=1
while [ $count -le 5 ]; do
    echo "Count: $count"
    count=$((count + 1))
done

함수:

# 함수 정의
greet() {
    local name=$1  # 첫 번째 인자
    echo "Hello, $name!"
}
# 함수 호출
greet "Alice"
# Hello, Alice!
# 반환값
add() {
    local result=$(($1 + $2))
    echo $result
}
sum=$(add 10 20)
echo "Sum: $sum"
# Sum: 30

파이프와 리다이렉션 - Unix 철학의 핵심

파이프 (|) - 프로세스 간 통신의 마법

파이프의 내부 동작:

# 간단한 예제
cat log.txt | grep "error"

# 내부에서 일어나는 일:
# 1. Shell이 파이프(pipe) 생성: pipe()
#    - 읽기 파일 디스크립터 (fd[0])
#    - 쓰기 파일 디스크립터 (fd[1])
#
# 2. 첫 번째 프로세스 (cat) 생성: fork()
#    - stdout(fd 1)을 파이프의 쓰기 끝(fd[1])으로 리다이렉트
#    - 파일 읽기 끝(fd[0]) 닫기
#
# 3. 두 번째 프로세스 (grep) 생성: fork()
#    - stdin(fd 0)을 파이프의 읽기 끝(fd[0])으로 리다이렉트
#    - 파일 쓰기 끝(fd[1]) 닫기
#
# 4. 부모 Shell은 파이프 양쪽 끝 모두 닫기
#
# 5. 데이터 흐름:
#    cat → [pipe buffer] → grep
#    
# 파이프 버퍼: 커널 메모리 (보통 64KB)
# 버퍼가 가득 차면 cat은 블록됨
# grep이 읽으면 cat 재개

파이프 체인의 효율성:

# 비효율적 (임시 파일 사용)
cat log.txt > tmp1.txt
grep "error" tmp1.txt > tmp2.txt
sort tmp2.txt > tmp3.txt
uniq tmp3.txt > result.txt
rm tmp1.txt tmp2.txt tmp3.txt
# 문제:
# - 디스크 I/O 느림
# - 임시 파일 관리 번거로움

# 효율적 (파이프 사용)
cat log.txt | grep "error" | sort | uniq > result.txt
# 장점:
# - 메모리 버퍼 사용 (빠름)
# - 병렬 실행 (각 프로세스 동시 실행)
# - 첫 번째 출력이 나오면 즉시 다음 명령으로 전달

실전 파이프 패턴:

# 1. 파일에서 "error" 찾기
cat log.txt | grep "error"
# 더 나은 방법:
grep "error" log.txt  # cat 불필요 (grep이 직접 파일 읽기)

# 2. 중복 제거 후 정렬
cat names.txt | sort | uniq
# sort는 먼저 정렬해야 uniq가 작동:
# uniq는 연속된 중복만 제거
# 예: "a b a" → "a b a" (중복 제거 안 됨)
# 예: "a a b" → "a b" (중복 제거됨)

# 3. 상위 10개 프로세스 (CPU 사용률)
ps aux | sort -k3 -r | head -10
# ps aux: 모든 프로세스
# sort -k3 -r: 3번째 컬럼(CPU) 기준 역순 정렬
# head -10: 상위 10개만

# 4. 파일 개수 세기
ls | wc -l
# wc -l: line count
# 주의: 파일명에 공백이나 줄바꿈이 있으면 부정확
# 더 나은 방법:
find . -maxdepth 1 -type f | wc -l

# 5. JSON 파싱 (jq 사용)
curl https://api.example.com/users | jq '.[] | .name'
# jq: JSON 처리 전용 도구
# .[]: 배열 각 요소
# .name: name 필드 추출

# 6. 로그 분석 실전
cat access.log | 
  grep "404" |                    # 404 에러만
  awk '{print $1}' |              # IP 주소 추출
  sort |                          # 정렬
  uniq -c |                       # 개수 세기
  sort -rn |                      # 개수 역순 정렬
  head -10                        # 상위 10개
# 결과: 404 에러를 가장 많이 발생시킨 IP 10개

리다이렉션 - 파일 디스크립터 조작

파일 디스크립터의 이해:

# 표준 파일 디스크립터:
# 0: stdin  (표준 입력)
# 1: stdout (표준 출력)
# 2: stderr (표준 에러)

# 프로세스가 시작될 때:
# fd 0 → 키보드 (또는 파이프)
# fd 1 → 터미널 (또는 파이프)
# fd 2 → 터미널

# ls 명령어 예시:
ls /tmp    # stdout → 터미널 (파일 목록 출력)
ls /xyz    # stderr → 터미널 ("No such file" 에러)

출력 리다이렉션 상세:

# 1. stdout 리다이렉션 (덮어쓰기)
echo "Hello" > output.txt
# 내부 동작:
# - output.txt 파일을 쓰기 모드로 열기
# - fd 1(stdout)을 파일로 리다이렉트
# - echo 실행 → 출력이 파일로 감

# 2. stdout 리다이렉션 (추가)
echo "World" >> output.txt
# >> 는 O_APPEND 플래그로 열기
# 파일 끝에 추가

# 3. stderr 리다이렉션
command 2> error.log
# 2>: fd 2(stderr)를 error.log로 리다이렉트
# stdout은 여전히 터미널로 출력

# 4. stdout과 stderr 모두 리다이렉트
command > output.log 2>&1
# 순서 중요!
# 1) 1> output.log  (stdout → output.log)
# 2) 2>&1           (stderr → stdout이 가리키는 곳, 즉 output.log)

# ❌ 잘못된 순서:
command 2>&1 > output.log
# 1) 2>&1  (stderr → 현재 stdout, 즉 터미널)
# 2) > output.log  (stdout → output.log)
# 결과: stderr는 터미널, stdout만 파일

# 최신 Bash 문법 (간결):
command &> output.log  # stdout + stderr
command &>> output.log  # stdout + stderr (추가)

# 5. /dev/null로 버리기 (Black Hole)
command > /dev/null 2>&1
# /dev/null: 특수 파일, 쓰기는 무시, 읽기는 EOF
# 사용 예: 출력을 완전히 무시하고 싶을 때

# 6. 출력 분리
command > stdout.log 2> stderr.log
# stdout과 stderr를 다른 파일로

입력 리다이렉션:

# 1. 파일에서 입력 받기
mysql -u root -p < schema.sql
# < : stdin을 파일로 리다이렉트
# mysql은 키보드 대신 schema.sql에서 SQL 읽기

# 2. Here Document (여러 줄 입력)
cat << EOF > config.txt
server {
    listen 80;
    server_name example.com;
}
EOF

# 내부 동작:
# 1. Shell이 EOF까지의 모든 줄을 임시 버퍼에 저장
# 2. cat의 stdin을 버퍼로 리다이렉트
# 3. cat이 읽고 stdout으로 출력
# 4. stdout은 config.txt로 리다이렉트

# Here Document 활용:
ssh user@server << 'ENDSSH'
  cd /var/www
  git pull
  sudo systemctl restart nginx
ENDSSH
# 'ENDSSH': 따옴표로 감싸면 변수 치환 안 함

# 3. Here String (한 줄 입력)
grep "pattern" <<< "This is a test string"
# <<<: 문자열을 stdin으로 전달
# 동등한 명령:
echo "This is a test string" | grep "pattern"

고급 리다이렉션 기법:

# 1. 파일 디스크립터 3+ 사용
exec 3> output.txt  # fd 3을 파일로
echo "Hello" >&3    # fd 3에 쓰기
exec 3>&-           # fd 3 닫기

# 2. 파일 디스크립터 복사
exec 3>&1           # fd 3 = 현재 stdout 복사
exec 1> output.txt  # stdout을 파일로 변경
echo "To file"      # 파일로 출력
exec 1>&3           # stdout 복원
exec 3>&-           # fd 3 닫기
echo "To terminal"  # 터미널로 출력

# 3. 양방향 리다이렉션 (프로세스 치환)
diff <(ls dir1) <(ls dir2)
# <(...): 명령어 출력을 임시 파일처럼 사용
# 내부 동작:
# 1. /dev/fd/63 같은 임시 파일 디스크립터 생성
# 2. 백그라운드에서 명령어 실행
# 3. 출력을 파이프로 연결

# 4. 출력 tee (화면 + 파일 동시)
command | tee output.txt
# tee: stdout을 복제
# - 하나는 터미널로 (화면에 출력)
# - 하나는 파일로
command | tee -a output.txt  # 추가 모드

리다이렉션 성능 고려사항:

# ❌ 비효율적: 파일을 반복해서 열기
for i in {1..1000}; do
    echo "Line $i" >> output.txt  # 1000번 파일 열기/닫기
done

# ✅ 효율적: 파일을 한 번만 열기
{
    for i in {1..1000}; do
        echo "Line $i"
    done
} > output.txt  # 1번만 파일 열기

# 또는
for i in {1..1000}; do
    echo "Line $i"
done > output.txt

Zsh와 Oh My Zsh

Zsh란?

Zsh (Z Shell)는 Bash를 호환하면서 확장이 많은 대안 셸입니다. (macOS Catalina부터 기본) Bash vs Zsh:

Bash:
- 표준, 호환성 높음
- 기본 기능
Zsh:
- 강력한 자동 완성
- 테마 및 플러그인
- 더 나은 히스토리
- 스펠링 교정

“Bash 호환”이라는 말은 대화형으로 쓰는 명령 대부분이 똑같이 동작한다는 뜻이지, Bash 스크립트가 그대로 돈다는 뜻은 아닙니다. 스크립트를 옮길 때 부딪히는 대표적인 차이는 다음과 같습니다.

  • word splitting: Zsh는 따옴표 없는 $var를 공백으로 쪼개지 않습니다. Bash에서 for f in $files로 여러 항목을 돌던 코드가 Zsh에서는 한 번만 돕니다. (setopt SH_WORD_SPLIT이나 ${=files}로 Bash처럼 동작시킬 수 있습니다.)
  • 배열 인덱스: Zsh 배열은 기본이 1부터 시작해 ${arr[0]}이 비어 있습니다.
  • glob 실패: 매칭되는 파일이 없으면 Bash는 패턴 문자열을 그대로 넘기지만, Zsh는 zsh: no matches found: *.log 에러를 내고 명령을 실행하지 않습니다. rm *.tmp 같은 정리 명령이 Zsh에서만 실패하는 이유입니다.

그래서 팀 스크립트는 첫 줄에 #!/usr/bin/env bash를 명시하고 bash script.sh로 실행해, 사용자의 로그인 셸이 무엇이든 Bash가 해석하도록 하는 것이 안전합니다.

Zsh 설치

# macOS (기본 설치됨)
chsh -s /bin/zsh
# Ubuntu/Debian
sudo apt install zsh
chsh -s $(which zsh)
# 로그아웃 후 재로그인

Oh My Zsh 설치

Oh My Zsh는 Zsh 설정을 묶어 둔 프레임워크입니다.

# 설치
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# 설치 후 ~/.zshrc 파일 생성됨

Oh My Zsh 테마

# ~/.zshrc 편집
nano ~/.zshrc
# 테마 변경
ZSH_THEME="robbyrussell"  # 기본
ZSH_THEME="agnoster"      # 인기
ZSH_THEME="powerlevel10k/powerlevel10k"  # 최고 인기
# 적용
source ~/.zshrc

Powerlevel10k 설치 (추천):

git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k
# ~/.zshrc
ZSH_THEME="powerlevel10k/powerlevel10k"
# 설정 마법사
p10k configure

Oh My Zsh 플러그인

# ~/.zshrc
plugins=(
    git                    # Git 단축키
    zsh-autosuggestions   # 명령어 자동 제안
    zsh-syntax-highlighting  # 문법 하이라이팅
    docker                # Docker 자동 완성
    kubectl               # Kubernetes 자동 완성
    npm                   # npm 자동 완성
)
# zsh-autosuggestions 설치
git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
# zsh-syntax-highlighting 설치
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
# 적용
source ~/.zshrc

Zsh 강력한 기능

1) 자동 완성:

# 디렉토리 이동 (Tab으로 자동 완성)
cd /u/l/b  # Tab → /usr/local/bin
# 명령어 옵션 자동 완성
git co  # Tab → git checkout, git commit, ...
# 파일 이름 자동 완성 (대소문자 무시)
ls doc  # Tab → Documents/

2) 히스토리 검색:

# Ctrl+R: 히스토리 검색
# 입력: git
# → 이전에 실행한 git 명령어 표시
# 히스토리 공유 (여러 터미널)
setopt share_history

3) 스펠링 교정:

$ gti status
# zsh: correct 'gti' to 'git' [nyae]? y
# → git status 실행

PowerShell 기초

PowerShell이란?

PowerShell은 Windows에서 널리 쓰이는 객체 중심 셸입니다. cmd.exe vs PowerShell:

cmd.exe (옛날):
- 텍스트 기반
- 제한적인 기능
- 레거시
PowerShell (현대):
- 객체 기반
- .NET 통합
- 강력한 스크립팅
- 크로스 플랫폼 (Linux/macOS 지원)

PowerShell 설치

# Windows (기본 설치됨)
# PowerShell 7 (최신 버전) 설치
winget install Microsoft.PowerShell
# macOS
brew install powershell/tap/powershell
# Ubuntu
sudo snap install powershell --classic
# 실행
pwsh

PowerShell 기본 명령어

Cmdlet (Command-let):

# 동사-명사 형식
Get-Process      # 프로세스 목록
Get-Service      # 서비스 목록
Get-ChildItem    # 파일 목록 (ls 별칭)
Set-Location     # 디렉토리 이동 (cd 별칭)
Copy-Item        # 파일 복사 (cp 별칭)
Remove-Item      # 파일 삭제 (rm 별칭)
# 별칭 확인
Get-Alias ls
# CommandType     Name
# -----------     ----
# Alias           ls -> Get-ChildItem

객체 기반 파이프라인:

# Bash (텍스트 기반)
ps aux | grep chrome | awk '{print $2}'
# PowerShell (객체 기반)
Get-Process | Where-Object {$_.Name -like "*chrome*"} | Select-Object Id
# 더 간단하게
Get-Process chrome | Select-Object Id, CPU, WorkingSet

객체 파이프라인의 장점은 “몇 번째 칸이 PID인가”를 신경 쓸 필요가 없다는 것입니다. Bash의 awk '{print $2}'는 ps 출력 형식이 바뀌면 조용히 틀린 값을 뽑지만, PowerShell의 Select-Object Id는 속성 이름으로 접근하므로 출력 모양과 무관합니다. 대신 외부 프로그램(git, docker 등)의 출력은 여전히 문자열 배열로 들어오므로, 이 경우에는 PowerShell에서도 텍스트를 파싱해야 합니다.

Bash 경험자가 PowerShell에서 자주 걸리는 함정도 있습니다. Windows PowerShell 5.1에서 curl과 wget은 실제 curl이 아니라 Invoke-WebRequest의 별칭이라 curl -X POST ... 같은 옵션이 A parameter cannot be found that matches parameter name 'X' 에러로 실패합니다(curl.exe로 부르면 Windows에 내장된 진짜 curl이 실행됩니다). 또 5.1의 > 리다이렉션은 파일을 UTF-16LE로 저장해서, 그 파일을 다른 도구가 읽으면 글자 사이에 널 바이트가 끼어 보입니다. PowerShell 7부터는 기본 인코딩이 BOM 없는 UTF-8로 바뀌었습니다. 외부 명령의 성공 여부는 $?보다 $LASTEXITCODE로 확인하는 편이 정확합니다.

PowerShell 변수

# 변수 선언
$name = "Alice"
$age = 25
# 변수 사용
Write-Host "Name: $name, Age: $age"
# 배열
$fruits = @("Apple", "Banana", "Cherry")
$fruits[0]  # Apple
# 해시테이블
$user = @{
    Name = "Alice"
    Age = 25
    Email = "[email protected]"
}
$user.Name  # Alice
$user["Age"]  # 25

Shell 스크립트 작성

Bash 스크립트

기본 구조:

#!/bin/bash
# Shebang: 어떤 Shell로 실행할지 지정
# 스크립트 설명
# 작성자: Alice
# 날짜: 2026-03-31
# 에러 발생 시 중단
set -e
# 변수
NAME="World"
# 함수
greet() {
    echo "Hello, $1!"
}
# 실행
greet "$NAME"

실행 방법:

# 실행 권한 부여
chmod +x script.sh
# 실행
./script.sh
# 또는
bash script.sh

실전 예제 1: 백업 스크립트

#!/bin/bash
# 설정
SOURCE_DIR="/home/user/documents"
BACKUP_DIR="/backup"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="backup_$DATE.tar.gz"
# 백업 디렉토리 생성
mkdir -p "$BACKUP_DIR"
# 압축 백업
echo "Starting backup..."
tar -czf "$BACKUP_DIR/$BACKUP_FILE" "$SOURCE_DIR"
# 결과 확인
if [ $? -eq 0 ]; then
    echo "Backup successful: $BACKUP_FILE"
    
    # 7일 이상 된 백업 삭제
    find "$BACKUP_DIR" -name "backup_*.tar.gz" -mtime +7 -delete
    echo "Old backups cleaned"
else
    echo "Backup failed!"
    exit 1
fi

실전 예제 2: 로그 분석

#!/bin/bash
LOG_FILE="/var/log/nginx/access.log"
echo "=== Nginx 로그 분석 ==="
echo
# 총 요청 수
total_requests=$(wc -l < "$LOG_FILE")
echo "총 요청 수: $total_requests"
# 상위 10개 IP
echo
echo "상위 10개 IP:"
awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10
# 상위 10개 URL
echo
echo "상위 10개 URL:"
awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10
# HTTP 상태 코드 분포
echo
echo "HTTP 상태 코드:"
awk '{print $9}' "$LOG_FILE" | sort | uniq -c | sort -rn
# 시간대별 요청 수
echo
echo "시간대별 요청 수:"
awk '{print $4}' "$LOG_FILE" | cut -d: -f2 | sort | uniq -c

실전 예제 3: 배포 자동화

#!/bin/bash
set -e  # 에러 시 중단
PROJECT_DIR="/var/www/myapp"
BRANCH="main"
echo "🚀 배포 시작..."
# 1. Git Pull
echo "📥 최신 코드 가져오기..."
cd "$PROJECT_DIR"
git fetch origin
git reset --hard origin/$BRANCH
# 2. 의존성 설치
echo "📦 의존성 설치..."
npm ci --production
# 3. 빌드
echo "🔨 빌드..."
npm run build
# 4. 서비스 재시작
echo "🔄 서비스 재시작..."
pm2 restart myapp
# 5. 헬스 체크
echo "🏥 헬스 체크..."
sleep 5
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health)
if [ "$response" = "200" ]; then
    echo "✅ 배포 성공!"
else
    echo "❌ 배포 실패! (HTTP $response)"
    # 롤백
    git reset --hard HEAD~1
    npm ci --production
    npm run build
    pm2 restart myapp
    exit 1
fi

PowerShell 스크립트

기본 문법

# 변수
$name = "Alice"
$age = 25
# 조건문
if ($age -gt 18) {
    Write-Host "Adult"
} else {
    Write-Host "Minor"
}
# 반복문
foreach ($i in 1..5) {
    Write-Host "Number: $i"
}
# 파일 순회
Get-ChildItem *.txt | ForEach-Object {
    Write-Host "Processing $($_.Name)"
    Get-Content $_.FullName
}
# 함수
function Greet {
    param($Name)
    Write-Host "Hello, $Name!"
}
Greet -Name "Alice"

실전 예제 1: 파일 정리

#!/usr/bin/env pwsh
# 30일 이상 된 로그 파일 삭제
$logDir = "C:\Logs"
$daysOld = 30
Get-ChildItem -Path $logDir -Filter "*.log" | 
    Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$daysOld) } |
    ForEach-Object {
        Write-Host "Deleting: $($_.Name)"
        Remove-Item $_.FullName -Force
    }
Write-Host "Cleanup complete!"

실전 예제 2: 시스템 모니터링

#!/usr/bin/env pwsh
# CPU 사용률
$cpu = Get-Counter '\Processor(_Total)\% Processor Time' | 
    Select-Object -ExpandProperty CounterSamples | 
    Select-Object -ExpandProperty CookedValue
Write-Host "CPU Usage: $([math]::Round($cpu, 2))%"
# 메모리 사용률
$os = Get-CimInstance Win32_OperatingSystem
$totalMemory = $os.TotalVisibleMemorySize / 1MB
$freeMemory = $os.FreePhysicalMemory / 1MB
$usedMemory = $totalMemory - $freeMemory
$memoryPercent = ($usedMemory / $totalMemory) * 100
Write-Host "Memory Usage: $([math]::Round($memoryPercent, 2))% ($([math]::Round($usedMemory, 2))GB / $([math]::Round($totalMemory, 2))GB)"
# 디스크 사용률
Get-PSDrive -PSProvider FileSystem | 
    Where-Object { $_.Used -ne $null } |
    ForEach-Object {
        $usedGB = $_.Used / 1GB
        $freeGB = $_.Free / 1GB
        $totalGB = $usedGB + $freeGB
        $percent = ($usedGB / $totalGB) * 100
        
        Write-Host "$($_.Name): $([math]::Round($percent, 2))% ($([math]::Round($usedGB, 2))GB / $([math]::Round($totalGB, 2))GB)"
    }
# 상위 10개 프로세스 (메모리)
Get-Process | 
    Sort-Object WorkingSet -Descending | 
    Select-Object -First 10 Name, @{Name="Memory(MB)";Expression={[math]::Round($_.WorkingSet / 1MB, 2)}} |
    Format-Table -AutoSize

실전 자동화 예제

예제 1: Git 자동화

Bash:

#!/bin/bash
# Git 저장소 일괄 업데이트
REPOS_DIR="$HOME/projects"
for repo in "$REPOS_DIR"/*; do
    if [ -d "$repo/.git" ]; then
        echo "Updating $(basename $repo)..."
        cd "$repo"
        
        # 현재 브랜치 확인
        branch=$(git branch --show-current)
        
        # Pull
        git pull origin "$branch"
        
        # 변경사항 확인
        if [ -n "$(git status --porcelain)" ]; then
            echo "  ⚠️  Uncommitted changes"
        else
            echo "  ✅ Up to date"
        fi
        
        echo
    fi
done

예제 2: 서버 헬스 체크

#!/bin/bash
# 서버 목록
SERVERS=(
    "https://api.example.com/health"
    "https://web.example.com/health"
    "https://admin.example.com/health"
)
echo "=== Server Health Check ==="
echo "Time: $(date)"
echo
for server in "${SERVERS[@]}"; do
    response=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 "$server")
    
    if [ "$response" = "200" ]; then
        echo "✅ $server - OK"
    else
        echo "❌ $server - FAILED (HTTP $response)"
        
        # Slack 알림 (선택)
        # curl -X POST https://hooks.slack.com/... \
        #   -d "{\"text\":\"Server down: $server\"}"
    fi
done

예제 3: 데이터베이스 백업

#!/bin/bash
# 설정
DB_HOST="localhost"
DB_USER="admin"
DB_NAME="mydb"
BACKUP_DIR="/backup/db"
DATE=$(date +%Y%m%d_%H%M%S)
# 백업 디렉토리 생성
mkdir -p "$BACKUP_DIR"
# PostgreSQL 백업
echo "Starting database backup..."
pg_dump -h "$DB_HOST" -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_DIR/backup_$DATE.sql.gz"
# 결과 확인
if [ $? -eq 0 ]; then
    echo "✅ Backup successful: backup_$DATE.sql.gz"
    
    # 파일 크기 확인
    size=$(du -h "$BACKUP_DIR/backup_$DATE.sql.gz" | cut -f1)
    echo "Size: $size"
    
    # 30일 이상 된 백업 삭제
    find "$BACKUP_DIR" -name "backup_*.sql.gz" -mtime +30 -delete
    
    # S3 업로드 (선택)
    # aws s3 cp "$BACKUP_DIR/backup_$DATE.sql.gz" s3://my-bucket/backups/
else
    echo "❌ Backup failed!"
    exit 1
fi

유용한 명령어 모음

파일 및 디렉토리

# 파일 찾기
find . -name "*.log"
find . -type f -mtime +7  # 7일 이상 된 파일
# 파일 내용 검색
grep -r "error" ./logs
grep -i "warning" file.txt  # 대소문자 무시
# 디스크 사용량
df -h
du -sh *
# 파일 권한 변경
chmod 755 script.sh
chmod +x script.sh
# 소유자 변경
chown user:group file.txt

프로세스 관리

프로세스 실행의 내부 메커니즘:

Shell이 명령어를 실행하는 과정:

1. 명령어 입력:
   $ ls -la /home
   
2. Shell 파싱:
   - 명령어: ls
   - 인자: ["-la", "/home"]
   - 리다이렉션: 없음
   - 파이프: 없음

3. fork() 시스템 콜:
   Shell 프로세스 복제
   
   Parent (Shell):          Child (복제본):
   PID: 1234                PID: 5678
   계속 대기                다음 단계 진행
   
   메모리 구조 (Copy-on-Write):
   ┌─────────────────┐     ┌─────────────────┐
   │ Shell Memory    │     │ Child Memory    │
   │ (공유, 읽기만)  │ ←→  │ (필요시 복사)   │
   └─────────────────┘     └─────────────────┘

4. execve() 시스템 콜 (Child에서):
   Child 프로세스를 ls 프로그램으로 교체
   
   Before:                 After:
   Child (bash 복제)  →    Child (ls 프로그램)
   PID: 5678               PID: 5678 (유지)
   메모리: bash 코드       메모리: ls 코드
   
   execve("/bin/ls", ["-la", "/home"], environ)

5. ls 프로그램 실행:
   - 시스템 콜: openat("/home"), getdents64(), ...
   - 파일 목록 읽기
   - 포맷팅 (컬러, 권한 등)
   - stdout에 출력

6. exit() 시스템 콜:
   ls 프로세스 종료
   Exit Code: 0 (성공)

7. wait() 시스템 콜 (Parent에서):
   Shell이 Child 종료 대기
   Exit Code 수신
   
   $? = 0  # 마지막 명령어 종료 코드

프로세스 계층:
systemd (PID 1)
  └─ bash (PID 1234) ← Shell
       └─ ls (PID 5678) ← 명령어
            (완료 후 사라짐)

백그라운드 실행의 내부 동작:

Foreground (기본):
$ sleep 10
Shell: wait(child_pid)  ← 10초 동안 대기
User: (입력 불가)

Background (&):
$ sleep 10 &
[1] 5678
Shell: 
  - Child PID 기록 (Job Table)
  - wait() 호출 안 함
  - 즉시 프롬프트 반환
User: (다른 명령어 입력 가능)

Job Table:
Job ID  PID    Status    Command
[1]     5678   Running   sleep 10

SIGCHLD 시그널:
Child 종료 시 Parent에게 알림
Shell이 SIGCHLD 받으면:
- Job Table 업데이트
- "[1]+ Done sleep 10" 출력

Signal (시그널) 메커니즘:

주요 시그널:

SIGTERM (15):
  정상 종료 요청
  kill <PID>  (기본)
  
  프로그램 동작:
  1. SIGTERM 수신
  2. 정리 작업:
     - 열린 파일 닫기
     - 네트워크 연결 종료
     - 임시 파일 삭제
  3. exit(0)

SIGKILL (9):
  강제 종료 (막을 수 없음)
  kill -9 <PID>
  
  커널이 즉시 프로세스 종료
  정리 작업 없음
  → 데이터 손실 위험

SIGINT (2):
  Ctrl+C
  인터럽트 요청
  
  프로그램이 핸들러 등록 가능:
  trap 'cleanup; exit' INT
  
SIGHUP (1):
  터미널 연결 끊김
  SSH 세션 종료 시
  
  nohup으로 무시:
  nohup ./task.sh &
  → SIGHUP 무시, 계속 실행

SIGSTOP (19), SIGCONT (18):
  일시정지 / 재개
  Ctrl+Z: SIGSTOP
  bg / fg: SIGCONT

Signal 전달 과정:
User: kill -TERM 5678
  ↓
Kernel: 시그널 큐에 SIGTERM 추가
  ↓
Scheduler: 프로세스 5678 실행 시
  ↓
Process 5678: signal_handler() 실행
  ↓
Handler: cleanup() → exit(0)

프로세스 명령어:

# 프로세스 목록 (상세)
ps aux
# a: 모든 사용자
# u: 사용자 이름 표시
# x: 터미널 없는 프로세스도 표시

출력:
USER  PID  %CPU %MEM    VSZ   RSS TTY   STAT START   TIME COMMAND
root    1   0.0  0.1 169564 13140 ?     Ss   Apr01   0:05 /sbin/init
john 5678   2.5  1.2 450120 98304 pts/0 S+   10:30   0:03 node server.js

컬럼 의미:
- PID: Process ID
- %CPU: CPU 사용률
- %MEM: 메모리 사용률
- VSZ: 가상 메모리 크기 (KB)
- RSS: 실제 메모리 크기 (KB)
- TTY: 연결된 터미널 (?: 없음)
- STAT: 상태
  * S: Sleeping (대기 중)
  * R: Running (실행 중)
  * Z: Zombie (종료되며, Parent가 wait() 안 함)
  * T: sTopped (일시정지)
  * +: Foreground
  * <: 높은 우선순위

# 프로세스 트리
pstree -p
systemd(1)─┬─sshd(1234)───sshd(5678)───bash(5679)───node(5680)
           ├─nginx(2345)─┬─nginx(2346)
           │             └─nginx(2347)
           └─docker(3456)

# 특정 프로세스 찾기
pgrep -a nginx
2345 nginx: master
2346 nginx: worker
2347 nginx: worker

# 프로세스 종료
kill <PID>         # SIGTERM (15) - 정상 종료
kill -9 <PID>      # SIGKILL (9) - 강제 종료
kill -STOP <PID>   # SIGSTOP (19) - 일시정지
kill -CONT <PID>   # SIGCONT (18) - 재개

killall nginx      # 이름으로 종료 (모든 nginx)
pkill -f "node.*server"  # 패턴 매칭

# 백그라운드 실행
./long_running_task.sh &
[1] 5678  # Job ID와 PID

# nohup (로그아웃 후에도 실행)
nohup ./task.sh > output.log 2>&1 &
# SIGHUP 무시
# stdout/stderr → output.log

# 작업 목록
jobs
[1]+  Running    ./task.sh &
[2]-  Stopped    vim file.txt

# 작업 제어
fg %1              # Job 1을 Foreground로
bg %2              # Job 2를 Background로
kill %1            # Job 1 종료

파이프 (Pipe)의 내부 동작:

명령어: ls -la | grep txt | wc -l

Shell이 실행하는 과정:

1. 파이프 생성 (pipe() 시스템 콜):
   Pipe 1: [read_fd1, write_fd1]
   Pipe 2: [read_fd2, write_fd2]
   
   커널 메모리에 버퍼 생성 (보통 64KB)

2. 프로세스 생성 (3개):
   
   ls -la:
     fork() → Child 1 (PID 5678)
     dup2(write_fd1, STDOUT) ← stdout을 파이프로
     close(read_fd1, write_fd1)
     execve("/bin/ls", ["-la"])
   
   grep txt:
     fork() → Child 2 (PID 5679)
     dup2(read_fd1, STDIN)   ← stdin을 파이프에서
     dup2(write_fd2, STDOUT) ← stdout을 파이프로
     close(...)
     execve("/bin/grep", ["txt"])
   
   wc -l:
     fork() → Child 3 (PID 5680)
     dup2(read_fd2, STDIN)   ← stdin을 파이프에서
     close(...)
     execve("/usr/bin/wc", ["-l"])

3. 데이터 흐름:
   
   ls (5678):
     파일 목록 생성
     → write(STDOUT) 
     → Pipe 1 Buffer
   
   grep (5679):
     read(STDIN) ← Pipe 1
     "txt" 포함 줄만 필터링
     → write(STDOUT)
     → Pipe 2 Buffer
   
   wc (5680):
     read(STDIN) ← Pipe 2
     줄 수 카운트
     → write(STDOUT) → Terminal
     
     출력: 3

4. 프로세스 종료:
   ls → EOF → Pipe 1 close
   grep → EOF 감지 → Pipe 2 close
   wc → EOF 감지 → 출력 후 종료

5. Shell 복귀:
   wait(5678)
   wait(5679)
   wait(5680)
   모두 완료 → 프롬프트 반환

파이프 버퍼:
- 크기: 64KB (Linux), 8KB (macOS)
- 버퍼 가득 차면: write() 블록
- 버퍼 비면: read() 블록
- 비동기 I/O (Producer-Consumer 패턴)

예시:
ls 출력: 100KB
→ 64KB 쓰기 → 블록 (grep 읽을 때까지)
→ grep 64KB 읽기 → ls 계속 쓰기 (36KB)

확인:
ulimit -p  # 파이프 버퍼 크기
cat /proc/sys/fs/pipe-max-size  # 최대 크기

리다이렉션의 내부 동작:

명령어: echo "Hello" > file.txt

1. Shell이 처리:
   open("file.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644)
   → fd = 3
   
2. 파일 디스크립터 복제:
   dup2(3, STDOUT_FILENO)  # fd 3 → stdout (fd 1)
   
   프로세스 파일 디스크립터 테이블:
   0: STDIN  → /dev/pts/0 (terminal)
   1: STDOUT → file.txt  ← 변경됨!
   2: STDERR → /dev/pts/0 (terminal)

3. echo 실행:
   write(STDOUT, "Hello\n", 6)
   → file.txt에 쓰임

4. 종료 후 Shell 복구:
   dup2(saved_stdout, STDOUT)

리다이렉션 종류:

>   : 덮어쓰기 (O_TRUNC)
>>  : 추가 (O_APPEND)
<   : 입력
2>  : stderr 리다이렉션
&>  : stdout + stderr
2>&1: stderr → stdout

예시:
command > out.txt 2> err.txt
  stdout: out.txt (fd 3)
  stderr: err.txt (fd 4)

command &> all.txt
  stdout: all.txt (fd 3)
  stderr: dup2(3, 2)  ← stdout과 같은 곳

command 2>&1 | tee log.txt
  stderr → stdout
  stdout → 파이프 → tee
  tee → 화면 + log.txt
# 프로세스 목록 (상세)
ps aux
ps aux | grep nginx

# 프로세스 종료
kill <PID>         # SIGTERM (15)
kill -9 <PID>      # SIGKILL (9) - 강제
killall nginx      # 이름으로 종료
pkill -f "pattern" # 패턴 매칭

# 백그라운드 실행
./long_running_task.sh &

# nohup (로그아웃 후에도 실행)
nohup ./task.sh > output.log 2>&1 &

# 작업 목록
jobs

# 백그라운드 → 포그라운드
fg %1

네트워크

# 포트 확인
netstat -tulpn
ss -tulpn  # 더 빠름
# 특정 포트 사용 프로세스
lsof -i :8080
# HTTP 요청
curl https://api.example.com
curl -X POST -H "Content-Type: application/json" -d '{"key":"value"}' https://api.example.com
# 다운로드
wget https://example.com/file.zip
# DNS 조회
dig example.com
nslookup example.com
# 핑
ping -c 4 google.com
# 경로 추적
traceroute google.com

시스템 정보

# CPU 정보
lscpu
cat /proc/cpuinfo
# 메모리 정보
free -h
cat /proc/meminfo
# 디스크 정보
lsblk
fdisk -l
# OS 정보
uname -a
cat /etc/os-release
# 시스템 부하
uptime
top
htop  # 더 보기 좋음

고급 Shell 기법

명령어 치환

# $() 사용 (권장)
current_date=$(date +%Y-%m-%d)
echo "Today: $current_date"
# 백틱 (옛날 방식)
current_date=`date +%Y-%m-%d`
# 중첩 가능
files_count=$(ls $(pwd) | wc -l)

배열

# 배열 선언
fruits=("Apple" "Banana" "Cherry")
# 접근
echo ${fruits[0]}  # Apple
echo ${fruits[@]}  # 모든 요소
echo ${#fruits[@]}  # 배열 크기
# 순회
for fruit in "${fruits[@]}"; do
    echo "$fruit"
done
# 추가
fruits+=("Date")

문자열 처리

text="Hello World"
# 길이
echo ${#text}  # 11
# 부분 문자열
echo ${text:0:5}  # Hello
echo ${text:6}    # World
# 치환
echo ${text/World/Bash}  # Hello Bash
echo ${text//o/0}        # Hell0 W0rld (모든 o)
# 대소문자 변환
echo ${text^^}  # HELLO WORLD (대문자)
echo ${text,,}  # hello world (소문자) — Bash 4.0 이상 필요
# 기본값
echo ${name:-"Guest"}  # name이 없으면 "Guest"

${text^^} 같은 대소문자 변환과 연관 배열(declare -A), mapfile은 Bash 4에서 추가된 기능입니다. macOS에 기본으로 들어 있는 /bin/bash는 라이선스 문제로 2007년의 3.2 버전에 머물러 있어서, 이 문법을 쓴 스크립트는 맥에서 bad substitution 에러를 냅니다. 맥에서 최신 Bash가 필요하면 brew install bash로 설치하고, 스크립트 첫 줄을 #!/usr/bin/env bash로 두어 PATH에서 먼저 찾은 Bash를 쓰게 합니다. #!/bin/bash로 고정하면 새로 설치한 Bash가 아니라 3.2가 실행됩니다.

에러 처리

#!/bin/bash
# 에러 발생 시 중단
set -e
# 에러 발생 시 함수 호출
trap 'echo "Error on line $LINENO"' ERR
# 종료 시 정리
cleanup() {
    echo "Cleaning up..."
    rm -f /tmp/temp_file
}
trap cleanup EXIT
# 명령어 실행
if ! command -v git &> /dev/null; then
    echo "Git is not installed"
    exit 1
fi
# 명령어 성공 여부 확인
if git pull; then
    echo "Pull successful"
else
    echo "Pull failed"
    exit 1
fi

set -e는 “명령이 실패하면 스크립트를 멈춘다”고 설명되지만, 실제로는 예외가 많아서 믿고 기대기 어렵습니다. if, while의 조건, &&·|| 목록의 앞부분, !로 뒤집은 명령에서의 실패는 무시되고, 이 규칙이 함수 안까지 적용되어 if my_func; then으로 부른 함수 내부의 실패도 멈추지 않습니다. 또 local out=$(cmd)는 cmd가 실패해도 local 명령 자체가 성공하므로 스크립트가 계속 진행됩니다. local out; out=$(cmd)처럼 선언과 대입을 나누어야 실패가 전파됩니다. 파이프는 기본적으로 마지막 명령의 종료 코드만 보므로 curl ... | tar xz에서 다운로드가 실패해도 tar가 성공하면 통과하는데, 이것을 막는 것이 set -o pipefail입니다. trap ... ERR도 기본으로는 함수 안의 에러에 반응하지 않아 set -E(errtrace)를 함께 켜야 합니다. 이런 이유로 set -euo pipefail은 안전장치로 켜 두되, 중요한 명령은 위 예제의 if git pull처럼 명시적으로 결과를 확인하는 방식을 함께 쓰는 것이 좋습니다.


Shell 비교

문법 비교

변수:

# Bash/Zsh
name="Alice"
echo $name
# PowerShell
$name = "Alice"
Write-Host $name

조건문:

# Bash/Zsh
if [ "$age" -gt 18 ]; then
    echo "Adult"
fi
# PowerShell
if ($age -gt 18) {
    Write-Host "Adult"
}

반복문:

# Bash/Zsh
for i in {1..5}; do
    echo $i
done
# PowerShell
foreach ($i in 1..5) {
    Write-Host $i
}

파이프:

# Bash/Zsh (텍스트)
ps aux | grep nginx
# PowerShell (객체)
Get-Process | Where-Object {$_.Name -eq "nginx"}

성능 비교

스크립트 실행 속도는 셸 자체보다 외부 명령을 몇 번 띄우는지에 더 크게 좌우됩니다. 일반적인 경향만 정리하면 다음과 같습니다.

  • dash(Debian·Ubuntu의 /bin/sh)는 기능이 적은 대신 시작과 실행이 가벼워, 시스템 부팅 스크립트처럼 짧은 POSIX 스크립트를 많이 실행하는 곳에 쓰입니다.
  • bash와 zsh는 기능이 많아 dash보다 무겁지만, 보통의 자동화 스크립트에서는 체감 차이가 크지 않습니다.
  • PowerShell은 .NET 런타임을 띄우므로 시작 시간이 가장 길고, 대신 객체 파이프라인과 .NET 라이브러리를 그대로 쓸 수 있습니다.

루프 안에서 grep·sed·awk를 행마다 호출하는 스크립트는 셸 종류와 상관없이 느립니다. 프로세스 생성 비용이 반복되기 때문이며, 한 번의 awk 호출로 처리하거나 셸 내장 기능(${var#prefix} 같은 매개변수 확장)을 쓰는 편이 훨씬 효과적입니다. 대화형 사용에서는 zsh와 PowerShell이 자동 완성·플러그인 설정에 손이 더 많이 가지만 그만큼 편의 기능이 많고, dash는 대화형보다 스크립트용에 가깝습니다.

기능 비교

기능BashZshPowerShell
자동 완성기본강력강력
플러그인제한적풍부풍부
객체 파이프없음없음있음
크로스 플랫폼Linux/MacLinux/MacAll
스크립트 속도빠름보통느림
학습 곡선낮음중간높음
.NET 통합없음없음있음

개발 환경 설정

.bashrc / .zshrc 추천 설정

# ~/.bashrc 또는 ~/.zshrc
# 별칭 (Alias)
alias ll='ls -lah'
alias la='ls -A'
alias l='ls -CF'
alias ..='cd ..'
alias ...='cd ../..'
alias gs='git status'
alias ga='git add'
alias gc='git commit'
alias gp='git push'
alias gpl='git pull'
alias gd='git diff'
alias gl='git log --oneline --graph --decorate'
# 함수
mkcd() {
    mkdir -p "$1" && cd "$1"
}
extract() {
    if [ -f "$1" ]; then
        case "$1" in
            *.tar.gz)  tar xzf "$1"   ;;
            *.tar.bz2) tar xjf "$1"   ;;
            *.zip)     unzip "$1"     ;;
            *.rar)     unrar x "$1"   ;;
            *)         echo "Unknown format" ;;
        esac
    fi
}
# 환경 변수
export EDITOR=vim
export VISUAL=vim
export LANG=en_US.UTF-8
# PATH 추가
export PATH="$HOME/bin:$PATH"
export PATH="/usr/local/bin:$PATH"
# 히스토리 설정
export HISTSIZE=10000
export HISTFILESIZE=20000
export HISTCONTROL=ignoredups:erasedups
# 프롬프트 커스터마이징
PS1='\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
# 자동 완성
if [ -f /etc/bash_completion ]; then
    . /etc/bash_completion
fi

PowerShell 프로필

# $PROFILE 파일 편집
notepad $PROFILE
# 별칭
Set-Alias ll Get-ChildItem
Set-Alias g git
# 함수
function mkcd {
    param($Path)
    New-Item -ItemType Directory -Path $Path -Force
    Set-Location $Path
}
function gs { git status }
function ga { git add $args }
function gc { git commit -m $args }
function gp { git push }
# 프롬프트 커스터마이징
function prompt {
    $location = Get-Location
    Write-Host "PS " -NoNewline -ForegroundColor Green
    Write-Host "$location" -NoNewline -ForegroundColor Blue
    return "> "
}
# 모듈 자동 로드
Import-Module posh-git  # Git 통합
Import-Module PSReadLine  # 향상된 입력
# PSReadLine 설정
Set-PSReadLineOption -PredictionSource History
Set-PSReadLineOption -PredictionViewStyle ListView

디버깅

Bash 디버깅

# 디버그 모드 실행
bash -x script.sh
# 스크립트 내에서 활성화
set -x  # 디버그 시작
# ... 코드 ...
set +x  # 디버그 종료
# 출력 예시:
# + echo 'Hello'
# Hello
# + name=Alice
# + echo 'Name: Alice'
# Name: Alice

디버깅 옵션:

#!/bin/bash
set -e  # 에러 시 즉시 종료
set -u  # 미정의 변수 사용 시 에러
set -o pipefail  # 파이프라인 에러 감지
set -x  # 디버그 모드
# 또는 한 줄로
set -euxo pipefail

PowerShell 디버깅

# 디버그 모드
Set-PSDebug -Trace 1  # 명령어 추적
Set-PSDebug -Trace 2  # 변수 할당도 추적
# 중단점
Set-PSBreakpoint -Script script.ps1 -Line 10
# 단계별 실행
Set-PSDebug -Step
# 디버그 해제
Set-PSDebug -Off

크로스 플랫폼 스크립트

플랫폼 감지

#!/bin/bash
# OS 감지
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
    echo "Linux"
    package_manager="apt"
elif [[ "$OSTYPE" == "darwin"* ]]; then
    echo "macOS"
    package_manager="brew"
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then
    echo "Windows"
    package_manager="choco"
fi
# 패키지 설치
case "$package_manager" in
    apt)
        sudo apt update
        sudo apt install -y git
        ;;
    brew)
        brew install git
        ;;
    choco)
        choco install git -y
        ;;
esac

PowerShell 크로스 플랫폼

# OS 감지
if ($IsLinux) {
    Write-Host "Running on Linux"
    $packageManager = "apt"
} elseif ($IsMacOS) {
    Write-Host "Running on macOS"
    $packageManager = "brew"
} elseif ($IsWindows) {
    Write-Host "Running on Windows"
    $packageManager = "choco"
}
# 경로 구분자
$separator = [System.IO.Path]::DirectorySeparatorChar
$path = "folder$($separator)file.txt"

보안 모범 사례

안전한 스크립트 작성

#!/bin/bash
# 1. 엄격 모드
set -euo pipefail
# 2. 입력 검증
if [ $# -ne 1 ]; then
    echo "Usage: $0 <filename>"
    exit 1
fi
filename="$1"
# 3. 경로 검증
if [[ "$filename" != *.txt ]]; then
    echo "Only .txt files allowed"
    exit 1
fi
# 4. 변수 인용 (공백 처리)
if [ -f "$filename" ]; then  # 인용 필수!
    cat "$filename"
fi
# 5. 임시 파일 안전하게 생성
temp_file=$(mktemp)
trap 'rm -f "$temp_file"' EXIT   # 작은따옴표: trap 실행 시점에 확장, 공백 경로도 안전
# 6. 비밀번호 입력 (숨김)
read -s -p "Password: " password
echo

비밀 정보 관리

# ❌ 스크립트에 하드코딩
DB_PASSWORD="secret123"
# ✅ 환경 변수
export DB_PASSWORD="secret123"
./script.sh
# ✅ .env 파일
# .env
DB_PASSWORD=secret123
# script.sh
source .env
echo $DB_PASSWORD
# ⚠️ .env를 .gitignore에 추가!
# ✅ 안전한 입력
read -s -p "Database password: " DB_PASSWORD
echo

성능 최적화

빠른 스크립트 작성

# ❌ 느림 (외부 명령어 반복 호출)
for file in *.txt; do
    lines=$(wc -l < "$file")
    echo "$file: $lines lines"
done
# ✅ 빠름 (한 번에 처리)
wc -l *.txt
# ❌ 느림 (불필요한 cat과 wc)
cat file.txt | grep "error" | wc -l
# ✅ grep이 파일을 직접 읽고 줄 수까지 셈
grep -c "error" file.txt
# 주의: grep "error" | grep "critical"(두 단어가 모두 있는 줄)을
# grep "error.*critical"로 바꾸면 순서가 고정되어 "critical ... error" 줄을 놓칩니다

병렬 처리

# 순차 처리 (느림)
for file in *.jpg; do
    convert "$file" -resize 800x600 "resized_$file"
done
# 병렬 처리 (빠름)
for file in *.jpg; do
    convert "$file" -resize 800x600 "resized_$file" &
done
wait  # 모든 백그라운드 작업 완료 대기
# GNU parallel 사용 (가장 빠름)
parallel convert {} -resize 800x600 resized_{} ::: *.jpg

실전 도구

fzf (퍼지 파인더)

# 설치
# macOS
brew install fzf
# Ubuntu
sudo apt install fzf
# 사용
# Ctrl+R: 명령어 히스토리 검색
# Ctrl+T: 파일 검색
# Alt+C: 디렉토리 검색
# 스크립트에서 사용
selected_file=$(find . -type f | fzf)
echo "Selected: $selected_file"

ripgrep (빠른 검색)

# 설치
brew install ripgrep  # macOS
sudo apt install ripgrep  # Ubuntu
# 사용 (grep보다 훨씬 빠름)
rg "error" ./logs
rg -i "warning"  # 대소문자 무시
rg -t js "function"  # JavaScript 파일만
rg "TODO" --stats  # 통계 포함

bat (cat 개선)

# 설치
brew install bat  # macOS
sudo apt install bat  # Ubuntu
# 사용 (문법 하이라이팅)
bat file.js
bat --style=numbers,changes file.js

exa (ls 개선)

# 설치
brew install exa  # macOS
sudo apt install exa  # Ubuntu
# 사용
exa -l  # 자세히
exa -T  # 트리 구조
exa -l --git  # Git 상태 포함

CI/CD에서 Shell 활용

GitHub Actions

name: Deploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Build
      run: |
        npm ci
        npm run build
    
    - name: Deploy
      run: |
        #!/bin/bash
        set -e
        
        echo "Deploying to production..."
        
        # SSH로 서버 접속 및 배포
        ssh user@server << 'EOF'
          cd /var/www/app
          git pull
          npm ci --production
          npm run build
          pm2 restart app
        EOF
        
        echo "Deploy complete!"

GitLab CI

deploy:
  stage: deploy
  script:
    - |
      #!/bin/bash
      set -euxo pipefail
      
      # Docker 이미지 빌드
      docker build -t myapp:$CI_COMMIT_SHA .
      
      # Docker Hub 푸시
      docker push myapp:$CI_COMMIT_SHA
      
      # Kubernetes 배포
      kubectl set image deployment/myapp \
        myapp=myapp:$CI_COMMIT_SHA
  only:
    - main

Shell 스크립트 테스트

ShellCheck (정적 분석)

# 설치
brew install shellcheck  # macOS
sudo apt install shellcheck  # Ubuntu
# 사용
shellcheck script.sh
# 출력 예시:
# In script.sh line 5:
# if [ $name = "Alice" ]; then
#      ^-- SC2086: Double quote to prevent globbing

Bats (테스트 프레임워크)

# 설치
npm install -g bats
# test.bats
#!/usr/bin/env bats
@test "addition" {
    result=$(echo $((2 + 2)))
    [ "$result" -eq 4 ]
}
@test "file exists" {
    touch /tmp/test_file
    [ -f /tmp/test_file ]
    rm /tmp/test_file
}
# 실행
bats test.bats

색상·진행 표시는 터미널일 때만 켜기

셸 스크립트에 ANSI 색상 코드( 같은)나 로 한 줄을 덮어쓰는 진행 바를 넣는 것 자체는 흔합니다. 문제는 같은 스크립트를 ./build.sh > build.log로 리다이렉트하거나 CI에서 돌릴 때입니다. 출력이 터미널이 아니어도 이스케이프 시퀀스는 그대로 기록되기 때문에 로그 파일에 ^[[0;32m 같은 문자열이 섞이고, grep "Success" build.log처럼 로그를 다시 검색할 때 색상 코드가 단어 사이에 끼어 매칭이 어긋나기도 합니다. 진행 바는 CI 로그 뷰어에서 줄마다 새로 찍혀 수백 줄로 불어납니다.

그래서 출력 대상이 터미널인지 -t 테스트로 확인하고, 아니면 색상 변수를 빈 문자열로 두는 방식이 안전합니다. 많은 CLI 도구가 따르는 NO_COLOR 환경 변수도 함께 존중하면 사용자가 직접 끌 수 있습니다. 또 echo -e는 Bash에서만 이스케이프를 해석하고 sh(dash)나 macOS의 /bin/sh에서는 동작이 다르므로, 이식성이 필요하면 printf를 씁니다.

#!/bin/bash
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
    GREEN=''; RED=''; NC=''
else
    GREEN=''; RED=''; NC=''
fi
success() { printf '%b%s%b
' "$GREEN" "$1" "$NC"; }
error()   { printf '%b%s%b
' "$RED" "$1" "$NC" >&2; }
success "Build complete"
error "Build failed"   # 에러는 stderr로 보내야 파이프 뒤 명령이 결과와 섞지 않습니다

한 번 쓰고 말 명령을 ~/.bashrc 함수로 옮기는 기준도 비슷합니다. 같은 파이프라인을 세 번째 타이핑하고 있다면 함수나 alias로 만들 때가 된 것이고, 인자를 받거나 여러 줄이 되면 alias가 아니라 함수로 만드는 편이 인용 문제를 덜 겪습니다.


실전 프로젝트

프로젝트 1: 개발 환경 셋업 스크립트

#!/bin/bash
set -e
echo "🚀 개발 환경 셋업 시작..."
# OS 감지
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
    OS="linux"
elif [[ "$OSTYPE" == "darwin"* ]]; then
    OS="macos"
else
    echo "Unsupported OS"
    exit 1
fi
# 패키지 매니저 업데이트
echo "📦 패키지 매니저 업데이트..."
if [ "$OS" = "linux" ]; then
    sudo apt update && sudo apt upgrade -y
elif [ "$OS" = "macos" ]; then
    brew update && brew upgrade
fi
# Git 설치
echo "📥 Git 설치..."
if ! command -v git &> /dev/null; then
    if [ "$OS" = "linux" ]; then
        sudo apt install -y git
    elif [ "$OS" = "macos" ]; then
        brew install git
    fi
fi
# Node.js 설치
echo "📥 Node.js 설치..."
if ! command -v node &> /dev/null; then
    if [ "$OS" = "linux" ]; then
        curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
        sudo apt install -y nodejs
    elif [ "$OS" = "macos" ]; then
        brew install node
    fi
fi
# Docker 설치
echo "🐳 Docker 설치..."
if ! command -v docker &> /dev/null; then
    if [ "$OS" = "linux" ]; then
        curl -fsSL https://get.docker.com | sh
        sudo usermod -aG docker $USER
    elif [ "$OS" = "macos" ]; then
        echo "Please install Docker Desktop manually"
    fi
fi
# VSCode 설치
echo "💻 VSCode 설치..."
if ! command -v code &> /dev/null; then
    if [ "$OS" = "linux" ]; then
        sudo snap install code --classic
    elif [ "$OS" = "macos" ]; then
        brew install --cask visual-studio-code
    fi
fi
# Git 설정
echo "⚙️  Git 설정..."
read -p "Git username: " git_username
read -p "Git email: " git_email
git config --global user.name "$git_username"
git config --global user.email "$git_email"
git config --global init.defaultBranch main
# SSH 키 생성
if [ ! -f ~/.ssh/id_ed25519 ]; then
    echo "🔑 SSH 키 생성..."
    ssh-keygen -t ed25519 -C "$git_email" -f ~/.ssh/id_ed25519 -N ""
    echo "SSH 공개 키:"
    cat ~/.ssh/id_ed25519.pub
fi
echo
echo "✅ 개발 환경 셋업 완료!"
echo
echo "설치된 도구:"
echo "- Git: $(git --version)"
echo "- Node.js: $(node --version)"
echo "- npm: $(npm --version)"
echo "- Docker: $(docker --version 2>/dev/null || echo 'Not installed')"
echo "- VSCode: $(code --version 2>/dev/null | head -1 || echo 'Not installed')"

프로젝트 2: 로그 모니터링

#!/bin/bash
LOG_FILE="/var/log/nginx/access.log"
ALERT_EMAIL="[email protected]"
ERROR_THRESHOLD=100
# 실시간 로그 모니터링
tail -f "$LOG_FILE" | while read line; do
    # 에러 감지
    if echo "$line" | grep -q "500\|502\|503"; then
        echo "⚠️  Error detected: $line"
        
        # 에러 카운트
        error_count=$(grep -c "50[0-3]" "$LOG_FILE")
        
        # 임계값 초과 시 알림
        if [ "$error_count" -gt "$ERROR_THRESHOLD" ]; then
            echo "🚨 Too many errors! Sending alert..."
            
            # 이메일 알림
            echo "Error count: $error_count" | mail -s "Server Alert" "$ALERT_EMAIL"
            
            # Slack 알림
            curl -X POST https://hooks.slack.com/services/YOUR/WEBHOOK/URL \
                -H 'Content-Type: application/json' \
                -d "{\"text\":\"🚨 Server errors: $error_count\"}"
        fi
    fi
done

프로젝트 3: 서버 배포 스크립트

#!/bin/bash
set -euo pipefail
# 색상
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# 함수
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# 설정
APP_NAME="myapp"
APP_DIR="/var/www/$APP_NAME"
BRANCH="main"
BACKUP_DIR="/backup/$APP_NAME"
# 백업
backup() {
    log_info "Creating backup..."
    
    local backup_file="$BACKUP_DIR/backup_$(date +%Y%m%d_%H%M%S).tar.gz"
    mkdir -p "$BACKUP_DIR"
    
    tar -czf "$backup_file" -C "$APP_DIR" .
    log_info "Backup created: $backup_file"
}
# 배포
deploy() {
    log_info "Starting deployment..."
    
    cd "$APP_DIR"
    
    # 1. 백업
    backup
    
    # 2. Git Pull
    log_info "Pulling latest code..."
    git fetch origin
    git reset --hard origin/$BRANCH
    
    # 3. 의존성 설치
    log_info "Installing dependencies..."
    npm ci --production
    
    # 4. 빌드
    log_info "Building..."
    npm run build
    
    # 5. 데이터베이스 마이그레이션
    log_info "Running migrations..."
    npm run migrate
    
    # 6. 서비스 재시작
    log_info "Restarting service..."
    pm2 restart "$APP_NAME"
    
    # 7. 헬스 체크
    log_info "Health check..."
    sleep 5
    
    local response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/health)
    
    if [ "$response" = "200" ]; then
        log_info "✅ Deployment successful!"
    else
        log_error "❌ Health check failed (HTTP $response)"
        rollback
        exit 1
    fi
}
# 롤백
rollback() {
    log_warn "Rolling back..."
    
    # 최신 백업 찾기
    local latest_backup=$(ls -t "$BACKUP_DIR"/backup_*.tar.gz | head -1)
    
    if [ -z "$latest_backup" ]; then
        log_error "No backup found!"
        exit 1
    fi
    
    log_info "Restoring from: $latest_backup"
    
    cd "$APP_DIR"
    tar -xzf "$latest_backup"
    
    npm ci --production
    pm2 restart "$APP_NAME"
    
    log_info "Rollback complete"
}
# 메인
case "${1:-}" in
    deploy)
        deploy
        ;;
    rollback)
        rollback
        ;;
    backup)
        backup
        ;;
    *)
        echo "Usage: $0 {deploy|rollback|backup}"
        exit 1
        ;;
esac

PowerShell 고급 기능

객체 파이프라인

# 프로세스 정보 (객체)
Get-Process | 
    Where-Object { $_.CPU -gt 10 } |
    Sort-Object CPU -Descending |
    Select-Object -First 10 Name, CPU, WorkingSet |
    Format-Table -AutoSize
# 파일 크기 합계
Get-ChildItem -Recurse |
    Measure-Object -Property Length -Sum |
    Select-Object @{Name="TotalGB";Expression={[math]::Round($_.Sum / 1GB, 2)}}
# CSV 처리
Import-Csv users.csv |
    Where-Object { $_.Age -gt 18 } |
    Select-Object Name, Email |
    Export-Csv adults.csv -NoTypeInformation

원격 실행

# 원격 서버에서 명령어 실행
Invoke-Command -ComputerName Server01 -ScriptBlock {
    Get-Service | Where-Object { $_.Status -eq "Running" }
}
# 여러 서버
$servers = @("Server01", "Server02", "Server03")
Invoke-Command -ComputerName $servers -ScriptBlock {
    Get-Process | Sort-Object CPU -Descending | Select-Object -First 5
}
# 자격 증명
$cred = Get-Credential
Invoke-Command -ComputerName Server01 -Credential $cred -ScriptBlock {
    Restart-Service IIS
}

모듈 및 패키지

# 모듈 검색
Find-Module -Name "*Azure*"
# 모듈 설치
Install-Module -Name Az -Scope CurrentUser
# 모듈 가져오기
Import-Module Az
# 설치된 모듈 목록
Get-Module -ListAvailable
# 명령어 검색
Get-Command -Module Az

트러블슈팅

Bash 문제 해결

1) “Permission denied”

# 원인: 실행 권한 없음
# 해결:
chmod +x script.sh

2) “command not found”

# 원인: PATH에 없음
# 해결:
export PATH="/usr/local/bin:$PATH"
# 또는 전체 경로 사용
/usr/local/bin/mycommand

3) “No such file or directory”

# 원인: 공백 처리 안 됨
# ❌ 잘못된 코드
file=my file.txt   # 대입이 아니라 "file=my" 환경에서 file.txt 명령 실행 → command not found
file="my file.txt"
cat $file  # cat이 "my"와 "file.txt"를 별도 파일로 인식 → No such file or directory
# ✅ 올바른 코드
file="my file.txt"
cat "$file"  # 인용 필수!

4) /bin/bash^M: bad interpreter 또는 $'\r': command not found

Windows에서 작성하거나 core.autocrlf=true인 Git으로 체크아웃한 스크립트는 줄 끝이 CRLF(\r\n)가 됩니다. Bash는 \r을 명령의 일부로 읽기 때문에 첫 줄의 인터프리터 경로가 /bin/bash\r이 되어 “그런 파일이 없다”는 에러가 나고, 파일 내용은 멀쩡해 보여서 원인을 찾기 어렵습니다. file script.sh로 with CRLF line terminators가 표시되는지 확인하고, dos2unix script.sh나 sed -i 's/\r$//' script.sh로 고친 뒤, 저장소에 *.sh text eol=lf를 담은 .gitattributes를 두면 재발하지 않습니다.

PowerShell 문제 해결

1) “실행 정책” 에러

# 원인: 스크립트 실행 정책
# 해결:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
# 확인
Get-ExecutionPolicy

2) “명령어를 찾을 수 없습니다”

# 원인: 모듈 미설치
# 해결:
Install-Module -Name ModuleName
# 또는 PATH 추가
$env:PATH += ";C:\MyTools"

FAQ

Q1. Bash와 Zsh 중 무엇을 써야 하나요? 서버 스크립트와 CI는 Bash가 기본값인 경우가 많습니다. 로컬 터미널에서 자동 완성과 플러그인을 쓰고 싶다면 Zsh를 많이 씁니다. macOS는 Catalina부터 기본 셸이 Zsh입니다.

Q2. PowerShell을 Linux에서도 쓸 수 있나요? PowerShell 7(pwsh)은 크로스 플랫폼입니다. 다만 Linux 서버의 문서와 예제는 여전히 Bash 기준이 대부분입니다.

Q3. Shell과 Python은 어떻게 나눠 쓰나요? 짧은 파이프라인, 배포 스크립트, 크론 작업은 셸이 가볍습니다. 데이터 가공, 복잡한 분기, 외부 라이브러리가 필요하면 Python이 나은 경우가 많습니다.

Q4. 무엇부터 익혀야 하나요? Linux를 다룬다면 Bash는 피하기 어렵습니다. 그다음 로컬 환경에 Zsh를 얹거나, Windows 관리를 한다면 PowerShell을 함께 익히면 됩니다.


같이 보면 좋은 글