Bun Shell로 child_process 걷어내기 — TypeScript 안에서 크로스플랫폼 스크립트를 타입 안전하게 실행하기
child_process.execSync를 처음 쓸 때는 그냥 넘어갑니다. 빌드 스크립트 한 줄, 배포 명령 몇 개. 로컬에서 잘 돌아가니까요. 그런데 CI가 Windows runner로 바뀌거나 팀원 중 한 명이 Windows 환경을 쓰기 시작하면 문제가 드러납니다. cp -r이 안 먹히고, 파이프 동작이 달라지고, bash 경로가 없어서 스크립트가 터집니다. 그때마다 .sh와 .bat 파일을 따로 관리하거나, cross-env·rimraf·mkdirp 같은 패키지를 붙이는 작업이 반복됩니다.
Bun Shell은 이 문제를 다른 방향에서 접근합니다. 외부 셸(bash, PowerShell)을 래핑하는 게 아니라, Rust로 짠 자체 셸 인터프리터를 Bun 런타임 안에 내장합니다. 덕분에 Windows·macOS·Linux에서 동일한 명령이 동일하게 동작하고, TypeScript 안에서 Tagged Template Literal 문법으로 자연스럽게 쓸 수 있습니다. 변수 보간이 기본으로 이스케이프되기 때문에 OS 커맨드 인젝션에 대한 기본 방어도 얻습니다.
이 글에서는 왜 Bun Shell이 child_process와 근본적으로 다른지, 어떤 시나리오에서 진짜 유용한지, 어디까지가 한계인지를 코드와 함께 정리합니다. Bun을 이미 쓰고 있거나 전환을 고민 중인 팀이 빌드·배포 스크립트를 통합할 때 참고할 수 있는 내용입니다.
왜 지금 Bun Shell인가
child_process의 구조적 한계
child_process.exec과 execSync는 편합니다. 다만 내부적으로는 Unix에서 /bin/sh, Windows에서 cmd.exe를 별도 프로세스로 fork해 명령을 넘기는 구조입니다. 플랫폼마다 셸이 다르니 동작도 달라질 수밖에 없고, 반환 값도 string | Buffer를 직접 파싱해야 합니다.
import { execSync } from "child_process";
execSync("rm -rf dist && tsc && cp -r dist/ build/", { stdio: "inherit" });이 한 줄은 Windows에서 rm, cp 자체가 없어서 실패합니다. 이를 우회하려고 cross-env, rimraf, mkdirp 등을 붙이다 보면 devDependencies가 두꺼워지고, 각 명령의 인자 규칙과 예외 처리를 별도로 익혀야 합니다.
TypeScript 직접 실행 흐름과의 만남
최근 몇 년 사이 Node.js 22+의 --experimental-strip-types, Deno, Bun이 모두 TypeScript를 트랜스파일 없이 실행하는 방향으로 수렴했습니다. 이 흐름은 자연스럽게 "빌드 스크립트도 TypeScript로" 라는 수요를 만들었고, Bun Shell이 그 자리에 놓이는 도구로 자리 잡았습니다.
Bun Shell은 2024년 1월 공식 블로그에서 처음 발표됐고, Bun 1.1 릴리즈 노트에서 Windows 정식 지원과 함께 package.json scripts 실행 엔진으로 채택됐습니다. 2026년 8월 기준으로는 Bun 기반 프로젝트에서 빌드·배포 스크립트를 TypeScript로 통합하려는 팀이 실무 선택지로 검토할 만큼의 사용 사례가 축적되어 있습니다.
아키텍처가 다르다
핵심은 명령 해석과 파이핑, 리다이렉션, 그리고 cat·echo 같은 내장 빌트인 실행이 Bun 프로세스 안에서 이뤄진다는 점입니다. tsc, docker, aws 같은 외부 바이너리는 여전히 별도 프로세스가 fork되지만, 셸 해석 계층 자체가 플랫폼에 무관하므로 스크립트 동작의 일관성이 확보됩니다.
기본 문법과 API
import { $ } from "bun" 하나로 시작합니다. Tagged Template Literal이라 코드가 셸 스크립트처럼 읽힙니다.
import { $ } from "bun";
const output = await $`echo "Hello Bun"`.text();
const filename = "my file.txt";
await $`cat ${filename}`;
const count = await $`ls | wc -l`.text();
const { exitCode } = await $`nonexistent-cmd`.nothrow().quiet();
const pkg = await $`cat package.json`.json();
await $`node build.js`.env({ NODE_ENV: "production" });.nothrow()는 명령이 0이 아닌 종료 코드를 반환해도 예외를 던지지 않게 하고, .quiet()은 stdout/stderr를 부모 프로세스로 흘리지 않고 삼킵니다. 둘을 조합하면 "실패해도 되는 명령"을 조용히 실행하고 종료 코드만 검사할 수 있습니다.
출력 형식은 필요에 따라 골라 씁니다.
| 메서드 | 반환 타입 |
|---|---|
.text() |
Promise<string> |
.json() |
Promise<unknown> |
.arrayBuffer() |
Promise<ArrayBuffer> |
.blob() |
Promise<Blob> |
.bytes() |
Promise<Uint8Array> |
실전 시나리오별 코드
1. 기존 child_process 스크립트 교체
마이그레이션은 대개 한 줄 대 한 줄로 대응됩니다.
// Before: child_process
import { execSync } from "child_process";
execSync("tsc && cp -r dist/ build/", { stdio: "inherit" });
// After: Bun Shell
import { $ } from "bun";
await $`tsc && cp -r dist/ build/`;같은 cp -r이 Windows에서도 동작하는 이유는 Bun Shell이 cp를 자체 내장 빌트인으로 구현하기 때문입니다. 즉 Windows에서도 파일 복사가 Bun 내부 구현으로 수행되므로 별도 유틸리티가 필요 없습니다. tsc는 여전히 외부 바이너리로 fork되어 실행되지만, 셸 문법 자체가 인터프리터 안에서 해석되므로 && 연산자의 동작이 플랫폼에 무관해집니다.
2. 병렬 빌드 태스크
Promise.all로 여러 명령을 동시에 실행할 수 있습니다. 각 $ 호출이 독립적인 Promise를 반환하기 때문에 자연스럽게 병렬 동작합니다.
import { $ } from "bun";
await Promise.all([
$`bun run build:client`,
$`bun run build:server`,
$`bun run generate:types`,
]);3. Docker 빌드 + ECR 배포 자동화
배포 스크립트에서 자주 마주치는 패턴입니다. 변수 보간이 자동 이스케이프되어 TAG 값에 특수 문자가 섞여도 명령이 깨지지 않습니다.
import { $ } from "bun";
const IMAGE = "my-app";
const TAG = process.env.GIT_SHA ?? "latest";
const REPO = "123456789.dkr.ecr.ap-northeast-2.amazonaws.com";
await $`aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin ${REPO}`;
await $`docker build -t ${IMAGE}:${TAG} .`;
await $`docker tag ${IMAGE}:${TAG} ${REPO}/${IMAGE}:${TAG}`;
await $`docker push ${REPO}/${IMAGE}:${TAG}`;4. 조건부 오류 처리 — 배포 전 검사
uncommitted 변경이 있으면 배포를 막는 패턴입니다. git diff --exit-code는 차이가 있으면 종료 코드 1을 반환하므로, .nothrow()로 예외를 억누르고 .quiet()으로 diff 출력을 감춘 뒤 종료 코드만 검사합니다.
import { $ } from "bun";
const { exitCode } = await $`git diff --exit-code`.nothrow().quiet();
if (exitCode !== 0) {
console.error("Uncommitted changes detected. Aborting deploy.");
process.exit(1);
}5. 글로빙 + 파일 일괄 처리
Bun의 네이티브 Glob과 함께 쓰면 파일 처리 파이프라인이 TypeScript 안에서 완결됩니다. Glob은 클래스 형태로 노출되므로 인스턴스를 만들어 .scan()을 호출합니다.
import { $, Glob } from "bun";
const glob = new Glob("src/**/*.ts");
for await (const f of glob.scan(".")) {
await $`prettier --write ${f}`;
}6. 스크립트별 환경 변수 오버라이드
$.env()로 특정 환경 변수를 오버라이드한 셸 인스턴스를 만들 수 있습니다. 완전히 격리된 환경이 만들어지는 건 아니며, PATH나 HOME 같은 값은 그대로 상속됩니다. 즉 "테스트 스크립트에 DATABASE_URL만 다르게 넣고 싶다" 같은 오버라이드 용도로 적절합니다.
import { $ } from "bun";
const testShell = $.env({
...process.env,
NODE_ENV: "test",
DATABASE_URL: "postgres://localhost/test",
});
await testShell`bun test`;실제 프로세스 격리가 필요하다면 컨테이너나 별도 셸 세션을 활용해야 합니다.
7. 모노레포 패키지별 빌드
--cwd 옵션을 이용하면 모노레포의 각 패키지에서 스크립트를 실행할 수 있습니다.
import { $ } from "bun";
import { readdir } from "node:fs/promises";
const packages = await readdir("packages");
for (const pkg of packages) {
await $`bun run --cwd packages/${pkg} build`.nothrow();
}트레이드오프
Bun Shell vs 대안 도구
| 도구 | 방식 | 플랫폼 | 특징 |
|---|---|---|---|
| Bun Shell | 내장 인터프리터 | 크로스플랫폼 | Bun 전용, 추가 설치 없음 |
| zx (google/zx) | 시스템 bash/sh 래핑 | Unix (+Windows 제한) | 성숙한 생태계, Node.js 지원 |
| child_process | OS 셸 fork/exec | 플랫폼 의존 | 표준 Node.js API, 타입 안전성 부족 |
| execa | child_process 래퍼 | 플랫폼 의존 | Promise 지원, 더 나은 DX |
| shelljs | bash 명령 JS 구현 | 크로스플랫폼 | 오래된 라이브러리, 유지보수 둔화 |
장점 한눈에 보기
| 항목 | 설명 |
|---|---|
| 크로스플랫폼 | Windows·macOS·Linux에서 bash 없이 동일 동작 |
| 타입 안전성 | TypeScript 안에서 인자·출력 타입을 명시적으로 다룸 |
| 자동 이스케이프 | 보간 변수 이스케이프로 OS 커맨드 인젝션 방어 |
| 낮은 오버헤드(빌트인 한정) | cat·echo·cp 등 내장 명령은 프로세스 fork 없이 실행 |
| JS 객체 interop | Blob, ArrayBuffer, Bun.file()을 stdin/stdout으로 연결 |
| 추가 의존성 없음 | 별도 npm 패키지 불필요, Bun 런타임에 내장 |
| 병렬 실행 | Promise.all로 여러 명령 동시 실행 |
한계와 주의할 점
Bun 전용입니다. Node.js 환경에서는 동작하지 않습니다. 팀과 CI 모두 Bun을 도입한 상태여야 의미가 있습니다.
bash의 모든 기능을 지원하지 않습니다. 복잡한 sed/awk 파이프라인이나 POSIX 완전 호환이 필요한 경우에는 한계가 있습니다. 기존 .sh 스크립트를 그대로 이식하려 하면 일부 문법·동작 차이를 만납니다. 이럴 때는 Bun.spawn(["bash", "script.sh"])로 기존 셸 파일을 호출하는 혼용도 유효합니다.
인터랙티브 명령은 실행할 수 없습니다. rustup, npm init처럼 TTY 입력이 필요한 대화형 명령은 지원하지 않습니다.
외부 바이너리는 여전히 프로세스 fork가 발생합니다. docker, aws, tsc 같은 외부 실행 파일은 Bun의 내장 빌트인이 아니므로 fork/exec 비용이 그대로 발생하고, 시스템 PATH에 설치되어 있어야 합니다. "저비용 실행"이라는 특성은 어디까지나 내장 빌트인에 국한됩니다.
내장 빌트인 목록은 릴리즈마다 확장됩니다. Bun 공식 문서의 Shell 페이지에서 cat, cd, cp, echo, exit, ls, mkdir, mv, pwd, rm, touch, which 등이 확인 가능합니다. wc나 find 같은 명령은 사용하는 Bun 버전에 따라 내장이 아닐 수 있으며, 이 경우 시스템 바이너리에 의존하게 됩니다. 사용 전에는 로컬에서 실제로 실행해 보고, 필요하다면 릴리즈 노트에서 지원 여부를 확인하는 편이 안전합니다.
마이그레이션 판단 흐름
보안: 자동 이스케이프가 전부는 아닙니다
Bun Shell의 변수 보간 자동 이스케이프는 실용적입니다. 다만 이스케이프가 있다고 해서 모든 인젝션 위협이 사라지는 건 아닙니다.
import { $ } from "bun";
const userInput = process.argv[2];
await $`echo ${userInput}`;
await $`echo ${$.raw(userInput)}`;$.raw는 이스케이프를 우회해 문자열을 셸 문법 그대로 삽입합니다. 즉 이 안에 들어간 값은 명령·리다이렉션·파이프의 일부로 해석될 수 있으므로, 외부에서 들어온 입력에는 절대 사용하면 안 됩니다. 사용해도 안전한 경우는 코드 내부에서 이미 검증되고 통제된 문자열(고정 인자, 컴파일 시 알려진 값 등)에 한정됩니다. 사용자 입력·환경 변수·외부 API 응답을 다룰 때는 항상 일반 보간을 사용하세요.
마무리 — 마이그레이션을 실제로 시작할 때 보는 체크리스트
Bun Shell의 가치는 "셸 명령을 TypeScript에서 쓸 수 있다"에 그치지 않습니다. 빌드·배포 스크립트를 별도 .sh 파일이나 플랫폼 분기 없이, TypeScript 타입 시스템 안에서 관리할 수 있다는 데 있습니다. 다만 도입을 결정한 뒤에 부딪히는 문제는 대체로 언어가 아니라 인프라 쪽에 있습니다.
첫 번째로 짚어야 할 지점은 CI runner의 Bun 설치입니다. GitHub Actions라면 oven-sh/setup-bun, GitLab이나 Jenkins라면 커스텀 이미지 또는 curl -fsSL https://bun.sh/install | bash가 필요합니다. 로컬에서 잘 돌아가던 스크립트가 CI에서 "bun: command not found"로 죽는 시나리오는 첫 번째 병목이자 가장 흔한 실패 지점입니다.
두 번째는 기존 .sh 스크립트 인벤토리 파악입니다. package.json의 scripts, Makefile, .husky/, .github/workflows/에 흩어져 있는 셸 호출을 한 번에 목록화한 뒤, 각 스크립트에서 사용하는 명령이 Bun Shell 내장인지 외부 바이너리인지, POSIX 고유 문법(프로세스 치환 <(), set -o pipefail, 배열 등)에 의존하는지를 분류합니다. 내장으로 커버되고 문법 특수 기능이 없는 스크립트가 가장 먼저 옮기기 쉬운 후보이고, POSIX 고유 문법을 쓰는 스크립트는 Bun.spawn(["bash", ...]) 혼용 대상으로 남깁니다.
세 번째는 .raw 사용처 감사입니다. 마이그레이션 과정에서 이스케이프 우회가 필요해 보이는 순간이 오면, 그 자리에서 입력 소스를 다시 확인하세요. 외부 입력이 조금이라도 섞이면 CVE 후보가 됩니다.
이 세 지점만 넘기면 나머지는 대개 한 줄씩 옮기는 작업입니다. child_process를 걷어내고 싶었던 팀이라면, 지금이 검토를 시작하기 나쁘지 않은 시점입니다.
참고 자료
- Shell — Bun 공식 문서 —
$,.nothrow(),.quiet(),.env(), 내장 빌트인 목록 등 전체 API 레퍼런스 - The Bun Shell — Bun Blog (2024년 1월) — Bun Shell 최초 발표 및 설계 철학
- Bun 1.1 릴리즈 노트 — Windows 정식 지원 및
package.json scripts실행 엔진 채택 - Bun Glob — 공식 문서 —
new Glob(pattern).scan()API 사용법 - Bun.spawn — 공식 문서 — 외부 프로세스(bash 스크립트 등) 실행 API
- Node.js child_process — 공식 문서 —
exec,spawn,execSync의 플랫폼별 셸 동작 규정