Bun Shell($)로 크로스 플랫폼 스크립트 쓰기: 파이프·리다이렉션과 Bash와의 차이
이 글의 핵심
Bun Shell은 OS의 셸을 부르지 않고 Bun 안의 인터프리터가 Bash 비슷한 문법을 해석하므로, Windows에서도 rimraf나 cross-env 없이 같은 스크립트를 쓸 수 있습니다. 보간 값이 자동으로 이스케이프되는 이유와 그래도 막지 못하는 인자 주입, 비영 종료 코드에서 예외가 나는 기본 동작을 함께 짚습니다.
Bun Shell은 Bun 런타임에 내장된 크로스 플랫폼 셸입니다. JavaScript·TypeScript 코드 안에서 Bash와 유사한 문법으로 외부 명령을 실행하고, 파이프·리다이렉션·환경 변수·글로브를 다루며, Response나 Buffer 같은 JS 객체와 입출력을 연결할 수 있습니다. Windows에서도 rimraf나 cross-env 없이 동일한 스크립트를 유지하려는 팀에 특히 유용합니다.
Bun Shell이란 무엇인가
Bun Shell은 시스템의 /bin/sh나 cmd.exe를 그대로 호출하는 방식이 아니라, Bun 프로세스 안에서 동작하는 Bash에 가까운 작은 언어(구현은 Zig)입니다. 따라서 기본적으로 명령 주입(shell injection)에 대해 문자열 보간을 안전하게 처리하도록 설계되어 있습니다.
핵심 특징은 다음과 같습니다.
- 크로스 플랫폼: Windows, Linux, macOS에서 동일한 스크립트 패턴을 사용할 수 있습니다.
ls,cd,rm등 자주 쓰는 명령은 내장되어 있으며, 나머지는PATH에서 실행됩니다. - Bash 스타일: 리다이렉션(
>,2>,>>,2>&1등), 파이프(|), 환경 변수 할당,$(...)형태의 명령 치환을 지원합니다. - JavaScript 상호운용:
Response,Blob,ArrayBuffer,Bun.file()등을 stdin·stdout·stderr와 연결할 수 있습니다. - 글로브:
**,*,{a,b}형태의 패턴을 셸 수준에서 처리합니다.
이 API는 zx, dax 같은 기존 JavaScript 셸 스크립팅 라이브러리와 비슷한 사용감을 목표로 합니다.
내부 구조: 파서, 내장 명령, 외부 프로세스
Bun Shell은 운영체제에 넘기는 bash -c 한 줄이 아니라 Bun 바이너리에 들어 있는 별도의 셸 구현입니다. 공개 저장소(oven-sh/bun)의 src/shell/ 아래에 Zig로 작성된 파서, 인터프리터, 내장 명령이 있습니다. 구현 세부는 버전마다 바뀔 수 있으므로, 사용자 입장에서 알아 둘 동작만 정리하면 다음과 같습니다.
$ 태그 템플릿은 고정 문자열과 보간 값을 따로 받습니다. 보간 값은 셸 파서가 토큰을 나눌 때 하나의 인자로 취급되므로, 값 안에 공백이나 ;가 있어도 명령이 쪼개지지 않습니다. 그다음 파이프, 리다이렉션, $(...) 명령 치환, KEY=value 선행 할당, 글로브가 해석되고 실행 직전에 확장됩니다. Bash와 결과가 다른 경우는 대개 이 확장 규칙이나 경로 처리의 엣지 케이스에서 생깁니다.
명령 이름이 cd, ls, rm 같은 내장 명령이면 새 프로세스를 띄우지 않고 Bun 프로세스 안에서 처리합니다. 그래서 Windows에서도 같은 동작을 하고 시작 비용이 작지만, GNU coreutils의 모든 옵션을 그대로 구현한 것은 아닙니다. 내장에 없는 이름은 PATH에서 실행 파일을 찾아 자식 프로세스로 실행하며, 이때 인자는 이미 문자열 배열로 정리되어 있습니다. 외부 프로세스를 기다리거나 파이프로 데이터를 흘리는 동안에도 JavaScript 이벤트 루프는 막히지 않습니다.
크로스 플랫폼 호환 레이어
Windows와 Unix 계열은 프로세스 생성 API, 경로 규칙, 기본 셸이 모두 다릅니다. Bun Shell이 “크로스 플랫폼”이라고 불리는 이유는, 사용자 스크립트가 OS별 cmd.exe/sh 차이를 직접 흡수하지 않으며, 단일 인터프리터가 같은 문법을 해석하기 때문입니다.
무엇이 추상화되나
- 명령 체감:
ls,rm등을 내장으로 제공해 PowerShell 사용자에게도 POSIX 스타일 워크플로를 맞춥니다. - 경로와 실행 파일: 외부 도구는 결국 각 OS의 실행 파일이어야 하므로,
PATH에 등록된 이름이 플랫폼마다 다를 수 있습니다. 그래서 OS별로 다른 실행 파일을 고르는 분기는 여전히 필요할 수 있습니다. - 줄바꿈·인코딩: 스크립트 파일은 팀 규칙으로 LF 고정, CI에서
core.autocrlf등을 명시하는 것이 혼선을 줄입니다.
한계와 실무 대응
추상화 레이어는 셸 언어와 내장 명령에 가장 강하며, 네이티브 바이너리의 옵션·출력 형식까지 통일해 주지는 않습니다. 크로스 플랫폼 배포 스크립트를 쓸 때는 (1) Bun 버전 고정, (2) 세 OS에서 동일 테스트, (3) 외부 도구는 별도 설치 문서까지 세트로 두는 것이 일반적인 프로덕션 패턴입니다.
시작하기: $ 템플릿 리터럴
가장 기본적인 사용법은 bun 패키지에서 $를 가져와 태그된 템플릿 리터럴로 명령을 실행하는 것입니다.
import { $ } from "bun";
await $`echo "Hello, Bun Shell!"`;
기본적으로 표준 출력으로 결과가 출력됩니다. 출력을 숨기려면 .quiet()를 붙입니다.
await $`echo "quiet"`.quiet();
명령의 결과를 문자열로 받으려면 .text()를 사용합니다. .text()는 내부적으로 출력을 조용히 처리하는 동작과 맞물리도록 설계되어 있어, 캡처 목적일 때 편합니다.
const welcome = await $`echo "Hello World!"`.text();
console.log(welcome); // "Hello World!\n"
터미널에 출력하지 않고 stdout/stderr를 Buffer로 받으려면 .quiet()를 붙인 뒤 await 결과를 구조 분해합니다.
const { stdout, stderr } = await $`echo "Hello!"`.quiet();
console.log(stdout); // Buffer
화면에 찍을지, 문자열로 받을지, 버퍼로 다룰지를 메서드 체인으로 선택하는 패턴이 Bun Shell의 기본 사용 흐름입니다.
문법과 명령어
내장 명령과 PATH
크로스 플랫폼 호환을 위해 Bun Shell은 일부 명령을 내장(builtin)으로 제공합니다. 예를 들어 cd, ls, rm, echo, pwd, cat, touch, mkdir, which, mv, exit, true, false, yes, seq, dirname, basename 등이 여기에 해당합니다. 내장에 없는 실행 파일은 운영체제의 PATH를 통해 찾아 실행합니다.
내장 구현과 완전히 동일하지 않을 수 있으므로, 예를 들어 mv는 문서상 교차 디바이스 이동 등 일부 시나리오가 미구현일 수 있습니다. 배포 스크립트를 작성할 때는 대상 OS에서 한 번씩 검증하는 것이 안전합니다.
명령 치환 $(...)
다른 명령의 출력을 현재 명령줄에 끼워 넣을 때 Bash와 같이 $(...) 구문을 사용합니다.
await $`echo "현재 커밋: $(git rev-parse HEAD)"`;
여러 줄 스크립트에서 셸 변수에 결과를 담아 이어서 쓰는 패턴도 가능합니다.
await $`
REV=$(git rev-parse HEAD)
echo "빌드 태그: $REV"
`;
주의할 점은, JavaScript 템플릿 리터럴의 백틱 명령 치환은 Bun Shell에서 기대한 대로 동작하지 않을 수 있어, 공식 문서에서는 $(...) 형태를 사용할 것을 권장합니다.
$.braces와 $.escape
중괄호 확장(brace expansion)을 미리 문자열 배열로 펼쳐 보고 싶을 때 $.braces를 쓸 수 있습니다.
import { $ } from "bun";
const expanded = await $.braces(`echo {1,2,3}`);
// => ["echo 1", "echo 2", "echo 3"]
사용자 입력이나 동적 문자열을 셸에 넣기 전에 이스케이프 규칙을 맞추고 싶다면 $.escape를 사용합니다. 반대로 의도적으로 이스케이프를 건너뛰려면 { raw: '...' } 형태로 감쌀 수 있으나, 그 경우 셸이 문자열을 어떻게 해석하는지 직접 책임져야 합니다.
파이프와 리다이렉션
파이프 |
한 명령의 표준 출력을 다음 명령의 표준 입력으로 넘기는 방식은 Bash와 동일합니다.
const wordCount = await $`echo "Hello World!" | wc -w`.text();
fetch로 받은 Response를 stdin으로 넣고 파이프로 이어 붙이는 예는 공식 문서의 패턴 그대로 실무에서도 유용합니다.
const response = await fetch("https://example.com");
const byteLen = await $`cat < ${response} | wc -c`.text();
리다이렉션 연산자
Bun Shell이 지원하는 대표 연산자는 다음과 같습니다.
| 연산자 | 의미 |
|---|---|
< | 표준 입력 리다이렉션 |
>, 1> | 표준 출력(덮어쓰기) |
2> | 표준 에러 |
&> | 표준 출력과 표준 에러 동시에 |
>>, 1>> | 표준 출력 추가 |
2>> | 표준 에러 추가 |
&>> | 둘 다 파일 끝에 추가 |
2>&1 | stderr를 stdout으로 합침 |
1>&2 | stdout을 stderr로 보냄 |
파일뿐 아니라 JavaScript 객체로도 리다이렉션할 수 있습니다. 예를 들어 Buffer에 stdout을 쓰거나, Response 본문을 stdin으로 읽는 식입니다.
const buf = Buffer.alloc(256);
await $`echo "Hello" > ${buf}`;
const res = new Response("body text");
const out = await $`cat < ${res}`.text();
이 패턴은 임시 파일을 만들지 않고 메모리 상에서 데이터를 주고받을 때 특히 깔끔합니다.
구현 관점: 파이프, FD, 스트림 브리지
문법상 파이프(|)는 이전 명령의 표준 출력(파일 디스크립터 1)과 다음 명령의 표준 입력(0)을 잇는 연산입니다. 외부 프로세스끼리 연결할 때는 운영체제 수준에서 익명 파이프(읽기·쓰기 fd 쌍)를 만들고, 왼쪽 프로세스의 쓰기 끝과 오른쪽 프로세스의 읽기 끝을 연결하는 그림이 됩니다. Bash와 마찬가지로 파이프라인의 각 명령은 동시에 실행되며, 왼쪽 명령이 끝날 때까지 기다렸다가 오른쪽을 시작하는 방식이 아닙니다.
리다이렉션은 “어느 FD를 어디로 보낼지”를 바꾸는 연산입니다. > file은 stdout을 파일로 바꾸고, 2> file은 stderr(2)를 분리합니다. 2>&1은 stderr를 stdout과 같은 싱크로 합치는 FD 재배치이며, 이후 파이프나 | tee 같은 패턴과 결합할 때 Bash와 동일한 사고방식으로 읽을 수 있습니다. &> 계열은 두 스트림을 동일 대상에 묶는 축약 표기로 이해하면 됩니다.
Bun Shell의 특이점은 파일만이 아니라 JS 객체를 대상으로 할 수 있다는 점입니다. 이는 단순히 “파일 경로 문자열 대신 객체”를 받는 수준이 아니라, 런타임이 바이트 스트림 엔드포인트로 변환해 프로세스의 fd와 연결한다는 점에서 구현 난이도가 높습니다. 따라서 임시 파일을 만들지 않고도 Buffer나 Response를 파이프라인에 끼워 넣을 수 있으며, 반대로 대용량 스트림을 다룰 때는 메모리 사용량과 백프레셔(읽는 쪽이 느릴 때의 정체)를 머릿속에 두는 것이 좋습니다. 이런 경우에는 파일로 스풀(spool)하거나, 단계를 나누어 짧은 파이프라인으로 쪼개는 편이 디버깅에 유리합니다.
환경 변수와 글로빙
환경 변수
Bash처럼 명령 앞에 KEY=value 형태로 한 줄짜리 환경을 줄 수 있습니다.
await $`NODE_ENV=production bun -e 'console.log(process.env.NODE_ENV)'`;
템플릿 안에서 ${variable}으로 값을 끼워 넣으면 기본적으로 이스케이프되어, 세미콜론 등이 포함된 문자열이 별도의 명령으로 쪼개지지 않습니다. 이는 악의적 입력에 대한 1차 방어선입니다.
특정 호출에만 환경을 바꾸려면 .env({ ... })를 사용합니다.
await $`echo $FOO`.env({ ...process.env, FOO: "bar" });
모든 이후 $ 호출에 기본 환경을 고정하려면 $.env(...)를 호출하며, 기본값으로 되돌리려면 인자 없이 $.env()를 호출하는 방식으로 문서에 설명되어 있습니다.
작업 디렉터리는 .cwd("/path")로 바꿀 수 있으며, 마찬가지로 $.cwd로 전역 기본값을 설정할 수 있습니다.
글로빙(Glob)
Bun Shell은 **, *, {a,b} 같은 글로브 패턴을 네이티브로 처리합니다. 여러 파일을 한 번에 넘기거나 소스 트리 전체를 대상으로 명령을 돌릴 때 Bash 스크립트와 비슷한 표현을 유지할 수 있습니다. 다만 패턴이 매우 크거나 예외 경우가 많다면, Node 쪽에서 glob 라이브러리로 목록을 만든 뒤 인자로 넘기는 편이 디버깅에 유리할 때도 있습니다.
Node.js·Bun 스크립트와 통합
Bun에서의 표준 패턴
$는 import { $ } from "bun" 으로만 제공됩니다. 즉 이 API는 Bun 런타임을 전제로 합니다. 자동화 스크립트·CLI·빌드 도구를 Bun으로 실행하는 프로젝트에서는 package.json의 scripts에 bun run tools/deploy.ts처럼 두며, 그 안에서 $를 사용하면 됩니다.
Node.js와의 관계
Node.js 공식 런타임에는 동일한 $ API가 없습니다. Node 생태계에서는 node:child_process의 spawn/execFile, 또는 위에서 언급한 zx 같은 라이브러리를 쓰는 것이 일반적입니다. 팀 표준을 Bun으로 통일할 수 있다면 Shell 스크립트 대신 TypeScript 한 벌로 크로스 플랫폼 자동화를 가져갈 수 있다는 점이 Bun Shell의 실무적 이점입니다.
반대로 기존 npm 스크립트 안에서 한 줄만 Bun Shell을 쓰고 싶다면 bun -e '...'로 감싸 호출할 수는 있으나, 유지보수 측면에서는 전용 .ts 파일로 분리하는 편이 낫습니다.
.sh 로더와 배포
확장자 .sh 파일을 bun ./script.sh로 실행하면 Bun Shell이 해석합니다. Shebang 없이도 Windows PowerShell에서 동일 파일을 실행할 수 있다는 점이 문서에서 강조됩니다. 다만 운영 환경에 Bun이 설치되어 있어야 하므로, CI 이미지와 개발자 문서에 Bun 버전 고정을 명시하는 것이 좋습니다.
에러 처리
기본적으로 자식 프로세스가 0이 아닌 종료 코드로 끝나면 예외가 발생합니다. 타입은 문서상 ShellError로, exitCode, stdout, stderr 등을 확인할 수 있습니다.
import { $ } from "bun";
try {
await $`명령이-실패할-수-있음`.text();
} catch (err: any) {
console.error("코드:", err.exitCode);
console.error(err.stdout?.toString());
console.error(err.stderr?.toString());
}
예외를 던지지 않고 직접 분기하려면 .nothrow()를 사용합니다.
const { stdout, stderr, exitCode } = await $`명령`.nothrow().quiet();
if (exitCode !== 0) {
// 복구 로직
}
전역적으로 기본 동작을 바꾸려면 $.nothrow() 또는 $.throws(false) / $.throws(true)를 사용합니다. CI 파이프라인처럼 실패 시 즉시 중단이 기대되면 기본(throw)을 유지하며, 로그만 수집하는 도구에서는 .nothrow()를 선택하는 식으로 역할을 나누면 됩니다.
출력을 읽는 편의 메서드로는 .text(), .json(), .lines(), .blob() 등이 있어, 파이프라인 결과를 바로 파싱하거나 줄 단위로 스트리밍 처리할 수 있습니다.
Bash vs Bun Shell
| 구분 | Bash(및 일반 POSIX sh) | Bun Shell |
|---|---|---|
| 실행 환경 | OS에 설치된 셸 프로세스 | Bun 프로세스 내장 인터프리터 |
| 크로스 플랫폼 | Windows는 WSL/Git Bash 등 별도 대응이 필요한 경우가 많음 | Windows 포함 동일 문법 지향 |
| 보안 모델 | 문자열 조합 실수 시 주입 위험 | 보간 문자열 기본 이스케이프 |
| JS 연동 | 문자열로만 주고받기 쉽다 | Buffer, Response 등 직접 연결 |
| 완전한 Bash 호환 | 표준 | 일부 Bash 기능은 미구현·차이 가능 |
언제 Bash를 쓰고 언제 Bun Shell을 쓸까에 대한 실무 기준은 다음과 같습니다.
- 서버나 컨테이너 진입점이 이미
bash이고, 운영팀이 셸만 허용한다면 Bash를 유지합니다. - 프론트엔드·풀스택 모노레포에서 TypeScript로 빌드·배포·검증을 한 언어로 묶고 싶다면 Bun Shell이 유리합니다.
- Windows 개발자가 많고 동일한 npm 스크립트를 깨지 않게 하려면 Bun Shell + 내장 명령 조합을 검토합니다.
보안: 기본 이스케이프가 막지 못하는 것
기본 보간은 안전하지만, bash -c처럼 새 셸을 명시적으로 띄우면 그 이후의 해석은 Bun이 막을 수 없습니다. 또한 외부 프로그램(git 등)은 인자 하나를 자신만의 옵션으로 해석할 수 있으므로, --upload-pack= 같은 악성 플래그 형태의 입력은 애플리케이션 레벨에서 검증해야 합니다. 공식 문서의 “argument injection” 예시는 이 차이를 잘 보여 줍니다.
프로덕션 셸 스크립팅 패턴
Bun Shell은 로컬 편의를 넘어 CI·릴리스·운영 자동화에 들어갈 때, “스크립트가 아니라 소프트웨어”처럼 다루는 것이 안전합니다.
런타임과 재현성
- mise나 asdf 같은 버전 관리 도구, CI의
setup-bun설정 등으로 Bun 버전을 고정합니다.package.json의engines필드는 명시 용도일 뿐 실행을 막아 주지 않을 수 있습니다. 셸 파서와 내장 명령의 동작은 Bun 버전에 따라 달라질 수 있습니다. - 동일한 스크립트를 Windows·macOS·Linux에서 실행한다면 CI 매트릭스에 세 OS를 넣으며, 최소한 스모크 테스트(핵심
$호출 한 번)을 돌립니다. - 의존 외부 도구(
git,docker, 클라우드 CLI)는 Bun과 별개이므로, README에 최소 버전과 설치 링크를 적습니다.
실패 모델과 로그
- 기본 동작은 비영 종료 시 예외입니다. CI에서는 이것이 “첫 실패에서 멈춤”과 같아 유리합니다. 반면 진단 스크립트처럼 “실패해도 계속 시도”가 필요하면
.nothrow()로 분기하며, 성공한 단계만 요약 로그로 남깁니다. - 민감한 값은
console.log에 직접 붙이지 말고, CI 시크릿은 환경 변수로만 주입하고 출력 전에 마스킹합니다.$문자열 안에 토큰을 직접 이어 붙이기보다, 가능하면.env({ ... })로 주입해 실수로 인한 유출을 줄입니다.
구조·멱등·롤백
- 스크립트를 짧은 함수로 나누고, 각 단계는 입력·출력 경로만 명확히 합니다. “한 줄에 모든 것”은 재현이 어렵습니다.
- 배포·마이그레이션은 여러 번 실행해도 안전한지(멱등성)를 검토합니다. 동일한
$호출을 재실행했을 때 기대 결과가 같아야 합니다. - 임시 디렉터리는
Bun.write/Bun.file과 조합하거나 OS의 표준 임시 경로 규칙을 따르고, 실패 시 부분 산출물 정리를try/finally로 보장합니다.
운영 관점의 절충
- 서버의 공식 진입점이 Bash이고 감사 규칙이 셸만 허용한다면, Bun Shell은 빌드·개발 자동화에 두고 운영은 Bash를 유지하는 이중 레이어가 흔한 절충안입니다.
- 장기적으로는 스크립트 경로·버전·실패 정책을 문서화한 “작은 런북”을 두면, 팀 합류 시 온보딩 비용이 줄어듭니다.