Bun 1.1에서 바뀐 것: Windows 지원, 내장 번들러·테스트 러너, 성능 수치 읽는 법
이 글의 핵심
Bun의 성능 수치는 인상적이지만 특정 조건의 벤치마크를 그대로 내 프로젝트에 대입하면 기대와 다른 결과를 얻기 쉽습니다. Next.js·React·Astro에서 Bun을 패키지 관리자로 쓸 때와 런타임으로 쓸 때를 구분하고, 미니 API와 테스트를 단일 바이너리로 빌드하는 예제와 Docker·CI 배포, 롤백 전략까지 따라가 봅니다.
Bun은 런타임, 패키지 관리자, 번들러, 테스트 러너를 하나의 바이너리로 묶은 JavaScript 도구입니다. 2024년 4월에 나온 1.1은 공식 발표 기준 1.0 이후 1,700개가 넘는 커밋이 들어간 릴리스로, 가장 큰 변화는 Windows 공식 지원입니다. 이 글은 1.1에서 달라진 런타임·번들러·테스트 러너 기능을 정리하고, 공식 블로그의 성능 수치를 어떻게 읽어야 하는지, Next.js·React·Astro와 함께 쓰는 방법, 작은 API를 단일 바이너리로 배포하는 예제를 다룹니다.
이후 버전에서도 변화가 계속되고 있습니다. 대표적으로 Bun 1.2부터는 바이너리 bun.lockb 대신 텍스트 형식의 bun.lock이 기본 lockfile이 되었으므로, 아래 예제에서도 bun.lock을 기준으로 적었습니다. 프로덕션에 적용하기 전에는 사용하는 버전의 릴리스 노트와, 프레임워크·플러그인·네이티브 모듈 조합에 대한 회귀 테스트를 함께 확인하는 것이 좋습니다.
Bun 1.1의 주요 변화
| 영역 | Bun 1.1에서 강조되는 변화 |
|---|---|
| 플랫폼 | Windows에서 런타임·번들러·테스트·패키지 관리자 전 구간 지원 |
| 패키지 | bun install 속도, 라이프사이클 스크립트 병렬 실행, bun pm migrate로 lockfile 이전 |
| 런타임 | 대용량 파일 트랜스파일 캐시, Bun Shell(Bun.$), import.meta.env, Node.js 호환 API 확대 |
| 번들러 | --target=node 안정화, --compile과 NAPI(.node) 임베드, 매크로에서 내장 모듈 사용 |
| 테스트 | Jest 스타일 matcher 확장, ESM/CJS 모듈 목(mock), 런타임 인플레이스 목킹 |
핵심 기능 ① 빠른 런타임과 도구 일원화
런타임으로서의 Bun
Bun은 JavaScriptCore 기반의 JavaScript 런타임으로, TypeScript·JSX를 별도 설정 없이 실행할 수 있습니다. Node.js가 npm·코어 API·생태계로 “서버 사이드 JavaScript의 사실상 표준”이 된 것과 달리, Bun은 실행·의존성 설치·번들·테스트를 단일 바이너리로 제공해 도구 전환 비용을 줄이는 방향입니다.
트랜스파일러 캐시와 CLI 체감 성능
Bun은 TypeScript와 JSX를 실행할 때마다 트랜스파일합니다. 1.1에서는 50KB를 넘는 큰 파일의 트랜스파일 결과를 내용 해시 기반으로 디스크에 캐시해, 같은 파일을 다시 실행할 때 변환을 건너뜁니다. 공식 발표는 이 덕분에 tsc처럼 큰 JS 파일로 된 CLI 도구의 실행이 최대 2배 정도 빨라졌다고 소개했는데, 효과는 파일 크기와 실행 빈도에 따라 크게 다릅니다.
Bun Shell과 크로스 플랫폼 스크립팅
Bun Shell(Bun.$)은 1.0 후반에 추가된 크로스 플랫폼 셸로, bash에 가까운 문법과 ls, rm, cat 같은 기본 명령을 Bun이 직접 구현해 Windows에서도 같은 스크립트가 동작하게 합니다. 1.1의 Windows 지원과 맞물려 package.json 스크립트를 bun run으로 실행할 때 플랫폼별 cmd/sh 차이를 줄여 줍니다.
import { $ } from "bun";
// 표준 출력으로 파이프
await $`ls *.ts`;
// 결과를 문자열로
const listing = await $`ls *.ts`.text();
Windows에서는 shebang(#!/usr/bin/env node)이 무시되고, rm -rf 같은 명령이 기본으로 없으며, 환경 변수 설정 문법도 다릅니다. 팀에 Windows와 macOS·Linux 사용자가 섞여 있으면 이런 차이가 그대로 “제 컴퓨터에서는 되는데요” 문제가 됩니다.
템플릿 리터럴에 끼워 넣은 값은 Bun Shell이 하나의 인자로 이스케이프해 넘기므로, $`ls ${userInput}`처럼 써도 ; rm -rf / 같은 문자열이 명령으로 해석되지 않습니다. 이 보호는 { raw: ... }로 이스케이프를 끄거나, 값을 bash -c처럼 다른 셸에 문자열로 넘기는 순간 사라지므로 그런 패턴은 피해야 합니다.
핵심 기능 ② 내장 번들러(bun build)
Bun의 번들러는 브라우저·Bun·Node.js 타깃으로 산출물을 만들 수 있으며, 트랜스파일·번들·축소를 한 경로에서 처리합니다.
Node.js 타깃: --target=node
라이브러리나 CLI를 Node.js에서 실행할 단일 파일로 내보낼 때 --target=node를 씁니다. 1.0에서는 이 타깃의 산출물이 Node에서 require 관련 오류를 내는 경우가 있었는데, 1.1에서 정리되어 node:fs 같은 내장 모듈을 쓰는 번들도 Node에서 그대로 실행됩니다. npm에 배포하는 CLI 패키지나 서버리스 핸들러를 파일 하나로 만들 때 유용합니다.
bun build ./src/cli.ts --target=node --outfile=dist/cli.mjs
node dist/cli.mjs
단일 실행 파일: --compile
--compile은 코드와 Bun 런타임을 합쳐 실행 파일 하나를 만듭니다. 1.1부터는 N-API 애드온(.node 파일)도 함께 넣을 수 있어 네이티브 의존성이 있는 도구도 파일 하나로 배포할 수 있습니다.
bun build --compile ./src/app.ts --outfile=myapp
./myapp
결과물은 특정 OS와 CPU 아키텍처용 실행 파일입니다. --target=bun-linux-x64처럼 다른 플랫폼용으로 크로스 컴파일할 수는 있지만, 네이티브 애드온은 그 플랫폼용으로 빌드된 .node 파일이 있어야 하므로 네이티브 의존성이 있다면 OS별로 빌드하는 편이 안전합니다.
매크로와 번들 타임 실행
Bun의 매크로는 with { type: "macro" }로 가져온 함수를 번들링 시점에 실행하고, 그 반환값을 번들에 상수로 넣습니다. 1.1부터는 node:fs 같은 내장 모듈의 함수도 매크로로 가져올 수 있어, 빌드할 때 파일 내용이나 버전 정보를 코드에 넣는 일이 간단해졌습니다.
import { readFileSync } from "node:fs" with { type: "macro" };
export const buildTimeText = readFileSync("./version.txt", "utf8");
매크로는 빌드하는 머신의 상태를 결과물에 새겨 넣으므로, 같은 커밋에서도 빌드 환경에 따라 결과가 달라질 수 있습니다. 매크로가 읽는 파일은 저장소에 두고 버전 관리하는 것이 좋습니다. 반환값은 JSON으로 직렬화할 수 있어야 한다는 제약도 있습니다.
핵심 기능 ③ 테스트 러너(bun test)
Bun 테스트는 Jest와 유사한 API(describe, test, expect)를 제공하며, 별도 설정 없이 TypeScript 테스트를 실행할 수 있습니다.
풍부해진 matcher
Bun 1.1은 Jest의 비대칭 matcher(expect.objectContaining 등)와 Bun 고유의 toEqualIgnoringWhitespace 같은 matcher를 추가해, 기존 Jest 테스트를 고치지 않고 돌릴 수 있는 범위를 넓혔습니다.
import { expect, test } from "bun:test";
test("객체 부분 일치", () => {
expect({ a: { b: 1, c: 2 } }).toEqual({
a: expect.objectContaining({ b: 1 }),
});
});
모듈 목(mock)과 ESM/CJS
mock.module()은 ESM과 CommonJS 모듈을 모두 목으로 바꿀 수 있고, 이미 import된 모듈도 그 자리에서 갱신합니다. 아래 예제처럼 ./math를 먼저 import한 뒤 mock.module을 호출해도 fn이 목으로 바뀌므로, Jest처럼 목 선언을 파일 최상단으로 끌어올리는 호이스팅 규칙을 신경 쓸 필요가 줄어듭니다.
import { mock, test, expect } from "bun:test";
import { fn } from "./math";
test("로컬 모듈 목", () => {
mock.module("./math", () => ({ fn: () => 42 }));
expect(fn()).toBe(42);
});
대신 모듈 목은 같은 프로세스 안의 이후 테스트에도 남습니다. mock.restore()는 spyOn과 mock()으로 만든 함수 목을 되돌리지만 mock.module()로 바꾼 모듈은 되돌리지 않으므로, 모듈 목은 그것을 쓰는 테스트 파일 안에 가두고 다른 파일과 섞이지 않게 구성하는 것이 좋습니다.
--watch와 개발 루프
bun test --watch는 파일이 바뀌면 테스트를 다시 실행하며, 1.1에서는 Windows에서도 같은 방식으로 동작합니다. 저장소가 크다면 bun test src/feature처럼 경로를 지정해 범위를 좁히면 재실행이 빨라집니다.
Node.js·npm과의 성능 비교: 올바른 읽는 법
공식 블로그가 제시한 Windows 기준 사례
Bun 1.1 발표 글은 Windows에서 Vite React 템플릿을 설치할 때 bun install이 yarn보다 최대 약 18배, npm보다 최대 약 30배 빠르고, bun run과 bunx가 각각 npm run, npx보다 최대 약 11배 빠르다고 소개했습니다.
이 숫자를 읽을 때는 세 가지를 구분해야 합니다. 먼저 “최대”라는 말이 붙은 배율은 특정 조건(전역 캐시가 채워져 있는지, node_modules를 지웠는지, 네트워크와 디스크, Windows Defender 같은 실시간 검사)에서 나온 값이라 내 환경에서 같은 배율이 나온다는 뜻이 아닙니다. 다음으로 설치·스크립트 실행 속도와 프로덕션에서 애플리케이션이 처리하는 속도는 별개의 문제입니다. 후자는 애플리케이션 코드, I/O, GC 특성에 좌우됩니다. 마지막으로 핵심 의존성이 Bun에서 제대로 동작하지 않으면 속도는 의미가 없으므로, 자기 프로젝트에서 설치·빌드·테스트를 직접 돌려 보고 판단해야 합니다.
.bunx와 Windows에서의 실행 경로
Windows에서는 npm 패키지의 실행 파일을 .cmd 배치 파일로 감싸는 것이 일반적인데, 배치 파일은 Ctrl+C를 누르면 “Terminate batch job (Y/N)?” 프롬프트를 띄우고 실행 자체도 느립니다. Bun 1.1은 node_modules/.bin에 이 대신 .bunx 파일과 작은 실행기를 두어 shebang을 읽고 적절한 런타임을 실행하게 했습니다. shebang이 node를 가리키면 Node로 실행되므로, Bun을 패키지 관리자로만 쓰는 프로젝트에서도 동작합니다.
파일 시스템 API 미세 벤치
같은 Node.js API라도 구현이 다르므로 API마다 속도 차이가 납니다. Bun 측 벤치마크에서는 Windows의 fs.readdir()이 Node보다 빠르게 나온 사례가 소개됐지만, 반대로 Node보다 느린 API도 있을 수 있습니다. 성능이 중요한 경로라면 그 API만 따로 재 보는 것이 확실합니다.
Next.js·React·Astro와의 통합
공통: 패키지 관리자로서의 Bun
대부분의 프론트엔드 프로젝트에서 가장 안전한 첫걸음은 npm/pnpm 대신 bun install로 의존성을 설치하고 package.json 스크립트를 bun run으로 실행하는 것입니다. 이때 스크립트가 호출하는 vite, next 같은 도구는 shebang에 따라 여전히 Node로 실행되므로, 런타임까지 바꾸는 것은 아닙니다. 기존 package-lock.json이 있으면 bun install이 처음 실행될 때 자동으로 읽어 Bun의 lockfile로 옮깁니다.
bun install
bun run dev
React(예: Vite·CRA 계열)
React 자체는 런타임에 Node를 요구하지 않으며, 빌드 체인이 중요합니다. Vite + React라면 Bun은 주로 의존성 설치·스크립트 실행·때로는 테스트(bun test)에 기여합니다. 번들러를 Bun으로 완전히 대체할지는 팀 표준과 플러그인 호환성에 따라 결정합니다.
Next.js: 런타임과 번들러를 구분해 이해하기
Next.js는 자체 빌드 파이프라인(Turbopack/Webpack)을 가지므로 Bun이 번들러를 대신하지는 않습니다. 흔한 방식은 패키지 설치만 Bun으로 하거나, 한 걸음 더 나아가 --bun 플래그로 Next의 CLI 프로세스 자체를 Bun 런타임에서 실행하는 것입니다. --bun이 없으면 bun run dev라도 next는 shebang대로 Node에서 실행됩니다.
{
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start"
}
}
--bun으로 실행하면 Next 내부와 서버 컴포넌트 코드가 Bun의 Node 호환 계층 위에서 돌아가므로, 호환되지 않는 API가 있으면 개발 서버에서는 괜찮다가 특정 경로에서만 깨질 수 있습니다. 배포 플랫폼이 실제로 어떤 런타임으로 실행하는지도 따로 확인해야 하므로, 스테이징에서 E2E와 핵심 API 경로를 검증한 뒤 적용합니다.
Astro(SSG·엣지 친화)
Astro 프로젝트도 Bun으로 만들고 설치·실행할 수 있습니다.
bun create astro@latest my-astro-site
cd my-astro-site
bun install
bun run dev
다만 어댑터나 이미지 처리, MDX 같은 통합 중에는 Node 전용 API나 네이티브 모듈을 가정하는 것이 있을 수 있어, 문제가 생기면 해당 통합만 Node로 실행해 보는 식으로 원인을 좁힙니다.
실전 프로젝트 예제: 미니 API + 테스트 + 단일 바이너리 배포
아래는 Bun 내장 HTTP 서버, 단위 테스트, 컴파일 산출물까지 한 흐름으로 묶는 최소 예시입니다.
프로젝트 구조
mini-bun-service/
├─ package.json
├─ src/
│ ├─ server.ts
│ └─ server.test.ts
└─ README.md
package.json
{
"name": "mini-bun-service",
"type": "module",
"scripts": {
"dev": "bun --watch src/server.ts",
"start": "bun src/server.ts",
"test": "bun test",
"build:bin": "bun build --compile src/server.ts --outfile=dist/server"
}
}
bun --watch는 코드 변경 시 서버를 재시작해 로컬 반복 속도를 높입니다.
src/server.ts
export function createServer(port = Number(process.env.PORT ?? 3000)) {
return Bun.serve({
port,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health") {
return Response.json({ ok: true, bun: process.versions.bun ?? "dev" });
}
return new Response("Not Found", { status: 404 });
},
});
}
if (import.meta.main) {
const server = createServer();
console.log(`listening on ${server.url}`);
}
Bun.serve는 Web 표준 Request/Response를 받는 fetch 핸들러 하나로 HTTP 서버를 띄웁니다. import.meta.main은 이 파일이 직접 실행될 때만 true이므로, 테스트에서 import할 때는 서버가 자동으로 뜨지 않습니다. 운영 환경에서는 로깅, 요청 크기 제한, 보안 헤더를 직접 넣거나 리버스 프록시에 맡겨야 합니다.
src/server.test.ts
import { describe, test, expect } from "bun:test";
import { createServer } from "./server.ts";
describe("/health", () => {
test("200과 JSON 본문", async () => {
const server = createServer(0); // 0: 비어 있는 포트를 OS가 고름
try {
const res = await fetch(`${server.url}health`);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
} finally {
server.stop();
}
});
});
빌드와 실행
bun test
bun run build:bin
./dist/server
테스트에서 포트 0을 쓰면 개발 서버가 3000번을 쓰고 있어도 충돌하지 않습니다. server.url에는 실제로 배정된 포트가 들어 있습니다.
배포 전략
컨테이너(Docker) 기반
멀티 스테이지 빌드로 Bun 공식 이미지에서 의존성 설치, 테스트, --compile까지 마친 뒤 실행 파일만 작은 런타임 이미지로 복사하면, 최종 이미지에 Bun이나 node_modules가 들어가지 않아 크기와 공격 표면이 줄어듭니다.
# 예시: 빌드 스테이지에서 테스트까지 수행
FROM oven/bun:1 AS build
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun test && bun build --compile src/server.ts --outfile /out/server
FROM gcr.io/distroless/base-debian12
COPY --from=build /out/server /server
ENV PORT=8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]
--compile 결과물은 glibc에 동적으로 링크되므로 glibc가 있는 distroless/base나 Debian 계열 이미지를 써야 하고, Alpine(musl)에서는 musl용 타깃으로 따로 빌드해야 합니다.
정적·하이브리드 프론트(Astro·Next export 등)
Astro의 dist/ 같은 정적 산출물은 어떤 도구로 빌드했든 Cloudflare Pages, Netlify, S3+CloudFront 같은 정적 호스팅에 그대로 올리면 되므로, 이 경우 Bun은 빌드 단계에서만 쓰입니다. 서버 기능이 있다면 배포 대상 플랫폼이 Bun 런타임을 지원하는지, 아니면 Node나 엣지 런타임에서 실행되는지를 먼저 확인하고 그 런타임에서 테스트해야 합니다.
CI(GitHub Actions 등)
CI에서는 bun install --frozen-lockfile로 lockfile과 package.json이 어긋나면 실패하게 해 재현 가능한 설치를 보장하고, 의존성 캐시 키에는 lockfile 해시를 넣습니다. bun test --coverage로 커버리지를 함께 확인할 수 있습니다.
# 개념 예시 (실제 워크플로는 저장소에 맞게 조정)
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- run: bun install --frozen-lockfile
- run: bun test
롤백
Node에서 Bun으로 런타임을 바꾸는 배포는 런타임 변경 자체가 위험 요소이므로, 이전 이미지나 바이너리로 바로 되돌릴 수 있게 버전 태그를 남기고, 전환 전후의 에러율과 지연 시간을 비교할 수 있게 지표를 준비해 둡니다.
문제 해결 체크리스트
- 네이티브 모듈: Bun은 N-API로 만든 애드온(
sharp등)은 대부분 그대로 로드하지만, V8 API에 직접 의존하는 애드온(better-sqlite3등)은 동작하지 않거나 버전에 따라 제한적으로만 동작합니다. SQLite라면 내장bun:sqlite로 바꾸는 것을 검토하고, 대체가 어려우면 그 경로는 Node로 유지합니다. - 프레임워크와 번들러 플러그인: Node 내부 동작에 기대는 플러그인은 Bun에서 깨질 수 있습니다. 문제가 나면 최소 재현 저장소를 만들어 Bun 문제인지 플러그인 문제인지부터 가립니다.
- 엔진 차이: Bun은 V8이 아닌 JavaScriptCore를 쓰므로
Date.parse의 비표준 형식 해석, 에러 스택 형식,Error.captureStackTrace같은 V8 전용 API에서 차이가 날 수 있습니다. 날짜 문자열은 ISO 8601 형식만 쓰는 것이 안전합니다. - Windows 경로: 경로를 문자열로 이어 붙이지 말고
path.join,path.resolve를 씁니다.
자주 묻는 질문 (FAQ)
Q. bun build —compile로 만든 실행 파일은 다른 OS에서도 그대로 실행되나요?
A. —compile은 코드와 Bun 런타임을 단일 바이너리로 묶는 기능이라 결과물은 빌드한 타깃 플랫폼용 실행 파일입니다. Bun 1.1부터 NAPI 애드온(.node)도 함께 포함할 수 있지만, 네이티브 모듈이나 외부 공유 라이브러리는 OS와 ABI에 따라 달라집니다. 여러 OS에 배포한다면 타깃 OS별로 빌드하는 CI 파이프라인을 따로 두는 편이 안전합니다.