Rewriting Bun in Rust

Bun 창시자 Jarred Sumner가 Claude Code의 동적 워크플로로 최대 64개 에이전트를 11일간 돌려 Bun을 Zig에서 Rust로 재작성한 과정. 언어 독립 테스트 스위트와 적대적 리뷰로 100만 줄 넘는 LLM 작성 코드를 검증했고, 그 결과 메모리 누수 제거, 바이너리 약 20% 축소, 2~5% 성능 향상을 얻었다.

공개: Bun은 2025년 12월 Anthropic에 인수되었습니다. 저와 Bun 팀의 다른 사람들은 Anthropic에서 일합니다. Rust 재작성의 상당 부분에 Claude Fable 5의 사전 출시 버전을 사용했습니다.

Bun은 esbuild의 JavaScript, TypeScript 트랜스파일러를 Go에서 Zig로 한 줄 한 줄 옮긴 데서 시작했습니다. 저는 2021년 4월 16일에 Zig 코드를 처음 한 줄 썼습니다. Hacker News에서 한 페이지짜리 Zig 언어 레퍼런스를 보고 저수준 제어와 성능에 대한 세심함에 크게 끌려서 Zig에 걸었습니다.

처음부터 Bun의 범위는 방대했습니다.

Bun의 첫 버전은 오클랜드의 비좁은 아파트에서, LLM이 나오기 전에, 제가 1년 동안 Zig로 혼자 썼습니다. Bun처럼 야심 차게 범위를 잡은 프로젝트가 맞는 기본 결말은 GitHub 프로필의 죽은 사이드 프로젝트 무덤에 합류하는 것입니다. Zig가 Bun을 가능하게 했습니다. Zig가 없었다면 1년 만에 이만큼 만들지 못했을 겁니다.

요즘 Bun CLI는 월 2,200만 회 넘게 내려받습니다. Claude Code와 OpenCode 같은 인기 도구가 런타임으로 Bun에 걸었습니다. Vercel, Railway, DigitalOcean 등이 Bun을 1차 지원합니다.

Bun의 넓은 범위는 안정성 면에서 숙제이기도 했습니다. Bun v1.3.14에서 고친 버그를 조금만 추려 보면 이렇습니다.

이런 버그를 계속 하나하나 영원히 고칠 수도 있었지만, 우리를 믿고 쓰는 사용자에게 그보다 더 나은 것을 해야 했고, 이런 부류의 버그가 되풀이되지 않게 체계적으로 막아야 했습니다.

이미 하고 있던 것들

많은 프로젝트가 하는 것보다 더 많이 하고 있는 셈입니다.

그냥 아주 똑똑하게, 실수 없이 하면 되지 않나?

버그 수정 목록을 보면 기분이 나빴고, Bun의 크래시를 걱정하며 잠드는 데 지쳤습니다. 그렇다고 Zig를 탓하지는 않습니다. Zig를 쓰는 다른 사람들은 우리가 겪은 버그를 겪지 않고, GC와 수동 관리 메모리를 섞는 일은 소프트웨어에 흔히 필요한 게 아니어서 어떤 언어도 이걸 위해 설계되지 않았습니다. Zig가 없었다면 여기까지 오지 못했을 것이고, 늘 고맙게 생각합니다. 아주 최근까지, Bun 같은 프로젝트에게 프로그래밍 언어 선택은 되돌릴 수 없는 결정이었습니다.

JavaScript는 가비지 컬렉션 언어이고, JavaScriptCore(그리고 V8) 같은 현대 JavaScript 엔진은 예외 처리와 가비지 컬렉터를 두고 엄격한 규칙을 갖고 있습니다. Zig는 C처럼 메모리를 대신 관리해 주지 않는데, 많은 프로젝트에는 이게 오히려 Zig를 쓸 좋은 이유가 되는 트레이드오프입니다. Zig에는 생성자/소멸자가 없고, 대부분의 정리(cleanup)는 각 호출 지점에 defer로 명시적으로 적어 주기를 기대합니다.

Bun에게는 가비지 컬렉션 값과 수동 관리 값의 수명을 올바로 다루는 일이 안정성 문제의 큰 원천이었습니다. 대개는 작은 메모리 누수였고 가끔은 크래시였습니다. 모든 메모리 할당을 꼼꼼히 검토해야 합니다. 이 바이트는 어디서 해제되나? 딱 한 번만 해제되게 어떻게 보장하나? JavaScript 예외를 제대로 확인했나? 이 가비지 컬렉션 포인터가 보수적 스택 스캐너에 보이나? 이건 가비지 컬렉션 메모리인가, 수동 관리 메모리인가?

안정성 문제는 최대한 빨리 아는 게 최선입니다. 퍼징은 코드가 머지된 뒤에 일어납니다. CI는 코드가 푸시될 때 돕니다. 런타임 안전 검사와 address sanitizer는 코드가 실행될 때(되도록 CI 전 개발 단계에서) 작동합니다.

이런 부류의 문제를 줄이는 흔한 방법 하나는, 정리 코드가 필요한 곳에서 정확히 한 번만 돌게 보장하는 것입니다. Zig는 숨은 제어 흐름이 없는 단순한 언어를 지향해서, C++의 암묵적 ~소멸자나 Rust의 암묵적 Drop보다 스코프 끝에서 코드를 돌리는 명시적 defer 키워드를 선호합니다.

언어 정리(Cleanup)
Zig defer, errdefer
C++ ~소멸자, &&Move
Rust Drop

Zig 코드에서 정리 코드는 정확히 언제 돌려야 할까요? 같은 *T를 여러 함수에 넘긴다면, 언제 더는 접근 불가라서 정리해도 되는지 어떻게 알까요? 함수 호출 뒤에도 계속 메모리를 참조해야 하는 함수가 있을 때는 어떻게 될까요? 지금 우리 방식은 이 셋을 섞은 것입니다.

많은 프로젝트가 이런 질문에 스타일 가이드로 답합니다. TigerBeetle의 TigerStyle이 Zig 쪽 예이고, Google의 31,000단어짜리 C++ 스타일 가이드가 또 하나입니다. 스타일 가이드의 어려움은 강제입니다. 가이드를 지키는지 어떻게 보장하나요? 역사적으로는 코드 리뷰가 답이었고, 린터와 정적 분석기로 최선을 다해 강제했습니다.

소유권 기대치를 타입 시스템에 명시적으로 적어 둔 엄격한 스타일 가이드도 Bun에게 실제 선택지였습니다. Zig에는 연산자 오버로딩이 없어서, 아마 이런 모양의 코드가 잔뜩 나왔을 겁니다.

이건 우리가 기대하는 Zig보다 덜 편합니다.

fn foo(a: *TCPSocket) !void {

  const b = try do_something_with_a(a);

  // ...

}

C/C++는 어떤가?

Bun 코드의 약 20%는 C++로 쓰였고, Bun은 여러 C/C++ 라이브러리를 임베드합니다.

Zig 대신 C++도 Bun에게 합리적 선택이었을 겁니다. 생성자와 소멸자를 얻고, 수많은 extern "C" 래퍼 코드를 지울 수 있었을 테니까요.

하지만 여전히 코드 리뷰로 강제하는 스타일 가이드에 기대야 했을 것이고, ASAN이 있어도 메모리 손상과 메모리 누수는 계속 일어났을 겁니다.

왜 Rust인가?

앞 목록의 버그 중 상당수는 use-after-free, double-free, 그리고 에러 경로에서 "해제를 깜빡한" 부류입니다. safe Rust에서는 이것들이 컴파일 에러이고, Drop으로 RAII 같은 자동 정리가 됩니다. 컴파일 에러는 스타일 가이드보다 나은 피드백 루프입니다.

역사적으로 재작성은 끔찍한 아이디어입니다. 주석을 빼고 Bun은 Zig 코드 535,496줄입니다. 다른 언어로 다시 쓰면 소규모 엔지니어 팀이 꼬박 1년 걸립니다. 그 기간 동안 버그 수정, 보안 수정, 기능 개발을 얼려야 한다는 뜻입니다. 출시 가능한 무언가를 얻는 가장 위험이 적은 방법은, 동작 변경을 최소로 두고 Zig에서 Rust로 기계적으로 옮기면서, 이미 Bun 테스트에 쓰는 바로 그 테스트 스위트를 그대로 쓰는 것입니다.

다행히 Bun의 테스트 스위트는 TypeScript로 쓰여 있어서 런타임의 프로그래밍 언어에 의존하지 않습니다.

사용자 영향이 1년간 0이라는 건 현실적으로 고려할 수 없는 선택지였습니다. 그래서 안정성 문제를 코드 스타일 강제로 고치는 게 최선의 방책이었고, Bun 코드베이스에 Rust에서 영감받은 스마트 포인터를 넣었을 때의 계획이기도 했습니다.

하지만 솔직히 저는 그러고 싶지 않았습니다. 직접 만든 스마트 포인터는 Rust보다 편의성이 떨어지면서도 아무 보장을 주지 못합니다.

대신, Anthropic의 새 모델이 Bun을 Rust로 다시 쓸 수 있는지 일주일 동안 시험해 보면 어떨까?

처음엔 될 거라고 기대하지 않았습니다. 며칠 지나자 테스트 스위트의 높은 비율이 통과하기 시작했고, 새 Rust 코드가 원래 Zig 코드베이스와 얼마나 잘 들어맞는지 봤습니다. "시도해 볼 만하다"던 제 생각은 "이걸 머지하겠다"로 바뀌었습니다.

Claude, Bun을 Rust로 다시 써줘.

이걸 엉망으로 하는 방법은 많습니다. 예를 들어 Claude에게 "Bun을 Rust로 다시 써. 실수하지 마."라고 프롬프트하고 되기를 비는 것, 그게 제가 한 방식은 아닙니다.

사람이라면 이걸 어떻게 할지 생각해 봅시다. 첫 번째 큰 질문은 이겁니다.

esbuild의 트랜스파일러를 Bun의 첫 버전을 위해 Go에서 Zig로 옮겨 본 제 경험(LLM 없이)으로는, 한꺼번에 전부 하는 게 낫습니다. 점진적 재작성은 결국 지워지길 바라는 임시 코드를 더하고, 단기~중기에 고통스럽습니다.

두 번째 큰 질문: 어떻게?

아키텍처, 성능, 기능 집합이 같은 그대로의 Bun을 유지하면서, 동시에 borrow checker 같은 Rust의 언어 기능을 얻으려면 어떻게 해야 할까요? 재작성 뒤에도 팀이 계속 유지보수할 수 있게 하려면요?

우리 Zig 코드를 Rust로 트랜스파일한 것처럼 보이는 재작성을 하는 것입니다. Bun v1.4를 출시한 뒤에 unsafe 사용을 줄이고 관용적인 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로 다시 썼습니다.

각 동적 워크플로는 이런 루프였습니다.

그 11일 대부분(그리고 그 뒤)에 저는 워크플로를 지켜봤습니다. 출력을 직접 읽어 문제와 버그를 확인하고, 루프를 고치라고 Claude에게 프롬프트했습니다.

+100만 줄이 추가된 PR을 어떻게 리뷰하나요? LLM이 쓴 방대한 코드를 책임 있게 머지할 만한 확신은 어떻게 쌓기 시작하나요?

100만 개의 assertion이 든 언어 독립 테스트 스위트, 적대적 코드 리뷰, 그리고 무언가 잘못되면 코드를 손으로 고치는 대신 그 코드를 만들어 내는 프로세스를 고치는 것입니다.

적대적 리뷰(adversarial review)

적대적 리뷰는 (별도 컨텍스트 창에서) Claude에게 변경이 버그를 만들거나 동작하지 않을 이유를 남김없이 대라고 요청합니다.

분리된 컨텍스트 창

보통 사람의 경우, 코드를 리뷰하는 사람은 코드를 쓴 사람이 아닙니다. 코드를 쓴 사람은 그 코드를 머지하고 싶어 해서, 준비되기 전에 내보내려는 쪽으로 편향될 수 있습니다.

Claude도 마찬가지입니다. 코드를 쓴 Claude는 코드가 받아들여지길 원합니다. 리뷰하는 Claude는 코드에서 문제를 찾고 싶어 합니다.

구현자 1명, 구현자당 적대적 리뷰어 2명 이상. 리뷰어의 유일한 일: 버그와 코드가 동작하지 않을 이유를 찾는 것. 구현자는 리뷰하지 않습니다. 리뷰어는 구현하지 않습니다.

[그림: 동적 워크플로 적대적 리뷰가 머지 전에 잡은 여러 버그 가운데 3개를 보여주는 애니메이션. 구현자 Claude와 적대적 리뷰어 Claude가 함께 나옴. 예: Subprocess::on_pipe_close에서 async uv_close 전에 Box<uv::Pipe>Box::leak 처리해 UAF/double-free를 피하도록 고친 커밋 f0a454376c7 (win-review: js_bun_spawn_bindings.rs)]

실제로는 어떤 모습인가?

크고 비싼 일을 하려 한다면, 먼저 위험을 줄여 두는 게 시간과 돈을 아낍니다.

준비 작업

코드를 한 줄도 쓰기 전에, 저는 우리 Zig 코드베이스의 패턴을 Rust에 촘촘히 대응시키는 방법을 두고 Claude와 약 3시간을 이야기했습니다. Claude는 이 논의를 PORTING.md 문서로 직렬화했고, 그 문서는 Hacker News에 올라갔습니다.

다음 질문: 메모리를 수동으로 관리하는 코드에 Rust 수명(lifetime)을 어떻게 더하나요?

거기서 저는 Claude에게 대략 이렇게 프롬프트했습니다.

Me: 코드베이스의 모든 struct 필드의 적절한 수명을 분석하는 동적 워크플로를 시작하자. 이 워크플로는 모든 파일 안의 모든 struct 필드를 읽고 제어 흐름을 추적해야 해. 먼저 Rust로 표현하기 복잡한 수명을 가진 struct 필드를 찾고, 그 필드의 수명을 제안한 다음, 적대적 리뷰 에이전트 2개로 그 수명을 리뷰하고, 피드백을 반영해 다른 claude들이 볼 수 있게 LIFETIMES.tsv로 직렬화해.

그런 다음 PORTING.mdLIFETIMES.tsv를 함께 놓고 한 차례 적대적 리뷰를 돌려 상충하는 제안을 고치고 모든 걸 다시 확인했습니다. 저도 직접 읽어 봤습니다.

시험 실행

1,448개의 .zig 파일 전부를 .rs 파일로 옮기라고 Claude에게 요청하기 전에, 딱 3개로 시작했습니다. 각 파일마다 구현자 1명이 새 .rs 파일을 쓰고, 적대적 리뷰어 2명이 그 .rs 파일이 .zig 파일의 동작과 맞는지, PORTING.mdLIFETIMES.tsv를 따르는지 확인했습니다. 그 뒤 fixer 1명이 제안을 반영했습니다.

헛발질

Claude에게 1,448개 .zig 파일 전부에 워크플로를 돌리라고 했더니, 약 2분 만에 한 Claude가 커밋 전에 git stash를 돌렸습니다. 다른 Claude는 git stash pop을 돌렸습니다. 그다음엔 git reset HEAD --hard. 서로 발을 밟고 있었던 겁니다! 그렇다고 각 Claude를 별도 worktree에 넣으면, Bun의 git 저장소가 너무 커서 디스크 공간이 바닥나고, 결국 변경을 컴파일해 함께 봐야 하니 곤란했습니다.

그래서 저는 Claude에게 워크플로를 고쳐, git stashgit reset, 또는 특정 파일을 한 번에 커밋하지 않는 어떤 git 명령도 절대 돌리지 말라고 지시하게 했습니다. cargo도 금지. 느린 명령은 전부 금지.

그런 다음 Claude가 워크플로를 재개했습니다. 그리고 됐습니다! 너무 느려서, worktree를 각자 가진 워크플로 샤드 4개(총 4개 worktree)로 나눴고, 각 샤드는 16개 claude가 파일을 커밋하고 푸시하게 했습니다.

드디어 코드 작성

이 모든 병렬화와 준비 작업 덕분에, 피크 때 Claude는 분당 약 1,300줄의 코드를 썼습니다. 모든 줄은 별개의 적대적 리뷰어 2명(역시 Claude)이 리뷰했고 커밋 전에 한 차례 수정을 거쳤습니다. 물론 아직 아무것도 동작하지 않았습니다.

[그림: 11일 × 24시간(PDT) 동안 시간대별 커밋 수를 보여주는 히트맵. 총 6,502 커밋, 피크 시간당 695 커밋. 5월 4일부터 5월 14일까지 각 시간 칸이 커밋 수와 추가 줄 수로 색이 짙어짐]

타이밍이 들쭉날쭉한 게 보이시나요? 이걸 돌린 EC2 인스턴스의 기본 IOPS를 올리는 걸 깜빡했습니다. 느린 grep 명령 하나에 몇 분 동안 디스크 읽기/쓰기가 얼어붙었습니다.

코드를 전부 쓴 뒤, 저는 모든 컴파일러 에러를 고치는 워크플로를 만들라고 Claude에게 요청했습니다. crate별로 진행했습니다.

[그림: 동적 워크플로 화면. 5월 6일 오전 12:40 PDT 기준 남은 에러 약 16,000개, fix 커밋 0개. 64개 claude가 4개 worktree에 나뉘어 작업. crate마다 1 fix / 2 review / 1 apply로 진행하고, crate 단위로 커밋이 들어감]

가장 까다로운 부류의 에러는 순환 의존성이었습니다.

우리 Zig 코드베이스는 하나의 컴파일 단위(사실상 하나의 crate)였습니다. 저는 Rust가 더 빨리 컴파일되도록 새 Rust 코드베이스를 약 100개 crate로 나누고 싶었는데, 이때 원래 Zig 구현 대비 변경을 최소로 하면서 순환 의존성을 피해야 했습니다. Rust 재작성을 시작하기 직전에 이걸 하려고 낸 제 PR로는 부족했습니다. 처음부터 다시 하는 대신, 순환 의존성이 있는 코드를 어디로 보내야 할지 분류해 전부 적어 두는 워크플로를 하나 돌리고, 그다음 그 리팩터를 수행하는 워크플로를 또 하나 돌렸습니다.

순환 의존성을 고치자 컴파일러 에러가 약 16,000개 드러났습니다. 사람 1명에게는 엄청난 숫자지만, 한 번에 64개 claude에게는 말도 안 되는 숫자가 아닙니다.

병렬성을 극대화하려고 워크플로는 각 crate를 루프로 돌았습니다.

claude들이 서로 발을 밟지 않게, cargo check는 맨 처음에만 돌렸고 다른 실행과 마찬가지로 끝까지 git은 없었습니다.

또 한 번의 헛발질

Claude는 "모든 crate가 컴파일되게 하자"를 "컴파일 에러가 나는 함수를 스텁(stub) 처리하자"로 해석했습니다. Claude는 또 우회책을 문서화하려고 수상하리만치 긴 설명 주석을 붙이기 시작했습니다. 그래서 적대적 리뷰어가 거부하도록 이 규칙을 더했습니다.

프롬프트 한 번 고치고 몇 시간이 지나자 이런 일들이 멈췄습니다.

스모크 테스트

모델들은 "smoke tests"라는 말을 참 좋아합니다.

cargo check가 통과하자, 다음은 컴파일해서 bun --version을 돌리는 것이었습니다. 링커 에러가 있었습니다. 그다음엔 시작하자마자 패닉했습니다.

다음 목표는 bun test <file>을 돌리는 것이었습니다. 그게 되자 테스트를 돌리기 시작할 수 있었습니다! 또 하나의 워크플로 차례로, bun CLI 서브커맨드를 루프로 돌았습니다.

로컬에서 테스트 스위트 통과시키기

이 워크플로는 테스트 파일을 루프로 돌았습니다.

무작위 테스트 파일 약 100개를 코드베이스의 폴더 기준으로 4개 worktree 중 하나에 샤딩해 돌렸습니다. 실패한 각 테스트마다 스택트레이스와 에러를 파일에 저장하고, 구현자 1명이 수정을 제안하고, 적대적 리뷰어 2명, 그다음 fixer 1명이 반영했습니다.

우리 테스트 스위트에는 메모리 누수 테스트가 많고, 1분 넘게 걸리는 통합 테스트도 몇 개 있습니다. 예를 들어 next dev를 돌려 hot module reloading이 변경을 100번 잡아내는지 확인하는 테스트가 있습니다. 이 중 몇몇은 디버그 빌드에서 타임아웃 납니다.

또 머신의 최대 TCP 소켓 수를 소진하는 스트레스 테스트, 디스크에 기가바이트를 읽고 쓰는 테스트, 약 1만 개 프로세스를 생성하는 테스트도 있습니다.

이건 "제발"보다 강한 격리가 필요해서, systemd-run(cgroups)으로 메모리와 CPU 사용을 제한하고 pid 네임스페이스를 격리했습니다. 그래도 머신은 디스크 공간이 바닥나 몇 번 크래시했습니다.

CI에서 테스트 스위트 통과시키기

첫 CI 실행 이틀 뒤, 실패 목록은 테스트 파일 972개에서 23개로 줄었습니다. 그로부터 하루 반 뒤, Linux가 완전히 그린이 됐고, 그때 처음으로 이 Rust 재작성이 정말 될 것 같다는 느낌이 들었습니다.

[그림: buildkite 동적 워크플로. 플랫폼별 그린 도달 경쟁을 보여줌. Windows가 마지막으로, 5월 11일 오전 6:23 PDT에 6/6 플랫폼 그린, build #54202가 머지됨]

머지까지 남은 시간은 수월했습니다. 각 플랫폼의 CI 테스트 실패를 더는 없을 때까지 고치는 워크플로 하나. Windows 관련 정리, 코드 중복 제거, unsafe 사용 줄이기, 전반적 코드 정리를 위한 워크플로 여럿.

Rust 재작성 머지

Bun 테스트 스위트의 100%가 모든 플랫폼 CI에서 통과하고(그리고 테스트를 건너뛰지 않고 실제로 돌리고 있는지 직접 확인한 뒤), 로컬에서 여러 명령을 돌려 확인하고, 머지 버튼을 눌렀습니다.

main에 머지하는 건 버전 릴리스가 아닙니다. 이 시점에 저는 재작성을 밀어붙여 확정할 만큼은 확신했지만, 아직 출시할 만큼 확신하지는 않았습니다.

통계

피크 때 우리는 각기 별도 worktree에서 이런 워크플로 4개를 동시에 돌렸고, 워크플로마다 Claude가 16개였습니다. 한 번에 약 64개 Claude였습니다.

[그림: git log · claude/phase-a-port 카운터. 피크 1분에 58 커밋. 5월 4일 월요일 오전 7:05 PDT 기준 커밋 수와 추가 줄 수(재작성 포함)가 올라가는 애니메이션]

건너뛰거나 삭제한 테스트 0개

11일(5월 3일 → 5월 14일 머지) · 6,778 커밋

플랫폼 expect() 호출 테스트 파일
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의 동적 워크플로가 64개 Claude를 11일 동안 계속 돌렸습니다(안 그랬다면 이걸 해내려고 제 하네스를 직접 만들어야 했을 겁니다).

작업은 계속된다

Rust 포트를 머지한 뒤, 우리는 Claude Code Security의 보안 리뷰를 11차례 마치고 발견 사항을 처리했습니다.

또 Bun의 모든 파서(JavaScript, TypeScript, JSX, CSS, JSON5, JSONC, TOML, YAML, Markdown, INI, Bun Shell 스크립트, semver 범위, .patch 파일, CSS 색상)에 24시간 커버리지 기반 퍼징을 추가했습니다. 퍼저는 자신이 찾은 버그를 Claude에게 자동으로 보내 재현과 수정 PR을 내게 하고, 사람이 그 PR을 리뷰합니다. 지금까지 우리 파서를 1,000억 번 실행했고, 그 결과 약 15개 PR이 나왔습니다.

이 글을 쓰는 시점에 Bun의 Rust 코드 중 약 4%가 unsafe 블록 안에 있습니다(약 780,000줄 중 약 27,000줄에 걸친 약 13,000개 unsafe 키워드). 그리고 그 블록의 78%는 한 줄짜리로, C++에서 온 포인터이거나 C 라이브러리 호출 한 번입니다. 충실한 Zig 포트(grep으로 찾을 unsafe 키워드가 없었던)에서 관용적 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은 프런트엔드 개발 서버의 hot reload 그래프에 파일을 더합니다. 릴리스 빌드에서 이게 안 돌아서, React를 쓰는 HTML 라우트 프로젝트에서 hot reload된 파일이 무효화되는 특정 경우에 HMR이 깨졌습니다: Cannot destructure property 'isLikelyComponentType' of 'k'. 디버그 빌드는 됐습니다. #30678

홀수 길이 슬라이스

Bun의 Zig 헬퍼 reinterpretSlice(u16, bytes)(슬라이스를 지원하는 내장 캐스트가 나오기 전 것)는 @divTrunc를 써서 끝의 홀수 바이트를 무시했습니다. 대신 bytemuck::cast_slice는 그럴 때 패닉합니다. UTF-16 byte order mark 뒤에 홀수 개의 바이트가 붙은 Blob.text()가 문자열을 반환하는 대신 프로세스를 패닉시켰습니다. 우리는 홀수 바이트를 무시하던 방식으로 되돌렸습니다: &buf[..buf.len() & !1]. #31188

경계 검사(bounds check)

macOS와 Linux에서 우리는 Bun의 Zig 코드를 ReleaseFast로 컴파일했는데, 이는 경계 검사를 제거합니다. Rust의 릴리스 빌드는 경계 검사를 유지합니다.

Bun의 모듈 해석기는 긴 파일명을 전역 목록에 intern하고, 이 목록은 오버플로 블록으로 넘칩니다. 원래 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] off-by-one을 도달 가능하게 만들었습니다. Rust는 끝을 넘어 쓰는 대신 패닉했습니다. Zig도 ReleaseSafe를 썼다면 이 경우 패닉했을 텐데, 우리는 Windows에서만 그걸 썼습니다. #31503

comptime 포맷 문자열

Output.pretty<r><d> 색상 마커를 ANSI 이스케이프로 다시 씁니다. Zig에서 fmtcomptime이라, 인자가 치환되기 전에 마커가 사라집니다. 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이 텍스트로 출력됩니다.

update-interactive-r

oxfmtr가 아니라 oxfmt라고 나와야 합니다.

Rust에서는 이게 매크로여야 합니다: bun_core::pretty!("<r>{}<r>", hyperlink). #30693

Rust로 쓴 Bun이 더 낫다

지금까지 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의 LeakSanitizer 연동을 개선했습니다.

예를 들어 봅시다. 같은 프로세스 안에서 실행한 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에서는 빌드마다 약 3MB가 영원히 샙니다. 요청마다 번들하는 개발 서버 같은 도구는 결국 메모리가 바닥납니다. Bun v1.4.0에서는 메모리가 평평해집니다.

빌드 횟수 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.8MB, macOS에서 5.5MB, Linux에서 6.8MB 줄었습니다. 대체로 우리 Zig 코드에서 comptime을 너무 많이 썼기 때문입니다.

그 초기 축소 뒤, 팀은 Identical Code Folding 같은 링커 최적화, ICU에서 안 쓰는 데이터 제거, libicu의 작은 부분을 zstd 사전으로 필요할 때 지연 압축 해제하기 등 바이너리 크기를 줄일 기회를 더 살폈습니다.

Rust 재작성, ICU 변경, identical code folding을 합쳐 Bun의 바이너리 크기는 Linux와 Windows에서 약 20% 줄어듭니다.

버전 플랫폼 크기
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의 다른 모든 재귀 하강 파서(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.startllvm.lifetime.end intrinsic을 내보내는데, 이는 LLVM이 스택 공간 슬롯을 재사용하게 해 줍니다. 덕분에 중첩 스코프가 있는 큰 함수가 스택 공간을 크게 덜 씁니다.

이전에는 열린 이슈특히 큰 함수를 여러 작은 함수로 리팩터링해 손으로 우회했습니다.

2~5% 빠름

Rust는 C/C++와 Rust 사이의 교차 언어 link-time optimization을 지원하는데, 이는 프로그래밍 언어를 넘나드는 인라이닝을 가능하게 합니다(정말 멋지지 않나요!!).

Bun v1.3.14와 v1.4.0을 Linux x64(EC2, Xeon Platinum 8488C)에서 벤치마크했습니다. HTTP 처리량은 hello-world 서버 대상 oha로, 앱 워크로드는 hyperfine으로 측정했습니다.

HTTP 처리량 (req/s, 3라운드 평균)

서버 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%

앱 / CLI (hyperfine)

워크로드 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% 빨라졌지만, 그 외에는 거의 아무도 눈치채지 못했습니다. 지루한 게 좋은 겁니다.

claude-code-rust-bun-startup

프로덕션 텔레메트리로 본 Claude Code 시작 시간(Linux p50): v2.1.179가 517ms, Rust Bun 첫 릴리스인 v2.1.181이 464ms로 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 코드의 불일치를 제대로 잡고 있는지, 포팅 가이드와 수명 가이드를 지키게 하고 있는지 확인하고, Zig 코드와 Rust 코드를 나란히 놓고 상당 부분을 직접 읽어 가며 리뷰했습니다.

다음 단계

Bun v1.4는 Bun을 더 빠르고, 더 작고, 메모리를 덜 쓰게 하며, 앞으로 안정성을 체계적으로 개선할 대단히 강력한 도구를 팀에 줍니다: Rust의 borrow checker, Miri(CI에서 점점 더 많은 코드에 돌립니다), LeakSanitizer, 그리고 파서를 위한 24시간 커버리지 기반 퍼징. 리팩터링할 게 아직 더 있지만, 출발은 아주 좋습니다.

이 Rust 재작성은 코드베이스를 완전히 파악한 엔지니어 팀이 1년 걸렸을 일입니다. Fable을 쓰고 Claude Code를 면밀히 지켜본 엔지니어 1명으로, 우리는 시작부터 모든 플랫폼에서 테스트 스위트 100% 통과까지 11일 만에 갔습니다.

엔지니어 1명이 1년 전보다 오늘 훨씬 더 많은 일을 할 수 있습니다.