1 왜 셸 스크립팅을 알아야 하는가
직접 코드를 짤 줄 모르더라도 다음 자동화 도구가 모두 셸 명령으로 동작한다.
| 도구 | 어떤 셸 |
|---|---|
Makefile (make install, make dev-backend) |
Linux/Mac은 Bash, Windows는 PowerShell 또는 WSL |
| render.sh (블로그 렌더링) | Bash |
| render-changed.ps1 (블로그 Windows 렌더링) | PowerShell |
GitHub Actions workflow yml의 run: 명령 |
Linux runner는 Bash, Windows runner는 PowerShell |
Docker Dockerfile의 RUN |
sh (대부분 Bash와 호환) |
| Dockerfile CMD/ENTRYPOINT | sh 또는 명시적 셸 |
본 글이 두 셸의 공통 개념·결정적 차이·cross-platform 호환 패턴을 정리한다. 한 셸을 마스터하기보다 두 셸 사이를 오가며 막히지 않을 수준이 목표다.
2 두 셸이 공존하는 이유
- Bash (Bourne Again Shell): Unix 계열 표준 셸. Linux·macOS의 기본 (macOS 최신은 zsh이지만 거의 호환)
- PowerShell: Microsoft가 만든 객체 기반 셸. Windows의 기본. PowerShell 7+(
pwsh)는 cross-platform
OS 표준이 다른 게 가장 큰 이유다. 그 위에 두 셸의 철학이 다르다:
| 항목 | Bash | PowerShell |
|---|---|---|
| 기본 단위 | 텍스트 (string) | 객체 (.NET 객체) |
| 파이프 | 텍스트 줄을 다음 명령에 전달 | 객체를 다음 명령에 전달 |
| 변수 | VAR=value (= 양옆 공백 금지) |
$var = "value" |
| 명령 | 짧은 이름 (ls, cat, grep) |
Verb-Noun 패턴 (Get-ChildItem, Get-Content) |
| 종료 코드 | $? 또는 $ENV:LASTEXITCODE |
$LASTEXITCODE (외부 명령), $? (내장 명령 boolean) |
| 스크립트 확장자 | .sh |
.ps1 |
| 셸 자체 호출 | bash script.sh 또는 shebang #!/bin/bash |
pwsh -File script.ps1 |
운영 자동화 스크립트를 쓸 때 양쪽을 모두 제공하거나 한쪽만 지원하면서 그 사실을 README에 명시하는 두 전략이 있다. MINERVA의 render.sh + render-changed.ps1이 양쪽 제공 패턴이다.
3 변수와 인자
3.1 Bash
3.2 PowerShell
# 변수 — $ 접두사 + = 양옆 공백 OK
$name = "minerva"
$port = 8000
# 사용 — $ 또는 ${...}
Write-Host "App: $name on port $port"
Write-Host "App: ${name}_v2"
# 환경변수
$env:AZURE_OPENAI_API_KEY = "sk-..." # 현재 세션
[Environment]::SetEnvironmentVariable("KEY", "value", "User") # 영구
# 스크립트 인자 — param 블록
param(
[string]$Name = "default",
[int]$Port = 8000
)
Write-Host "Name: $Name, Port: $Port"
# CLI에서 호출
# .\script.ps1 -Name minerva -Port 8080PowerShell이 더 명시적(타입·기본값)이지만 코드 양이 늘어난다. Bash가 더 간결하지만 따옴표·공백 함정이 많다.
4 파이프와 리다이렉션
4.1 Bash — 텍스트 파이프
# 파이프
ls -la | grep ".qmd" | wc -l # qmd 파일 수
# 리다이렉션
echo "log line" >> app.log # append
python app.py > output.txt 2> error.txt # stdout, stderr 분리
python app.py > output.txt 2>&1 # stderr를 stdout으로 합침
python app.py &> all.txt # 둘 다 한 파일
# 파이프 + 리다이렉션 조합
cat large.log | grep ERROR | tee errors.txt | wc -l # tee로 중간 저장4.2 PowerShell — 객체 파이프
# 파이프 — 객체가 흘러감
Get-ChildItem | Where-Object Name -Match ".qmd" | Measure-Object | Select-Object Count
# 짧은 alias도 가능 (Bash 흉내)
ls | ? Name -Match ".qmd" | measure | select Count
# 리다이렉션 (Bash와 비슷)
"log line" | Out-File -Append app.log
python app.py > output.txt 2> error.txt
python app.py *> all.txt # 모든 스트림객체 파이프의 강점:
# Bash로 하기 어려운 것
Get-Process | Where-Object CPU -gt 100 | Sort-Object CPU -Descending | Select-Object -First 5
# CPU 100 이상 프로세스 중 CPU 사용 높은 5개를 정렬해 표시PowerShell은 .CPU, .Name 같은 객체 속성에 직접 접근. Bash는 awk·sort·head로 텍스트 파싱이 필요.
5 조건문과 루프
5.1 Bash
# if
if [ -f "config.yaml" ]; then
echo "Config found"
elif [ -f "config.yml" ]; then
echo "Config found (yml)"
else
echo "Config missing"
fi
# 비교 연산자
[ "$a" = "$b" ] # 문자열 비교 (= 또는 ==)
[ "$a" != "$b" ]
[ "$n" -eq 5 ] # 숫자 비교 (-eq, -ne, -lt, -le, -gt, -ge)
[ -f "file" ] # 파일 존재
[ -d "dir" ] # 디렉토리 존재
[ -z "$VAR" ] # 변수가 비어있는지
# for 루프
for f in *.qmd; do
echo "Processing $f"
done
# while
while [ "$count" -lt 10 ]; do
echo "$count"
count=$((count + 1))
done
# 명령 종료 코드 기반
if command -v docker &> /dev/null; then
echo "Docker installed"
fi5.2 PowerShell
# if
if (Test-Path "config.yaml") {
Write-Host "Config found"
} elseif (Test-Path "config.yml") {
Write-Host "Config found (yml)"
} else {
Write-Host "Config missing"
}
# 비교 연산자
$a -eq $b # equal
$a -ne $b # not equal
$a -lt $b # less than
$a -gt $b # greater than
$a -match "pattern" # regex
[string]::IsNullOrEmpty($var) # 비어있는지
# foreach 루프
foreach ($f in Get-ChildItem -Filter "*.qmd") {
Write-Host "Processing $($f.Name)"
}
# 또는 파이프 ForEach-Object
Get-ChildItem -Filter "*.qmd" | ForEach-Object {
Write-Host "Processing $($_.Name)" # $_ = 현재 항목
}
# while
while ($count -lt 10) {
Write-Host $count
$count++
}
# 명령 존재 확인
if (Get-Command docker -ErrorAction SilentlyContinue) {
Write-Host "Docker installed"
}6 함수
6.1 Bash
6.2 PowerShell
# 함수 정의
function Greet {
param(
[string]$Name,
[int]$Count = 1
)
1..$Count | ForEach-Object {
Write-Host "Hello, $Name!"
}
}
# 호출 (named 또는 positional)
Greet -Name "Alice"
Greet -Name "Bob" -Count 3
Greet "Bob" 3 # positional도 가능
# 반환값
function Get-Timestamp {
return Get-Date -Format "yyyy-MM-dd_HH:mm:ss"
}
$ts = Get-Timestamp7 운영 핵심 — set -e와 에러 처리
7.1 Bash — set -e/-u/-o pipefail
#!/bin/bash
set -euo pipefail
# -e: 명령 하나라도 실패하면 즉시 스크립트 중단
# -u: 정의 안 된 변수 사용 시 에러
# -o pipefail: 파이프 중간 명령 실패도 캐치
cd "$(dirname "$0")" # 스크립트 위치로 이동
echo "Step 1: Checking prerequisites"
command -v docker &> /dev/null || { echo "Docker not found"; exit 1; }
echo "Step 2: Building"
docker build -t minerva:latest . # 실패하면 set -e로 즉시 중단
echo "Step 3: Pushing"
docker push minerva:latest
echo "All steps completed"set -euo pipefail은 운영 스크립트의 사실상 표준이다 — 실패가 조용히 묻히지 않게 막는다.
7.2 PowerShell — $ErrorActionPreference
$ErrorActionPreference = "Stop" # 에러 시 즉시 중단 (set -e 등가)
Set-Location $PSScriptRoot # 스크립트 위치
Write-Host "Step 1: Checking prerequisites"
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
Write-Error "Docker not found"
exit 1
}
Write-Host "Step 2: Building"
docker build -t minerva:latest .
if ($LASTEXITCODE -ne 0) { # 외부 명령은 LASTEXITCODE 직접 체크
Write-Error "Build failed"
exit $LASTEXITCODE
}
Write-Host "Step 3: Pushing"
docker push minerva:latest
Write-Host "All steps completed"PowerShell의 함정: $ErrorActionPreference="Stop"은 cmdlet 에러는 catch하지만 외부 명령(docker, git 등)의 비정상 종료는 자동으로 catch하지 않는다. $LASTEXITCODE 직접 검사 필요.
8 Cross-platform 호환 패턴
8.1 패턴 1 — 두 스크립트 모두 제공
scripts/
├── render.sh # Linux/Mac
└── render-changed.ps1 # Windows
README에 둘 다 명시:
이 패턴이 MINERVA 블로그가 채택한 방식이다.
8.2 패턴 2 — Makefile로 추상화
Makefile 자체는 OS 독립적인 명령 디스패처. 그 안에서 OS 분기.
# Makefile
.PHONY: install dev-backend dev-frontend
install:
ifeq ($(OS),Windows_NT)
pwsh -Command "pip install -r requirements.txt"
else
pip install -r requirements.txt
endif
dev-backend:
uvicorn services.api.main:app --reload --port 8000
dev-frontend:
cd frontend && npm run dev사용자는 make install만 외우면 OS 차이를 신경 쓰지 않는다.
8.3 패턴 3 — Python 스크립트로 통일
복잡한 로직은 셸 대신 Python으로:
# scripts/release.py
import platform
import subprocess
def main():
os_name = platform.system() # "Linux"·"Darwin"·"Windows"
if os_name == "Windows":
subprocess.run(["pwsh", "-File", "scripts/release.ps1"], check=True)
else:
subprocess.run(["bash", "scripts/release.sh"], check=True)
if __name__ == "__main__":
main()python scripts/release.py 한 명령. Python은 모든 OS에서 동일하게 동작.
8.4 패턴 4 — 컨테이너 안에서 실행
빌드·테스트를 Linux 컨테이너 안에서 돌리면 OS 차이가 사라진다 (Docker 기초).
docker run --rm -v "$(pwd):/app" -w /app python:3.11 bash -c "pip install -r requirements.txt && pytest"CI/CD 워크플로가 거의 모두 이 패턴 — runs-on: ubuntu-22.04 runner에서 컨테이너 빌드·테스트.
9 자주 쓰는 패턴 — exit code
# Bash — 마지막 명령 종료 코드
docker build -t minerva .
echo "Exit: $?"
# 명시적 종료
exit 0 # 성공
exit 1 # 일반 실패
exit 2 # 잘못된 사용
exit 130 # Ctrl+C# PowerShell — 외부 명령 종료 코드
docker build -t minerva .
Write-Host "Exit: $LASTEXITCODE"
# 명시적 종료
exit 0
exit 1CI 워크플로에서 step 실패 = 비0 종료 코드. exit 1로 의도적 실패를 트리거.
10 자주 발생하는 오류 패턴
CORRECT:
Bash의 가장 흔한 함정. = 양옆 공백은 절대 금지. 변수 사용 시 "$VAR"로 감싸 spaces·glob을 보호.
CORRECT:
PowerShell은 string을 외부 명령에 전달할 때 한 인자로 묶는다. 여러 인자는 배열(@(...))로.
#!/bin/bash
set -e
curl https://api.example.com/data | jq '.users' > users.json
echo "Done" # curl 실패해도 jq가 0 반환하면 진행됨CORRECT:
#!/bin/bash
set -euo pipefail # pipefail 추가
curl https://api.example.com/data | jq '.users' > users.json
echo "Done" # curl 실패하면 즉시 중단set -e만으로는 파이프 중간 명령의 실패를 catch 못 한다. pipefail을 추가해야 진짜 안전.
CORRECT:
# 1회성 우회 (관리자 권한 불필요)
pwsh -ExecutionPolicy Bypass -File .\render-changed.ps1
# 또는 사용자 환경 영구 변경 (한 번만)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserWindows의 보안 기본값은 .ps1 실행 차단. RemoteSigned로 로컬 스크립트는 허용, 인터넷 다운로드는 서명 필요. 대부분의 개발자가 한 번 설정 후 끝.
#!/bin/bash
cd "$(dirname "$0")" # 스크립트 위치로 이동
# ... 그러나 사용자가 source script.sh로 실행하면 사용자 셸의 cd가 영구 변함CORRECT:
#!/bin/bash
cd "$(dirname "$0")" || exit 1 # cd 실패 시 명시적 종료
# ... 또는 subshell로 격리
(cd "$(dirname "$0")" && do_something)source script.sh로 호출되면 cd가 사용자 셸에 그대로 적용된다. 명시적 실행(bash script.sh) 또는 subshell로 격리.
11 정리
| 영역 | Bash | PowerShell |
|---|---|---|
| 변수 | VAR=val (공백 금지) |
$var = "val" |
| 환경변수 | export VAR= |
$env:VAR = "" |
| 인자 | $1 $2 $@ $# |
param(...) |
| 비교 | [ "$a" = "$b" ] -eq -lt |
-eq -lt -match |
| 루프 | for f in *.qmd while |
foreach ($f in ...) while |
| 함수 | name() { ... } |
function Name { param() ... } |
| 안전 모드 | set -euo pipefail |
$ErrorActionPreference="Stop" + $LASTEXITCODE |
| 종료 코드 | $? exit N |
$LASTEXITCODE exit N |
| Cross-platform | sh-only | pwsh 7+ on Linux/Mac/Win |
12 응용 분야
| MINERVA 운영 사용처 | 본 글 절 |
|---|---|
Makefile로 OS 추상화 (make install/make dev-backend) |
패턴 2 — Makefile |
render.sh + render-changed.ps1 양쪽 제공 |
패턴 1 — 두 스크립트 모두 |
| GitHub Actions runner 셸 분기 (Linux Bash, Windows PowerShell) | 패턴 4 — 컨테이너 |
| 운영 자동화 안전성 | set -euo pipefail / $ErrorActionPreference |
13 관련 주제
선행 학습
- (없음 — 본 글이 가장 기초)
바로 이어 읽을 글 (Tier 3 다음 편)
- Git 워크플로 기초 — 작성 예정
- WebSocket vs SSE 비교 — 작성 예정
MINERVA 시리즈 응용
- MINERVA 프로덕션 배포 (07-0) – Makefile 타겟 + render 스크립트
- MINERVA CI/CD GitHub Actions (07-1) – workflow yml의
run:명령
다른 카테고리 연결
- GitHub Actions 워크플로 기초 – Bash/PowerShell이 step의
run:명령으로 들어감 - Docker Compose 기초 – 컨테이너 안에서는 보통 Bash 환경