Bun 런타임 실전: bun install·Bun.serve·bun:test와 Node.js 호환성 점검
이 글의 핵심
Bun이 빠르다는 이야기만 듣고 옮기면 타입 검사가 빠진 채 배포되거나, 네이티브 모듈이 설치 후 동작하지 않거나, Jest에서 통과하던 테스트가 순서에 따라 실패하는 식으로 막히는 경우가 있습니다. 런타임·패키지 매니저·번들러·테스트 러너를 한 도구로 쓸 때의 장점과 한계를 나란히 놓고, 프로덕션에서 Bun을 써도 되는 조건과 Node.js를 유지하는 편이 나은 경우를 구분할 수 있게 합니다.
Bun이란 무엇인가
Bun은 JavaScript와 TypeScript를 실행하는 런타임이자, 번들러·테스트 러너·패키지 매니저를 하나로 묶은 올인원 도구입니다. Node.js가 V8 엔진 위의 런타임이고 패키지 관리·번들링·테스트는 npm, webpack, Jest 같은 별도 도구에 맡겨 왔다면, Bun은 처음부터 “자바스크립트 프로젝트에서 반복적으로 필요한 도구를 하나의 바이너리에 담자”는 목표로 설계되었습니다. 엔진은 V8이 아니라 Safari의 JavaScriptCore이고, 런타임의 HTTP·파일·트랜스파일러 같은 핵심 경로는 Zig로 작성된 네이티브 코드입니다.
이 글에서는 처음 Bun을 접하는 개발자를 대상으로 설치부터 서버 구축, 데이터베이스 사용, 테스트, 번들링, 배포, 그리고 실제 프로덕션 도입 여부까지 실무 관점에서 정리합니다. “무조건 빠르다”는 마케팅 문구보다는, Node.js에서 옮길 때 실제로 달라지는 동작과 언제 Node.js를 유지해야 하는지에 초점을 맞춥니다. 런타임 내부 구조(JSC 임베딩, bun:ffi, 이벤트 루프)는 Bun 런타임 내부 구조 심화에서, 1.1 버전에서 바뀐 점은 Bun 1.1 정리에서 따로 다룹니다.
설치하기
macOS와 Linux에서는 셸 스크립트를, Windows에서는 PowerShell 스크립트를 사용합니다.
# macOS / Linux / WSL
curl -fsSL https://bun.sh/install | bash
# Homebrew
brew install oven-sh/bun/bun
bun --version
powershell -c "irm bun.sh/install.ps1 | iex"
Docker 환경이라면 공식 oven/bun 이미지를 베이스로 쓰는 것이 가장 간편합니다. CI 파이프라인에서 매번 스크립트를 내려받는 대신 이 이미지를 쓰면 설치 단계 자체를 생략할 수 있습니다. 설치 후 bun --version이 출력되지 않는다면 셸의 PATH에 Bun 설치 경로(~/.bun/bin)가 추가되었는지 확인합니다.
프로젝트 시작하기: bun init과 bun run
새 프로젝트는 bun init으로 시작합니다. 이 명령은 package.json, tsconfig.json, 진입점 파일을 만들어 주므로 TypeScript 설정을 손으로 작성할 필요가 없습니다.
mkdir my-app && cd my-app
bun init -y
bun add zod
// index.ts
const res = await fetch("https://api.github.com")
const data = await res.json() as Record<string, unknown>
console.log(data)
bun run index.ts # 컴파일 단계 없이 바로 실행
Node.js 환경에서는 보통 tsc로 컴파일한 뒤 node dist/index.js를 실행하거나 ts-node·tsx 같은 패키지를 추가해야 했습니다. Bun은 .ts·.tsx·.jsx를 설정 없이 실행하고, tsconfig.json의 paths를 인식하며, 최상위 await와 import.meta.main(진입점 판별)도 지원합니다.
여기서 가장 중요한 사실은 Bun이 TypeScript를 실행할 뿐 타입 검사는 하지 않는다는 점입니다. Bun의 트랜스파일러는 타입 주석을 지우고 바로 실행하므로, const n: number = "hello" 같은 코드도 아무 경고 없이 돌아갑니다. 위 예제의 as Record<string, unknown>도 실행 시점에는 아무 의미가 없습니다. “TS로 작성했으니 타입이 보장된다”고 착각하기 쉬운데, 타입 안전성을 원한다면 CI나 pre-commit에서 bunx tsc --noEmit을 따로 돌려야 합니다. esbuild, swc, Node의 타입 제거 실행(--experimental-strip-types) 같은 다른 “빠른 TS 실행” 도구도 모두 같은 트레이드오프입니다. 실행 속도를 얻는 대신 검사는 분리한 것입니다.
패키지 매니저로서의 Bun: bun install
Bun은 npm을 대체하는 패키지 매니저로도 동작합니다. 전역 캐시와 하드링크, 병렬 다운로드, 네이티브 코드로 작성된 설치 로직 덕분에 특히 캐시가 채워진 상태의 반복 설치가 빠릅니다.
bun install # package.json 기준으로 의존성 설치
bun add zod # 의존성 추가
bun add -d typescript # devDependencies로 추가
bun remove lodash # 의존성 제거
bun update
설정은 bunfig.toml(레지스트리·캐시·스코프)에 두고, package.json의 스크립트는 bun <script>로 실행합니다.
lockfile 형식은 실무에서 자주 부딪히는 부분입니다. 예전 버전의 bun.lockb는 바이너리라서 코드 리뷰에서 diff를 볼 수 없고 병합 충돌을 해결하기 어려웠는데, Bun 1.2부터 텍스트 형식 bun.lock이 기본이 되면서 이 문제가 풀렸습니다. 기존 프로젝트에 bun.lockb만 있다면 최신 Bun에서 bun install --save-text-lockfile로 변환하고 .lockb를 지우면 됩니다. 한 저장소에 package-lock.json과 bun.lock이 함께 있으면 팀원이나 CI가 쓰는 도구에 따라 서로 다른 버전이 설치되어 “내 컴퓨터에서는 되는데” 문제가 생기므로, 팀 차원에서 하나를 표준으로 정하고 CI 스크립트와 README에 적어 두는 것이 원칙입니다.
또 하나의 차이는 라이프사이클 스크립트입니다. npm은 의존성의 postinstall 스크립트를 기본으로 실행하지만, Bun은 공급망 공격을 막기 위해 널리 쓰이는 일부 패키지를 제외하고는 실행하지 않습니다. 네이티브 모듈이 설치 후에 동작하지 않는다면 이 때문일 가능성이 높습니다. bun pm untrusted로 차단된 패키지를 확인하고, 필요한 것만 package.json의 trustedDependencies에 추가한 뒤 bun install --force로 다시 설치합니다.
Bun.serve로 서버 만들기
Bun은 Bun.serve라는 내장 API로 별도 프레임워크 없이 HTTP 서버를 띄울 수 있습니다.
// server.ts
const server = Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/health") return new Response("ok")
if (url.pathname === "/api/hello") {
return Response.json({ message: "Hello JSON" })
}
if (url.pathname.startsWith("/file/")) {
const file = Bun.file(`./public${url.pathname.replace("/file", "")}`)
return new Response(file)
}
return new Response("Not Found", { status: 404 })
},
error(e) {
return new Response(`Error: ${e.message}`, { status: 500 })
},
})
console.log(`Listening on http://localhost:${server.port}`)
Bun.serve는 표준 웹 Request/Response 객체를 그대로 사용합니다. Node.js의 http 모듈이 자체 요청/응답 인터페이스를 정의했던 것과 달리, 같은 형태의 핸들러가 Cloudflare Workers, Deno, 최신 Node 어댑터에서도 쓰이므로 코드 이식성이 좋습니다. 단순한 “Hello World” 벤치마크에서는 Node의 http 모듈보다 높은 처리량이 나오는 경우가 많지만, DB 조회나 외부 API 호출이 들어가는 실제 서버에서는 그 차이가 크게 줄어듭니다.
이 예제에는 운영에 옮기기 전에 고쳐야 할 부분이 두 가지 있습니다. /file/ 경로는 URL 경로를 그대로 파일 경로에 붙이므로, 요청 경로를 검증하지 않으면 의도하지 않은 파일을 노출할 위험이 있습니다. 허용된 디렉터리 안에 있는지 path.resolve 결과로 확인하고, 파일이 없을 때 404를 돌려주도록 await file.exists()를 검사하는 편이 안전합니다. 또 error 핸들러가 e.message를 그대로 응답에 넣으면 내부 경로나 쿼리 같은 정보가 사용자에게 노출되므로, 운영에서는 로그에만 남기고 응답은 일반 메시지로 바꾸는 것이 좋습니다.
라우팅과 미들웨어가 필요하면 Web 표준 기반의 Hono가 Bun과 잘 맞습니다. Express도 대부분 그대로 동작하므로, 기존 코드를 크게 고치지 않고 런타임만 Bun으로 바꾸는 것도 현실적인 선택지입니다.
import { Hono } from "hono"
const app = new Hono()
app.get("/", (c) => c.text("Bun + Hono"))
app.post("/users", async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ created: body.name }, 201)
})
export default app // Bun이 default export의 fetch를 서버로 띄움
개발 중에는 bun --watch server.ts(변경 시 프로세스 재시작)나 bun --hot server.ts(프로세스를 유지한 채 모듈만 다시 불러오기)로 nodemon·tsx watch를 대신할 수 있습니다.
bun:sqlite로 데이터베이스 다루기
Bun에는 SQLite를 다루는 bun:sqlite 모듈이 내장되어 있습니다. 별도 패키지 설치 없이 네이티브 바인딩으로 동작하며, better-sqlite3와 비슷한 동기 API를 제공합니다.
import { Database } from "bun:sqlite"
const db = new Database("app.db")
db.exec("PRAGMA journal_mode = WAL;")
db.run(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
)
`)
const insert = db.prepare("INSERT INTO users (name, email) VALUES (?, ?)")
insert.run("Alice", "[email protected]")
const user = db.query("SELECT * FROM users WHERE email = ?").get("[email protected]")
console.log(user)
동기 API라서 쿼리가 끝날 때까지 이벤트 루프가 멈추지만, SQLite는 같은 프로세스 안의 파일을 읽으므로 대부분의 쿼리는 네트워크 DB보다 훨씬 짧게 끝나 문제가 되지 않습니다. 큰 테이블 전체 스캔이나 대량 쓰기처럼 오래 걸리는 작업은 요청을 처리하는 서버에서 다른 요청까지 막으므로 워커로 분리하는 편이 좋습니다.
db.query()는 SQL 문자열별로 준비된 문장을 캐시하고, db.prepare()는 캐시하지 않은 새 문장을 만듭니다. 반복 실행하는 쿼리는 query()나 한 번 만든 prepare() 결과를 재사용해야 파싱 비용이 들지 않습니다. 여러 프로세스가 같은 파일에 쓰는 경우라면 위 예제처럼 WAL 모드를 켜야 읽기와 쓰기가 서로를 덜 막습니다. 추상화 계층이 얇아서 간단한 CRUD에는 ORM 없이도 충분하지만, 마이그레이션 이력 관리나 스키마 타입이 필요해지면 Drizzle ORM 같은 상위 라이브러리를 함께 쓰는 편이 관리하기 쉽습니다.
bun:test로 테스트 작성하기
Bun은 bun:test라는 내장 테스트 러너를 제공하며, API가 Jest와 매우 비슷해 기존 테스트 코드를 옮기는 부담이 적습니다. mock·spy·스냅샷도 내장되어 있습니다.
import { describe, it, expect, beforeAll } from "bun:test"
beforeAll(() => { /* setup */ })
describe("add 함수", () => {
it("두 숫자를 더한다", () => {
expect(1 + 1).toBe(2)
})
it.each([
[1, 2, 3],
[5, 5, 10],
])("%d + %d = %d", (a, b, r) => {
expect(a + b).toBe(r)
})
})
bun test # 전체 테스트 실행
bun test --watch # 파일 변경 감지 후 자동 재실행
bun test --coverage
bun test src/utils.test.ts # 특정 파일만 실행
변환 단계와 jest.config.js·babel 설정 없이 TypeScript 테스트를 바로 실행하므로, 작은 테스트 파일이 많은 프로젝트일수록 시작 시간 차이가 크게 느껴집니다. 테스트 파일이 몇 개 되지 않는 프로젝트에서는 체감 차이가 크지 않습니다.
Jest와 동작이 다른 부분도 알아 둬야 합니다. bun test는 기본적으로 테스트 파일들을 한 프로세스 안에서 순서대로 실행합니다. Jest처럼 파일마다 격리된 환경(워커)을 만들지 않으므로, 한 테스트 파일이 전역 상태나 모듈 수준 변수를 바꾸면 다음 파일에 영향을 줄 수 있습니다. Jest에서는 통과하던 테스트가 Bun에서 순서에 따라 실패한다면 이 격리 차이를 먼저 의심하세요. 또 jest.fn()은 bun:test의 mock()으로, jest.mock()으로 모듈 전체를 가짜로 바꾸던 테스트는 mock.module()로 옮겨야 하고, DOM이 필요한 테스트는 happy-dom 같은 환경을 bunfig.toml의 preload로 따로 설정해야 합니다. @testing-library/jest-dom 같은 확장 matcher도 설정을 추가해야 동작합니다.
bun build로 번들링하기
Bun은 bun build CLI 또는 Bun.build API로 번들을 만듭니다. 트리 셰이킹과 코드 스플리팅을 지원하는 esbuild 계열의 빠른 번들러입니다.
bun build ./src/index.ts --outdir ./dist --target node --minify
bun build ./src/index.ts --outdir ./dist --target bun
// build.ts
await Bun.build({
entrypoints: ["./src/index.ts"],
outdir: "./dist",
target: "browser",
minify: true,
sourcemap: "external",
})
--target은 결과물이 실행될 환경을 뜻합니다. browser는 Node 내장 모듈을 쓸 수 없고, node는 Node에서 실행 가능한 코드를, bun은 Bun 전용 API(Bun.serve, bun:sqlite)를 그대로 쓰는 코드를 만듭니다. --target browser로 빌드했는데 fs를 import하는 의존성이 섞여 있으면 빌드 에러가 나므로, 서버 전용 코드와 클라이언트 코드를 진입점부터 분리해 두는 편이 좋습니다. 다만 Vite나 webpack이 제공하는 개발 서버, CSS 처리, 로더·플러그인 생태계까지 대체하는 것은 아니어서, 프런트엔드 앱 빌드에는 여전히 Vite를 쓰는 경우가 많습니다. 기존 대규모 프로젝트를 한 번에 옮기기보다 새로 만드는 서비스나 내부 도구 하나에 먼저 적용해 보는 편이 안전합니다.
단일 실행 파일
bun build ./src/cli.ts --compile --outfile ./mycli
./mycli
--compile은 Bun 런타임을 함께 넣은 단일 실행 파일을 만들어, 받는 쪽에 Node나 Bun이 설치되어 있지 않아도 실행됩니다. 대신 런타임 전체가 들어가므로 “Hello World” CLI도 수십 MB가 되어 Go나 Rust로 만든 CLI보다 훨씬 큽니다. --target=bun-linux-x64, bun-darwin-arm64, bun-windows-x64처럼 지정하면 한 머신에서 다른 OS용 바이너리를 교차 빌드할 수 있지만, 네이티브 애드온(.node 파일)을 쓰는 의존성은 대상 플랫폼용 바이너리가 함께 있어야 하므로 교차 빌드에서 실패하기 쉽습니다. 인자 파싱은 Node 호환 util.parseArgs로 충분한 경우가 많습니다.
#!/usr/bin/env bun
// src/cli.ts
import { parseArgs } from "util"
const { values } = parseArgs({
args: Bun.argv.slice(2),
options: {
name: { type: "string", short: "n" },
verbose: { type: "boolean", short: "v" },
},
})
console.log(`Hello ${values.name ?? "world"}`)
환경 변수와 Bun Shell
Bun은 .env, .env.local, 그리고 NODE_ENV에 따라 .env.development/.env.production을 자동으로 읽습니다. 여러 파일을 명시하려면 --env-file을 반복합니다.
bun --env-file=.env --env-file=.env.local run server.ts
편리하지만 운영 서버에 개발용 .env 파일이 실수로 함께 배포되면 그 값이 적용되는 사고로 이어질 수 있습니다. Docker 이미지를 만들 때 .dockerignore에 .env*를 넣고, 운영 환경 변수는 오케스트레이터나 비밀 저장소에서 주입하는 것이 안전합니다. 또 Node는 .env를 자동으로 읽지 않으므로(dotenv를 쓰거나 Node 20.6 이상에서 node --env-file=.env를 명시해야 함), 두 런타임을 섞어 쓰는 프로젝트에서는 “Bun에서는 되는데 Node에서는 환경 변수가 없다”는 차이가 생깁니다.
import { $ } from "bun"
await $`ls -la`
const log = await $`git log --oneline -n 5`.text()
console.log(log)
const file = "my file.txt"
await $`rm ${file}` // 변수는 자동 이스케이프되어 인자 하나로 전달됨
bun에 내장된 셸 DSL은 zx와 비슷합니다. 템플릿에 넣은 변수는 자동으로 이스케이프되므로 사용자 입력을 넣어도 셸 인젝션으로 해석되지 않는다는 점이 child_process.exec보다 안전합니다. 시스템 셸(bash)을 부르지 않고 자체 구현으로 ls, cat, rm 같은 기본 명령과 파이프를 처리하므로 Windows에서도 같은 스크립트가 동작합니다. 반대로 bash 전용 문법(배열, [[ ]])은 지원하지 않으며, 명령이 0이 아닌 종료 코드로 끝나면 기본적으로 예외를 던지므로 실패를 허용하려면 .nothrow()를 붙입니다. 자세한 내용은 Bun Shell 크로스 플랫폼 스크립팅에서 다룹니다.
Workspaces (모노레포)
// package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": ["apps/*", "packages/*"]
}
bun install # 루트에서 모든 워크스페이스 설치
bun run --filter=web dev
bun run --filter='./apps/*' build
워크스페이스 간 의존성은 "@my/ui": "workspace:*"처럼 선언하면 로컬 패키지로 연결됩니다. --filter는 여러 패키지의 스크립트를 실행하는 기본 기능을 제공하지만, Turborepo나 Nx처럼 입력이 바뀌지 않은 작업의 결과를 캐시하거나 원격 캐시를 공유하는 기능은 없습니다. 패키지 수가 적으면 Bun만으로 충분하고, 빌드가 오래 걸리는 큰 모노레포라면 설치는 Bun, 작업 오케스트레이션은 Turborepo로 나누는 조합이 흔합니다.
Node.js와의 호환성
Bun은 Node.js의 핵심 API를 상당 부분 구현하고 있습니다. fs, path, os, crypto, stream, events, http, buffer, child_process, worker_threads 같은 코어 모듈을 지원하고, require와 import를 모두 지원하므로 대부분의 npm 패키지를 수정 없이 쓸 수 있습니다. pm2로 Bun 프로세스를 관리하는 것도 가능합니다.
import fs from "node:fs"
import path from "node:path"
const filePath = path.join(import.meta.dir, "data.json")
const data = fs.readFileSync(filePath, "utf-8")
다만 완벽한 호환을 전제로 하는 것은 위험합니다. 실무에서 자주 부딪히는 지점은 네이티브 애드온입니다. N-API만 쓰는 모듈(sharp 등)은 대부분 동작하지만, V8 내부 API에 직접 의존하는 애드온은 실패할 수 있습니다. --inspect 기반 도구 일부처럼 V8 전용 기능에 기대는 워크플로도 그대로 옮겨지지 않습니다. 이런 상황을 만나면 무리하게 우회하기보다, 해당 모듈을 쓰는 부분만 Node.js로 실행하거나 컨테이너를 분리하는 방식이 현실적입니다. 마이그레이션 전에 의존성 목록에서 네이티브 바인딩을 쓰는 패키지를 미리 확인해 두는 것을 권장합니다.
마이그레이션 시 주의할 점
기존 Node.js 프로젝트를 Bun으로 옮길 때는 다음 사항을 점검하는 것이 좋습니다.
- 타입 검사 분리: Bun은 타입 검사를 하지 않으므로 CI에
tsc --noEmit단계를 둡니다. - 락파일 정리:
package-lock.json또는yarn.lock을 제거하고bun install로bun.lock을 새로 만듭니다. - CI/CD 스크립트 수정:
npm ci대신bun install --frozen-lockfile을 쓰고, GitHub Actions라면~/.bun/install/cache를actions/cache로 캐시합니다. - 라이프사이클 스크립트: 설치 후 동작하지 않는 네이티브 모듈은
trustedDependencies를 확인합니다. - 테스트 격리 차이: 전역 상태를 바꾸는 테스트가 있으면 순서에 따라 실패할 수 있습니다.
- 환경 변수 로딩: Bun은
.env를 자동으로 읽고 Node는 그렇지 않습니다. - 점진적 도입: 핵심 서비스를 한 번에 바꾸기보다, 내부 도구나 배치 스크립트처럼 리스크가 낮은 영역부터 시작합니다.
배포
Docker
FROM oven/bun:1-alpine AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build ./src/server.ts --outdir ./dist --target bun
FROM oven/bun:1-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "dist/server.js"]
bun build는 기본적으로 의존성까지 한 파일로 번들하므로, 순수 JavaScript 의존성만 있다면 마지막 단계에서 node_modules를 복사하지 않아도 동작합니다. 네이티브 모듈이나 동적 require를 쓰는 패키지는 번들에 포함되지 않으니 --external로 빼고 bun install --production으로 운영 의존성만 설치하는 편이 이미지를 작게 유지하는 방법입니다. 이미지 태그를 1-alpine처럼 주 버전으로만 두면 빌드할 때마다 다른 Bun 버전이 들어올 수 있으므로, 재현성이 중요하면 정확한 버전을 고정하세요.
서버리스
- Cloudflare Workers: Workers는 자체 런타임(workerd)이라
Bun.*API와bun:sqlite는 쓸 수 없습니다. 코드를 공유하려면 표준 Fetch API 중심으로 작성합니다. - Vercel: Functions에서 Bun 런타임을 선택할 수 있습니다(지원 범위는 Vercel 문서에서 확인).
- AWS Lambda: 커스텀 런타임으로 Bun 바이너리를 동봉합니다.
트러블슈팅
bun install후 네이티브 모듈이 로드되지 않음: 라이프사이클 스크립트가 차단된 경우가 많습니다.bun pm untrusted로 확인하고trustedDependencies에 추가한 뒤bun install --force로 다시 설치합니다.ERR_MODULE_NOT_FOUND: Bun은.ts경로를 확장자 없이 import할 수 있지만, 같은 코드를 Node로 실행하면 ESM 규칙상 확장자가 필요합니다. 두 런타임에서 모두 돌릴 코드라면 import 경로 규칙을 하나로 맞춥니다.- 장기 실행 서버의 메모리 증가: 먼저 애플리케이션 쪽(전역 캐시, 해제하지 않은 타이머·리스너)을 확인하고,
bun:jsc의heapStats()로 객체 수 변화를 관찰합니다. 런타임 자체의 누수가 의심되면 릴리스 노트에서 관련 수정이 있는지 보고 업그레이드해 비교합니다. - Jest 테스트 일부 실패: 앞의 테스트 격리 차이,
jest.*전역, 확장 matcher 설정을 차례로 확인합니다.
프로덕션에서 Bun을 써도 될까
Bun 1.0 이후 프로덕션 도입 사례는 꾸준히 늘고 있습니다. 그럼에도 결제, 인증, 감사 로그처럼 안정성이 최우선인 영역에서는 신중한 접근이 필요합니다. 장애가 났을 때 참고할 수 있는 레퍼런스와 커뮤니티 자료, 스택 트레이스 분석 노하우, 채용 시장의 엔지니어 수까지 고려하면 아직은 Node.js 쪽이 더 두텁습니다. Bun은 릴리스가 잦아 수정이 빠르게 들어오는 대신 업그레이드마다 동작이 바뀔 수 있으므로, 버전을 고정하고 테스트를 통과한 뒤 올리는 절차도 필요합니다.
반대로 내부 대시보드, 자동화 봇, CLI 도구, 프리뷰 배포 환경처럼 실패의 영향 범위가 제한적인 곳이라면 Bun을 적극적으로 시도해 볼 만합니다. 런타임을 갑자기 교체하기보다, 로컬과 스테이징에서 충분한 기간 운용해 보고 락파일과 설치 스크립트를 팀 전체가 같게 쓰도록 규칙을 정해 두는 것이 안전합니다.
언제 Bun을 쓰고 언제 Node.js를 쓸까
| 상황 | 권장 런타임 |
|---|---|
| 신규 사이드 프로젝트, CLI 도구 | Bun |
| CI 파이프라인의 설치·테스트 속도 개선 | Bun (런타임은 Node 유지 가능) |
| V8 전용 기능·네이티브 애드온에 크게 의존하는 서비스 | Node.js |
| 결제·인증 등 안정성이 최우선인 코어 서비스 | Node.js |
| 내부 대시보드, 프리뷰 배포 | Bun |
| LTS 정책과 보수적인 업그레이드 주기가 중요한 서비스 | Node.js |
같이 보면 좋은 글
- Bun 1.1에서 바뀐 것: Windows 지원, 내장 번들러·테스트 러너, 성능 수치 읽는 법
- Bun 런타임 내부 구조 심화 — JavaScriptCore·Zig FFI·번들러·HTTP·프로덕션
- Hono 웹 프레임워크: Web 표준 기반 라우팅·미들웨어와 Express·Fastify 비교
- pnpm 패키지 매니저: 심볼릭 링크 구조, 모노레포 워크스페이스, phantom dependency 해결
- Deno 2.0 — npm 호환·JSR·워크스페이스·Node 비교·마이그레이션