Bash + PowerShell 기초 — Cross-platform 셸 스크립팅

Makefile·render.sh·CI 워크플로의 토대 — 두 셸의 공존과 호환 패턴

Linux/Mac은 Bash, Windows는 PowerShell이 표준이다. 운영 자동화 스크립트(Makefile·render.sh·.github/workflows)는 두 셸을 모두 다뤄야 하므로 변수·파이프·조건문·루프 같은 공통 개념과 두 셸의 결정적 차이를 정리한다. cross-platform 호환 패턴, set -e 운영, exit code, 자주 발생하는 오류까지.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 06일

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 DockerfileRUN 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

# 변수 — = 양옆 공백 금지
NAME="minerva"
PORT=8000

# 사용 — $ 또는 ${...}
echo "App: $NAME on port $PORT"
echo "App: ${NAME}_v2"                          # 인접 문자와 구분 시 중괄호

# 환경변수 (자식 프로세스로 전달)
export AZURE_OPENAI_API_KEY="sk-..."

# 스크립트 인자
echo "Script name: $0"
echo "First arg:   $1"
echo "All args:    $@"
echo "Arg count:   $#"

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 8080

PowerShell이 더 명시적(타입·기본값)이지만 코드 양이 늘어난다. 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"
fi

5.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

# 함수 정의
greet() {
    local name=$1                                 # local로 함수 스코프
    local count=${2:-1}                           # 기본값
    for i in $(seq 1 $count); do
        echo "Hello, $name!"
    done
}

# 호출
greet "Alice"
greet "Bob" 3

# 반환값은 echo로 (return은 0~255 종료 코드만)
get_timestamp() {
    echo "$(date +%Y-%m-%d_%H:%M:%S)"
}

ts=$(get_timestamp)                              # command substitution

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-Timestamp

7 운영 핵심 — 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에 둘 다 명시:

# 렌더링
- macOS/Linux: `bash render.sh`
- Windows: `.\render-changed.ps1`

이 패턴이 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 1

CI 워크플로에서 step 실패 = 비0 종료 코드. exit 1로 의도적 실패를 트리거.

10 자주 발생하는 오류 패턴

WRONG:

NAME = "Alice"                                    # = 양옆 공백 — 명령으로 해석
echo $NAME                                         # 빈 값 출력

CORRECT:

NAME="Alice"                                      # = 양옆 공백 금지
echo "$NAME"                                      # 따옴표로 감싸 IFS 보호

Bash의 가장 흔한 함정. = 양옆 공백은 절대 금지. 변수 사용 시 "$VAR"로 감싸 spaces·glob을 보호.

WRONG:

$args = "--port 8000 --host 0.0.0.0"
uvicorn services.api.main:app $args              # 한 string으로 전달됨

CORRECT:

$args = @("--port", "8000", "--host", "0.0.0.0")  # 배열로 분리
uvicorn services.api.main:app $args

PowerShell은 string을 외부 명령에 전달할 때 한 인자로 묶는다. 여러 인자는 배열(@(...))로.

WRONG:

#!/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을 추가해야 진짜 안전.

WRONG:

.\render-changed.ps1
# Error: cannot be loaded because running scripts is disabled

CORRECT:

# 1회성 우회 (관리자 권한 불필요)
pwsh -ExecutionPolicy Bypass -File .\render-changed.ps1

# 또는 사용자 환경 영구 변경 (한 번만)
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Windows의 보안 기본값은 .ps1 실행 차단. RemoteSigned로 로컬 스크립트는 허용, 인터넷 다운로드는 서명 필요. 대부분의 개발자가 한 번 설정 후 끝.

WRONG:

#!/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 시리즈 응용

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: