Zx — JavaScript로 더 나은 셸 스크립트 작성하기
개요
zx는 Google이 만든 JavaScript/TypeScript로 셸 스크립트를 작성하기 위한 도구입니다. child_process를 래핑하고, 인수를 자동으로 이스케이프하며, 합리적인 기본값을 제공하여 스크립트에서 JavaScript 생태계의 강력함을 누릴 수 있게 해줍니다.
#!/usr/bin/env zx
await $`cat package.json | grep name`
const branch = await $`git branch --show-current`
await $`dep deploy --branch=${branch}`
const name = 'foo & bar'
await $`mkdir /tmp/${name}` // 따옴표 불필요 — 자동 이스케이프
Bash는 간단한 작업에 훌륭하지만, 스크립트가 복잡해지면 완전한 프로그래밍 언어가 도움이 됩니다. zx는 child_process 주변에 유용한 래퍼를 추가하고, 인수를 이스케이프하며, 합리적인 기본값을 제공합니다. 하나의 스크립트에서 bash + JavaScript를 결합한다고 생각하세요.
트리거
사용자가 JavaScript에서 셸 명령 실행, bash 스크립트를 zx로 변환, 원격 스크립트 실행, Markdown 스크립트 또는 TypeScript 셸 스크립트에 대해 물을 때도 트리거됩니다.
빠른 시작
npm install zx
스크립트를 .mjs 파일로 작성합니다(최상위 await 지원). #!/usr/bin/env zx shebang을 추가하거나 CLI를 통해 실행:
zx ./script.mjs # 직접 실행
npx zx ./script.mjs # npx 통해 실행
node --import zx/globals # Node.js 로더로 사용
모든 함수($, cd, fetch 등)는 zx 스크립트에서 가져오기 없이 전역적으로 사용할 수 있습니다. 명시적 가져오기(더 나은 VS Code 자동 완성):
import 'zx/globals'
핵심 개념
$`command` — 셸 명령 실행
태그된 템플릿 리터럴이 zx의 핵심입니다. ${...} 안의 모든 것은 자동으로 이스케이프되고 인용됩니다.
// 비동기 (표준) — ProcessPromise 반환
const output = await $`ls -la`
// 동기 변형 — ProcessOutput 직접 반환
const dir = $.sync`pwd`
// 배열은 펼쳐짐
const flags = ['--oneline', '--decorate', '--color']
await $`git log ${flags}`
// 0이 아닌 종료 코드는 ProcessOutput을 throw
try {
await $`exit 1`
} catch (p) {
console.log(`종료 코드: ${p.exitCode}, 오류: ${p.stderr}`)
}
$({...})로 사전 설정 구성
사전 설정 옵션으로 사용자 정의 $ 인스턴스 생성 — 연결 및 구성 가능:
const $$ = $({ verbose: false, env: { NODE_ENV: 'production' } })
const pwd = $$.sync`pwd`
// 사전 설정은 연결 가능
const $1 = $({ nothrow: true })
const $2 = $1({ sync: true }) // nothrow + sync 모두 적용
ProcessPromise 및 ProcessOutput
$`cmd` ProcessPromise (Promise 확장)
├── .pipe() 스트림 파이핑
├── .kill() 프로세스 종료
├── .text() 문자열로 출력
├── .json() 파싱된 JSON으로 출력
├── .lines() 줄로 나누어진 출력
├── .nothrow() 이 명령에 대한 오류 억제
├── .quiet() 이 명령에 대한 출력 억제
├── .timeout() 시간 초과 후 자동 종료
├── .stdio() I/O 구성
├── .exitCode Promise<종료 코드>
├── .stdout 읽기 가능한 스트림
├── .stderr 읽기 가능한 스트림
├── .stdin 쓰기 가능한 스트림
├── .pid / .cmd 프로세스 메타데이터
└── await → ProcessOutput
├── .stdout 문자열
├── .stderr 문자열
├── .exitCode 숫자
├── .signal 문자열|null
├── .text() / .json() / .lines() / .buffer() / .blob()
└── .ok 불리언 (nothrow 사용 시)
결정 트리
zx 스크립트를 작성할 때 이 결정 트리를 사용하세요:
| 목표 | 접근 방식 |
|---|---|
| 명령 실행 | await $`cmd` |
| 동기적으로 실행 | $.sync`cmd` |
| 출력 파이핑 | .pipe($`next`) / .pipe('file.txt') |
| 오류 우아하게 처리 | $({nothrow: true}) / .nothrow() |
| 시간 초과 설정 | $({timeout: '30s'}) / .timeout('30s') |
| JSON 출력 파싱 | (await $`cmd`).json() |
| 실시간 스트리밍 | for await (const line of $`cmd`) |
| 실패 시 재시도 | retry(5, () => $`cmd`) |
| 사용자 프롬프트 | question('이름: ') |
| 진행 표시기 | await spinner('작업 중...', () => $`cmd`) |
| 디렉토리 변경 | cd('/경로') 또는 within(() => { $.cwd = '/tmp' }) |
| 임시 파일/디렉토리 | tmpfile() / tmpdir() |
| CLI 인수 파싱 | argv.flag 또는 minimist(process.argv.slice(2)) |
| .env 파일 로드 | dotenv.config('.env') |
효과적인 zx 스크립트 작성
병렬 실행
const results = await Promise.all([
$`sleep 1; echo 1`,
$`sleep 2; echo 2`,
$`sleep 3; echo 3`,
])
nothrow로 오류 처리
$.nothrow = true
const repos = ['zx', 'webpod']
const clones = repos.map(n => $`git clone https://github.com/google/${n}`)
const results = await Promise.all(clones)
const errors = results.filter(o => !o.ok).map(o => o.stderr.trim())
console.log('오류:', errors.join('\n'))
스트림 파이핑
// bash 파이프처럼 명령 연결
const greeting = await $`printf "hello"`
.pipe($`awk '{printf $1", world!"}'`)
.pipe($`tr '[a-z]' '[A-Z]'`)
// 파일로 파이핑
await $`echo "안녕하세요!"`.pipe('/tmp/output.txt')
// 터미널로 실시간 출력
await $`echo 1; sleep 1; echo 2; sleep 1; echo 3`.pipe(process.stdout)
스트림 분할 및 병합
// 하나의 소스를 여러 소비자로 분할
const p = $`some-command`
const [o1, o2] = await Promise.all([
p.pipe`log`,
p.pipe`extract`,
])
// 여러 소스 병합
const $h = $({ halt: true })
const p1 = $`echo foo`
const p2 = $h`echo a && sleep 0.1 && echo b`
const p3 = $h`echo c && sleep 0.1 && echo d`
const cat = $h`cat`
p1.pipe(cat); p2.pipe(cat); p3.pipe(cat)
await cat.run()
출력 포맷터
const p = $`echo '{"foo":"bar"}\nline2'`
await p.json() // { foo: 'bar' }
await p.lines() // ['{"foo":"bar"}', 'line2']
await p.text() // '{"foo":"bar"}\nline2\n'
비동기 반복
for await (const line of $`git log --oneline --max-count=5`) {
console.log(line)
}
셸 구성
zx는 기본적으로 bash를 사용합니다. 필요에 따라 셸을 전환하세요:
import { useBash, usePowerShell, usePwsh } from 'zx'
usePowerShell() // PowerShell.exe
usePwsh() // PowerShell v7+
useBash() // bash로 돌아가기
// 수동 재정의
$.shell = '/bin/zsh'
Windows에서는 bash 지원을 위해 WSL 또는 Git Bash를 고려하거나, usePowerShell()을 통해 PowerShell로 전환하세요.
내장 헬퍼 요약
| 헬퍼 | 목적 | 예시 |
|---|---|---|
cd() | 디렉토리 변경 | cd('/tmp') |
fetch() | HTTP 요청, .pipe() 지원 | fetch('https://api.example.com') |
question() | 대화형 사용자 입력 | question('이름: ') |
sleep() | 실행 지연 | await sleep(1000) |
echo() | 표준 출력에 출력 | echo`상태: ${p}` |
stdin() | 표준 입력 읽기 | JSON.parse(await stdin()) |
within() | 격리된 비동기 구성 컨텍스트 | within(() => { $.cwd = '/tmp' }) |
retry() | 지연/백오프로 재시도 | retry(5, () => $curl url) |
spinner() | CLI 진행 표시기 | await spinner(() => $long-cmd) |
glob() | 파일 글로브 매칭 (globby) | glob('**/*.js') |
which() | 실행 파일 경로 찾기 | await which('node') |
ps | 크로스 플랫폼 프로세스 목록 | ps.lookup({ command: 'node' }) |
tmpdir() | 임시 디렉토리 | tmpdir('sub') |
tmpfile() | 임시 파일 | tmpfile('f.txt', 'content') |
argv | 파싱된 CLI 인수 | argv.verbose |
dotenv | .env 파일 로드 | dotenv.config('.env') |
노출된 npm 패키지: chalk (색상), fs (fs-extra), os, path, YAML (yaml), minimist.
리소스
- references/api.md — 전체 API 참조:
$옵션,cd(),fetch(),question(),sleep(),echo(),stdin(),within(),retry(),spinner(),glob(),which(),ps,kill(),tmpdir(),tmpfile(),minimist,argv,chalk,fs,os,path,YAML,dotenv,quote(),useBash(),usePowerShell(),usePwsh() - references/configuration.md — 모든
$.options:shell,prefix,postfix,quote,verbose,quiet,env,cwd,timeout,nothrow,detached,preferLocal,spawn,kill,log,input,signal,stdio,halt,delimiter,defaults - references/cli.md — CLI 사용법: 플래그, 환경 변수, Markdown 스크립트, 원격 스크립트, stdin 실행, REPL 모드
- references/process.md — ProcessPromise/ProcessOutput 수명 주기, 파이핑, 종료, 중단, 출력 포맷터, 스트림 처리