Bun Shell 완벽 가이드 — 크로스 플랫폼 스크립팅

Bun Shell 완벽 가이드 — 크로스 플랫폼 스크립팅

이 글의 핵심

Bun Shell($)로 크로스 플랫폼 스크립트를 작성하는 방법을 정리했습니다. 파이프·리다이렉션, 환경 변수, 글로브, 에러 처리, Bash와의 차이까지 다룹니다.

이 글의 핵심

Bun Shell은 Bun 런타임에 내장된 크로스 플랫폼 셸입니다. JavaScript·TypeScript 코드 안에서 Bash와 유사한 문법으로 외부 명령을 실행하고, 파이프·리다이렉션·환경 변수·글로브를 다루며, ResponseBuffer 같은 JS 객체와 입출력을 연결할 수 있습니다. Windows에서도 rimrafcross-env 없이 동일한 스크립트를 유지하려는 팀에 특히 유용합니다.

이 글에서는 $ 템플릿 리터럴의 동작, 내장 명령과 PATH 실행, 파이프와 리다이렉션, 환경 변수 및 글로브, Node·Bun과의 연동, ShellError.nothrow() 등 에러 처리, Bash와의 비교까지 실무 관점에서 정리합니다.


1. Bun Shell이란 무엇인가

Bun Shell은 시스템의 /bin/shcmd.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 등 기존 프로젝트의 영향을 받았으며, Bun 생태계 안에서 스크립트 자동화를 단순화하는 것이 목표입니다.


2. 시작하기: $ 템플릿 리터럴

가장 기본적인 사용법은 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/stderrBuffer로 받으려면 .quiet()를 붙인 뒤 await 결과를 구조 분해합니다.

const { stdout, stderr } = await $`echo "Hello!"`.quiet();
console.log(stdout); // Buffer

정리하면, 화면에 찍을지·문자열로 받을지·버퍼로 다룰지를 메서드 체인으로 선택하는 패턴이 Bun Shell의 기본 사용 흐름입니다.


3. 문법과 명령어

3.1 내장 명령과 PATH

크로스 플랫폼 호환을 위해 Bun Shell은 일부 명령을 내장(builtin) 으로 제공합니다. 예를 들어 cd, ls, rm, echo, pwd, cat, touch, mkdir, which, mv, exit, true, false, yes, seq, dirname, basename 등이 여기에 해당합니다. 내장에 없는 실행 파일은 운영체제의 PATH를 통해 찾아 실행합니다.

내장 구현과 완전히 동일하지 않을 수 있으므로, 예를 들어 mv는 문서상 교차 디바이스 이동 등 일부 시나리오가 미구현일 수 있습니다. 배포 스크립트를 작성할 때는 대상 OS에서 한 번씩 검증하는 것이 안전합니다.

3.2 명령 치환 $(...)

다른 명령의 출력을 현재 명령줄에 끼워 넣을 때 Bash와 같이 $(...) 구문을 사용합니다.

await $`echo "현재 커밋: $(git rev-parse HEAD)"`;

여러 줄 스크립트에서 셸 변수에 결과를 담아 이어서 쓰는 패턴도 가능합니다.

await $`
  REV=$(git rev-parse HEAD)
  echo "빌드 태그: $REV"
`;

주의할 점은, JavaScript 템플릿 리터럴의 백틱 명령 치환은 Bun Shell에서 기대한 대로 동작하지 않을 수 있어, 공식 문서에서는 $(...) 형태를 사용할 것을 권장합니다.

3.3 $.braces$.escape

중괄호 확장(brace expansion)을 미리 문자열 배열로 펼쳐 보고 싶을 때 $.braces를 쓸 수 있습니다.

import { $ } from "bun";

const expanded = await $.braces(`echo {1,2,3}`);
// => ["echo 1", "echo 2", "echo 3"]

사용자 입력이나 동적 문자열을 셸에 넣기 전에 이스케이프 규칙을 맞추고 싶다면 $.escape를 사용합니다. 반대로 의도적으로 이스케이프를 건너뛰려면 { raw: '...' } 형태로 감쌀 수 있으나, 그 경우 셸이 문자열을 어떻게 해석하는지 직접 책임져야 합니다.


4. 파이프와 리다이렉션

4.1 파이프 |

한 명령의 표준 출력을 다음 명령의 표준 입력으로 넘기는 방식은 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();

4.2 리다이렉션 연산자

Bun Shell이 지원하는 대표 연산자는 다음과 같습니다.

연산자의미
<표준 입력 리다이렉션
>, 1>표준 출력(덮어쓰기)
2>표준 에러
&>표준 출력과 표준 에러 동시에
>>, 1>>표준 출력 추가
2>>표준 에러 추가
&>>둘 다 파일 끝에 추가
2>&1stderr를 stdout으로 합침
1>&2stdout을 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();

이 패턴은 임시 파일을 만들지 않고 메모리 상에서 데이터를 주고받을 때 특히 깔끔합니다.


5. 환경 변수와 글로빙

5.1 환경 변수

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로 전역 기본값을 설정할 수 있습니다.

5.2 글로빙(Glob)

Bun Shell은 **, *, {a,b} 같은 글로브 패턴을 네이티브로 처리합니다. 여러 파일을 한 번에 넘기거나 소스 트리 전체를 대상으로 명령을 돌릴 때 Bash 스크립트와 비슷한 표현을 유지할 수 있습니다. 다만 패턴이 매우 크거나 예외 케이스가 많다면, Node 쪽에서 glob 라이브러리로 목록을 만든 뒤 인자로 넘기는 편이 디버깅에 유리할 때도 있습니다.


6. Node.js·Bun 스크립트와 통합

6.1 Bun에서의 표준 패턴

$import { $ } from "bun" 으로만 제공됩니다. 즉 이 API는 Bun 런타임을 전제로 합니다. 자동화 스크립트·CLI·빌드 도구를 Bun으로 실행하는 프로젝트에서는 package.jsonscriptsbun run tools/deploy.ts처럼 두고, 그 안에서 $를 사용하면 됩니다.

6.2 Node.js와의 관계

Node.js 공식 런타임에는 동일한 $ API가 없습니다. Node 생태계에서는 node:child_processspawn/execFile, 또는 위에서 언급한 zx 같은 라이브러리를 쓰는 것이 일반적입니다. 팀 표준을 Bun으로 통일할 수 있다면 Shell 스크립트 대신 TypeScript 한 벌로 크로스 플랫폼 자동화를 가져갈 수 있다는 점이 Bun Shell의 실무적 이점입니다.

반대로 기존 npm 스크립트 안에서 한 줄만 Bun Shell을 쓰고 싶다면 bun -e '...'로 감싸 호출할 수는 있으나, 유지보수 측면에서는 전용 .ts 파일로 분리하는 편이 낫습니다.

6.3 .sh 로더와 배포

확장자 .sh 파일을 bun ./script.sh로 실행하면 Bun Shell이 해석합니다. Shebang 없이도 Windows PowerShell에서 동일 파일을 실행할 수 있다는 점이 문서에서 강조됩니다. 다만 운영 환경에 Bun이 설치되어 있어야 하므로, CI 이미지와 개발자 문서에 Bun 버전 고정을 명시하는 것이 좋습니다.


7. 에러 처리

기본적으로 자식 프로세스가 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() 등이 있어, 파이프라인 결과를 바로 파싱하거나 줄 단위로 스트리밍 처리할 수 있습니다.


8. 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 + 내장 명령 조합을 검토합니다.

9. 보안을 끝까지 이해하기

기본 보간은 안전하지만, bash -c처럼 새 셸을 명시적으로 띄우면 그 이후의 해석은 Bun이 막을 수 없습니다. 또한 외부 프로그램(git 등)은 인자 하나를 자신만의 옵션으로 해석할 수 있으므로, --upload-pack= 같은 악성 플래그 형태의 입력은 애플리케이션 레벨에서 검증해야 합니다. 공식 문서의 “argument injection” 예시는 이 차이를 잘 보여 줍니다.


10. 정리

Bun Shell은 크로스 플랫폼 스크립팅을 TypeScript 한가운데로 가져오면서, Bash 사용자에게 익숙한 파이프·리다이렉션·환경 변수·명령 치환을 제공합니다. $ 템플릿, .text()·.lines() 등 출력 API, .nothrow()ShellError로 구성된 에러 모델만 익혀도 대부분의 자동화 시나리오를 커버할 수 있습니다. Bash와 100% 동일하지는 않으므로, 중요한 배포 스크립트는 대상 OS에서 반드시 검증하고, 보안 민감한 입력은 외부 명령의 옵션 규칙까지 함께 고려하는 것이 좋습니다.


자주 묻는 질문

Q. Node.js만 설치된 환경에서도 $를 쓸 수 있나요?
A. 아니요. import { $ } from "bun"은 Bun 전용입니다. Node에서는 child_process나 zx 등을 사용해야 합니다.

Q. Windows에서도 ls, rm 같은 명령이 동작하나요?
A. Bun Shell이 내장 명령으로 제공하므로, 문서에 설명된 범위에서는 PowerShell과 별도로 동일한 스타일로 사용할 수 있습니다. 다만 PATH에 올라온 외부 도구는 각 OS별 실행 파일이어야 합니다.

Q. 비영(非零) 종료 코드가 나와도 예외를 피하고 싶습니다.
A. 해당 호출에 .nothrow()를 붙이거나, 전역으로 $.nothrow()를 설정한 뒤 exitCode를 검사하세요.