Bun의 초고속 Rust 재작성에서 무엇을 배울 수 있을까?
TMThttps://blog.pragmaticengineer.com/the-pulse-what-can-we-learn-from-buns-rapid-rust-rewrite-with-ai/
지난주 샌프란시스코에서 JavaScript 런타임 Bun의 창시자 Jarred Sumner를 만나, Bun을 Zig에서 Rust로 재작성한 이야기를 더 듣고 싶었습니다. 하지만 당시 Jarred는 말을 아꼈습니다. 마이그레이션에 사용한 도구인 Fable이 미국 정부의 수출 규제로 쓸 수 없는 상태였기 때문입니다.
다행히 지금은 상황이 해결되어 Fable을 전 세계에서 쓸 수 있게 되었고, Jarred도 프로젝트에 대한 상세한 글을 공개했습니다. 마이그레이션 이야기에 앞서 배경부터 짚어보겠습니다:
Bun은 수많은 프로덕션 소프트웨어가 의존하는 복잡한 프로젝트입니다. Bun 자체가 하는 일이 많습니다:
- JavaScript, TypeScript, CSS의 트랜스파일, 미니파이, 번들링
- 테스트 러너
- 패키지 매니저(npm 호환)
- 그 외: 모듈 해석(module resolution), WebSocket 클라이언트, Node.js 구현체와 수많은 모듈
오늘날 Bun은 월 2,200만 회 다운로드되고, Claude Code와 OpenCode 같은 소프트웨어가 Bun에 의존하며, Vercel, Railway, DigitalOcean 같은 호스팅 업체는 Bun을 자체 기능으로 지원합니다.
왜 재작성인가?
Zig는 메모리 안전 언어가 아니어서 메모리 관련 버그가 끊이지 않았습니다. Jarred는 Bun 최신 버전에서 발생한 메모리 관련 버그를 나열합니다. 메모리 누수, 메모리 문제로 인한 크래시, 힙 범위 밖 쓰기(heap-out-of-bounds write) 같은 것들입니다. Bun 팀이 메모리 문제를 줄이려고 Zig 컴파일러를 패치하고 엔드투엔드 메모리 누수 테스트까지 갖춘 뒤에도 그랬습니다. Jarred의 말입니다:
"버그 수정 목록을 보면 착잡했고, Bun의 크래시를 걱정하며 잠드는 데 지쳐 있었습니다. Zig를 탓하는 건 아닙니다. 다른 Zig 사용자들은 우리가 겪은 버그를 겪지 않고, GC와 수동 메모리 관리를 섞어 써야 하는 소프트웨어는 워낙 드물어서 그런 경우를 제대로 염두에 두고 설계된 언어가 사실상 없기 때문입니다. (...)
Bun에서는 가비지 컬렉션되는 값과 수동으로 관리되는 값의 수명을 올바르게 다루는 일이 안정성 문제의 주요 원인이었습니다. 대부분은 작은 메모리 누수였고 가끔은 크래시였습니다. 모든 메모리 할당을 꼼꼼히 검토해야 합니다. 이 바이트들은 어디서 해제되는가? 딱 한 번만 해제된다는 걸 어떻게 보장하는가? JavaScript 예외는 제대로 확인했는가? 이 가비지 컬렉션 포인터가 보수적 스택 스캐너에 보이는가? 이 메모리는 가비지 컬렉션 대상인가, 수동 관리 대상인가?"
메모리 안전을 보장하면서도 성능이 좋은 언어로 옮기면 이런 오류를 없앨 수 있는데, Rust가 바로 그 조건에 맞는 언어였습니다. Jarred:
"그 목록의 버그 중 상당수는 use-after-free, double-free, 그리고 오류 처리 경로에서 해제를 깜빡한 경우입니다. 안전한(safe) Rust에서는 이런 것들이 컴파일러 오류가 되고, Drop을 통해 RAII 방식으로 자동 정리됩니다. 스타일 가이드보다 컴파일러 오류가 더 나은 피드백 루프입니다."
하지만 Rust로 전면 재작성하는 것은 언제나 끔찍한 발상이었습니다. 적어도 예전에는 그랬습니다. 견딜 수 없을 만큼 오래 걸렸을 테니까요:
재작성에는 두 가지 문제가 있습니다. 너무 오래 걸린다는 것, 그리고 정말이지 너무너무 오래 걸린다는 것입니다. 재작성을 해 본 개발자라면 일이 보통 어떻게 흘러가는지 알 것입니다:
- 얼마나 걸릴지 나름 근거 있게 추정합니다. 예를 들어 9개월이라고 합시다.
- 9개월 뒤에도 아직 6개월쯤 더 남아 있습니다. 그 사이 원래 코드베이스에 새 기능이 추가됐고, 이제 그 기능까지 새로 넣어야 하기 때문입니다!
- 15개월째에도 같은 이유로 여전히 몇 달이 더 남아 있습니다!
- 결국 두 달간 "기능 동결(feature freeze)"을 강제해서, 운이 좋으면 18개월쯤에 재작성을 끝냅니다. 처음 9개월이던 추정이 2년 넘게 걸리기도 합니다.
Jarred는 Zig로 된 Bun을 재작성하는 일도 이와 비슷하다고 말했습니다:
"역사적으로 재작성은 끔찍한 선택입니다. 주석을 빼면 Bun은 535,496줄의 Zig 코드입니다.
다른 언어로 재작성하려면 소규모 엔지니어 팀이 꼬박 1년을 매달려야 합니다.
사용자에게 보이는 개선이 1년 동안 하나도 없는 상황은 현실적으로 고려할 수 있는 선택지가 아니었습니다. 그래서 코드 스타일을 강제해 안정성 문제를 잡는 것이 최선이었고, Rust에서 영감을 받은 스마트 포인터를 Bun 코드베이스에 추가한 것도 그 계획의 일환이었습니다.
하지만 솔직히 그렇게 하고 싶지 않았습니다. 직접 만든 스마트 포인터는 Rust보다 쓰기 불편하면서, Rust가 주는 보장은 하나도 없으니까요.
차라리 일주일을 들여서, Anthropic의 새 모델 [Fable]이 Bun을 Rust로 재작성할 수 있는지 테스트해 보면 어떨까?"
Fable로 Bun 재작성하기
당연하게도 이 재작성은 "Claude, Bun을 Rust로 재작성해 줘. 실수는 하나도 하지 말고." 같은 프롬프트 한 줄로 되는 일이 아니었습니다. Jarred가 실제로 진행한 방식은 이렇습니다:
1단계: 사전 준비. Claude와 함께한 3시간의 집중 준비 작업입니다. Jarred의 설명:
"코드를 한 줄도 쓰기 전에, 우리 Zig 코드베이스의 패턴을 Rust에 최대한 가깝게 매핑하는 방법을 놓고 Claude와 3시간쯤 이야기했습니다. Claude는 이 논의를 PORTING.md 문서로 정리했고, 이 문서는 [Zig → Rust 포팅 가이드로] Hacker News에 오르기도 했습니다"
이 가이드는 600줄짜리 파일로, 다음과 같은 지침이 담겨 있습니다:
기본 규칙:
- tokio, rayon, hyper, async-trait, futures 금지.
std::fs,std::net,std::process금지. 이벤트 루프와 시스템 콜은 Bun이 직접 소유한다. (Rust core/std의slice,iter,mem,fmt와core::ffi는 괜찮음 — I/O를 건드리는 모듈만 금지.) - async fn 금지. Zig에서와 똑같이, 모든 것은 콜백 + 상태 머신으로 한다.
- 빌림 검사기(borrow checker)에 맞춘 구조 변경은 허용. Zig 흐름을 그대로 옮겼을 때
&mut가 겹치면, 필요한 스칼라 값(.len(), 인덱스)을 지역 변수에 받아 두고 빌림을 해제한 뒤 다시 빌린다. 빌림 검사를 조용히 통과시키려고 원시 포인터(raw pointer)에 손대지 말 것. Phase B에서 diff를 읽는 사람이 헷갈리지 않도록// PORT NOTE: reshaped for borrowck주석을 남긴다.
Rust 전문가라면 납득할 만한 지침의 연속입니다. 더 알고 싶다면 Alice Ryhl과 함께 Rust 기초와 Rust가 다른 이유를 다룬 편을 참고하세요.
2단계: 시험 실행 + 적대적 리뷰. 전체 1,448개 파일 중 3개만 골라 Claude에게 재작성을 맡겼습니다. 재작성이 끝난 뒤 Jarred는 결과를 비판적으로 검토하는 적대적 리뷰(adversarial review)를 Claude로 두 차례 돌렸는데, 변경을 만든 세션과는 별도의 세션에서 진행했습니다.
3단계: 작업을 64개 AI 에이전트에 분배. Jarred는 에이전트들이 서로 겹치지 않는 파일을 병렬로 작업하도록 일을 쪼갰습니다.
4단계: 실행 과정의 문제 해결(약 1일). Jarred가 이 전체를 돌려 보니, 에이전트들이 서로 발목을 잡았습니다:
"1,448개 .zig 파일 전체에 워크플로를 반복 실행하라고 Claude에게 시켰는데, 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개의 워크플로 샤드(총 4개 워크트리)로 나눴고, 각 샤드에서 16개의 Claude가 파일을 커밋하고 푸시했습니다."
5단계: 돌려 놓고 약 2일 대기. 병렬 에이전트들이 작업에 들어갔고, 이틀에 걸쳐 535,496줄의 Zig 코드 재작성을 완료했습니다. 모든 커밋은 커밋되기 전에 두 차례의 적대적 리뷰로 검증됐습니다.
7단계: 약 1,600개의 컴파일러 오류 수정(약 12시간). 재작성은 끝났지만 컴파일되는 것은 하나도 없었습니다. Jarred는 크레이트(crate, Rust의 최상위 컴파일 단위) 단위로 하나씩 Claude에게 컴파일러 오류를 고치게 했습니다. 사람 엔지니어에게는 이것만으로도 초인적인 작업이지만, Claude에게는 아닙니다:
"순환 의존성을 고치고 나니 컴파일러 오류가 약 16,000개 드러났습니다. 사람 1명에게는 어마어마한 수지만, 64개의 Claude가 동시에 달려들면 말이 안 되는 수는 아닙니다.
병렬성을 극대화하기 위해 워크플로는 크레이트 단위로 반복했습니다.
- 각 크레이트마다 cargo check를 실행하고, 출력을 파일별로 묶어 오류를 파일에 저장
- 해당 크레이트 안의 컴파일러 오류를 전부 수정
- 크레이트 변경 사항에 적대적 리뷰어 2개 투입
- 수정 담당(fixer) 1개가 수정 사항을 반영"
에이전트들이 오류를 하나씩 고쳐 나가는 모습의 시각화. 출처: Anthropic
이 마이그레이션 단계에서 즐거웠던 점은, 에이전트들이 자정부터 오전 11시 30분까지 알아서 컴파일러 버그를 고치며 돌아갔다는 것입니다. 그동안 Jarred와 팀은 잠을 잤습니다.
8단계: 로컬에서 테스트 실행(약 2일). Bun에는 대규모 테스트 스위트가 있습니다. 다음 단계는 이 테스트들이 컴파일 오류 없이 돌아가게 만드는 것이었습니다.
9단계: 테스트 스위트 CI 통과(약 3일). 테스트가 돌아가기 시작하자(그리고 실패하기 시작하자), 다음 단계는 테스트가 통과하도록 코드를 고치는 것이었습니다. 여기에 이틀이 걸렸습니다.
10단계: 11일 만에 완료! 모든 테스트가 통과하고 모든 것이 기대대로 동작하는지 확인한 뒤, Jarred는 변경 사항을 머지했습니다. 계획부터 마무리까지 전 과정에 11일이 걸렸습니다.
이번 재작성: 약 55만 줄의 코드를 64개 에이전트로 11일 동안 6,500개 커밋에 걸쳐 포팅
이 과정은 얼마나 재현 가능할까?
이번 재작성에는 API 요금 기준으로 무려 16만 5,000달러가 들었습니다. Fable의 API 가격 기준으로, 이번 재작성은 캐시되지 않은 입력 토큰 59억 개, 출력 토큰 6억 9,000만 개, 캐시된 입력 토큰 읽기 720억 개를 소모했습니다. Anthropic은 API 토큰에 마진을 붙여 파는 것이 사업이므로, Anthropic 입장에서 재작성의 실제 비용은 이보다 낮았을 것입니다. 그래도 큰 금액입니다. 미국 중견 기업 소프트웨어 엔지니어 한 명의 연간 기본급에 맞먹으니까요!
하지만 다시 생각해 보면, 엔지니어 한 명이 이 모든 일을 1년 안에 해낼 수 있었을까요? 아마 아닐 겁니다. Mitchell Hashimoto도 같은 말을 합니다:
"비용 이야기를 하자면, Fable API 요금으로 16만 5,000달러(직접 확인하지는 않았습니다)는 믿기지 않을 만큼 싼 겁니다. 그만한 연봉을 받는 엔지니어가 Claude가 11일 만에 달성한 성과를 해낼 방법은 절대 없습니다. 절대로요. (총 16만 5,000달러를 엔지니어 N명에게 나눠 주고 11일을 준다고 계산해 봐도 답이 안 나옵니다)
다만 이 사례는 제 평소 생각을 다시 확인시켜 주기도 합니다. 특히 Fable은 보상 함수(reward function)가 명확한, 어렵고 집중된 작업에 가장 탁월하다는 것입니다. 최근 계속 트윗하고 있는 이야기입니다."
AI 덕분에, 예전 같으면 고려조차 못 했을 재작성과 마이그레이션이 가능해진다면 어떨까요? AI 없이 Bun을 Rust로 재작성한다는 발상은 비현실적이었다고 Jarred는 인정합니다:
"손으로 했다면 코드베이스 전체 맥락을 아는 엔지니어 3명이 1년쯤 걸렸을 겁니다. 그동안 Node.js 호환성 개선도, 버그 수정도, 보안 문제 해결도, 신기능 구현도 못 했겠죠. 그런 선택은 절대 하지 않았을 겁니다. 현실적인 대안은 아무것도 하지 않고, 이 글 첫머리에 나온 버그들을 영원히 계속 고치는 것이었습니다."
재작성이나 마이그레이션이 몇 달, 몇 년씩 걸린다는 점이야말로 이런 프로젝트 대부분이 아예 시작조차 못 하는 이유입니다. 비용은 잠시 접어 두고 이 질문을 생각해 봅시다. AI가 1년짜리 재작성을 일주일로 줄여 준다면, 하시겠습니까?
답이 "당연히 하지!"라면, 이제 Bun 마이그레이션이라는 형태로 그 방법을 담은 청사진이 존재합니다. 다만 그 글에 자세히 나오지 않은 유의점이 몇 가지 있습니다:
- 의욕이 넘치고 코드베이스를 아주 잘 아는 엔지니어가 필요합니다
- 극도로 탄탄한 테스트 스위트가 필요합니다. 테스트가 통과하면 제대로 동작한다고 믿을 수 있어야 하니까요
- 얼마나 잘될지 모르는 채로 토큰에 큰돈을 쓸 각오가 필요합니다
공정하게 말하면 3번이 가장 약한 고리입니다. LLM이 코드 마이그레이션 같은 "단조로운" 작업에 꽤 능하다는 것은 이미 알려져 있기 때문입니다. 좋은 테스트 스위트(2번)와 세부 문제를 다듬어 줄 의욕적인 엔지니어(1번)가 있다면, 성공할 가능성이 실패할 가능성보다 큽니다.
남는 질문은 돈을 얼마나 쓸 수 있느냐입니다. 16만 5,000달러까지 들지는 않을 가능성이 높습니다. 프로젝트가 더 단순하거나 모델 사용을 신중하게 설계하면 비용을 줄일 수 있습니다. 예를 들어 상위 수준 계획은 가장 비싼 모델로 하고, 코딩과 리뷰 작업은 더 싼 모델에 맡기는 식입니다.
AI를 활용한 마이그레이션은 분명 빨라지고 있습니다. 다만 Bun처럼 엔지니어링이 탄탄하게 되어 있는 프로젝트라야 가능한 이야기입니다.