Bun으로 갈아탈까? 실제 모노레포로 검증해본 결과
왜 Bun을 고려하게 되었는가
Bun이 빠르다는 이야기는 이제 새롭지 않습니다. "Node.js 대비 4배 빠른 시작 시간", "npm install의 30배 속도" 같은 벤치마크 숫자를 자주 접하다 보면, 자연스럽게 "나도 바꿔야 하나?"라는 생각이 듭니다.
저도 그런 고민을 하게 된 계기가 있었습니다. 블로그 플랫폼을 모노레포로 운영하면서 워크트리(worktree) 기반 병렬 개발을 자주 하는데, 새 워크트리를 만들 때마다 pnpm install을 돌려야 합니다. 이 시간이 줄어든다면 개발 흐름이 더 매끄러워지지 않을까, 하는 기대가 출발점이었습니다.
결론부터 말하면, 기대와 꽤 다른 결과를 얻었습니다.
분석 대상: 실제 프로덕션 모노레포
단순 벤치마크가 아닌, 실제로 운영 중인 레포를 기준으로 분석했습니다.
기술 스택
├── Backend: NestJS 10 + MongoDB + MySQL (Drizzle ORM)
├── Frontend: Next.js 15/16, Vite + React 19
├── 패키지 매니저: pnpm 10 (workspace)
├── 총 패키지: 1,633개 (node_modules/.pnpm)
├── 워크스페이스: 24개 프로젝트
└── node_modules 크기: 1.6GB
NestJS 백엔드, Next.js 웹 3개(개인 블로그, SaaS, 수영 정보), Vite 기반 어드민, 그리고 여러 공유 패키지로 구성된 구조입니다. "너무 특수한 기능이 없으면 Bun 전환이 무난할까?"라는 질문에서 시작했습니다.
첫 번째 질문: 마이그레이션이 가능한가?
발견된 블로커들
레포를 분석해보니, "특수한 기능 없으면 무난하다"에 해당하지 않았습니다.
bcrypt (네이티브 C++ 모듈) — 가장 큰 블로커였습니다. bcrypt는 node-gyp으로 컴파일되는 네이티브 모듈인데, Bun은 이를 지원하지 않습니다. bcryptjs(순수 JS)나 argon2로 교체해야 합니다.
Jest + ts-jest — 백엔드 테스트 전체가 Jest 기반입니다. Bun의 네이티브 테스트 러너나 vitest로 재작성해야 하는데, 이건 단순 설정 변경이 아니라 테스트 코드 전체를 손봐야 하는 작업입니다.
child_process — git 정보를 가져오는 서비스에서 execFile을 사용하고 있었습니다. Bun에서는 Bun.spawn()이라는 다른 API를 써야 합니다.
반면에 호환되는 부분도 많았습니다. Mongoose(MongoDB 드라이버), Express, crypto 모듈, Buffer API, Drizzle ORM 등은 문제없이 동작합니다. 그래서 "불가능"은 아니지만, 5~8주 정도의 마이그레이션 작업이 필요하다는 판단이었습니다.
두 번째 질문: 프론트엔드만 Bun으로 바꾸면?
전면 전환이 부담스러우니, 프론트엔드(Next.js)만 Bun으로 실행하면 어떨까 하는 생각이 들었습니다. 개발 서버 시작 속도나 HMR이 빨라진다면 그것만으로도 의미가 있으니까요.
각 프론트엔드 패키지의 규모를 측정해봤습니다.
패키지별 소스 파일 수 / 의존성 수
├── personal/web (Next.js 16): 55 파일 / 20 deps
├── saas/blog (Next.js 15): 86 파일 / 42 deps (Radix UI 22개 + Tiptap)
├── swimming/web (Next.js 15): 19 파일 / 31 deps
└── personal/admin (Vite): 81 파일 / 48 deps (Tiptap 16개)
체감 속도 예측
여기서 중요한 사실을 깨달았습니다. 현대 프론트엔드 툴체인에서 JS 런타임이 차지하는 비중은 극히 작습니다.
Next.js dev 서버가 시작될 때 시간이 걸리는 구간을 분해해보면 이렇습니다.
Cold Start 시간 분해
1. 런타임 시작 (Node.js ~45ms → Bun ~10ms) 차이: -35ms
2. Next.js 초기화 (~800-1200ms → ~700-1000ms) 차이: -100~200ms
3. SWC/Turbopack 컴파일 (~2-4초 → ~2-4초) 차이: 거의 없음 ← Rust 바이너리
4. 첫 페이지 렌더링 (~1-2초 → ~1-2초) 차이: 거의 없음 ← React SSR
───────────────────────────────────────────
합계: ~4-7초 → ~3.5-6.5초 총 차이: ~0.5초
SWC는 Rust로 작성된 네이티브 바이너리이고, esbuild는 Go로 작성되어 있습니다. 이 도구들이 컴파일 시간의 대부분을 차지하는데, Node.js든 Bun이든 이 부분에는 영향을 주지 못합니다.
HMR(Hot Module Replacement)은 아예 차이가 없습니다. 파일 변경 감지(OS-level FSEvents) → SWC 리빌드(Rust) → WebSocket 전송 → 브라우저 DOM 업데이트, 이 전체 과정에서 JS 런타임이 관여하는 비중이 거의 없기 때문입니다.
솔직히 말하면, 0.5초 차이를 체감할 수 있는 사람은 없습니다.
세 번째 질문: 패키지 매니저로서의 Bun은?
런타임이 아니라 패키지 매니저 자체를 pnpm에서 Bun으로 바꾸는 것은 어떨까 하는 생각도 해봤습니다.
기능적으로는 호환됩니다
pnpm의 워크스페이스 기능과 Bun의 워크스페이스 기능을 비교하면, 구조 자체는 거의 동일하게 옮길 수 있습니다.
# 현재 pnpm-workspace.yaml
packages:
- 'packages/backend'
- 'packages/core/*'
- 'packages/adapters/**/*'
- 'packages/personal/*'
- 'packages/saas/*'
- 'packages/swimming/*'
- 'packages/tools/*'
// Bun 등가 (root package.json)
{
"workspaces": [
"packages/backend",
"packages/core/*",
"packages/adapters/**/*",
"packages/personal/*",
"packages/saas/*",
"packages/swimming/*",
"packages/tools/*"
]
}
glob 패턴, workspace:* 프로토콜, --filter 명령어 모두 지원합니다.
하지만 트레이드오프가 있습니다
의존성 격리 방식이 근본적으로 다릅니다. pnpm은 기본적으로 symlink 기반의 strict 격리를 합니다. 선언하지 않은 패키지에 접근할 수 없게 막아주는데, Bun은 기본값이 hoisted(npm v1 방식)라서 phantom dependency 문제가 생길 수 있습니다. bunfig.toml에서 linker = "isolated"를 설정하면 해결되지만, 기본값이 아니라는 점이 걸립니다.
lockfile이 바이너리입니다. pnpm의 pnpm-lock.yaml은 텍스트 파일이라 git diff로 변경 내용을 확인할 수 있지만, Bun의 bun.lockb는 바이너리라서 diff가 불가능합니다. 1인 개발이라 당장 큰 문제는 아니지만, 나중에 불편해질 수 있는 부분입니다.
lifecycle 스크립트가 기본 비활성입니다. postinstall 등의 스크립트를 Bun은 기본적으로 실행하지 않습니다. 이 레포에서는 husky의 prepare 스크립트가 이에 해당하므로, 별도로 허용 설정을 해줘야 합니다.
진짜 병목은 어디에 있었나
여기서 가장 중요한 실측을 했습니다. 애초에 Bun을 고려하게 된 이유가 워크트리 생성 시 모듈 설치 시간이었으니까요.
새 디렉토리에서 pnpm install --frozen-lockfile을 실행해봤습니다. 메인 레포와 동일한 lockfile이 있고, pnpm 글로벌 store(4.5GB)에 패키지가 캐시된 상태입니다. 워크트리를 만드는 상황과 완전히 동일한 조건입니다.
pnpm install 실측 결과
────────────────────────────
resolved 1603, reused 1584, downloaded 0
────────────────────────────
네트워크 다운로드: 0건
글로벌 store 재사용: 1584건 (98.8%)
총 소요 시간: 8.9초
node_modules 크기: 1.6GB
다운로드가 0건이었습니다. 1603개 패키지 중 1584개를 글로벌 store에서 그대로 재사용했습니다.
이 9초가 대체 어디에 쓰인 걸까요?
설치 시간 분해
1. Resolution (의존성 해석): ~0초 — lockfile이 있어서 스킵
2. Download (네트워크): ~0초 — store에 이미 다 있음
3. Linking (node_modules): ~9초 — 1603개 패키지를 하드링크로 연결
전체 시간이 하드링크 생성과 node_modules 디렉토리 구조를 만드는 I/O 작업에 쓰이고 있었습니다. Bun이 pnpm보다 빠른 건 주로 네트워크 다운로드와 의존성 해석 단계인데, 워크트리 시나리오에서는 이 두 단계가 모두 0입니다.
Bun으로 바꿔도 이 링킹 단계에서 2~3초 정도 줄어들 수는 있겠지만, 9초가 3초가 되는 수준의 변화는 아닙니다.
실질적인 해결책
Bun 마이그레이션이라는 큰 작업 대신, 현재 pnpm 그대로 워크트리 설치 시간을 줄이는 방법이 있었습니다.
# 전체 설치 (현재): ~9초, 1603 패키지
pnpm install --frozen-lockfile
# 필요한 패키지만 설치: ~2-3초
pnpm install --frozen-lockfile --filter web...
--filter를 사용하면 해당 패키지와 그 패키지가 의존하는 패키지만 설치합니다. 전체 1603개가 아니라 실제로 필요한 패키지만 링킹하면, 9초가 2~3초로 줄어듭니다.
마이그레이션 비용 0으로 같은 결과를 얻을 수 있었습니다.
돌이켜 생각해보면
이번 분석을 통해 몇 가지를 깨달았습니다.
첫째, 벤치마크 숫자는 내 상황이 아닙니다. "30배 빠른 설치 속도"는 캐시가 없는 클린 설치, 네트워크 다운로드가 포함된 상황에서의 숫자입니다. 같은 머신에서 워크트리를 만드는 시나리오에서는 그 이점의 대부분이 사라집니다.
둘째, 현대 프론트엔드의 병목은 JS 런타임이 아닙니다. SWC(Rust), esbuild(Go), Turbopack(Rust) 같은 네이티브 도구들이 무거운 작업을 처리합니다. JS 런타임을 아무리 빠르게 바꿔도 이 부분은 변하지 않습니다.
셋째, 도구를 바꾸기 전에 병목을 측정해야 합니다. "느린 것 같다"는 느낌으로 도구 전환을 고민했지만, 실제로 측정해보니 9초였고, 그마저도 --filter 하나로 2~3초로 줄일 수 있었습니다. 문제를 정의하기 전에 해결책부터 찾으려 했던 셈입니다.
Bun은 분명히 좋은 도구입니다. 하지만 모든 레포에 Bun이 필요한 것은 아닙니다. 적어도 이 레포에서는 아직 때가 아니라는, 그리고 현재 도구를 더 잘 활용하는 것이 먼저라는 결론을 내렸습니다.
관련 글
Node.js만 있는 게 아니다 — Bun과 Deno, 같은 언어 다른 런타임
JavaScript를 실행하는 런타임은 Node.js만이 아닙니다. Bun과 Deno는 같은 언어를 다른 방식으로 실행합니다. 세 런타임의 설계 철학, 내부 구조, 실용적 차이를 정리합니다.
npm, yarn, pnpm — 패키지 매니저가 node_modules를 만드는 세 가지 방식
같은 package.json인데 npm, yarn, pnpm이 만드는 node_modules 구조가 다릅니다. nested에서 flat으로, 다시 symlink로 — 구조가 바뀌어 온 이유와 각 방식의 트레이드오프를 정리합니다.
Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)
면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.