Bun의 Rust 재작성: 11일, 6,778 커밋, 실제로 바뀐 것
Bun 팀이 2026년 5월에 특이한 것을 출시했다: 런타임의 전체 네이티브 코어를 Rust 포트로 교체했으며, 11일 만에 해냈다.
발표문 상단의 공개가 분위기를 설정한다. Bun은 2025년 12월 Anthropic에 인수됐다. Bun 창시자 Jarred Sumner는 작업의 상당 부분에 Fable 5라고 부르는 “Mythos급 모델”의 사전 출시 버전을 사용했다. 이는 사고 실험이 아니다. 첫 번째 Rust 버전인 Bun v1.4.0이 현재 canary에 있다.
원시 숫자:
- 535,496줄의 Zig (주석 제외)가 Rust 코드베이스로 변환
- 5월 3일부터 5월 14일까지 6,778 커밋
- 1,448개의
.zig파일이 기계적으로.rs로 포팅 - 약 $165,000의 API 비용 (5.9B 비캐시 입력 토큰, 690M 출력 토큰)
- 최대: 4개 워크트리에서 64개 Claude 동시 실행
Bun은 항상 Zig 베팅이었다
Bun은 esbuild의 JS/TS 트랜스파일러를 Go에서 Zig로 라인별 포팅하면서 시작했다. Sumner는 Hacker News에서 단일 페이지 Zig 언어 레퍼런스를 본 후 2021년 4월 16일에 첫 Zig 코드를 작성했다. 초기 버전(트랜스파일러, 미니파이어, 번들러, npm 호환 패키지 관리자, Jest 스타일 테스트 러너, Node.js API 표면)은 LLM이 이런 작업에 유용해지기 전에 비좁은 오클랜드 아파트에서 한 사람이 1년 만에 작성했다.
그 베팅은 성과를 거뒀다. Bun의 CLI는 이제 월 2,200만 이상의 다운로드를 기록한다. Claude Code와 OpenCode가 이를 런타임으로 사용한다. Vercel, Railway, DigitalOcean이 자사 지원을 제공한다.
범위가 문제이기도 했다.
부채: GC 메모리 옆의 수동 메모리
JavaScript는 가비지 컬렉션된다. Bun은 JavaScriptCore(Safari의 엔진)와 C/C++ 라이브러리 더미(uWebSockets, BoringSSL, SQLite, lsquic)를 임베드한다. Zig는 C처럼 메모리를 관리해주지 않는다. Bun의 역할은 GC 언어와 수동 관리 네이티브 코드 사이에 위치하는 것이며, 그 경계에 최악의 버그 대부분이 살았다.
발표문은 v1.3.14만으로 수정한 항목 샘플을 나열한다: node:zlib의 heap-use-after-free, 재진입 콜백으로 인한 node:http2의 use-after-free, UDPSocket.sendMany의 범위 초과 쓰기, tls.connect당 메모리 누수, CSS 파서의 double-free. 목록은 계속되며 기술적이다.
Sumner는 이것이 Zig의 잘못이 아니라고 명시한다. “Zig가 아니었다면 여기까지 오지 못했을 것이며, 항상 감사할 것이다.” 문제는 구조적이다: GC 경계를 넘어 생명주기를 처리하는 것은 이를 설계하지 않은 언어에서는 어렵고, Zig의 답변(모든 호출 사이트의 명시적 defer)은 거의 도달하지 않는 오류 경로에서 틀리기 쉽다.
그는 C++을 고려했다. Bun의 약 20%는 이미 C++이며, 더 옮기면 생성자와 소멸자를 얻을 수 있다. 하지만 여전히 코드 리뷰를 통해 강제되는 스타일 가이드에 의존해야 하며, 메모리 손상은 여전히 발생할 것이다.
Rust의 제안은 다르다: 안전한 Rust에서 use-after-free, double-free, “해제를 잊음”은 컴파일러 오류다. 대여 검사기와 Drop이 스타일 가이드 문제를 타입 시스템 문제로 바꾼다.
전략: 트랜스파일, 재설계하지 말 것
재작성은 나쁜 평판을 가지고 있으며, 그럴 만한 이유가 있다. 535K 줄의 처음부터 재작성은 기능과 수정을 1년 동안 동결시킬 것이다. Bun이 선택한 형태는 기계적 포트였다: 동일한 아키텍처, 동일한 성능 목표, 동일한 기능, 동일한 테스트 스위트 — Rust로만.
두 가지 결정이 전체 노력을 주도했다:
- 한 번에, 점진적으로 하지 않음. Sumner의 esbuild를 Zig로 포팅한 경험은 점진적 재작성이 도움보다 해를 끼치는 임시 스캐폴딩을 남긴다고 말해줬다.
- 트랜스파일된 것처럼 보이게. 목표는 v1.4 출시 후에 관용적 Rust로 리팩토링하기 위해, 원본 Zig처럼 읽히는 Rust였다.
결정적으로, Bun의 테스트 스위트는 TypeScript로 작성되어 있어 런타임이 어떤 언어로 구현되는지 신경 쓰지 않는다. 이를 통해 “모든 테스트 통과”를 완료의 정의로 삼을 수 있었다.
준비는 작았지만 신중했다. 코드 작성 전 Sumner는 ~3시간 동안 Claude와 함께 Zig 패턴을 Rust에 매핑했고, 그 대화가 PORTING.md가 됐다(나중에 Hacker News에 올라갔다). 두 번째 패스는 코드베이스 전체의 모든 구조체 필드의 적절한 Rust 생명주기를 분석해 LIFETIMES.tsv로 직렬화했다. 두 문서 모두 단 한 줄의 코드도 포팅되기 전에 적대적 검토를 거쳤다.
그대로 둔 것: JavaScriptCore와 임베디드 C/C++ 라이브러리. 재작성은 네이티브 글루와 Bun 특화 코드였지, JS 엔진이 아니었다.
실행: 64개 Claude, 11일
포트는 Claude Code에서 약 50개의 “동적 워크플로우”로 11일 동안 지속적으로 실행됐다. 각 워크플로우는 루프였다: 태스크 받기, 코드 생성, 리뷰 받기, 피드백 적용.
리뷰 모델이 흥미로운 부분이다. 모든 구현자에 대해 두 명 이상의 적대적 검토자가 있었다 — 코드가 틀렸다고 가정하고 실패하는 모든 이유를 찾으라고 지시받은 별도의 Claude 세션. 구현자는 절대 리뷰하지 않고, 검토자는 절대 구현하지 않는다. 발표문은 이를 인간 리뷰를 반영한다고 설명한다: 작성자는 병합을 원하므로 다른 당사자가 확인한다.
몇 가지 메커니즘을 꼽아보자:
- 먼저 시험 실행. 1,448개 모두를 약속하기 전에 3개 파일을 먼저 포팅했다.
- 워크트리 샤딩. 초기 실행에서는 Claude들이
git stash와git reset으로 서로 충돌했다. 해결책: 4개 워크트리, 각각 16개 Claude, 작업 중 git이나 cargo를 절대 실행하지 않는 규칙. - 컴파일러 오류를 작업 큐로.
cargo check가 ~16,000개 오류를 파일로 덤프했고, crate별로 그룹화되어 64개 Claude가 하나씩 처리했다. - 순환 의존성. Zig는 사실상 하나의 컴파일 단위였지만, Rust는 ~100개 crate가 필요했다. 순환을 푸는 과정에서 16,000개 오류 대부분이 드러났다.
- 격리. TCP 소켓을 소진하거나 ~10,000개 프로세스를 생성하는 스트레스 테스트가
systemd-runcgroup 아래에서 실행됐다. 그래도 머신이 디스크를 가득 채우고 몇 번은 충돌했다.
숫자로 보면: 분당 최대 1,300줄 코드, 가장 바쁜 시간에 695 커밋(5월 6일), 단일 가장 바쁜 분에 58 커밋. 모든 커밋은 두 명의 적대적 검토자가 확인 후 병합됐다. 최종 diff는 +1,009,272줄. 테스트는 하나도 건너뛰거나 삭제되지 않았다.
Rust로 실제로 얻은 것
명시된 목표는 안정성이었고, 게시물은 이를 하드 숫자로 뒷받침한다.
버그. Bun v1.4.0은 v1.3.14에서 재현되는 128개 버그를 수정한다.
메모리. Drop이 호출 사이트별 defer를 대체했다. 게시물의 예: 동일한 60-모듈 프로젝트를 단일 프로세스에서 2,000번 번들링. v1.3.14에서는 모든 빌드가 ~3 MB를 영원히 누수하지만, v1.4.0에서는 메모리가 안정화된다.
| 빌드 | v1.3.14 | v1.4.0 |
|---|---|---|
| 500 | 1,914 MB | 526 MB |
| 1,000 | 3,506 MB | 586 MB |
| 1,500 | 5,097 MB | 608 MB |
| 2,000 | 6,745 MB | 609 MB |
Zig에서 이 문제를 수정하려는 이전 시도는 병합되지 않았다고 Sumner는 쓴다 — Drop에 해당하는 것이 없어 확신을 가지기 어려웠기 때문이다.
바이너리 크기. Rust 재작성만으로 3.8 MB(Windows), 5.5 MB(macOS), 6.8 MB(Linux)가 줄었다 — 대부분 과도한 Zig comptime을 제거해서다. 추가 링커 작업(동일 코드 폴딩, ICU 데이터 트리밍)으로 Linux와 Windows에서 총 약 20% 축소됐다.
속도. C/C++와 Rust 간의 크로스 언어 LTO로 컴파일러가 언어 경계를 넘어 인라인할 수 있다. Bun은 2-5%의 이득을 측정했다.
포트가 실수한 곳
535K 줄의 충실한 트랜스파일은 공짜가 아니다. 게시물은 19개의 알려진 회귀를 문서화하며, 모두 수정됐다. 교훈적인 것들은 언어 간의 작은 의미적 간극이다:
debug_assert!부작용. Zig의assert는 함수이므로 인수가 모든 빌드에서 실행된다. Rust의debug_assert!는 릴리스에서 지워지는 매크로다 — HMR 상태를 변경하던insert_stale호출이 조용히 실행을 멈췄다.- 홀수 길이 슬라이스. Zig의 헬퍼는 후행 홀수 바이트를 무시했지만,
bytemuck::cast_slice는 패닉을 발생시킨다. UTF-16 BOM과 홀수 바이트 수의Blob.text()가 충돌하기 시작했다. - 범위 검사. Zig의
ReleaseFast는 범위 검사를 제거하지만 Rust는 유지한다. 자리 표시자 상수가 파일명 인터닝 상한을 8.4M에서 270K로 낮췄고, 실제 프로젝트가 이를 hitting했다.
의미
LLM 각도를 빼면 실제 엔지니어링 스토리가 있다: 프로젝트가 대규모에서 수동 메모리 관리가 제공할 수 있는 한계에 도달했고, Rust의 대여 검사기를 버그 전체 클래스를 단순히 권장되지 않는 것이 아니라 불가능하게 만드는 도구로 선택했다.
LLM 각도는 사람들이 논쟁할 부분이다. Sumner은 이것이 전체 코드베이스 컨텍스트를 가진 엔지니어 3명이 약 1년 걸렸을 것이며, 절대 하지 않았을 것이라고 솔직히 말한다 — 현실적 대안은 “아무것도 하지 않고 계속 버그 수정”이었다. 대신 한 명의 엔지니어가 64개 Claude를 감독해 11일 만에 약 $165K에 해냈다.
Bun의 Rust 코드 약 4%가 unsafe 블록에 있으며, 병합 이후 11라운드의 보안 검토를 실행했고 모든 파서에 24/7 커버리지 기반 퍼징을 추가했다 — 지금까지 1,000억 번 실행, 약 15 PR.
유지보수성 질문이 지켜볼 점이다. Sumner는 Rust가 Zig처럼 읽힌다고 말하며, 충실한 포트는 포트별로 검토 가능하다는 것이 한 사람이 100만 줄 diff에 서명할 수 있게 한 점이라고 설명한다.
그의 마무리 문장이 흥미롭게 나이를 먹을 것이다: “한 명의 엔지니어가 1년 전보다 오늘날 훨씬 더 많은 것을 할 수 있다.”