Rewriting Bun in Rust
TMT밝혀둘 점: Bun은 2025년 12월 Anthropic에 인수되었습니다. 저와 Bun 팀 동료들은 Anthropic에서 일하고 있습니다. 이번 Rust 재작성의 상당 부분에 Claude Fable 5 프리릴리스 버전을 사용했습니다.
Bun은 esbuild의 JavaScript·TypeScript 트랜스파일러를 Go에서 Zig로 한 줄 한 줄 옮기는 것으로 시작됐습니다. 제가 처음 Zig 코드를 쓴 날은 2021년 4월 16일입니다. Hacker News에서 한 페이지짜리 Zig 언어 레퍼런스를 보고, 저수준 제어와 성능을 향한 세심함에 잔뜩 들떠 Zig에 승부를 걸었습니다.
처음부터 Bun의 범위는 어마어마했습니다:
- JavaScript·TypeScript·CSS 트랜스파일러, 미니파이어, 번들러
- npm 호환 패키지 매니저
- Jest 스타일 테스트 러너
- Node.js·TypeScript 호환 모듈 해석
- HTTP/1.1·WebSocket 클라이언트
fs,net,tls를 비롯한 수십 개 모듈의 Node.js API 구현
Bun의 첫 버전은 LLM이 나오기 전, 오클랜드의 비좁은 아파트에서 제가 혼자 1년 만에 Zig로 만들었습니다. Bun처럼 야심차게 판을 벌인 프로젝트의 기본 결말은 GitHub 프로필 페이지의 죽은 사이드 프로젝트 묘지에 합류하는 것입니다. Zig가 있었기에 Bun이 가능했습니다. Zig가 아니었다면 1년 만에 이만큼 만들어내지 못했을 겁니다.
요즘 Bun CLI는 월 2,200만 회 넘게 다운로드됩니다. Claude Code, OpenCode 같은 인기 도구들이 Bun을 런타임으로 택했고, Vercel, Railway, DigitalOcean 등이 Bun을 공식 지원합니다.
동시에 Bun의 넓은 범위는 안정성에는 부담이기도 했습니다. Bun v1.3.14에서 고친 버그 가운데 일부만 추려보면 이렇습니다:
- 스레드풀에서 비동기
.write()가 아직 진행 중인 zlib·Brotli·Zstd 스트림에.reset()을 호출하면 발생하는node:zlib의 heap-use-after-free 크래시 onerror콜백이 네이티브 핸들에 재진입write()에 이어close()를 호출할 때 발생하는node:zlib의 use-after-free 크래시- 재진입 JS 콜백(예: 타임아웃 리스너, 옵션 getter, write 콜백 안에서 호출된
session.request())이 해시맵 재해싱을 일으켜 내부 스트림 포인터를 무효화하면서 발생하는node:http2의 use-after-free 크래시 valueOf()·toString()콜백의 사용자 코드가 페이로드 캡처와 실제 전송 사이에ArrayBuffer를 분리(detach)할 수 있던UDPSocket.send()·sendMany()의 use-after-free- 인수 강제 변환 중에
valueOf콜백이 기반ArrayBuffer를 분리하거나 크기를 바꾸면Buffer#copy·Buffer#fill에서 발생하던 크래시와 범위 밖 읽기 - 사용자 JS 콜백 때문에 반복 도중 소켓 연결 상태가 바뀌면
UDPSocket.sendMany()에서 발생하던 힙 범위 밖 쓰기 - 출력 버퍼 할당이 실패하면 콜백과 보호된 password/salt 버퍼가 영영 해제되지 않던
crypto.scrypt의 메모리 누수 SSLWrapper.init이 오류 경로에서 strdup한 passphrase를 누수d2i_SSL_SESSION뒤에SSL_SESSION_free가 빠져 호출할 때마다SSL_SESSION하나(호출당 약 6.5KB)가 새던tlsSocket.setSession()의 메모리 누수- 참조 카운트 언더플로로 각 워처가 GC 루트로 영구히 고정되는 바람에
.close()후에도fs.watch()워처가 가비지 컬렉션되지 않던 메모리 누수 background-clip에 벤더 프리픽스와 다중 레이어 배경이 함께 쓰일 때 CSS 파서에서 나던 double-free 크래시DuplexUpgradeContext가 아예 해제되지 않음 —tls.connect({ socket: duplex })호출마다 통째로 누수BroadcastChannel이나MessagePort에서 동시 접근하는 동안 GC 마커 스레드가m_data의 찢어진(torn) variant를 관측할 수 있던MessageEvent의 경쟁 조건 크래시
이런 버그를 영원히 하나씩 고치며 버틸 수도 있었습니다. 하지만 우리를 믿고 쓰는 사용자들에게는 그보다 나은 대응, 즉 이런 부류의 버그가 재발하지 않도록 구조적으로 막을 의무가 있습니다.
이미 하고 있던 것들
- Zig 컴파일러를 패치해 Address Sanitizer 지원을 추가했고, 매 커밋마다 ASAN을 켠 채 테스트 스위트를 돌립니다
- Windows에서는 Zig 안전성 검사가 켜진 ReleaseSafe 빌드를 배포합니다
- V8과 JavaScriptCore가 쓰는 JavaScript 엔진 퍼저 Fuzzilli로 Bun의 런타임 API를 24시간 퍼징합니다
- 엔드투엔드 메모리 누수 테스트도 아주 많이 갖추고 있습니다
이 정도면 웬만한 프로젝트보다 많이 하는 편입니다.
그냥 진짜 똑똑해져서 실수를 안 하면 되지 않냐고요?
버그 수정 목록을 볼 때마다 마음이 무거웠고, Bun의 크래시를 걱정하며 잠드는 것도 지긋지긋했습니다. Zig를 탓하는 게 아닙니다. 다른 Zig 사용자들은 우리 같은 버그를 겪지 않고, GC와 수동 메모리 관리를 섞어 쓰는 요구는 소프트웨어에서 워낙 드물어서 이를 염두에 두고 설계된 언어 자체가 없습니다. Zig가 아니었다면 여기까지 오지 못했을 것이고, 늘 고맙게 생각할 겁니다. 아주 최근까지만 해도 Bun 같은 프로젝트에서 프로그래밍 언어 선택은 한번 정하면 되돌릴 수 없는 결정이었습니다.
JavaScript는 가비지 컬렉션 언어이고, JavaScriptCore(그리고 V8) 같은 현대 JavaScript 엔진에는 예외 처리와 가비지 컬렉터를 둘러싼 엄격한 규칙이 있습니다. Zig는 C처럼 메모리를 대신 관리해주지 않는데, 이 트레이드오프야말로 많은 프로젝트가 Zig를 택하는 훌륭한 이유이기도 합니다. Zig에는 생성자/소멸자가 없고, 대부분의 정리 코드는 각 호출 지점에서 defer로 직접 써주는 것을 전제로 합니다.
Bun에서는 가비지 컬렉션되는 값과 수동으로 관리되는 값의 수명을 올바르게 다루는 일이 안정성 문제의 주요 원인이었습니다. 대개는 자잘한 메모리 누수였고, 가끔은 크래시였습니다. 모든 메모리 할당을 일일이 꼼꼼하게 검토해야 합니다. 이 바이트들은 어디서 해제되는가? 딱 한 번만 해제된다고 어떻게 보장하는가? JavaScript 예외는 제대로 확인했는가? 이 가비지 컬렉션 대상 포인터가 보수적 스택 스캐너에 보이는가? 이건 GC 메모리인가, 수동 관리 메모리인가?
안정성 문제는 최대한 일찍 알수록 좋습니다. 퍼징은 코드가 머지된 뒤에 돌고, CI는 코드를 푸시할 때 돌며, 런타임 안전성 검사와 Address Sanitizer는 코드가 실행될 때(바라건대 CI 전, 개발 중에) 작동합니다.
이런 부류의 문제를 줄이는 흔한 방법 하나는 정리 코드가 필요한 곳에서 정확히 한 번 실행되도록 보장하는 것입니다. Zig는 숨은 제어 흐름이 없는 단순한 언어를 지향하기 때문에, C++의 암묵적 ~소멸자나 Rust의 암묵적 Drop보다 스코프 끝에서 코드를 실행하는 명시적 defer 키워드를 선호합니다.
| 언어 | 정리 방식 |
|---|---|
| Zig | defer, errdefer |
| C++ | ~Destructor, &&Move |
| Rust | Drop |
Zig 코드에서 정리 코드는 정확히 언제 실행해야 할까요? 같은 *T를 여러 함수에 넘기고 있다면, 더는 접근되지 않아 정리해도 되는 시점을 어떻게 알까요? 함수 호출이 끝난 뒤에도 일부 함수가 그 메모리를 계속 참조해야 한다면 어떻게 될까요? 우리의 기존 접근은 다음의 조합이었습니다:
- 접근 가능한 범위가 분명한 곳에는 아레나(arena) 수명 (파서 상태는 호출한 함수를 벗어나지 않으므로 AST 노드가 좋은 예)
- 참조 카운팅
- 정말 바짝 신경 쓰기
많은 프로젝트가 이런 질문에 스타일 가이드로 답합니다. Zig에서는 TigerBeetle의 TigerStyle이 한 예이고, Google의 31,000단어짜리 C++ 스타일 가이드도 마찬가지입니다. 스타일 가이드의 난점은 강제입니다. 가이드가 지켜지는지 어떻게 보장할까요? 역사적으로 그 답은 코드 리뷰였고, 린터와 정적 분석기가 최선을 다해 거들었습니다.
소유권에 대한 기대를 타입 시스템에 명시적으로 못 박은 엄격한 스타일 가이드도 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);
// ...
}C/C++는 어떨까?
Bun 코드의 약 20%는 C++로 작성돼 있고, Bun은 여러 C/C++ 라이브러리를 내장합니다:
- Safari를 구동하는 JavaScript 엔진 JavaScriptCore
- uWebSockets & usockets — HTTP/WebSocket 서버와 이벤트 루프
- lshpack & lsquic —
HPACK과 HTTP/3 라이브러리 - Google의 OpenSSL 포크인 BoringSSL
- SQLite
Zig 대신 C++를 쓰는 것도 Bun에게 합리적인 선택이었을 겁니다. 생성자와 소멸자가 생기고, 수많은 extern "C" 래퍼 코드를 지울 수 있었겠죠.
하지만 그래도 코드 리뷰로 강제하는 스타일 가이드에 기대야 하는 건 마찬가지고, ASAN이 있어도 메모리 손상과 메모리 누수는 계속 일어났을 겁니다.
왜 Rust인가?
저 버그 목록에서 큰 비중을 차지하는 것이 use-after-free, double-free, 그리고 오류 경로에서 '해제를 깜빡함'입니다. 안전한 Rust에서 이런 것들은 컴파일 오류가 되고, Drop을 통한 RAII식 자동 정리가 있습니다. 컴파일 오류는 스타일 가이드보다 나은 피드백 루프입니다.
역사적으로 재작성은 끔찍한 아이디어입니다. 주석을 빼면 Bun은 535,496줄의 Zig 코드입니다. 다른 언어로 다시 쓰려면 소규모 엔지니어 팀이 꼬박 1년을 매달려야 하고, 그동안 버그 수정도 보안 패치도 기능 개발도 얼어붙습니다. 출시 가능한 결과물에 이르는 가장 덜 위험한 접근은 Zig에서 Rust로의 기계적 포팅이었습니다. 동작 변경은 최소화하고, Bun 테스트에 이미 쓰고 있는 것과 정확히 같은 테스트 스위트를 쓰면서요.
다행히 Bun의 테스트 스위트는 TypeScript로 작성돼 있어 런타임의 구현 언어에 의존하지 않습니다.
사용자에게 보이는 성과가 1년 동안 0인 것은 현실적으로 고려할 수 있는 선택지가 아니었습니다. 그래서 코드 스타일 강제로 안정성 문제를 잡는 쪽이 최선이었고, Bun 코드베이스에 Rust에서 영감을 받은 스마트 포인터를 추가할 당시의 계획도 그것이었습니다.
하지만 솔직히 내키지 않았습니다. 직접 만든 스마트 포인터는 Rust보다 쓰기 불편하면서, Rust가 주는 보장은 하나도 없으니까요.
그러지 말고, Anthropic의 새 모델이 Bun을 Rust로 다시 쓸 수 있는지 일주일 들여 시험해보면 어떨까?
처음에는 될 거라고 기대하지 않았습니다. 며칠 지나자 테스트 스위트의 상당 비율이 통과하기 시작했고, 새 Rust 코드가 원래 Zig 코드베이스와 얼마나 잘 맞아떨어지는지 눈에 들어왔습니다. 제 생각은 "해볼 만하다"에서 "이거 머지한다"로 바뀌었습니다.
Claude, Bun을 Rust로 다시 써줘.
이 일을 형편없이 하는 방법은 아주 많습니다. 예를 들어 Claude에게 "Bun을 Rust로 다시 써. 실수하지 마."라고 프롬프트를 넣고 되기를 비는 것 — 제가 한 일은 그게 아닙니다.
사람이라면 이 일을 어떻게 할지 생각해보세요. 첫 번째 큰 질문은:
점진적으로 다시 쓸 것인가, 한 번에 전부 다시 쓸 것인가?
(LLM 없이) Bun 첫 버전을 만들며 esbuild 트랜스파일러를 Go에서 Zig로 포팅해본 경험으로는, 한 번에 전부가 낫습니다. 점진적 재작성은 언젠가 지워지길 바라는 임시 코드를 늘리고, 단기·중기적으로 고통스럽습니다.
두 번째 큰 질문: 어떻게?
Rust로 옮긴 Bun을 어떻게 이전과 같은 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의 동적 워크플로(dynamic workflow) 약 50개를 11일 동안 쉼 없이 돌려 Bun을 Rust로 다시 썼습니다.
각 동적 워크플로는 이런 루프 하나였습니다. 이를테면 다음과 같은 워크플로들입니다:
- Zig 패턴·타입을 Rust 패턴·타입으로 대응시키는 포팅 가이드 생성
PORTING.md와LIFETIMES.tsv에 맞춰 모든.zig파일을.rs파일로 기계적으로 포팅- 모든 크레이트의 컴파일 오류 수정
bun test,bun build같은 서브커맨드 동작시키기- Bun 전체 테스트 스위트의 모든 테스트 통과시키기
- 여러 차례의 대규모 리팩터링과 정리 패스
그 11일의 대부분(그리고 그 이후에도) 저는 워크플로를 모니터링했습니다. 출력을 직접 읽으며 문제와 버그를 확인하고, Claude에게 루프를 고치라고 프롬프트를 넣었습니다.
+100만 줄이 추가된 PR을 어떻게 리뷰할까요? LLM이 작성한 대량의 코드를 책임 있게 머지하는 데 필요한 확신은 어떻게 쌓기 시작할까요?
단언(assertion) 100만 개짜리 언어 독립 테스트 스위트, 적대적 코드 리뷰, 그리고 뭔가 잘못됐을 때는 코드를 손으로 고치는 대신 그 코드를 만들어낸 프로세스를 고치는 것.
적대적 리뷰
적대적 리뷰(adversarial review)는 (별도의 컨텍스트 윈도에 있는) Claude에게, 이 변경이 버그를 만들거나 동작하지 않을 이유를 남김없이 찾아내라고 요구하는 것입니다.
컨텍스트 윈도 분리
사람의 경우에도 보통 코드를 리뷰하는 사람과 작성한 사람은 다릅니다. 코드를 쓴 사람은 그 코드를 머지하고 싶어 하고, 그 마음은 준비가 덜 된 코드를 내보내는 쪽으로 행동을 치우치게 만들 수 있습니다.
Claude도 마찬가지입니다. 코드를 쓴 Claude는 그 코드가 받아들여지길 원합니다. 리뷰하는 Claude는 코드에서 문제를 찾아내길 원합니다.
구현자 1명에, 구현자당 적대적 리뷰어 2명 이상. 리뷰어의 유일한 임무는 버그와 코드가 동작하지 않을 이유를 찾는 것. 구현자는 리뷰하지 않고, 리뷰어는 구현하지 않습니다.
적대적 리뷰어가 실제로 잡아낸 세 가지 버그입니다 — 인용한 커밋마다 제목줄에 리뷰 출처가 남아 있습니다. 셋 다 컴파일은 됐고, 셋 다 그럴듯해 보였습니다. 리뷰어는 자기만의 컨텍스트 윈도를 가진 두 번째 Claude로, diff만 받고 그 외에는 아무것도 — 구현자의 추론조차 — 받지 않으며, 이 코드가 어디서 틀렸는지 찾아내라는 지시를 받습니다. 코드는 인용한 커밋에서 간추린 것으로, 버그도 수정도 실제 그대로입니다.
실제로는 어떤 모습이었나?
크고 비싼 일을 시작하기 전에는 위험 요소부터 걷어내는 것이 시간과 돈을 아낍니다.
준비 작업
코드를 한 줄도 쓰기 전에, Zig 코드베이스의 패턴을 Rust에 밀착 대응시키는 방법을 놓고 Claude와 3시간쯤 이야기했습니다. Claude는 이 논의를 PORTING.md 문서로 정리했고, 이 문서는 나중에 Hacker News에 오르기도 했습니다.
다음 질문: 메모리를 수동으로 관리하는 코드에 Rust 라이프타임을 어떻게 입힐 것인가?
그래서 Claude에게 대략 이런 프롬프트를 넣었습니다:
나: 코드베이스에 있는 모든 구조체 필드의 적절한 라이프타임을 분석하는 동적 워크플로를 시작하자. 이 워크플로는 모든 파일의 모든 구조체 필드를 읽고 제어 흐름을 추적해야 해. 먼저 Rust로 표현하기 까다로운 라이프타임을 가진 구조체 필드를 찾고, 그 필드의 라이프타임을 제안한 다음, 적대적 리뷰 에이전트 2개로 그 라이프타임을 검토하고, 피드백을 반영해서 다른 claude들이 볼 수 있게 LIFETIMES.tsv로 정리해줘.
그다음 PORTING.md와 LIFETIMES.tsv를 함께 놓고 적대적 리뷰를 한 차례 더 돌려 상충하는 제안을 고치고 전부 재확인했습니다. 저도 직접 읽어봤습니다.
시험 가동
1,448개의 .zig 파일 전부를 .rs 파일로 옮겨달라고 하기 전에, 딱 3개로 시작했습니다. 파일 3개 각각에 대해 구현자 1명이 새 .rs 파일을 쓰고, 적대적 리뷰어 2명이 .rs 파일이 .zig 파일의 동작과 일치하는지, PORTING.md와 LIFETIMES.tsv를 따르는지 검사했습니다. 그 뒤 수정자(fixer) 1명이 제안 사항을 반영했습니다.
헛발질들
Claude에게 1,448개 .zig 파일 전체에 워크플로를 돌리라고 했더니, 2분쯤 지나자 한 Claude가 커밋 전에 git stash를 실행했습니다. 다른 Claude는 git stash pop을 실행했고요. 그다음엔 git reset HEAD --hard. 서로를 밟고 있었던 겁니다! 그렇다고 Claude마다 별도 워크트리에 넣으면 디스크 공간이 바닥납니다. Bun의 git 저장소는 너무 크고, 결국 변경 사항은 한데 모아 컴파일하고 확인해야 하니까요.
그래서 Claude에게 워크플로를 고치라고 했습니다. git stash도 git reset도, 특정 파일 하나를 곧바로 커밋하는 것 이외의 어떤 git 명령도 실행하지 말 것. cargo도 금지. 느린 명령은 전부 금지.
그리고 Claude가 워크플로들을 재개했습니다. 잘 돌아갔습니다! 다만 너무 느려서, 각자 워크트리를 가진 워크플로 샤드 4개(총 워크트리 4개)로 쪼갰고, 각 샤드에서는 16개의 claude가 파일을 커밋하고 푸시했습니다.
드디어 코드 작성
이 모든 병렬화와 준비 작업 덕에, 정점에는 Claude가 분당 약 1,300줄의 코드를 썼습니다. 모든 코드 줄은 서로 별개인 적대적 리뷰어 2명(역시 Claude)의 리뷰를 받고 수정 한 차례를 거친 뒤 커밋됐습니다. 이 시점엔 아직 단 하나도 동작하지 않았습니다.
포팅 브랜치의 모든 커밋(머지 제외)을 시간 단위로 묶은 것. 가장 바빴던 한 시간: 커밋 695개.
타이밍이 들쭉날쭉한 게 보이나요? 이 작업이 돌던 EC2 인스턴스의 기본 IOPS를 올려두는 걸 깜빡했습니다. 느린 grep 명령 하나면 디스크 읽기·쓰기가 몇 분씩 얼어붙기에 충분했습니다.
컴파일 오류를 작업 큐로
코드를 전부 쓴 다음, Claude에게 모든 컴파일 오류를 고치는 워크플로를 작성하게 했습니다. 크레이트 단위로 진행했습니다.
페이즈 D가 돌아간 방식으로, 실제 커밋 1,610개(PDT 기준 5월 6일)를 재생한 것입니다. cargo check가 약 16,000개의 오류를 크레이트별로 묶어 파일에 기록했고, 워크플로는 이를 64개의 Claude에게 나눠줬습니다 — 워크트리 4개에 걸친 루프 16개, 각 루프마다 Claude 1개가 고치고 2개가 리뷰하고 1개가 반영합니다. 칩 하나하나가 실제 커밋 묶음입니다: 실제 크레이트에 안착해야만 카운터가 움직입니다. 오류 줄은 실제 커밋 제목입니다.
가장 까다로운 부류의 오류는 순환 의존성이었습니다.
우리 Zig 코드베이스는 하나의 컴파일 단위(사실상 크레이트 하나)였습니다. Rust 컴파일이 빨라지도록 새 코드베이스를 약 100개의 크레이트로 나누고 싶었는데, 원래 Zig 구현 대비 변경은 최소화하면서 순환 의존성을 피해야 했습니다. Rust 재작성 직전에 이를 위해 올린 제 PR로는 부족했습니다. 처음부터 다시 하는 대신, 순환 의존성이 있는 코드가 어디로 가야 하는지 분류해 전부 기록하는 워크플로를 하나 돌리고 — 그 리팩터링을 수행하는 워크플로를 또 하나 돌렸습니다.
순환 의존성을 고치자 약 16,000개의 컴파일 오류가 드러났습니다. 사람 1명에게는 어마어마한 수지만, 한꺼번에 달려드는 claude 64개에게는 터무니없는 수가 아닙니다.
병렬성을 최대로 끌어올리기 위해 워크플로는 크레이트 단위로 루프를 돌았습니다.
- 크레이트마다
cargo check를 실행하고, 출력을 파일별로 묶어 오류를 파일에 저장 - 그 크레이트 안의 컴파일 오류를 전부 수정
- 크레이트의 변경 사항에 적대적 리뷰어 2명
- 수정자 1명이 수정 사항을 반영
claude들이 서로를 밟지 않도록 cargo check는 맨 처음에만 실행했고, 다른 실행들과 마찬가지로 끝날 때까지 git은 금지였습니다.
또 한 번의 헛발질
Claude는 "크레이트를 전부 컴파일되게 하자"를 "컴파일 오류가 나는 함수는 스텁으로 채우자"로 해석했습니다. 우회책을 정당화하는 수상할 만큼 긴 설명 주석도 달기 시작했습니다. 그래서 적대적 리뷰어가 이런 것을 반려하도록 규칙을 추가했습니다:
우회책이 괜찮은 이유를 문단 하나 분량의 주석으로 정당화해야 한다면, 그 코드는 틀린 것이다 — 코드를 고쳐라.
프롬프트 한 번 고치고 몇 시간이 지나자, 이런 일은 사라졌습니다.
스모크 테스트
모델들은 "스모크 테스트"라는 말을 참 좋아합니다
cargo check가 통과한 뒤 다음 목표는 컴파일해서 bun --version을 실행하는 것이었습니다. 링커 오류가 났습니다. 그다음엔 시작하자마자 패닉이 났습니다.
그다음 목표는 bun test <file> 실행. 이게 되면 테스트를 돌리기 시작할 수 있습니다! bun CLI 서브커맨드를 도는 또 하나의 워크플로 차례입니다:
- 실패하는 스택트레이스를 해당 서브커맨드와 함께 각각 파일로 저장
- 서브커맨드별로 묶인 실패 스택트레이스마다 Claude 1개가 수정
- 적대적 리뷰어 2명
- 수정자 1명이 제안을 반영
로컬에서 테스트 스위트 통과시키기
이 워크플로는 테스트 파일 단위로 돌았습니다.
무작위 테스트 파일 약 100개를 코드베이스의 폴더 기준으로 4개 워크트리 중 하나에 샤딩해 실행합니다. 실패하는 테스트마다 스택트레이스와 오류를 파일에 저장하고, 구현자 1명이 수정을 제안하고, 적대적 리뷰어 2명이 검토한 뒤, 수정자 1명이 반영합니다.
더 많은 헛발질
우리 테스트 스위트에는 메모리 누수 테스트가 많고, 1분을 넘길 수 있는 통합 테스트도 몇 개 있습니다. 예를 들어 next dev를 실행해두고 핫 모듈 리로딩이 변경 사항을 100번 감지하는지 확인하는 테스트가 있습니다. 이런 테스트 몇 개는 디버그 빌드에서 타임아웃이 납니다.
머신의 최대 TCP 소켓 수를 소진하는 스트레스 테스트, 디스크에 기가바이트 단위로 읽고 쓰는 테스트, 프로세스를 1만 개쯤 띄우는 테스트도 있습니다.
이건 "제발 조심해줘" 수준보다 강한 격리가 필요했습니다. 그래서 systemd-run(cgroups)으로 메모리·CPU 사용량을 제한하고 pid 네임스페이스를 격리했습니다. 그래도 머신은 디스크 공간이 바닥나 몇 번이나 죽었습니다.
CI에서 테스트 스위트 통과시키기
첫 CI 실행 이틀 뒤, 실패 목록은 테스트 파일 972개에서 23개로 줄었습니다. 다시 하루 반이 지나자 Linux가 완전히 초록불이 됐고 — 그때 처음으로, 이 Rust 재작성이 정말 되겠구나 하는 느낌이 들었습니다.
테스트를 실행한 135개 빌드(BuildKite에서 수집한 420개 중)의 CI 테스트 샤드를 플랫폼별로 나타낸 것. 밝은 초록: 모든 샤드 통과. 어두운 초록: 실패는 없지만 도중에 중단된(다음 빌드로 대체된) 실행. 빨강: 샤드 하나 이상 실패. 각 레인에는 전체 스위트가 처음 통과한 시점이 찍혀 있습니다 — Linux의 60개 샤드는 Windows보다 거의 하루를 앞서 초록불이 됐습니다. 마지막 실패 테스트들이 잡힐 때까지 플랫폼들은 빨강으로 흔들렸고, 최종 전체 초록 빌드는 #54202였습니다.
머지까지 남은 기간은 순탄했습니다. 플랫폼별 CI 테스트 실패가 더는 없을 때까지 수정을 반복하는 워크플로 하나. Windows 관련 정리, 코드 중복 제거, unsafe 사용 축소, 전반적인 코드 정리를 위한 워크플로 몇 개.
Rust 재작성 머지하기
Bun 테스트 스위트의 100%가 모든 플랫폼 CI에서 통과하자(테스트가 건너뛰어지지 않고 실제로 실행되는지도 직접 확인했습니다), 로컬에서 이런저런 명령을 잔뜩 돌려 확인해본 뒤 — 머지 버튼을 눌렀습니다.
main에 머지한다고 버전 릴리스가 되는 건 아닙니다. 이 시점의 저는 재작성에 승부를 걸고 밀고 나갈 만큼은 확신했지만, 릴리스할 만큼 확신하지는 못했습니다.
통계
정점에는 이런 워크플로 4개를 각기 다른 워크트리에서 동시에 돌렸고, 워크플로마다 Claude가 16개씩 붙었습니다. 한 번에 약 64개의 Claude입니다.
전체 6,502개 커밋(머지 제외)을 재생한 것. 분홍 막대는 대부분 새 코드, 청록 막대는 대부분 삭제입니다. 줄 수 카운터는 도중의 모든 재작성까지 셉니다 — 최종 반영된 diff는 +1,009,272였습니다. 로그는 실제 커밋 메시지입니다.
건너뛰거나 지운 테스트 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줄, unsafe 키워드 약 13,000개). 그리고 그 블록의 78%는 한 줄짜리입니다 — C++에서 넘어온 포인터라든가, C 라이브러리 호출 한 번이라든가. (grep으로 잡히는 unsafe 키워드가 아예 없던) 충실한 Zig 포트에서 관용적인 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은 프런트엔드 개발 서버의 핫 리로드 그래프에 파일을 추가합니다. 릴리스 빌드에서 이 호출이 실행되지 않게 되면서, React를 쓰는 HTML 라우트가 있는 프로젝트에서 핫 리로드된 파일이 무효화되는 특정 상황에 HMR이 깨졌습니다: Cannot destructure property 'isLikelyComponentType' of 'k'. 디버그 빌드는 멀쩡했습니다. #30678
홀수 길이 슬라이스
Bun의 Zig 헬퍼 reinterpretSlice(u16, bytes)(슬라이스를 지원하는 내장 캐스트가 생기기 전에 만들어진)는 @divTrunc를 써서 끝의 홀수 바이트 하나를 무시했습니다. bytemuck::cast_slice는 그 대신 패닉을 냅니다. UTF-16 바이트 순서 표식(BOM) 뒤에 홀수 개의 바이트가 이어지면 Blob.text()가 문자열을 돌려주는 대신 프로세스를 패닉시키게 됐습니다. 원래대로 홀수 바이트를 무시하는 쪽으로 되돌렸습니다: &buf[..buf.len() & !1]. #31188
경계 검사
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;그 바람에 인턴 가능한 파일명 상한이 840만 개에서 270,272개로 내려갔는데, 실제 프로젝트가 이 한도에 부딪혔고, Zig에서 그대로 옮겨온 ptrs[4095] 오프바이원(off-by-one)에 도달할 수 있게 됐습니다. Rust는 끝을 넘어 쓰는 대신 패닉을 냈습니다. Zig도 ReleaseSafe였다면 이 경우 패닉을 냈을 겁니다(우리는 Windows에서만 그렇게 했습니다). #31503
comptime 포맷 문자열
Output.pretty는 <r>, <d> 색상 마커를 ANSI 이스케이프로 다시 써줍니다. 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는 패키지 이름을 ESC \로 끝나는 OSC 8 하이퍼링크로 출력합니다. 그 백슬래시가 마지막 <r>의 < 바로 앞에 오는 바람에 마커 파서가 백슬래시를 먹어버리고, r이 그냥 글자로 출력됩니다.
oxfmt라고 나와야 하는데 oxfmtr라고 나옵니다
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의 메모리 누수 여러 건을 고쳐줬습니다.
계측 가능한 메모리 누수를 전부 잡았다
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로 같은 시도를 한 PR은 머지되지 않았습니다. Drop에 해당하는 것이 없어, 머지해도 되겠다는 확신을 갖기가 더 어려웠기 때문입니다.
더 작아진 바이너리
Rust 재작성의 초기 변경만으로 바이너리 크기가 Windows에서 3.8MB, macOS에서 5.5MB, Linux에서 6.8MB 줄었습니다. Zig 코드에서 comptime을 과하게 쓴 탓이 큽니다.
그 첫 감량 이후 팀은 링커 최적화로 바이너리를 더 줄일 기회를 찾아 나섰습니다. 동일 코드 접기(Identical Code Folding), ICU에서 안 쓰는 데이터 제거, libicu의 작은 부분들을 zstd 사전으로 필요할 때만 압축 해제하기 같은 것들입니다.
Rust 재작성, ICU 변경, 동일 코드 접기를 합치면 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.start·llvm.lifetime.end 인트린식을 내보내고, 덕분에 LLVM이 스택 슬롯을 재사용할 수 있습니다. 중첩 스코프가 많은 큰 함수의 스택 사용량이 크게 줄어듭니다.
예전에는 특별히 큰 함수들을 여러 개의 작은 함수로 쪼개는 식으로 미해결 이슈를 수동으로 우회했습니다.
2~5% 더 빠르다
Rust는 C/C++와 Rust 사이의 언어 간 링크 타임 최적화(cross-language LTO)를 지원해서, 프로그래밍 언어의 경계를 넘는 인라이닝이 가능합니다(이거 정말 멋지지 않나요!!).
Linux x64(EC2, Xeon Platinum 8488C)에서 Bun v1.3.14와 Bun v1.4.0을 벤치마크했습니다. 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일 출시)부터는 Rust로 포팅된 Bun을 씁니다. Linux에서 시작 속도가 10% 빨라진 것 말고는 거의 아무도 눈치채지 못했습니다. 조용한 게 좋은 겁니다.
프로덕션 텔레메트리로 본 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이 됩니다. 지금 카나리 채널에서 쓸 수 있습니다 — 문제를 발견하면 제보해주세요:
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의 빌림 검사기, Miri(CI에서 돌리는 코드 범위가 점점 늘고 있습니다), LeakSanitizer, 그리고 파서에 대한 24시간 커버리지 기반 퍼징. 리팩터링할 거리가 아직 남아 있지만, 출발이 아주 좋습니다.
이번 Rust 재작성은 코드베이스를 속속들이 아는 엔지니어 팀이 붙어도 1년은 걸렸을 일입니다. Fable을 쓰면서 Claude Code를 밀착 모니터링한 엔지니어 1명으로, 시작부터 전 플랫폼 테스트 스위트 100% 통과까지 11일 만에 도달했습니다.
오늘의 엔지니어 한 명은 1년 전보다 훨씬 많은 일을 할 수 있습니다.