Bun이 Zig에서 Rust로 재작성된 이유와 과정, 대규모 코드 포트 전략, 안정성 개선, 성능 및 메모리 사용 변화, 그리고 실제 프로덕션 적용 결과를 정리합니다.
공개: Bun은 2025년 12월 Anthropic에 인수되었습니다. 저와 Bun 팀의 다른 구성원들은 Anthropic에서 일하고 있습니다. 저는 Rust 재작성의 많은 부분에 Claude Fable 5의 사전 공개 버전을 사용했습니다.
Bun은 원래 esbuild의 JavaScript & TypeScript 트랜스파일러를 Go에서 Zig로 한 줄씩 옮긴 포트에서 시작했습니다. 저는 2021년 4월 16일에 Zig로 첫 줄의 코드를 썼습니다. Hacker News에서 단일 페이지짜리 Zig Language Reference를 보고, 저수준 제어와 성능에 대한 세심한 배려에 크게 흥분한 뒤 Zig에 베팅했습니다.
처음부터 Bun의 범위는 거대했습니다:
fs, net, tls 및 그 밖의 수십 개 모듈 같은 Node.js API 구현Bun의 초기 버전은 비좁은 오클랜드 아파트에서, LLM 이전 시대에, Zig로 저 혼자 1년 동안 작성했습니다. Bun처럼 범위가 야심찬 프로젝트의 기본적인 귀결은 GitHub 프로필 페이지의 죽은 사이드 프로젝트 묘지에 합류하는 것입니다. Zig가 있었기에 Bun은 가능했습니다. Zig가 아니었다면 저는 1년 안에 이 정도를 절대 만들 수 없었을 것입니다.
요즘 Bun의 CLI는 월간 2천2백만 회가 넘는 다운로드를 기록합니다. Claude Code와 OpenCode 같은 인기 도구들도 런타임으로 Bun에 베팅하고 있습니다. Vercel, Railway, DigitalOcean 등은 Bun에 대한 1st-party 지원을 제공합니다.
Bun의 넓은 범위는 안정성 측면에서는 도전 과제이기도 했습니다. 다음은 Bun v1.3.14에서 수정한 버그의 일부입니다:
.write()가 아직 진행 중일 때 zlib, Brotli, 또는 Zstd 스트림에 대해 .reset()을 호출하면 node:zlib에서 발생하는 heap-use-after-free 크래시onerror 콜백이 재진입 write() 뒤에 native handle에 대한 close()를 호출할 때 node:zlib에서 발생하는 use-after-free 크래시session.request())이 hashmap rehash를 유발해 내부 stream 포인터를 무효화할 때 node:http2에서 발생하는 use-after-free 크래시valueOf() 또는 toString() 콜백의 사용자 코드가 payload 캡처와 실제 전송 사이에서 ArrayBuffer를 분리할 수 있었던 UDPSocket.send() 및 sendMany()의 use-after-freevalueOf 콜백이 기반 ArrayBuffer를 분리하거나 크기를 바꿀 때 Buffer#copy와 Buffer#fill에서 발생하는 크래시 및 범위 밖 읽기UDPSocket.sendMany()에서 발생하는 heap 범위 밖 쓰기crypto.scrypt의 메모리 누수SSLWrapper.init가 에러 경로에서 strdup된 passphrase를 누수함d2i_SSL_SESSION 뒤에 SSL_SESSION_free가 빠져 각 호출마다 SSL_SESSION 하나씩(호출당 약 6.5 KB) 누수되던 tlsSocket.setSession()의 메모리 누수.close() 이후에도 fs.watch() watcher가 가비지 컬렉션되지 않던 메모리 누수background-clip에 벤더 프리픽스와 다중 레이어 배경이 있을 때 CSS 파서에서 발생하는 double-free 크래시DuplexUpgradeContext가 전혀 해제되지 않음 — tls.connect({ socket: duplex })당 완전한 누수BroadcastChannel 또는 MessagePort에서의 동시 접근 중 GC marker 스레드가 m_data의 찢어진 variant를 관찰할 수 있었던 MessageEvent의 경쟁 조건 크래시우리는 이런 종류의 버그를 영원히 하나씩 고쳐 나갈 수도 있었겠지만, 우리를 믿고 사용하는 사용자들에게 그보다 더 나은 일을 해야 할 책임이 있고, 이런 종류의 버그가 다시 반복되지 않도록 체계적으로 막아야 합니다.
이 정도면 많은 프로젝트보다 더 많이 하는 편입니다.
우리의 버그 수정 목록은 기분이 좋지 않았고, 저는 Bun의 크래시를 걱정하며 잠드는 데 지쳐 있었습니다. 그 때문에 Zig를 탓하지는 않습니다. 다른 Zig 사용자들이 우리가 겪은 버그를 다 겪는 것도 아니고, GC와 수동 메모리 관리를 섞는 것은 소프트웨어에서 그렇게 흔한 요구 사항이 아니라서 어떤 언어도 이를 중심으로 설계되어 있지 않습니다. Zig가 아니었다면 여기까지 오지도 못했을 것이고, 저는 늘 감사할 것입니다. 아주 최근까지도, Bun 같은 프로젝트에서 프로그래밍 언어 선택은 사실상 되돌릴 수 없는 결정이었습니다.
JavaScript는 가비지 컬렉션 언어이고 JavaScriptCore(그리고 V8) 같은 현대적인 JavaScript 엔진은 예외 처리와 가비지 컬렉터에 대해 엄격한 규칙을 갖고 있습니다. Zig는 C처럼 메모리를 대신 관리해 주지 않으며, 이는 많은 프로젝트에서 Zig를 쓰는 훌륭한 이유가 되는 트레이드오프입니다. Zig에는 생성자/소멸자가 없고, 대부분의 정리 작업은 각 호출 지점에서 defer로 명시적으로 작성해야 합니다.
Bun에서는 가비지 컬렉션되는 값과 수동 관리 값의 수명을 올바르게 다루는 것이 안정성 문제의 주요 원인이었습니다. 대개는 작은 메모리 누수였고, 가끔은 크래시였습니다. 모든 메모리 할당을 매우 꼼꼼하게 검토해야 합니다. 이 바이트들은 어디서 해제되나? 정확히 한 번만 해제되게 하려면 어떻게 해야 하나? JavaScript 예외는 제대로 확인했나? 이 가비지 컬렉션 포인터는 conservative stack scanner에 보이나? 이 메모리는 가비지 컬렉션 메모리인가, 수동 관리 메모리인가?
안정성 문제는 가능한 한 빨리 아는 것이 최선입니다. 퍼징은 코드가 병합된 뒤에 일어납니다. CI는 코드가 푸시되면 일어납니다. 런타임 안전성 검사와 address sanitizer는 코드가 실행될 때 일어납니다(바라건대 CI 이전 개발 단계에서).
이런 종류의 문제를 줄이는 흔한 방법 하나는, 정리가 필요한 코드에 대해 정리 코드가 항상 정확히 한 번 실행되도록 보장하는 것입니다. Zig는 숨겨진 제어 흐름이 없는 단순한 언어를 지향하기 때문에, C++의 암묵적 ~Destructor나 Rust의 암묵적 Drop보다 스코프 끝에서 코드를 실행하는 명시적 defer 키워드를 선호합니다.
| Language | Cleanup |
|---|---|
| Zig | defer, errdefer |
| C++ | ~Destructor, &&Move |
| Rust | Drop |
Zig 코드에서는 정리 코드를 정확히 언제 실행해야 할까요? 같은 *T를 여러 함수에 넘길 때, 언제 더 이상 접근 불가능해졌고 정리해도 되는지 어떻게 알까요? 어떤 함수들은 호출 이후에도 그 메모리를 계속 참조해야 한다면 어떻게 될까요? 현재 우리의 접근은 다음을 섞은 것입니다:
많은 프로젝트는 이런 질문에 스타일 가이드로 답하려고 합니다. Zig에서는 TigerBeetle의 TigerStyle이 한 예이고, Google의 31,000단어짜리 C++ style guide도 또 다른 예입니다. 스타일 가이드의 어려운 점은 강제입니다. 스타일 가이드가 지켜지도록 어떻게 보장할까요? 역사적으로 그 답은 코드 리뷰였고, linter와 정적 분석기로 최선을 다해 보조하는 식이었습니다.
타입 시스템 안에 명확한 소유권 기대를 엄격한 스타일 가이드로 명시하는 것은 Bun에게도 실제 선택지였습니다. Zig에는 연산자 오버로딩이 없기 때문에, 결국 많은 코드가 대략 다음처럼 되었을 가능성이 큽니다:
fn foo(a_ptr: SharedPtr(TCPSocket)) !void {
const a: *TCPSocket = a_ptr.get();
defer a_ptr.deref();
const b = try do_something_with_a(a);
defer b.deref();
// ...
}
이것은 우리가 기대하는 Zig보다 사용성이 떨어집니다:
fn foo(a: *TCPSocket) !void {
const b = try do_something_with_a(a);
// ...
}
Bun 코드의 약 20%는 C++로 작성되어 있고, Bun은 여러 C/C++ 라이브러리를 내장합니다:
HPACK 및 HTTP/3 라이브러리인 lshpack & lsquicZig 대신 C++를 쓰는 것도 Bun에게는 합리적인 선택이었을 것입니다. 생성자와 소멸자를 얻을 수 있었을 것이고, 많은 extern "C" 래퍼 코드도 지울 수 있었을 것입니다.
하지만 여전히 코드 리뷰로 강제되는 스타일 가이드에 의존해야 했을 것이고, ASAN이 있어도 메모리 손상과 메모리 누수는 여전히 발생했을 것입니다.
저 목록의 버그 중 상당수는 use-after-free, double-free, 그리고 에러 경로에서의 “해제를 잊음”입니다. 안전한 Rust에서는 이런 것이 컴파일러 에러이며, Drop을 통한 RAII 유사 자동 정리가 있습니다. 스타일 가이드보다 컴파일러 에러가 더 나은 피드백 루프입니다.
역사적으로 재작성은 끔찍한 아이디어입니다. 주석을 제외하면 Bun은 Zig 코드가 535,496줄입니다. 이를 다른 언어로 재작성하려면 작은 엔지니어 팀이 꼬박 1년은 필요합니다. 그동안 버그 수정, 보안 수정, 기능 개발을 얼려야 한다는 뜻입니다. 배포 가능한 무언가를 얻기 위한 가장 위험이 적은 접근은 Zig에서 Rust로 기계적으로 포트하되, 동작 변경은 최소화하고, 기존 Bun 테스트에 쓰던 바로 그 테스트 스위트를 그대로 사용하는 것이었습니다.
다행히 Bun 자체의 테스트 스위트는 TypeScript로 작성되어 있어 런타임 구현 언어에 의존하지 않습니다.
사용자에게 보이는 변화가 1년 동안 전혀 없는 선택지는 우리가 현실적으로 고려할 수 없었습니다. 그래서 안정성 문제를 고치기 위한 최선의 선택은 코드 스타일을 통한 강제였고, 실제로 Bun 코드베이스에 Rust 영감을 받은 smart pointers를 추가했을 때의 계획도 그것이었습니다.
하지만 솔직히 말하면, 저는 그걸 하고 싶지 않았습니다. 자체 제작 smart pointer는 Rust보다 사용성이 나쁘고, 보장도 없습니다.
대신 Anthropic의 새 모델이 Bun을 Rust로 재작성할 수 있는지 일주일 동안 시험해 보면 어떨까?
처음에는 될 거라고 생각하지 않았습니다. 며칠 지나자 테스트 스위트의 높은 비율이 통과하기 시작했고, 새 Rust 코드가 원래 Zig 코드베이스와 얼마나 잘 맞아떨어지는지 보였습니다. 제 생각은 “이건 시도해 볼 가치가 있다”에서 “이건 병합하겠다”로 바뀌었습니다.
이 일을 형편없게 해내는 방법은 아주 많습니다. 예를 들어 Claude에게 “Bun을 Rust로 다시 작성해. 실수는 하지 마.”라고 프롬프트하고 잘 되길 기도하는 방식은 제가 한 일이 아닙니다.
사람이라면 이 일을 어떻게 할지 생각해 봅시다. 첫 번째 큰 질문은 이것입니다:
점진적 재작성? 아니면 한 번에 전부?
초기 버전의 Bun을 위해 esbuild의 트랜스파일러를 Go에서 Zig로 포팅했던 제 경험으로는(LLM 없이), 한 번에 전부가 더 낫습니다. 점진적 재작성은 나중에 결국 지워지길 바라는 임시 코드를 추가하게 되고, 단기~중기적으로 고통스럽습니다.
두 번째 큰 질문은: 어떻게?
아키텍처, 성능, 기능 집합은 그대로 유지하면서, 동시에 borrow checker 같은 Rust의 언어 기능도 얻으려면 어떻게 해야 할까요? 재작성 이후에도 팀이 계속 유지보수할 수 있으려면 어떻게 해야 할까요?
우리 Zig 코드를 Rust로 트랜스파일한 것처럼 보이는 재작성을 하자. Bun v1.4가 출시된 뒤에 unsafe 사용을 줄이고 더 idiomatic Rust처럼 보이도록 점진적으로 리팩터링하면 된다.
큰 질문은 이 두 가지뿐입니다. 나머지는 전부 전술입니다.
소프트웨어 엔지니어로서의 많은 일상적인 엔지니어링 작업은 과도하게 단순화하면 루프로 볼 수 있습니다.
// Pseudocode, not real code:
let task;
while ((task = todoList.pop())) {
const result = task();
const feedback = await Promise.all([review(result), review(result)]);
await apply(feedback, result);
}
task에는 관련된 맥락(Jira 티켓, GitHub 이슈 등)이 있습니다. result는 그것을 해결하기 위해 작성한 코드입니다. 코드 리뷰어는 변경사항을 review해서 회귀와 정확성을 확인합니다. 그리고 피드백을 반영합니다.
저는 Claude Code에서 약 50개의 동적 워크플로를 11일 동안 연속 실행해 Bun을 Rust로 재작성했습니다.
각 동적 워크플로는 이런 루프였습니다. 예를 들어 다음과 같은 워크플로가 있었습니다:
PORTING.md와 LIFETIMES.tsv에 맞춰 모든 .zig 파일을 .rs 파일로 기계적으로 포트bun test나 bun build 같은 서브커맨드 동작시키기그 11일의 대부분 동안(그리고 그 이후에도), 저는 워크플로를 모니터링했습니다. 출력을 수동으로 읽어 문제와 버그를 확인했고, Claude에게 루프를 수정하도록 프롬프트해 문제를 고쳤습니다.
+100만 줄이 추가된 PR을 어떻게 리뷰할까요? 대량의 LLM 작성 코드를 책임 있게 병합하는 데 필요한 확신은 어떻게 쌓을까요?
언어 독립적인 백만 개의 assertion이 있는 테스트 스위트, 적대적 코드 리뷰, 그리고 뭔가 잘못됐을 때 코드를 손으로 고치는 대신 코드를 생성하는 프로세스를 고치는 것입니다.
적대적 리뷰는 Claude에게(별도의 컨텍스트 창에서) 변경사항이 왜 버그를 만들거나 동작하지 않는지에 대한 이유를 철저하게 생각해 내라고 요청하는 것입니다.
보통 사람끼리는 코드를 리뷰하는 사람이 코드를 쓴 사람이 아닙니다. 코드를 쓴 사람은 그 코드가 병합되길 원하기 때문에, 준비되기 전에 배포하려는 편향이 생길 수 있습니다.
Claude도 마찬가지입니다. 코드를 쓴 Claude는 코드가 승인되길 원합니다. 리뷰하는 Claude는 코드의 문제를 찾고 싶어 합니다.
구현자 1명당 2명 이상의 적대적 리뷰어. 리뷰어의 유일한 일: 버그와 코드가 동작하지 않는 이유 찾기. 구현자는 리뷰하지 않습니다. 리뷰어는 구현하지 않습니다.
✻ claude code · dynamic workflow adversarial review 병합 전에 적대적 리뷰가 잡아낸 여러 버그 중 3개
bug 1 of 3 · the async close
✻claude implementer
its context: the .zig original, the port plan, its own reasoning
✻claude adversarial reviewer
its context: only the diff. told to assume the code is wrong.
✻
src/runtime/api/bun/js_bun_spawn_bindings.rs · compiles clean
for stdio in [spawned_stdout, spawned_stderr] {
match stdio {
StdioResult::Buffer(mut pipe) => {
// pipe: Boxuv::Pipe — hand it to libuv to close
pipe.close(Subprocess::on_pipe_close)
}
StdioResult::Fd(fd) => fd.close(),
StdioResult::Unavailable=> {}
}
}
✻
uv_close is asynchronous: libuv keeps the raw handle pointer until the next loop tick, then calls on_pipe_close, which frees the allocation. But pipe is a Box that drops at the end of this match arm — libuv is left holding freed memory, and the close callback then frees it a second time. Use-after-free, then double-free.
✻
Box::leak(pipe).close(Subprocess::on_pipe_close)
f0a454376c7 · win-review: js_bun_spawn_bindings.rs leak Boxuv::Pipe before async uv_close to avoid UAF/double-free in on_pipe_close
적대적 리뷰어들이 실제로 잡아낸 버그 세 개였습니다. 언급된 각 커밋은 제목에 리뷰 출처가 남아 있습니다. 셋 다 컴파일됐고, 셋 다 그럴듯해 보였습니다. 리뷰어는 자신만의 컨텍스트 창을 가진 두 번째 Claude입니다. 이 Claude는 diff만 받고 그 외에는 아무것도 받지 않으며, 구현자의 추론도 전혀 보지 못합니다. 그리고 코드가 어떻게 잘못되었는지 찾으라는 지시를 받습니다. 코드는 인용된 커밋에서 축약한 것이며, 같은 버그와 같은 수정입니다.
크고 비싼 일을 하려 한다면, 먼저 위험을 줄이는 편이 시간과 돈을 아낍니다.
코드를 쓰기 전에, 저는 Claude와 약 3시간 동안 우리 Zig 코드베이스의 패턴을 Rust에 어떻게 가깝게 매핑할지 이야기했습니다. Claude는 이 대화를 PORTING.md 문서로 직렬화했고, 이 문서는 Hacker News에까지 올라갔습니다.
다음 질문은 이것이었습니다. 수동으로 메모리를 관리하는 코드에 Rust lifetime을 어떻게 추가할 것인가?
그때 제가 Claude에게 대략 이렇게 프롬프트했습니다:
Me: Let's kick off a dynamic workflow to analyze the proper lifetimes of every struct field in the codebase. This workflow should read every struct field within every single file and trace the control flow. First, look for struct fields with complex lifetimes to express in Rust, then propose a lifetime for that field, then use 2 adversarial review agents to review that lifetime, then apply any feedback and serialize into a LIFETIMES.tsv for other claudes to look at.
그 다음에는 PORTING.md와 LIFETIMES.tsv를 함께 적대적으로 리뷰해 상충하는 제안을 수정하고 전체를 다시 확인했습니다. 저도 수동으로 직접 읽어봤습니다.
Claude에게 1,448개의 .zig 파일 전체를 .rs 파일로 번역해 달라고 하기 전에, 먼저 3개 파일만으로 시작했습니다. 각 파일마다 구현자 1명이 새 .rs 파일을 작성하고, 적대적 리뷰어 2명이 .rs 파일이 .zig 파일의 동작과 일치하는지, 그리고 PORTING.md와 LIFETIMES.tsv를 따르는지 확인했습니다. 그 후 수정자 1명이 제안을 반영했습니다.
저는 Claude에게 워크플로를 1,448개의 .zig 파일 전체에 대해 돌리라고 했고, 약 2분 뒤 한 Claude가 커밋 전에 git stash를 실행했습니다. 다른 Claude는 git stash pop을 실행했습니다. 그리고 git reset HEAD --hard까지. 서로 작업을 덮어쓰고 있었습니다! 그렇다고 각 Claude를 별도의 worktree에 넣으면 Bun의 git 저장소가 너무 커서 디스크 공간이 부족해집니다. 그리고 결국 변경사항은 함께 컴파일되고 함께 봐야 합니다.
그래서 저는 Claude에게 워크플로를 수정해 git stash, git reset, 또는 특정 파일 하나를 한 번에 커밋하지 않는 어떤 git 명령도 절대 실행하지 않도록 지시했습니다. cargo도 금지했습니다. 느린 명령은 전부 금지했습니다.
그 후 Claude는 워크플로를 재개했습니다. 그리고 실제로 작동하고 있었습니다! 다만 너무 느려서, 저는 이를 4개의 워크플로 샤드로 나누고 각 샤드에 자체 worktree를 두었습니다(총 4개 worktree). 각 샤드는 16개의 Claude가 파일을 커밋하고 푸시하도록 실행되었습니다.
이 병렬화와 준비 작업 덕분에, 최고 속도일 때 Claude는 분당 약 1,300줄의 코드를 작성했습니다. 모든 코드 줄은 두 개의 별도 적대적 리뷰어(역시 Claude)가 검토했고, 커밋 전에 한 차례 수정도 거쳤습니다. 물론 아직은 하나도 제대로 동작하지 않았습니다.
11 days × 24 hours · PDT
6,502 commits
1 695 commits/hour
포트 브랜치의 모든 커밋(병합 제외)을 시간 단위로 묶은 것입니다. 최고 시간대는 시간당 695커밋이었습니다.
시간이 들쭉날쭉한 것이 보이시나요? 제가 이 작업을 돌린 EC2 인스턴스의 기본 IOPS를 늘리는 걸 깜빡했기 때문입니다. 느린 grep 명령 하나만으로도 몇 분간 디스크 읽기/쓰기가 얼어붙기에 충분했습니다.
코드를 다 작성한 뒤, 저는 Claude에게 모든 컴파일러 에러를 고치는 워크플로를 작성하라고 했습니다. 우리는 crate별로 진행했습니다.
✻ claude code · dynamic workflow
≈16,000 errors left
Wed, May 6, 12:40 AM PDT
errors.txt 0 fix commits
error: deref *mut EventLoop before field access
error: js_parser/ast/E.rs: port json_stringify for Number/BigInt/RegExp
error: NodeHTTPResponse.rs: wire JSNodeHTTPResponse cached accessors vi
error[E0034]: multiple applicable items in scope
error: test_command.rs: wire coverage façade to bun_sourcemap_jsc::code
error: bundler/ungate_support.rs: un-gate bun_css shim to real ::bun_cs
error: dns.rs: implement pending_cache_for/get_key/get_or_put_into_reso
error: css/css_parser.rs: port DefineShorthand contract, parse_bundler,
error: runtime/crypto/mod.rs: create_crypto_error delegates to boringss
error: bun_core/fmt.rs: implement format_ip reborrow (offset-based slic
error: event_loop/EventLoopTimer.rs: port Timespec::ns from bun.zig
divvied up · 64 claudes
worktree 1
→→
→→
→→
→→
worktree 2
→→
→→
→→
→→
worktree 3
→→
→→
→→
→→
worktree 4
→→
→→
→→
→→
1 fixes 2 review 1 applies
→ commits land per crate
이것이 phase D의 동작 방식이었습니다. 실제 1,610개의 커밋(5월 6일, PDT)에서 재생한 것입니다. cargo check가 약 16,000개의 에러를 파일에 기록하고, 이를 crate별로 그룹화했습니다. 워크플로는 이를 64개의 Claude에게 나눠 주었습니다. 4개 worktree에 걸쳐 16개 루프가 있었고, 각 루프는 Claude 한 명이 수정하고, 두 명이 리뷰하고, 한 명이 적용했습니다. 각 칩은 실제 커밋 묶음이며, 실제 crate에 반영되고 나서야 카운터가 움직입니다. 에러 줄은 실제 커밋 제목입니다.
가장 까다로운 에러 종류는 순환 의존성이었습니다.
원래 우리의 Zig 코드베이스는 하나의 compilation unit(사실상 하나의 crate)이었습니다. 저는 새 Rust 코드베이스를 Rust 컴파일 속도를 높이기 위해 약 100개의 crate로 나누고 싶었지만, 원래 Zig 구현과 비교해 변경을 최소화하면서 순환 의존성은 피해야 했습니다. Rust 재작성을 시작하기 직전에 이를 위해 올린 제 PR은 충분하지 않았습니다. 처음부터 다시 시작하는 대신, 저는 순환 의존성이 있는 코드가 어디로 가야 하는지 분류하고 모두 기록하는 또 다른 워크플로를 돌렸고, 그 다음에는 실제로 그 리팩터링을 수행하는 또 다른 워크플로를 돌렸습니다.
순환 의존성을 고치고 나자 약 16,000개의 컴파일러 에러가 드러났습니다. 인간 1명에게는 엄청난 숫자지만, Claude 64개가 동시에 처리하기에는 그렇게 미친 숫자는 아닙니다.
병렬성을 최대화하기 위해 워크플로는 각 crate를 순회했습니다.
cargo check를 실행하고, 출력을 파일별로 그룹화해서 에러를 파일에 저장Claude들이 서로 덮어쓰지 않도록 cargo check는 맨 처음에만 실행했고, 다른 실행들과 마찬가지로 마지막까지 git은 금지했습니다.
Claude는 “모든 crate가 컴파일되게 하자”를 “컴파일 에러가 나는 함수를 stub 처리하자”로 해석했습니다. 그리고 우회책을 설명한다며 수상할 정도로 긴 주석을 추가하기 시작했습니다. 그래서 저는 적대적 리뷰어가 다음 규칙을 근거로 거부하게 했습니다:
왜 그 우회책이 괜찮은지 정당화하려고 한 문단짜리 주석이 필요하다면, 그 코드는 틀린 것이다 — 코드를 고쳐라.
프롬프트를 한 번 수정하고 몇 시간 지나자 이런 일은 더 이상 일어나지 않았습니다.
모델들은 “smoke tests”라는 말을 정말 좋아합니다.
cargo check가 통과한 뒤에는, 실제로 컴파일하고 bun --version을 실행하는 것이 다음 목표였습니다. 링커 에러가 있었습니다. 그 다음에는 시작하자마자 panic이 났습니다.
그 다음 목표는 bun test <file>가 돌아가게 만드는 것이었습니다. 이게 되면 비로소 테스트를 돌릴 수 있으니까요! 다시 다른 워크플로가 필요했습니다. 이번에는 bun CLI 서브커맨드를 순회하는 방식이었습니다:
이 워크플로는 테스트 파일을 순회했습니다.
코드베이스의 폴더 기준으로 4개 worktree 중 하나에 샤딩해 무작위 테스트 파일 약 100개를 실행합니다. 실패한 각 테스트에 대해 stacktrace와 에러를 파일에 저장하고, 구현자 1명이 수정안을 제안하고, 적대적 리뷰어 2명이 검토한 뒤, 수정자 1명이 적용합니다.
우리 테스트 스위트에는 메모리 누수 테스트가 많고, 1분 이상 걸릴 수 있는 통합 테스트도 몇 개 있습니다. 예를 들어 next dev를 실행하고 hot module reloading이 100번의 변경을 잡아내는지 확인하는 테스트가 있습니다. 이런 테스트들 중 일부는 디버그 빌드에서 타임아웃이 납니다.
또한 머신의 최대 TCP 소켓 수를 고갈시키는 스트레스 테스트, 디스크에 기가바이트 단위로 읽고 쓰는 테스트, 약 1만 개의 프로세스를 생성하는 테스트도 있습니다.
이건 “부탁해” 수준보다 강한 격리가 필요했고, 그래서 systemd-run(cgroups)을 사용해 메모리와 CPU 사용량을 제한하고 pid namespace를 격리했습니다. 그래도 머신은 디스크 공간이 부족해 여러 번 크래시했습니다.
첫 CI 실행 이틀 뒤, 실패 목록은 테스트 파일 972개에서 23개로 줄었습니다. 그로부터 하루 반쯤 더 지나 Linux가 완전히 초록불이 되었고, 그때 처음으로 이 Rust 재작성이 실제로 성공할 것 같은 느낌이 들었습니다.
✻ claude code · dynamic workflow buildkite · 플랫폼별 초록불 레이스 Windows가 마지막으로 끝남 · May 11, 6:23 AM PDT
6 / 6 platforms green
build #54202 · Thu, May 14, 12:23 AM PDT
macOS x64 · 2 shards
✓
Linux arm64 · 60 shards
✓
Linux x64 · 60 shards
✓
macOS arm64 · 4 shards
✓
Windows x64 · 8 shards
✓
Windows arm64 · 8 shards
✓
테스트를 실행한 135개의 모든 CI 빌드(그중 420개는 BuildKite에서 추출)의 플랫폼별 테스트 샤드입니다. 밝은 초록은 모든 샤드가 통과한 경우입니다. 어두운 초록은 실패는 없었지만 실행이 중간에 잘린 경우입니다(다른 빌드로 대체됨). 빨강은 적어도 하나의 샤드가 실패한 경우입니다. 각 레인은 해당 전체 스위트가 처음 통과한 시점이 찍혀 있습니다. Linux의 60개 샤드는 Windows보다 거의 하루 먼저 초록이 되었습니다. 마지막 실패 테스트들이 사라질 때까지 플랫폼은 계속 다시 빨개졌다가 초록이 되기를 반복했고, 최종적으로 전부 초록인 빌드는 #54202였습니다.
병합 전까지 남은 시간 동안의 일은 비교적 단순했습니다. 더 이상 테스트 실패가 없을 때까지 플랫폼별로 CI 테스트 실패를 고치는 워크플로를 반복했습니다. Windows 관련 정리, 코드 중복 제거, unsafe 사용 감소, 전반적인 코드 정리를 위한 여러 워크플로도 있었습니다.
모든 플랫폼의 CI에서 Bun 테스트 스위트가 100% 통과하자(그리고 제가 수동으로 테스트가 실제로 실행되고 있으며 건너뛰어지지 않았음을 확인한 뒤), 로컬에서 이것저것 시험하는 명령을 잔뜩 실행했고, 그 다음 병합 버튼을 눌렀습니다.
main에 병합한다고 해서 그것이 곧바로 버전 릴리스는 아닙니다. 이 시점에서 저는 앞으로 나아가 재작성에 커밋할 만큼은 충분히 확신했지만, 아직 릴리스할 만큼 충분히 확신한 것은 아니었습니다.
최고 시점에는 이런 워크플로를 각각 별도의 worktree에서 한 번에 4개씩 돌렸고, 워크플로 하나당 Claude가 16개였습니다. 한 시점에 약 64개의 Claude였습니다.
git log · claude/phase-a-port peak: 58 commits in one minute
0
commits
+0
lines written, rewrites included
Mon, May 4, 7:05 AM PDT
6,502개의 모든 커밋(병합 제외)을 재생한 것입니다. 분홍 막대는 대부분 새 코드이고, 청록 막대는 대부분 삭제입니다. 줄 수 카운터는 도중의 모든 재작성을 계산하며, 최종 반영된 diff는 +1,009,272였습니다. 로그는 실제 커밋 메시지입니다.
11일 (5월 3일 → 5월 14일 병합) · 6,778커밋
| Platform | expect() calls | Tests | Files |
|---|---|---|---|
| Debian 13 x64 | 1,386,826 | 60,624 | 4,174 |
| macOS 14 arm64 | 1,259,953 | 58,850 | 4,175 |
| Windows 2019 x64 | 1,007,544 | 57,337 | 4,173 |
병합 전 기준으로, 이는 캐시되지 않은 입력 토큰 59억 개, 출력 토큰 6억9천만 개, 캐시된 입력 토큰 읽기 720억 개가 들었고 — API 가격으로 약 165,000달러 수준이었습니다. 손으로 했다면, 코드베이스 전체 맥락을 아는 엔지니어 3명이 대략 1년은 걸렸을 거라고 생각합니다. 그 기간 동안 우리는 Node.js 호환성을 개선하거나, 버그를 고치거나, 보안 문제를 해결하거나, 새 기능을 구현할 수 없었을 것입니다. 우리는 그런 일은 절대 하지 않았을 것입니다. 현실적인 대안은 아무것도 하지 않고, 이 글 맨 위의 버그들을 영원히 계속 고치는 것이었습니다.
이것이 오늘날 가능한 것의 최전선입니다. 저는 Mythos급 모델인 Claude Fable 5의 사전 공개 버전을 사용했습니다. Claude Code의 동적 워크플로가 11일 동안 64개의 Claude를 계속 실행하게 해주었습니다(아니었다면 이를 해내기 위해 제가 직접 하네스를 작성해야 했을 것입니다).
Rust 포트를 병합한 이후, 우리는 Claude Code Security로 11차례의 보안 리뷰를 완료했고, 발견 사항을 해결했습니다.
또한 이제 Bun의 모든 파서에 대해 24시간 365일 coverage-guided fuzzing을 추가했습니다 — JavaScript, TypeScript, JSX, CSS, JSON5, JSONC, TOML, YAML, Markdown, INI, Bun Shell 스크립트, semver 범위, .patch 파일, CSS 색상까지 포함합니다. 퍼저는 발견한 버그를 자동으로 Claude에게 보내 재현 및 수정 PR을 제출하게 하고, 사람은 그 PR을 리뷰합니다. 지금까지 파서를 1천억 번 실행했고, 그 결과 약 15개의 PR로 이어졌습니다.
글을 쓰는 시점 기준으로, Bun의 Rust 코드 중 약 4%가 unsafe 블록 안에 있습니다(~780,000줄 중 약 27,000줄에 걸쳐 unsafe 키워드 약 13,000개). 그리고 그 블록의 78%는 한 줄짜리입니다 — C++에서 온 포인터 하나, 혹은 C 라이브러리 호출 하나 같은 식입니다. 시간이 지나면서 faithful한 Zig 포트(검색 가능한 unsafe 키워드 자체가 없었음)에서 idiomatic Rust로 리팩터링해 가며 이 숫자는 줄어들 것이라 예상하지만, 우리는 JavaScriptCore 같은 C & C++ 라이브러리를 계속 사용할 것이기 때문에 순수 Rust 프로젝트보다 unsafe가 더 많을 수밖에 없습니다.
Rust 재작성의 초점은 안정성이지만, 이 정도로 큰 변화를 배포하면서 회귀를 0개 도입하는 것은 불가능합니다.
이 재작성은 알려진 회귀 19개를 도입했고, 모두 수정되었습니다.
회귀의 대부분은 두 언어에서 문법상 동일해 보여도 의미론적으로는 다른 코드에서 나왔습니다.
debug_assert! 안의 부작용이 두 스니펫은 비슷해 보이지만 다르게 동작합니다. Zig의 assert는 함수이므로 인자는 모든 빌드에서 실행됩니다. Rust의 debug_assert!는 매크로이므로 릴리스 빌드에서는 전체 표현식이 지워지고, insert_stale 호출도 함께 사라집니다.
// Zig:
if (dev.framework.react_fast_refresh) |rfr| {
assert(try dev.client_graph.insertStale(rfr.import_source, false) == IncrementalGraph(.client).react_refresh_index);
}
// Rust:
if let Some(rfr) = &dev.framework.react_fast_refresh {
debug_assert!(dev.client_graph.insert_stale(&rfr.import_source, false)? == react_refresh_index);
}
insert_stale는 파일을 프런트엔드 dev server의 hot reload 그래프에 추가합니다. 릴리스 빌드에서는 이것이 더 이상 실행되지 않았고, React를 사용하는 HTML 라우트가 있는 프로젝트에서 hot reloaded 파일이 무효화되면 특정 경우 HMR이 깨졌습니다: Cannot destructure property 'isLikelyComponentType' of 'k'. 디버그 빌드에서는 잘 동작했습니다. #30678
Bun의 Zig 헬퍼 reinterpretSlice(u16, bytes)는(builtin cast가 슬라이스를 지원하기 이전부터 있던 것) @divTrunc를 사용하고 마지막 홀수 바이트를 무시했습니다. 반면 bytemuck::cast_slice는 여기서 panic을 일으킵니다. UTF-16 byte order mark 뒤에 홀수 개의 바이트가 오는 경우 Blob.text()는 더 이상 문자열을 반환하지 않고 프로세스를 panic시켰습니다. 우리는 다시 홀수 바이트를 무시하는 방식으로 돌아갔습니다: &buf[..buf.len() & !1]. #31188
macOS와 Linux에서는 Bun의 Zig 코드를 bounds check를 제거하는 ReleaseFast로 컴파일했습니다. Rust의 릴리스 빌드는 이를 유지합니다.
Bun의 모듈 해석기는 긴 파일명을 전역 리스트에 intern하고, 이 리스트는 overflow 블록으로 넘칩니다. 원래 Zig 코드는 각 블록 크기를 count / 4, 즉 2048로 잡았습니다. 포트는 여기에 임시값을 남겨 두었습니다:
/// ... so use a nonzero stand-in until Phase B threads the
/// per-instantiation value through.
pub const BSS_OVERFLOW_BLOCK_SIZE: usize = 64;
이로 인해 intern 가능한 파일명의 상한은 840만 개에서 270,272개로 낮아졌고, 실제 프로젝트들이 이 한계에 부딪혔습니다. 또 Zig에서 그대로 포트된 ptrs[4095] 오프바이원도 도달 가능해졌습니다. Rust는 끝을 넘어 쓰기 대신 panic을 일으켰습니다. Zig도 이 경우 ReleaseSafe였다면 panic했을 것입니다(Windows에서만 그렇게 했습니다). #31503
comptime 포맷 문자열Output.pretty는 <r>와 <d> 색상 마커를 ANSI escape로 바꿉니다. Zig에서는 fmt가 comptime이므로 인자가 치환되기 전에 마커가 사라집니다. Rust 함수는 comptime 매개변수가 없어서, Output::pretty는 완성된 문자열만 보게 되었고 인자 안의 마커까지 다시 써버렸습니다.
// Zig:
pub inline fn pretty(comptime fmt: string, args: anytype) void;
Output.pretty("<r>{f}<r>", .{hyperlink});
// Rust:
pub fn pretty(payload: impl PrettyFmtInput);
Output::pretty(format_args!("<r>{}<r>", hyperlink));
bun update -i는 패키지 이름을 OSC 8 하이퍼링크로 출력하며, 끝은 ESC \로 종료됩니다. 그 백슬래시가 뒤따르는 <r>의 < 바로 앞에 있어서, 마커 파서가 이를 먹어 버리고 r이 텍스트로 출력됩니다.
oxfmt가 아니라 oxfmtr라고 보여서는 안 됩니다
Rust에서는 이것이 매크로여야 합니다: bun_core::pretty!("<r>{}<r>", hyperlink). #30693
지금까지 Bun v1.4.0은 v1.3.14에서 재현되는 버그 128개를 수정했습니다. 여기에는 메모리 누수부터 크래시, 잘못된 도움말 텍스트 색상까지 포함됩니다.
Rust에는 메모리를 정리하는 강력한 언어 차원의 도구인 Drop이 있습니다. Drop이 구현되면 값이 스코프를 벗어날 때마다 drop 함수가 자동으로 호출됩니다.
impl Drop for Bytes {
fn drop(&mut self) {
if !self.pinned.is_empty() {
JSC__JSValue__unpinArrayBuffer(self.pinned);
}
}
}
Zig에서는 defer를 사용해 스코프 끝에서 코드를 실행할 수 있습니다:
const bytes: ArrayBuffer = try .fromPinned(global, value);
defer bytes.unpin();
Zig에서는 정리가 필요할 수 있는 각 호출 지점마다 defer를 개별적으로 추가해야 합니다. 정리를 잊어버리기(메모리 누수) 쉽고, 드물게 도달하는 에러 처리 코드에서 정리 코드를 두 번 실행하기도 쉽습니다(double-free). Rust에서는 값에 더 이상 접근할 수 없게 되면 Drop이 자동 실행됩니다 — “숨겨진 제어 흐름 없음”을 포기하는 대신 흔한 발총을 방지하는 것입니다.
Drop은 에러 처리 코드의 파일 경로와 관련된 Bun의 여러 메모리 누수를 해결했습니다.
우리는 Bun의 LeakSanitizer 통합을 개선해 모든 native code memory allocations을 추적하도록 했습니다.
예를 들어, 프로세스 내 Bun.build() 호출은 매번 몇 메가바이트의 메모리를 누수했습니다 — 자신이 속한 빌드보다 더 오래 살아남는 파싱된 소스 텍스트와 AST 심볼 테이블이었습니다.
// Bundle the same 60-module project 2,000 times in one process
for (let i = 0; i < 2_000; i++) {
await Bun.build({
entrypoints: ["./index.js"],
minify: true,
sourcemap: "external",
});
}
Bun v1.3.14에서는 매 빌드마다 약 3 MB가 영원히 누수됩니다 — 요청마다 번들링하는 dev server 같은 도구는 결국 메모리가 바닥납니다. Bun v1.4.0에서는 메모리가 어느 시점에서 안정됩니다:
| Builds | Bun v1.3.14 | Bun 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에서 이를 하려던 이전 시도는 Drop에 해당하는 개념이 없어 병합에 자신감을 갖기 더 어려웠기 때문에 병합되지 않았습니다.
Rust 재작성의 초기 변경만으로도 Windows에서 3.8 MB, macOS에서 5.5 MB, Linux에서 6.8 MB 바이너리 크기가 줄었습니다. 이는 주로 우리가 Zig 코드에서 comptime를 너무 많이 사용했기 때문입니다.
이 초기 축소 이후, 팀은 Identical Code Folding 같은 링커 최적화, ICU에서 사용하지 않는 데이터 제거, 그리고 libicu의 작은 일부를 필요 시 zstd dictionary로 지연 압축 해제하는 방식 등을 통해 바이너리 크기를 더 줄일 기회를 탐색했습니다.
Rust 재작성, ICU 변경, identical code folding을 합치면 Linux와 Windows에서 Bun의 바이너리 크기가 약 20% 줄어듭니다.
| Version | Platform | Size |
|---|---|---|
| Bun v1.4.0 (canary) | Windows | 76 MB |
| Bun v1.3.14 | Windows | 94 MB |
| Bun v1.4.0 (canary) | Linux | 70 MB |
| Bun v1.3.14 | Linux | 88 MB |
TOML 파서와 Bun의 다른 모든 recursive-descent parser(JSON, YAML, JavaScript, TypeScript 등)는 이제 스택 공간을 덜 사용합니다.
이것은 Rust 재작성을 병합하기 전에 일부 테스트 실패를 일으켰습니다:
bun test v1.3.14-canary.1 (e99311e58)
.......
105 | });
106 |
107 | it("Bun.TOML.parse throws on deeply nested inline tables instead of crashing", () => {
108 | const depth = 25_000;
109 | const deepToml = "a = " + "{ b = ".repeat(depth) + "1" + " }".repeat(depth);
110 | expect(() => Bun.TOML.parse(deepToml)).toThrow(RangeError);
^
error: expect(received).toThrow(expected)
Expected constructor: RangeError
Received function did not throw
Received value: {
a: {
b: {
b: {
b: {
b: {
b: {
b: {
b: {
b: [Object ...],
},
},
},
},
},
},
},
},
}
at <anonymous> (/var/lib/buildkite-agent/build/test/js/bun/resolve/toml/toml.test.js:110:42)
✗ Bun.TOML.parse throws on deeply nested inline tables instead of crashing [2907.64ms]
Rust의 LLVM IR 코드 생성은 스택 변수가 더 이상 사용되지 않을 때 LLVM의 llvm.lifetime.start와 llvm.lifetime.end intrinsic을 내보내며, 이 덕분에 LLVM이 스택 슬롯을 재사용할 수 있습니다. 그 결과 중첩 스코프가 있는 큰 함수들이 스택 공간을 훨씬 덜 사용할 수 있습니다.
이전에는 열려 있는 이슈를 수동으로 우회하기 위해 유난히 큰 함수들을 여러 작은 함수로 리팩터링했었습니다.
Rust는 C/C++와 Rust 사이의 cross-language link-time optimization을 지원하며, 덕분에 언어를 가로질러 인라이닝이 가능합니다(정말 멋지지 않나요!!).
우리는 Linux x64(EC2, Xeon Platinum 8488C)에서 Bun v1.3.14와 Bun v1.4.0을 벤치마크했습니다. HTTP 처리량은 hello-world 서버를 대상으로 oha로 측정했고, 앱 워크로드는 hyperfine로 측정했습니다.
HTTP throughput (req/s, avg of 3 rounds)
| server | Bun v1.3.14 | Bun v1.4.0 | Δ |
|---|---|---|---|
| Bun.serve | 169.6k | 177.7k | +4.8% |
| node:http | 103.8k | 108.5k | +4.5% |
| Elysia | 158.9k | 163.3k | +2.8% |
| express | 64.5k | 66.6k | +3.2% |
| fastify | 91.5k | 95.9k | +4.8% |
Apps / CLI (hyperfine)
| workload | Bun v1.3.14 | Bun v1.4.0 | Δ |
|---|---|---|---|
| next build | 13.62 s | 13.03 s | +4.5% |
| vite build (tsc + vite) | 1.69 s | 1.65 s | +2.2% |
| tsc -b --force | 0.94 s | 0.89 s | +4.7% |
Prisma는 Bun의 Rust 재작성을 기반으로 Prisma Compute 퍼블릭 베타를 출시했습니다.
“우리는 메모리 누수와, VM이 일시 중지되었다가 재개된 뒤 복구하지 못하는 연결 풀 문제를 겪었습니다. Rust 재작성이 나오자 같은 실패 모드로 테스트해 봤고, 완벽하게 처리했습니다.” - Alexey Orlenko
Claude Code v2.1.181(6월 17일 출시) 이후 버전은 Bun의 Rust 포트를 사용합니다. Linux에서 시작 속도가 10% 빨라졌지만, 그 외에는 거의 아무도 눈치채지 못했습니다. 눈에 띄지 않는 건 좋은 일입니다.
Bun v1.3.14는 Zig로 작성된 마지막 버전의 Bun이었습니다. Bun v1.4.0은 Rust로 작성된 첫 번째 버전의 Bun이 될 것입니다. 지금 canary에서 사용 가능하니, 발견하는 문제를 제보해 주세요:
bun upgrade --canary
저와 팀에게 새 Rust 코드베이스는 예전 Zig 코드베이스와 매우 비슷하게 느껴집니다. 예를 들어, 다음은 원래 Zig 코드와 새 Rust 코드의 일부입니다:
pub fn canMergeSymbols(
scope: *Scope,
existing: Symbol.Kind,
new: Symbol.Kind,
comptime is_typescript_enabled: bool,
) SymbolMergeResult {
if (existing == .unbound) {
return .replace_with_new;
}
if (comptime is_typescript_enabled) {
// In TypeScript, imports are allowed to silently collide with symbols within
// the module. Presumably this is because the imports may be type-only:
//
// import {Foo} from 'bar'
// class Foo {}
//
if (existing == .import) {
return .replace_with_new;
}
// ...
}
// ...
}
pub fn can_merge_symbol_kinds<const IS_TYPESCRIPT_ENABLED: bool>(
scope_kind: Kind,
existing: symbol::Kind,
new: symbol::Kind,
) -> SymbolMergeResult {
if existing == symbol::Kind::Unbound {
return SymbolMergeResult::ReplaceWithNew;
}
if IS_TYPESCRIPT_ENABLED {
// In TypeScript, imports are allowed to silently collide with symbols within
// the module. Presumably this is because the imports may be type-only:
//
// import {Foo} from 'bar'
// class Foo {}
//
if existing == symbol::Kind::Import {
return SymbolMergeResult::ReplaceWithNew;
}
// ...
}
// ...
}
원래 Zig 코드를 이해하는 사람이라면 누구나 기계적으로 번역된 Rust 코드도 이해할 수 있습니다. 저는 원래 Rust 재작성 PR을 리뷰할 때, 적대적 코드 리뷰 에이전트들이 Zig 코드와 Rust 코드의 차이를 제대로 잡아내는지, 포팅 가이드와 lifetime 가이드를 따르도록 제대로 강제하는지 확인했고, 또한 Zig와 Rust를 나란히 놓고 많은 코드를 직접 수동으로 읽었습니다.
Bun v1.4는 Bun을 더 빠르게, 더 작게, 더 적은 메모리를 쓰게 만들며, 앞으로 안정성을 체계적으로 개선할 수 있는 매우 강력한 도구를 팀에 제공합니다: Rust의 borrow checker, Miri(CI에서 점점 더 많은 코드에 대해 실행 중), LeakSanitizer, 그리고 파서를 위한 24시간 365일 coverage-guided fuzzing. 아직 더 리팩터링할 것이 남아 있지만, 시작은 아주 좋습니다.
이 Rust 재작성은 코드베이스의 전체 맥락을 아는 엔지니어 팀이라면 1년의 작업이 필요했을 것입니다. 하지만 Fable을 사용하는 엔지니어 1명이 Claude Code를 면밀히 모니터링하면서, 우리는 시작부터 모든 플랫폼에서 테스트 스위트 100% 통과까지 11일 만에 도달했습니다.
오늘날 엔지니어 한 명이 할 수 있는 일은 1년 전보다 훨씬 많습니다.