오래된 서비스의 핵심 언어를 바꾸자는 말이 회의에서 나왔다고 하자. 새 언어를 쓰면 메모리 버그가 줄어든다. 하지만 수십만 줄을 옮기는 동안 기능 개발을 멈출 수는 없다. 새 코드가 예전과 똑같이 움직인다는 보장도 없다. 이런 제안은 대개 “좋은 생각이지만 너무 위험하다”는 말로 끝난다.
Bun은 이런 일을 실제로 해냈다. 주석을 뺀 Zig 코드 535,496줄을 Rust로 옮겼다. Claude Code의 동적 워크플로우 약 50개를 돌렸고, 가장 바쁠 때는 Claude 64개가 동시에 일했다. 전체 테스트가 6개 플랫폼에서 통과하기까지 11일이 걸렸다. 수치만 놓고 보면 놀랍다. 그러나 여기에만 눈이 가면 정작 배울 만한 부분을 놓치기 쉽다.
Rust는 버그를 더 일찍 발견하게 했다
Bun은 JavaScript와 TypeScript를 실행하고 묶고 테스트하는 런타임이다. JavaScript 엔진에서는 가비지 컬렉터, 줄여서 GC가 더는 쓰지 않는 메모리를 정리한다. 기존 Bun의 Zig 코드는 메모리를 직접 관리했다. 한 프로그램 안에서 GC가 맡은 값과 개발자가 맡은 값이 자주 맞물렸다.
이 경계에서는 질문이 계속 생긴다. 이 메모리를 어디서 해제해야 하는가. 딱 한 번만 해제된다고 어떻게 보장하는가. JavaScript 콜백이 실행되는 사이 포인터가 무효가 되지는 않는가. Bun 팀은 AddressSanitizer와 퍼징, 메모리 누수 테스트를 이미 운영했지만 use-after-free, double-free, 누수와 충돌이 반복됐다.
그렇다고 Zig가 나쁜 언어였던 것은 아니다. Bun을 처음 만든 Jarred Sumner는 Zig가 있었기에 혼자서 1년 만에 이만큼 넓은 기능을 구현했다고 썼다. Bun 특유의 조합이 어려웠다. JavaScriptCore가 관리하는 GC 포인터와 직접 해제할 메모리가 복잡하게 얽혀 있었다. Zig의 defer를 쓰려면 정리할 위치를 코드마다 적어야 한다. 드물게 지나는 오류 경로에서는 그 한 줄을 놓치기 쉬웠다.
도구가 없었던 것은 아니다. 문제는 버그를 발견하는 시점이었다. 퍼징은 코드를 합친 뒤에야 버그를 찾는다. CI도 코드를 올려야 결과가 나온다. 런타임 검사는 해당 코드가 실제로 실행돼야 작동한다. Rust의 borrow checker는 값의 소유권과 수명을 컴파일할 때 검사한다. 안전한 Rust 코드라면 해제된 메모리를 다시 쓰거나 같은 메모리를 두 번 해제하는 순간 컴파일 오류가 난다.
Bun이 바꾼 것은 문법만이 아니라 피드백이 오는 시점이었다. 사람의 주의와 코드 리뷰에 기대던 일부 규칙을 컴파일러가 매번 확인한다. Rust가 모든 버그를 없애 주지는 않는다. 특정 종류의 실수를 실행 전 단계로 끌어올 뿐이다.
재설계하지 않고 기계적으로 옮겼다
대규모 재작성은 자주 실패한다. 새 언어로 옮기는 김에 구조를 고치고 오래된 코드까지 정리하고 싶어지기 때문이다. 언어와 동작을 한꺼번에 바꾸면 문제가 생겼을 때 원인을 가리기 어렵다. 번역이 틀렸는지, 새 설계가 틀렸는지부터 다시 따져야 한다.
Bun은 욕심을 덜어냈다. 새 Rust 코드가 Zig 코드를 자동 변환한 것처럼 보이게 했다. 기존 구조와 동작을 최대한 유지했다. Rust다운 구조로 다듬거나 unsafe를 줄이는 일은 Bun v1.4 이후로 미뤘다. 이번 목표는 더 좋은 코드가 아니라 같은 동작이었다.
그래서 전체를 한 번에 옮겼다. 일부만 Rust로 바꾸면 두 언어를 잇는 임시 코드가 생긴다. 언젠가 버릴 코드라도 전환이 끝날 때까지는 팀이 관리해야 한다. Bun 팀은 이 어정쩡한 상태를 오래 끌지 않았다. 구현 언어에 기대지 않는 TypeScript 테스트 스위트를 안전망으로 삼았다.
코드를 쓰기 전 준비도 따로 했다. 약 3시간 동안 Zig의 패턴과 타입을 Rust에 어떻게 대응할지 정리해 PORTING.md로 남겼다. 구조체 필드의 수명을 분석한 결과는 LIFETIMES.tsv에 모았다. 1,448개 파일을 전부 맡기기 전에 3개만 골라 구현자 1명, 리뷰어 2명, 수정자 1명으로 절차를 시험했다.
두 문서는 단순한 참고 자료가 아니었다. 수십 개의 에이전트가 번역 규칙을 공유하는 기준점이었다. 작업 도중 판단이 달라질 때마다 긴 대화를 다시 넘길 수는 없다. 대신 파일에 적힌 규칙을 읽게 했다. 모델의 컨텍스트가 끊겨도 결정은 저장소에 남았다. 대규모 에이전트 작업에서는 프롬프트보다 저장된 결정이 팀의 기억에 가깝다.
64개의 Claude보다 반복 구조가 중요했다
“Bun을 Rust로 다시 써 줘. 실수하지 마.”라고 한 번 요청해서 끝낸 작업은 아니었다. 약 50개 워크플로우에 서로 다른 할 일 목록을 맡겼다. 각 워크플로우는 목록이 빌 때까지 같은 절차를 되풀이했다. 파일을 옮기는 루프가 있었고 crate별 컴파일 오류를 고치는 루프가 있었다. bun test 같은 하위 명령을 살리고 실패한 테스트를 통과시키는 루프도 따로 돌았다.
작업의 기본 단위는 작았다. 구현자 1명이 코드를 바꾸면 별도 컨텍스트의 적대적 리뷰어 2명 이상이 diff를 살폈다. 처음부터 코드가 틀렸다고 가정했다. 이후 수정자 1명이 피드백을 반영했다. 코드를 쓴 모델은 자기 답을 완성하려는 쪽으로 기울기 쉽다. 리뷰만 맡은 모델은 반례를 찾는 일에 집중한다. 그래서 작성자와 리뷰어의 맥락을 갈랐다.
리뷰어가 찾아낸 버그는 모두 컴파일을 통과한 코드에 숨어 있었다. 비동기 uv_close가 끝나기 전에 메모리를 해제하는 문제도 있었다. 음수 시각을 잘못 나누거나 unwrap_or의 즉시 평가 때문에 panic이 나는 코드도 잡았다. 컴파일 성공을 완료 조건으로 삼았다면 모두 지나갔을 오류다.
병렬 실행은 처음부터 매끄럽지 않았다. 여러 Claude가 같은 저장소에서 git stash, git stash pop, git reset HEAD --hard를 실행하며 서로의 작업을 건드렸다. 팀은 워크플로우 안에서 허용할 명령을 더 좁혔다. 최종적으로 4개 worktree에 작업을 나누고, 각 worktree에서 Claude 16개를 돌렸다. 느린 명령도 제한했다.
모델 밖에서도 병목이 생겼다. EC2 인스턴스의 기본 IOPS를 올리지 않은 탓에 느린 grep 한 번으로 디스크 읽기와 쓰기가 몇 분씩 멈추기도 했다. 에이전트 64개는 저장소와 디스크, 컴파일러를 함께 쓴다. 모델 호출만 병렬화하고 나머지 자원이 버티지 못하면 전체 작업은 빨라지지 않는다.
에이전트 수를 늘리기 전에 작업의 경계와 금지 규칙부터 정해야 한다. 그렇지 않으면 병렬성은 처리량이 아니라 충돌을 키운다. 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이었다. 마지막에는 사람이 테스트가 실제로 실행됐는지 확인하고 병합 버튼을 눌렀다.
테스트도 아무 데서나 돌릴 수 없었다. TCP 소켓 한도를 소진하는 항목이 있었고, 기가바이트 단위로 디스크를 읽고 쓰는 항목도 있었다. 약 1만 개 프로세스를 만드는 테스트까지 있었다. 팀은 systemd-run과 cgroups로 메모리와 CPU를 제한하고 pid namespace를 나눴다. 그래도 디스크 공간이 부족해 머신이 여러 번 멈췄다. 자동화가 멈췄다고 해서 언제나 코드가 틀린 것은 아니다.
검증 장치마다 맡은 일이 달랐다. 문서는 번역 규칙을 고정했다. 컴파일러는 구조 오류를 빠르게 찾았고, 독립 리뷰는 그럴듯한 오역을 의심했다. 기존 테스트는 사용자에게 보이는 동작을 확인했다. 마지막으로 사람은 이 장치들이 우회되지 않았는지 살폈다. 백만 줄짜리 diff를 완벽하게 읽은 검증자는 없었다.
큰 재작성일수록 모델보다 공정을 먼저 설계한다
이 작업에는 병합 전 기준으로 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에서 재현됐다. 60개 모듈로 된 같은 프로젝트를 한 프로세스에서 2,000번 빌드해 보니 v1.3.14의 메모리 사용량은 6,745MB까지 늘었다. v1.4.0은 609MB에서 안정됐다. Rust 전환에 다른 최적화까지 더하자 Linux와 Windows 바이너리 크기도 약 20% 줄었다.
이 사례를 “AI가 개발자 3명의 1년을 11일로 줄였다”는 문장으로만 읽으면 따라 해 볼 것이 거의 없다. 교훈은 과정에 있다. 목표를 기계적 변환으로 좁힌다. 규칙은 문서로 고정하고 구현과 리뷰의 맥락은 나눈다. 실패를 다음 작업으로 바꿀 자동 검증 장치도 둔다. 사람의 일은 사라지지 않았다. 워크플로우를 관찰하고 잘못된 지름길을 막았으며 마지막 병합도 사람이 결정했다.
큰 변경에서 모델의 능력보다 먼저 확인할 것은 틀렸을 때 곧바로 드러나는 구조가 있는가이다. Bun의 11일은 거대한 한 번의 성공이 아니었다. 작게 실패하고, 실패를 기록하고, 다시 돌리는 공정을 아주 빠르게 반복한 시간이었다.
원문: Rewriting Bun in Rust (Jarred Sumner, 2026) / GeekNews 소개