오래된 서비스의 핵심 언어를 바꾸자는 의견이 회의에 올랐다. 새 언어를 쓰면 메모리 버그가 줄어든다. 하지만 수십만 줄을 옮기는 동안 기능 개발을 멈출 수는 없고, 새 코드가 예전과 똑같이 움직인다는 보장도 없다.

이런 제안은 대개 “좋은 생각이지만 너무 위험하다”는 말로 끝난다.

Bun은 주석을 뺀 Zig 코드 535,496줄을 Rust로 옮겼다. Claude Code의 동적 워크플로우 약 50개를 돌렸고, 가장 바쁠 때는 Claude 64개가 동시에 일했다. 6개 플랫폼에서 전체 테스트가 통과하기까지 11일이 걸렸다. 숫자부터 눈에 띄지만 정작 배울 부분은 모델 수보다 작업 공정에 있다.

Rust는 버그를 더 일찍 발견하게 했다

Bun은 JavaScript와 TypeScript를 실행하고 묶고 테스트하는 런타임이다. JavaScriptCore의 가비지 컬렉터가 관리하는 값과 개발자가 직접 해제할 메모리가 한 프로그램 안에서 자주 맞물렸다. 이 경계에서 use-after-free, double-free, 메모리 누수와 충돌이 반복됐다.

Zig가 나쁜 언어였던 것은 아니다. Bun을 처음 만든 Jarred Sumner는 Zig가 있었기에 혼자서 1년 만에 넓은 기능을 구현할 수 있었다고 썼다. 다만 Bun에서는 JavaScriptCore의 GC 포인터와 직접 관리할 메모리가 복잡하게 얽혔다. 드물게 지나는 오류 경로에서 정리 코드 한 줄을 놓치기 쉬웠다.

Bun 팀은 AddressSanitizer와 퍼징, 메모리 누수 테스트를 이미 운영했다. 버그가 늦게 발견되는 게 문제였다. Rust의 borrow checker는 값의 소유권과 수명을 컴파일할 때 검사한다. 안전한 Rust 코드라면 해제한 메모리를 다시 쓰거나 같은 메모리를 두 번 해제하는 실수가 실행 전에 드러난다.

Bun이 택한 변화의 핵심은 문법보다 피드백 시점에 있었다. 사람의 주의와 코드 리뷰에 기대던 일부 규칙을 컴파일러가 매번 확인한다. Rust가 모든 버그를 없애지는 않아도 특정 실수를 더 이른 단계에서 막는다.

코드 리뷰와 테스트와 컴파일러가 버그를 발견하는 시점을 비교한 그림
Rust를 고른 핵심 이유는 메모리 버그 일부를 더 이른 단계에서 막을 수 있다는 점이었다.

재설계하지 않고 기계적으로 옮겼다

대규모 재작성은 새 언어로 옮기는 김에 구조와 오래된 코드까지 고치려다 흔들리기 쉽다. 언어와 동작을 한꺼번에 바꾸면 문제가 생겼을 때 번역과 새 설계 중 어디가 틀렸는지부터 다시 따져야 한다.

Bun은 새 Rust 코드가 Zig 코드를 자동 변환한 것처럼 보이게 했다. 기존 구조와 동작을 최대한 유지했다. 이번 목표는 같은 동작을 재현하는 데 있었고, Rust다운 구조로 다듬거나 unsafe를 줄이는 일은 Bun v1.4 이후로 미뤘다. 일부만 Rust로 바꿀 때 생기는 임시 연결 코드도 피하려고 전체를 한 번에 옮겼다.

작업 전에는 약 3시간 동안 Zig의 패턴과 타입을 Rust에 어떻게 대응할지 정리해 PORTING.md로 남겼다. 구조체 필드의 수명 분석은 LIFETIMES.tsv에 모았다. 1,448개 파일을 맡기기 전에 3개만 골라 구현자 1명, 리뷰어 2명, 수정자 1명으로 절차를 시험했다.

두 문서는 수십 개 에이전트가 번역 규칙을 공유하는 기준점이었다. 모델의 컨텍스트가 끊겨도 결정은 저장소에 남았다. 대규모 에이전트 작업에서는 긴 프롬프트보다 저장소에 남긴 결정이 팀의 기억에 가깝다.

Bun의 재작성에서 유지한 것과 뒤로 미룬 것을 나눈 그림
동작 보존과 언어 이동만 이번 작업에 넣고, 구조 개선은 다음 단계로 분리했다.

64개의 Claude보다 반복 구조가 중요했다

한 번의 요청으로 끝낼 수 있는 작업이 아니었다. 약 50개 워크플로우가 서로 다른 할 일 목록을 맡았다. 파일을 옮기는 루프, crate별 컴파일 오류를 고치는 루프, bun test 같은 하위 명령과 실패한 테스트를 살리는 루프가 따로 돌았다.

구현자 1명이 코드를 바꾸면 별도 컨텍스트의 적대적 리뷰어 2명 이상이 diff를 살폈고, 수정자 1명이 피드백을 반영했다. 리뷰어가 찾아낸 버그는 모두 컴파일을 통과한 코드에 숨어 있었다. 비동기 uv_close가 끝나기 전에 메모리를 해제하거나 unwrap_or의 즉시 평가 때문에 panic이 나는 문제도 있었다.

병렬 실행은 처음부터 충돌했다. 여러 Claude가 같은 저장소에서 git stash와 git reset HEAD --hard를 실행하며 서로의 작업을 건드렸다. 팀은 허용할 명령을 좁히고 4개 worktree에 작업을 나눠 각 worktree에서 Claude 16개를 돌렸다. EC2의 낮은 IOPS 때문에 디스크가 멈추는 일도 겪었다.

에이전트 수를 늘리기 전에 작업 경계와 금지 규칙부터 정해야 한다. 저장소와 디스크, 컴파일러가 버티지 못하면 모델 호출만 병렬화해도 전체 작업은 빨라지지 않는다. Bun 사례의 핵심 자산은 64라는 숫자보다 파일과 crate, 테스트 단위로 잘린 작업 큐다.

구현자와 두 리뷰어와 수정자가 반복하는 코드 변환 흐름
한 모델의 긴 작업이 아니라 역할을 나눈 짧은 반복이 재작성의 기본 단위였다.

신뢰는 테스트를 지우지 않은 데서 쌓였다

코드 변환 직후에는 컴파일 오류가 약 16,000개 나왔다. 워크플로우는 crate마다 cargo check 결과를 파일별 작업 큐로 바꿨다. 컴파일 뒤에는 링크 오류와 시작 직후 panic을 고치고, bun --version, bun test <file>, 각 CLI 하위 명령 순으로 검증 범위를 넓혔다.

모델은 모든 crate를 컴파일하라는 목표를 오류 난 함수를 stub으로 비우라는 뜻으로 받아들이기도 했다. Bun 팀은 잘못된 결과를 하나씩 손으로 고치는 대신 생성 규칙을 바꿨다. 테스트를 삭제하거나 건너뛰지 못하게 했고, 긴 주석으로 임시방편을 설명할 시간에 코드를 고치라는 리뷰 규칙도 넣었다.

첫 CI 실행 이틀 뒤 실패한 테스트 파일은 972개에서 23개로 줄었다. 하루 반 뒤 Linux가 통과했고 마지막에는 macOS x64·arm64, Linux x64·arm64, Windows x64·arm64까지 6개 플랫폼이 모두 통과했다. 삭제하거나 건너뛴 테스트는 0개였다.

검증 장치마다 역할이 달랐다. 문서는 번역 규칙을 고정했고 컴파일러는 구조 오류를 찾았다. 독립 리뷰는 그럴듯한 오역을 의심했고 기존 테스트는 사용자에게 보이는 동작을 확인했다. 마지막으로 사람은 이 장치들이 우회되지 않았는지 살피고 병합을 결정했다.

큰 재작성일수록 모델보다 공정을 먼저 설계한다

이 작업에는 uncached input token 59억 개, output token 6억 9천만 개, cached input token read 720억 개가 들었다. API 가격으로 약 16만 5천 달러다. 11일 동안 커밋 6,778개가 생겼고 최종 diff는 1,009,272줄 추가였다. 작은 팀이 그대로 따라 할 수 있는 규모는 아니다.

Rust 포트에는 알려진 regression 19개가 남았고 모두 수정됐다. Rust 코드 약 780,000줄 중 약 27,000줄은 unsafe 블록 안에 있었다. C와 C++ 라이브러리를 계속 쓰기 때문에 위험한 경계도 여전히 남아 있다.

그래도 성과는 있었다. Bun v1.4.0에서 고친 버그 가운데 128개는 v1.3.14에서 재현됐다. 같은 프로젝트를 한 프로세스에서 2,000번 빌드했을 때 v1.3.14의 메모리 사용량은 6,745MB까지 늘었지만 v1.4.0은 609MB에서 안정됐다. Linux와 Windows 바이너리 크기도 약 20% 줄었다.

큰 변경에서는 모델의 능력보다 틀렸을 때 곧바로 드러나는 구조가 있는지 먼저 확인해야 한다. 목표를 기계적 변환으로 좁히고, 규칙은 문서로 고정하며, 구현과 리뷰의 맥락은 나눈다. 실패를 다음 작업으로 바꿀 검증 장치도 둔다. Bun의 11일은 작은 실패를 기록하고 다시 돌리는 공정을 빠르게 반복한 시간이었다.


원문: Rewriting Bun in Rust (Jarred Sumner, 2026) / GeekNews 소개