이 글은 Bun Blog의 번역본입니다. 에이전트 엔지니어링에 대한 새로운 미래를 제시하는 점에서 큰 의미가 있습니다.
정보
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 호환 모듈 해석(module resolution)
HTTP/1.1 & WebSocket 클라이언트
fs, net, tls를 비롯한 수십 개 모듈의 Node.js API 구현
Bun의 초기 버전은 내가 1년 만에, 비좁은 오클랜드 아파트에서, LLM이 등장하기 전에, Zig로 썼다. Bun처럼 야심차게 범위를 잡은 프로젝트의 기본 결말은 GitHub 프로필 페이지에 있는 죽은 사이드 프로젝트들의 무덤에 합류하는 것이다. Zig가 Bun을 가능하게 했다. Zig가 아니었다면 1년 안에 이만큼 만들어내지 못했을 것이다.
오늘날 Bun CLI는 월 2,200만 회 이상 다운로드된다. Claude Code와 OpenCode 같은 인기 도구들이 Bun을 런타임으로 채택했다. Vercel, Railway, DigitalOcean 등은 Bun을 지원한다.
Bun의 범위는 안정성 측면에서도 도전이었다.
다음은 Bun v1.3.14에서 고친 버그들의 작은 표본이다.
heap-use-after-free crash in node:zlib when calling .reset() on a zlib, Brotli, or Zstd stream while an async .write() is still in progress on the threadpool
use-after-free crash in node:zlib when an onerror callback issued a re-entrant write() followed by close() on native handles
use-after-free crashes in node:http2 when re-entrant JS callbacks (e.g. session.request() inside a timeout listener, an options getter, or a write callback) triggered a hashmap rehash, invalidating internal stream pointers
use-after-free in UDPSocket.send() and sendMany() where user code in valueOf() or toString() callbacks could detach an ArrayBuffer between payload capture and the actual send
crash and out-of-bounds read in Buffer#copy and Buffer#fill when a valueOf callback detaches or resizes the underlying ArrayBuffer during argument coercion
heap out-of-bounds write in UDPSocket.sendMany() when the socket's connection state changed mid-iteration via user JS callbacks
memory leak in crypto.scrypt where the callback and protected password/salt buffers were never released when the output buffer allocation failed
SSLWrapper.init leaked the strdup'd passphrase on error paths
memory leak in tlsSocket.setSession() where each call leaked one SSL_SESSION (~6.5 KB per call) due to a missing SSL_SESSION_free after d2i_SSL_SESSION
memory leak where fs.watch() watchers were never garbage collected after .close(), caused by a reference count underflow that permanently pinned each watcher as a GC root
double-free crash in the CSS parser when background-clip had vendor prefixes and multi-layer backgrounds
DuplexUpgradeContext was never freed — a full leak per tls.connect({ socket: duplex })
race condition crash in MessageEvent where the GC marker thread could observe a torn variant in m_data during concurrent access from a BroadcastChannel or MessagePort
이런 종류의 버그를 영원히 하나씩 고쳐 나갈 수도 있었다. 하지만 우리를 믿고 쓰는 사용자들에게는 그보다 더 잘할 의무가 있고, 이런 버그의 재발을 체계적으로 막아야 한다.
이미 하고 있는 것들
Zig 컴파일러를 패치해 Address Sanitizer 지원을 추가했다. 모든 커밋마다 ASAN을 켠 채 테스트 스위트를 실행한다.
Windows에는 Zig의 안전 검사가 켜진 ReleaseSafe 빌드를 배포한다.
V8과 JavaScriptCore가 사용하는 JavaScript 엔진 퍼저 Fuzzilli로 Bun의 런타임 API를 상시 퍼징한다.
엔드투엔드 메모리 누수 테스트가 매우 많다.
이 정도면 많은 프로젝트가 하는 것보다 많다.
???: 걍 똑똑하고 실수 안 하면 되는 거 아님? 진짜모름
그래도 버그 수정 목록을 보면 기분이 나빴고, Bun의 크래시를 걱정하며 잠드는 데 지쳐 있었다. Zig을 탓하는 게 아니다. 다른 Zig 사용자들은 우리가 겪은 버그를 겪지 않고, GC와 수동 관리 메모리를 섞어 쓰는 일은 소프트웨어에서 워낙 드물어서 어떤 언어도 그것을 위해 설계되지 않았다. Zig가 아니었다면 여기까지 오지도 못했을 테고, 늘 고마울 것이다. 아주 최근까지, Bun 같은 프로젝트에서 프로그래밍 언어 선택은 되돌릴 수 없는 결정이었다.
JavaScript는 가비지 컬렉션 언어이고, JavaScriptCore(그리고 V8) 같은 현대 JavaScript 엔진에는 예외 처리와 가비지 컬렉터를 둘러싼 엄격한 규칙이 있다. Zig는 C처럼 메모리를 대신 관리해 주지 않으며, 많은 프로젝트에는 이 트레이드오프가 Zig를 쓸 훌륭한 이유가 된다. Zig에는 생성자/소멸자가 없고, 대부분의 정리 코드는 각 호출 지점에서 defer로 직접 명시하게 되어 있다.
Bun의 경우, 가비지 컬렉션되는 값과 수동 관리되는 값의 수명을 올바르게 다루는 일이 안정성 문제의 주요 원인이었다. 대개는 작은 메모리 누수였고, 가끔은 크래시였다. 모든 메모리 할당을 꼼꼼히 검토해야 한다. 이 바이트들은 어디서 해제되는가? 딱 한 번만 해제된다는 것을 어떻게 보장하는가? JavaScript 예외는 제대로 확인했는가? 이 가비지 컬렉션 포인터는 보수적(conservative) 스택 스캐너에 보이는가? 이것은 GC 메모리인가, 수동 관리 메모리인가?
안정성 문제는 가능한 한 일찍 아는 것이 최선이다. 퍼징은 코드가 머지된 뒤에 일어난다. CI는 코드가 푸시될 때 일어난다. 런타임 안전 검사와 어드레스 새니타이저는 코드가 실행될 때(바라건대 CI 전, 개발 중에) 일어난다.
이 부류의 문제를 줄이는 흔한 방법 하나는, 정리가 필요한 코드에서 정리 코드가 항상 정확히 한 번 실행되도록 보장하는 것이다. Zig는 숨은 제어 흐름이 없는 단순한 언어로 설계되었고, 그래서 C++의 암묵적 ~Destructor나 Rust의 암묵적 Drop보다, 스코프 끝에서 코드를 실행하는 명시적 defer 키워드를 선호한다.
언어
정리 방식
Zig
defer, errdefer
C++
~Destructor, &&Move
Rust
Drop
Zig 코드에서 정리 코드는 정확히 언제 실행해야 할까? 같은 *T를 여러 함수에 넘긴다면, 더는 접근할 수 없어 정리해도 되는 시점을 어떻게 알까? 일부 함수가 호출된 뒤에도 그 메모리를 계속 참조해야 한다면? 우리의 현재 접근은 다음의 혼합이다.
접근 가능한 범위가 명확한 곳에서의 아레나(arena) 수명. 파서 상태는 호출 함수를 벗어나지 않으므로 AST 노드에 잘 맞는다
참조 카운팅
정말 세심하게 주의를 기울이기
많은 프로젝트가 이런 질문에 스타일 가이드로 답한다. Zig에서는 TigerBeetle의 TigerStyle이 한 예이고, Google의 31,000 단어짜리 C++ 스타일 가이드도 있다. 스타일 가이드의 난점은 집행이다. 가이드가 지켜지는지 어떻게 보장하나? 역사적으로 그 답은 코드 리뷰였고, 린터와 정적 분석기가 최선 노력 수준으로 거들었다.
소유권에 대한 기대를 타입 시스템에 명시적으로 적어 넣은 엄격한 스타일 가이드도 Bun에게는 실제 선택지였다. Zig에는 연산자 오버로딩이 없으므로, 결국 이런 모양의 코드가 잔뜩 생겼을 것이다.
Bun 코드의 약 20%는 C++로 작성되어 있고, Bun은 여러 C/C++ 라이브러리를 임베드한다.
Safari를 구동하는 JavaScript 엔진, JavaScriptCore
HTTP/WebSocket 서버이자 이벤트 루프인 uWebSockets & usockets
HPACK과 HTTP/3 라이브러리, lshpack & lsquic
Google의 OpenSSL 포크, BoringSSL
SQLite
Bun에게는 Zig 대신 C++도 합리적인 선택이었을 것이다. 생성자와 소멸자를 얻고, 수많은 extern "C" 래퍼 코드를 지울 수 있었을 것이다.
하지만 여전히 코드 리뷰로 집행하는 스타일 가이드에 의존해야 했을 것이고, ASAN이 있어도 메모리 손상과 메모리 누수는 계속 일어났을 것이다.
근데 왜 Rust?
저 목록의 버그 중 큰 비율이 use-after-free, double-free, 그리고 에러 경로에서의 "해제 깜빡함"이다. 안전한 Rust에서 이것들은 컴파일 에러이고, Drop을 통한 RAII식 자동 정리가 있다. 컴파일 에러는 스타일 가이드보다 나은 피드백 루프다.
역사적으로 재작성은 끔찍한 발상이다. 주석을 제외하면 Bun은 535,496줄의 Zig다. 다른 언어로 재작성하려면 소규모 엔지니어 팀이 꼬박 1년이 걸린다. 그동안 버그 수정, 보안 수정, 기능 개발을 동결해야 한다는 뜻이다. 배포 가능한 결과물에 도달하는 가장 덜 위험한 접근은, 이미 Bun 테스트에 쓰고 있는 것과 정확히 같은 테스트 스위트를 사용해, 행동 변화를 최소화한 Zig to Rust로의 기계적 포팅이었다.
다행히 Bun의 자체 테스트 스위트는 TypeScript로 작성되어 있어 런타임의 구현 언어에 의존하지 않는다.
사용자에게 아무 영향도 주지 못하는 1년은 우리가 고려할 수 있는 현실적 선택지가 아니었다. 그래서 안정성 문제를 코드 스타일 집행으로 고치는 것이 최선의 수였고, Bun 코드베이스에 Rust에서 영감을 받은 스마트 포인터를 추가했을 때의 계획이 바로 그것이었다.
하지만 솔직히, 하기 싫었다. 자체 제작 스마트 포인터는 Rust보다 인체공학은 나쁘면서 보장은 하나도 없다.
그 대신, Anthropic의 새 모델이 Bun을 Rust로 재작성할 수 있는지 일주일 동안 테스트해 보면 어떨까?
처음에는 될 것이라 기대하지 않았다. 며칠 지나자 테스트 스위트의 상당 비율이 통과하기 시작했고, 새 Rust 코드가 원래 Zig 코드베이스와 얼마나 맞아떨어지는지가 보였다. 내 생각은 "해볼 만하다"에서 "이것을 머지하겠다"로 바뀌었다.
클로드야 Bun을 Rust로 다시 써줘
이 일을 엉망으로 하는 방법은 많다. 예컨대 Claude에게 "Bun을 Rust로 재작성해. 실수하지 마."라고 프롬프트하고 잘 되길 기도하는 것... 내가 한 일은 그것이 아니다.
사람이라면 어떻게 할지 생각해 보라. 첫 번째 큰 질문:
점진적 재작성인가, 한 번에 전부인가?
초기 Bun을 만들며 esbuild의 트랜스파일러를 (LLM 없이) Go에서 Zig로 포팅해 본 경험상, 한 번에 전부가 낫다. 점진적 재작성은 언젠가 지워지길 바라는 임시 코드를 추가하게 되고, 단기부터 중기까지 내내 고통스럽다.
두 번째 큰 질문: 어떻게?
같은 아키텍처, 같은 성능, 같은 기능 셋을 유지한 채, 보로우 체커 같은 Rust의 언어 기능까지 얻으면서, Rust로 된 Bun을 이전과 같은 Bun으로 유지하려면? 재작성 뒤에도 팀이 계속 유지보수할 수 있으려면?
Zig 코드를 Rust로 트랜스파일한 것처럼 보이는 재작성을 한다. Bun v1.4가 출시된 뒤에 unsafe 사용을 줄이고 더 관용적인(idiomatic) Rust처럼 보이도록 점진적으로 리팩터링하면 된다.
큰 질문은 이 둘뿐이다. 나머지는 전부 전술이다.
작성하고, 리뷰하는 루프들
소프트웨어 엔지니어의 일상 업무 상당 부분은 루프로 과단순화할 수 있다.
javascript
// 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);}
태스크에는 컨텍스트가 딸려 있다(Jira 티켓, GitHub 이슈 등). result는 그것을 고치기 위해 쓴 코드다. 코드 리뷰어(들)가 변경을 검토해 회귀와 정확성을 확인한다. 그리고 그 피드백을 반영한다.
나는 11일 동안 연속으로 돌린 약 50개의 Claude Code 동적 워크플로우(dynamic workflow)로 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 code , 동적 워크플로우: 적대적 리뷰 머지 전에 적대적 리뷰가 잡아낸 수많은 버그 중 3개
for stdio in [spawned_stdout, spawned_stderr] { match stdio { StdioResult::Buffer(mut pipe) => { // pipe: Box<uv::Pipe> — hand it to libuv to close pipe.close(Subprocess::on_pipe_close) } StdioResult::Fd(fd) => fd.close(), StdioResult::Unavailable => {} }}
uv_close는 비동기다. libuv는 다음 루프 틱까지 원시 핸들 포인터를 쥐고 있다가 on_pipe_close를 호출하고, 그 콜백이 할당을 해제한다. 그런데 pipe는 이 match arm이 끝날 때 드롭되는 Box다. libuv는 해제된 메모리를 쥐게 되고, close 콜백은 그것을 다시 한 번 해제한다. use-after-free에 이어 double-free.
f0a454376c7 , win-review: js_bun_spawn_bindings.rs leak Box<uv::Pipe> before async uv_close to avoid UAF/double-free in on_pipe_close
적대적 리뷰어가 실제로 잡아낸 세 개의 버그다. 인용된 각 커밋의 제목 줄에는 리뷰 출처가 기록되어 있다. 셋 다 컴파일되었고, 셋 다 그럴듯해 보였다. 리뷰어는 자기만의 컨텍스트 윈도우를 가진 두 번째 Claude다. diff만 받을 뿐 그 외에는 아무것도, 구현자의 추론조차 받지 않은 채, 코드가 틀린 지점을 찾으라는 지시를 받는다. 코드는 인용된 커밋에서 압축한 것이다. 같은 버그, 같은 수정.
그래서 어떤 모습이었나
크고 비싼 일을 앞두고 있다면, 먼저 리스크를 줄여 두는 편이 시간과 돈을 아낀다.
준비
코드를 한 줄도 쓰기 전에, 나는 약 3시간 동안 Claude와 함께 우리 Zig 코드베이스의 패턴을 Rust에 밀접하게 매핑하는 방법을 논의했다. Claude는 이 논의를 PORTING.md 문서로 정리했고, 그 문서는 나중에 Hacker News에 오르기도 했다.
다음 질문: 메모리를 수동으로 관리하는 코드에 Rust의 수명(lifetime)을 어떻게 입히는가?
여기서 Claude에게 대략 이렇게 프롬프트했다.
나: 코드베이스의 모든 구조체 필드에 대해 적절한 수명을 분석하는 동적 워크플로우를 시작하자. 이 워크플로우는 모든 파일의 모든 구조체 필드를 읽고 제어 흐름을 추적해야 해. 먼저 Rust로 표현하기에 수명이 복잡한 구조체 필드를 찾고, 그 필드의 수명을 제안하고, 적대적 리뷰 에이전트 2개로 그 수명을 리뷰한 다음, 피드백을 반영해서 다른 claude들이 참고할 LIFETIMES.tsv로 정리해 줘.
그다음 PORTING.md와 LIFETIMES.tsv를 함께 놓고 적대적 리뷰를 한 차례 더 돌려 상충하는 제안을 고치고 전부 재확인했다. 나도 직접 읽어 보았다.
시범 운행
Claude에게 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 명령(git stash, git reset 등)은 절대 실행하지 말라고 지시했다. cargo도 금지. 느린 명령은 일절 금지.
그러고 나서 Claude가 워크플로우를 재개했다. 됐다! 다만 너무 느려서, 각자 워크트리를 가진 4개의 워크플로우 샤드로 쪼갰다(워크트리 총 4개). 각 샤드에서는 16개의 claude가 파일을 커밋하고 푸시했다.
드디어 코드 쓰기 시작
이 모든 병렬화와 사전 작업 덕분에, 정점에서 Claude는 분당 약 1,300줄의 코드를 썼다. 모든 코드 라인은 두 명의 독립적인 적대적 리뷰어(역시 Claude)의 리뷰를 거쳤고, 커밋 전에 한 차례 수정을 받았다. 그리고 그 시점에는 그중 어느 것도 아직 동작하지 않았다.
11일 × 24시간 , PDT 5,383 커밋 , 시간당 1695 커밋
포트 브랜치의 모든 커밋(머지 제외)을 시간 단위로 묶은 것. 최고 시간대: 695 커밋.
타이밍이 들쭉날쭉한 것이 보이는가? 이것을 돌린 EC2 인스턴스의 기본 IOPS를 올려 두는 것을 깜빡했다. 느린 grep 명령 하나면 디스크 읽기, 쓰기가 몇 분씩 얼어붙기에 충분했다.
컴파일 에러를 작업 목록으로 쓰기
코드를 전부 쓴 뒤, Claude에게 모든 컴파일 에러를 고치는 워크플로우를 쓰게 했다. 크레이트 단위로 진행했다.
✻ claude code , 동적 워크플로우 남은 에러 약 16,000개 , 5월 6일(수) 오전 12
PDT , errors.txt , 수정 커밋 0
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
64개의 claude에게 분배 , 1 수정 / 2 리뷰 / 1 반영
페이즈 D의 작동 방식을 실제 커밋 1,610개로 재생한 것(5월 6일, PDT): cargo check가 약 16,000개의 에러를 크레이트별로 묶어 파일에 기록했고, 워크플로우가 이를 64개의 Claude에게 나눠 주었다. 4개 워크트리에 걸친 16개 루프, 각 루프는 Claude 하나가 수정, 둘이 리뷰, 하나가 반영. 모든 칩은 실제 커밋 배치이며, 실제 크레이트에 안착해야만 카운터가 움직인다. 에러 라인은 실제 커밋 제목이다.
가장 까다로운 부류의 에러는 순환 의존성이었다.
우리 Zig 코드베이스는 하나의 컴파일 단위(사실상 하나의 크레이트)였다. Rust 컴파일이 빨라지도록 새 Rust 코드베이스를 약 100개의 크레이트로 나누고 싶었지만, 원래 Zig 구현 대비 변경을 최소화하면서 순환 의존성을 피해야 했다. Rust 재작성 직전에 이를 위해 올린 내 PR은 불충분했다. 처음부터 다시 하는 대신, 순환 의존성이 있는 코드가 어디로 가야 하는지 분류해 전부 기록하는 워크플로우를 하나 돌리고, 이어서 그 리팩터링을 수행하는 워크플로우를 또 하나 돌렸다.
순환 의존성을 고치자 약 16,000개의 컴파일 에러가 드러났다. 사람 1명에게는 어마어마한 숫자지만, 동시에 도는 64개의 claude에게는 그렇게 미친 숫자가 아니다.
병렬성을 극대화하기 위해 워크플로우는 크레이트 단위로 루프를 돌았다.
각 크레이트에 대해 cargo check를 실행하고, 출력을 파일별로 묶어 에러를 파일에 저장
그 크레이트 안의 모든 컴파일 에러 수정
크레이트 변경분에 대한 적대적 리뷰어 2명
수정자 1명이 수정 사항 반영
claude들이 서로를 밟지 않도록 cargo check는 맨 처음에만 실행했고, 다른 실행들과 마찬가지로 끝까지 git 금지였다.
또 한 번의 헛발질
Claude는 "모든 크레이트를 컴파일되게 하자"를 "컴파일 에러가 나는 함수는 스텁으로 처리하자"로 해석했다. 게다가 우회책을 정당화하는 수상할 만큼 긴 설명 주석을 달기 시작했다. 그래서 적대적 리뷰어가 기각할 규칙을 하나 추가했다.
우회책이 왜 괜찮은지 정당화하는 데 한 문단짜리 주석이 필요하다면, 그 코드는 틀린 것이다. 코드를 고쳐라.
프롬프트 한 번 고치고 몇 시간 지나자, 이런 일은 멈췄다.
모델들은 "스모크 테스트"라는 말을 참 좋아한다
cargo check가 통과한 다음 목표는 컴파일해서 bun --version을 실행하는 것이었다. 링커 에러가 났다. 그다음에는 시작하자마자 패닉이 났다.
다음 목표는 bun test <file> 실행. 그것이 되면 테스트를 돌리기 시작할 수 있다! 또 워크플로우 차례다. bun CLI 서브커맨드를 도는 루프:
실패하는 각 스택트레이스를 해당 서브커맨드와 함께 파일에 저장
서브커맨드별로 묶인 실패 스택트레이스마다 Claude 1명이 수정
적대적 리뷰어 2명
수정자 1명이 제안 반영
로컬에서 테스트 스위트 통과시키기
이 워크플로우는 테스트 파일 단위로 루프를 돌았다.
코드베이스의 폴더 기준으로 4개 워크트리 중 하나에 샤딩된 무작위 테스트 파일 약 100개를 실행한다. 실패한 각 테스트에 대해 스택트레이스와 에러를 파일에 저장하고, 구현자 1명이 수정을 제안하고, 적대적 리뷰어 2명이 검토한 뒤, 수정자 1명이 반영한다.
더 많은 헛발질
우리 테스트 스위트에는 메모리 누수 테스트가 많고, 1분을 넘길 수 있는 통합 테스트도 한 줌 있다. 예를 들어 next dev를 실행하고 핫 모듈 리로딩이 변경을 100번 감지할 수 있는지 확인하는 테스트. 이 중 몇 개는 디버그 빌드에서 타임아웃이 난다.
머신의 최대 TCP 소켓 수를 소진하는 스트레스 테스트, 디스크에 기가바이트 단위로 읽고 쓰는 테스트, 프로세스를 약 1만 개 스폰하는 테스트도 있다.
이것은 "부탁할게" 수준보다 강한 격리가 필요했고, 그래서 systemd-run(cgroups)으로 메모리와 CPU 사용량을 제한하고 pid 네임스페이스를 격리했다. 그래도 머신은 디스크가 꽉 차서 몇 번이나 죽었다.
CI에서 테스트 스위트 통과시키기
첫 CI 실행 이틀 뒤, 실패 목록은 972개 테스트 파일에서 23개로 줄었다. 그로부터 하루 반 뒤, Linux가 완전히 그린이 되었다. 그리고 처음으로, 이 Rust 재작성이 정말로 되겠구나 하는 느낌이 들었다.
✻ claude code , 동적 워크플로우: buildkite , 플랫폼별 그린을 향한 경주 Windows가 꼴찌 , 5월 11일 오전 6
PDT
6 / 6 플랫폼 그린 , 빌드 #54202 , 5월 14일(목) 오전 12
PDT
macOS x64 , 2 샤드 ✓ Linux arm64 , 60 샤드 ✓ Linux x64 , 60 샤드 ✓ macOS arm64 , 4 샤드 ✓ Windows x64 , 8 샤드 ✓ Windows arm64 , 8 샤드 ✓
✓ 6개 플랫폼 전부 그린 , 빌드 #54202 → 머지
테스트를 실행한 CI 빌드 135개(BuildKite에서 수집한 420개 중)의 플랫폼별 테스트 샤드. 밝은 그린: 모든 샤드 통과. 어두운 그린: 실패는 없었지만 실행이 도중에 중단됨(다른 빌드로 대체). 레드: 최소 한 샤드 실패. 각 레인에는 전체 스위트가 처음 통과한 시점이 찍혀 있다. Linux의 60개 샤드는 Windows보다 거의 꼬박 하루 먼저 그린이었다. 마지막 실패 테스트들이 떨어져 나갈 때까지 플랫폼들은 계속 레드로 흔들렸다. 최종 올그린 빌드는 #54202였다.
머지까지 남은 기간은 순탄했다. 플랫폼별 CI 테스트 실패가 없어질 때까지 수정을 반복하는 워크플로우 하나. Windows 관련 정리, 코드 중복 제거, unsafe 사용 축소, 전반적인 코드 정리를 위한 워크플로우 몇 개.
Rust 재작성 머지
Bun 테스트 스위트의 100%가 모든 플랫폼의 CI에서 통과하자(그리고 테스트가 실제로 실행되고 있으며 스킵되고 있지 않다는 것을 직접 확인하자), 로컬에서 이런저런 명령을 돌려 확인한 다음, 머지 버튼을 눌렀다.
main에 머지하는 것이 곧 버전 릴리스는 아니다. 이 시점의 나는 재작성에 전념하기로 할 만큼은 확신했지만, 릴리스할 만큼은 아직 아니었다.
통계
정점에는 이런 워크플로우 4개를 동시에, 각각 별도의 워크트리에서, 워크플로우당 16개의 Claude로 돌렸다. 한 번에 약 64개의 Claude.
git log , claude/phase-a-port , 최고 기록: 1분에 58 커밋
+0 , 작성된 줄 수(도중의 재작성 포함) , 5월 4일(월) 오전 7
PDT
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 라이브러리로의 호출 하나다. 충실한 Zig 포트(grep으로 잡히는 unsafe 키워드가 아예 없었다)에서 관용적인 Rust로 리팩터링해 가면서 이 수치는 내려갈 것으로 기대하지만, JavaScriptCore 같은 C/C++ 라이브러리는 계속 쓸 것이므로 순수 Rust 프로젝트보다는 언제나 unsafe가 많을 것이다.
포팅 실수
Rust 재작성의 초점은 안정성이지만, 이만한 규모의 변경을 내보내면서 회귀(regression)를 0으로 만드는 것은 불가능하다.
이 재작성으로 알려진 회귀 19건이 생겼고, 전부 수정되었다.
회귀 대부분은 두 언어에서 문법적으로는 동일하지만 의미적으로는 다른 코드에서 나왔다.
debug_assert! 안의 부작용
이 두 스니펫은 비슷해 보이지만 다르게 동작한다. Zig의 assert는 함수라서 인수가 모든 빌드에서 실행된다. Rust의 debug_assert!는 매크로라서, 릴리스 빌드에서는 insert_stale 호출을 포함해 표현식 전체가 지워진다.
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로 잡았다. 포트에는 플레이스홀더가 남아 있었다.
rust
/// ... 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의 defer는 정리가 필요할 수 있는 모든 호출 지점마다 일일이 추가해야 한다. 정리를 깜빡하기(메모리 누수) 쉽고, 좀처럼 타지 않는 에러 처리 코드에서 정리를 두 번 실행하기(double-free)도 쉽다. Rust의 Drop은 값에 더 이상 접근할 수 없게 되면 자동으로 실행된다. "숨은 제어 흐름 없음"을 내주고 흔한 함정 하나를 막은 셈이다.
Drop은 에러 처리 코드의 파일 경로와 관련된 Bun의 메모리 누수 여러 건을 고쳤다.
계측 가능한 메모리 누수를 전부 고쳤다
Bun의 LeakSanitizer 통합을 개선해 네이티브 코드의 모든 메모리 할당을 추적하게 했다.
한 가지 예: 프로세스 안에서 Bun.build()를 호출할 때마다 수 메가바이트의 메모리가 샜다. 파싱된 소스 텍스트와 AST 심볼 테이블이 자기가 속한 빌드보다 오래 살아남은 것이다.
javascript
// Bundle the same 60-module project 2,000 times in one processfor (let i = 0; i < 2_000; i++) { await Bun.build({ entrypoints: ["./index.js"], minify: true, sourcemap: "external", });}
Bun v1.3.14에서는 빌드 한 번마다 약 3MB가 영원히 샌다. 요청마다 번들하는 dev 서버류의 도구는 결국 메모리가 바닥난다. 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을 과하게 썼기 때문이다.
pic.twitter.com/RQiMNMNo8C (Bun @bunjavascript, 2026년 5월 18일)
그 초기 감소 이후, 팀은 Identical Code Folding 같은 링커 최적화, ICU에서 미사용 데이터 제거, libicu의 작은 부분들을 zstd 사전으로 필요할 때 지연 압축 해제하기 등 바이너리 크기를 더 줄일 기회를 탐색했다.
Rust 재작성, ICU 변경, identical code folding을 합치면 Linux와 Windows에서 Bun의 바이너리 크기가 약 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 재작성을 머지하기 전 몇몇 테스트 실패의 원인이 되기도 했다.
text
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: RangeErrorReceived function did not throwReceived 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 사이의 크로스 랭귀지 링크 타임 최적화(LTO)를 지원하며, 이는 프로그래밍 언어의 경계를 넘는 인라이닝을 가능하게 한다(얼마나 멋진가!!).
Bun v1.3.14와 Bun 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
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 시작 시간(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로 사용할 수 있다. 발견한 문제는 신고해 달라.
bash
bun upgrade --canary
유지보수성
나와 팀에게 새 Rust 코드베이스는 옛 Zig 코드베이스와 매우 비슷하게 느껴진다. 예를 들어, 원래 Zig 코드와 새 Rust 코드의 스니펫이다.
zig
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; } // ... } // ...}
rust
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년 전보다 훨씬 많아졌다.
Comments0
No comments yet. Be the first to share your thoughts.