AI 에이전트를 활용한 대규모 코드 마이그레이션 단계별 실전 가이드. Bun의 백만 줄 규모 Zig→Rust 포팅 사례도 함께 다룬다.
프로덕션 코드베이스를 새로운 언어로 옮기는 코드 마이그레이션은, 불과 얼마 전까지만 해도 수년이 걸리는 작업이었다.
지난 한 달간 Anthropic의 개발자들은 Claude Fable 5, Claude Opus 4.8, 그리고 동적 워크플로(dynamic workflow)를 활용해 수만~수십만 줄 규모의 코드 패키지 10개를 마이그레이션했다. 이 글에서는 그 중 두 가지 사례와 함께, 프로젝트에서 도출한 모범 사례를 소개한다.
Bun의 공동창업자이자 Anthropic의 Member of Technical Staff인 Jarred Sumner는 Claude Code를 사용해 Bun을 Zig에서 Rust로 마이그레이션했다. 2주도 채 안 되는 기간에 백만 줄의 코드가 생성됐으며, 머지 전 CI에서 Bun의 기존 테스트 스위트 전체가 통과했다. 머지 이후 19건의 회귀가 발견됐고, 모두 수정 완료됐다. Rust 포트는 6월 Claude Code에 탑재되어 배포됐다.
Anthropic Labs 공동 리드인 Mike Krieger는 주말 동안 Python 코드베이스를 165,000줄 규모의 TypeScript로 포팅했다. 이 작업에는 수백 개의 에이전트, 8단계의 페이즈 게이트(phase gate), 3차례의 적대적 리뷰, 그리고 모든 커맨드의 출력을 Python 원본과 비교하는 최종 동등성(parity) 검사가 포함됐다.
Claude Code의 새로운 기능들은 오랫동안 미뤄왔던 이런 프로젝트들의 셈법을 완전히 바꿔놓았다. 아래는 이번 마이그레이션들에서 얻은 교훈을 바탕으로 정리한 6단계 프로세스다.
핵심 인사이트는 코드를 고치는 게 아니라는 것이다. 고쳐야 할 것은 그 코드를 만들어낸 프로세스(루프)다.
본격적인 방법론에 앞서, 방법보다 시점과 이유를 먼저 짚어볼 필요가 있다. 이 프로젝트들을 둘러싼 전제 자체가 달라졌기 때문이다.
팀이 마이그레이션을 결정하는 건 최초 구축 시점과 현재 사이에 환경이 변했기 때문이다. 알고 있던 트레이드오프가 발목을 잡기 시작했거나, 더 나은 접근법이 등장했거나, 기존 에코시스템이 위축됐을 때다.
예를 들어 Jarred가 처음에 Zig를 선택한 건 C 수준의 성능을 단순한 구조로 구현할 수 있었기 때문이다. "LLM이 없던 시절, 오클랜드의 좁은 아파트에서 혼자 1년 만에 Bun을 만들던" 상황에 딱 맞는 선택이었다. 물론 이 단순함에는 알려진 트레이드오프가 따랐는데, 그가 직접 정리한 글에서 확인할 수 있다.
2026년 현재, Bun CLI는 월 1,000만 건 이상의 다운로드를 기록하고 있으며 Claude Code 내에서도 광범위하게 사용되고 있다.
불과 지난 분기까지만 해도, 그 트레이드오프만으로는 로드맵을 동결하고 수 분기에 걸친 프로젝트에 리소스를 투입할 명분이 되지 않았다. 언어 마이그레이션은 더 작고, 빠르고, 안전한 시스템을 만들어줄 수 있지만, 그 비용을 기꺼이 감수하려는 조직은 드물다.
개발자 개인에게도 부담이 있었다. 과거의 대규모 마이그레이션 프로젝트는 두 개의 코드베이스를 수 분기, 혹은 수년간 병행 유지해야 했고, 결과물이 90% 수준에 그치면 시작 전보다 더 큰 골칫거리를 안게 됐다.
이제는 최악의 시나리오가 그냥 브랜치를 삭제하고 다시 시도하는 것이다.
그렇다고 사업적 타당성이 필요 없다는 말은 아니다. 백만 줄 규모의 마이그레이션이 4년짜리 프로젝트에 수백만 달러의 엔지니어링 비용을 쏟아붓던 시대는 지났지만, 여전히 수만~수십만 달러 이상의 비용이 든다. Bun 마이그레이션의 경우, 캐시되지 않은 입력 토큰 59억 개와 출력 토큰 6억 9,000만 개를 소비했으며, API 가격 기준으로 약 165,000달러에 달한다. Mike의 포팅 주요 작업에 사용된 토큰은 2,700만 개였다.

이제 마이그레이션의 명분이 반드시 생존을 건 결정일 필요는 없다. 체인지로그에 메모리 버그 수정이 1년째 쌓여 있다거나, 만성적인 병목 하나만으로도 충분한 이유가 된다.
Mike의 프로젝트는 컴파일 단계가 직접적인 계기가 됐다. 그의 팀이 작업하는 내부 도구는 단일 바이너리로 배포되는데, Python 툴체인으로 바이너리를 빌드하면 플랫폼당 약 8분이 걸려 전체 빌드 매트릭스 기준으로 매 릴리스마다 30분을 기다려야 했다. 포팅 후 동일한 컴파일이 약 2초로 단축됐고, 바이너리 시작 속도는 6배 빨라졌으며, 별도의 배포 파이프라인도 통합할 수 있었다.
Claude Fable 5는 Anthropic의 가장 강력한 정식 출시 모델이다. Fable과 Opus 4.8은 서브에이전트를 활용한 병렬 작업 스트림의 위임, 지시, 검증에 특히 뛰어나며, 목표를 향한 다양한 경로를 탐색하는 능력도 탁월하다.
대규모 코드 마이그레이션이 이러한 고성능 모델에 특히 적합한 이유는 다음과 같다:
아래에서 살펴보겠지만, Mike와 Jarred 모두 마이그레이션의 핵심 단계에서 Fable을 활용했다. 특히 토큰 소비를 최적화하기 위해 여러 모델 클래스를 조합하는 어드바이저리 패턴(advisory pattern)을 적용했다.
아래 프로세스는 다양한 언어와 상황에 적용할 수 있도록 일반화했다. 더 자세한 내용은 Jarred의 블로그를 참고하라.
마이그레이션 프로젝트를 시작하기 전에 반드시 강력한 심판(judge)을 갖춰야 한다. 그렇지 않으면 종료 조건도, 성공 기준도 없는 상태로 작업하게 된다.
심판은 원본 코드와 대상 코드를 동일한 기준으로 평가할 수 있어야 한다. 원본 언어로 작성된 테스트 스위트는 대상 코드에 존재하지 않는 내부 함수에 의존하는 경우가 많다.
심판을 구축하려면:
Jarred의 경우 제3의 언어(TypeScript)로 작성된 대규모 테스트 스위트가 있었지만, 대부분의 프로젝트는 그렇지 않다. Mike는 Python에서 TypeScript로 포팅하면서 7가지 실제 시나리오로 구성된 동등성 검증 하네스(parity harness)를 만들고, 동작 변화는 모두 수정해야 할 버그로 간주했다.
각 단계를 살펴보기 전에, 아래 그래픽이 전체 흐름을 이해하는 데 도움이 될 것이다. 대체로 Jarred의 방법론을 따르며, 각 단계마다 리뷰와 게이트가 포함된다. Mike도 유사한 루프 워크플로를 사용해 전체적으로 비슷한 구조를 따랐지만, 처음부터 끝까지 마이그레이션 전체를 실행하고 결과를 바탕으로 규칙과 워크플로를 수정한 뒤 다시 실행하는 방식을 택했다. 세 번째 실행 전까지는 매번 결과물을 폐기했다.


이 단계에서는 마이그레이션의 기반을 만든다. 단순 번역이 아닌 리팩토링이 필요한 코드 목록인 갭 인벤토리(gap inventory), 코드 번역 방법을 정리한 룰북(rulebook), 그리고 마이그레이션 작업 스트림의 순서를 정할 의존성 맵(dependency map)이 이에 해당한다.
순서가 중요하다. 룰북이 갭 인벤토리보다 먼저 작성돼야 한다. 갭 인벤토리는 룰북의 기본 규칙으로 처리할 수 없는 영역을 정의하며, 두 가지는 공동 감사(joint audit)를 통해 함께 검증된다.
룰북의 구체적인 형태는 초반에 내려야 할 핵심 아키텍처 결정에 따라 달라진다. 가장 중요한 결정은 새 코드가 기존과 동일한 구조를 유지할지, 아니면 완전히 재설계할지의 여부다.
구조를 유지하는 경우(Jarred)라면, 룰북은 주로 언어 간 타입과 관용구를 변환하는 대응표(lookup table)가 되며, 번역하기 어려운 요소는 갭 인벤토리를 참조한다. 재설계하는 경우(Mike)라면, 룰북은 설계 문서 형태가 된다.
Jarred는 Claude와 대화하며 룰북을 만들었고, 모호한 영역마다 정책을 수립했다. 또한 자신의 경험에서 도출한 8가지 흔한 실패 유형을 각각 검토하도록 설계된 서브에이전트 8개를 활용했다.
병렬 마이그레이션을 위해 작업 스트림을 효과적으로 분배하려면 파일 의존성을 파악해야 한다. 어떤 파일을 먼저 마이그레이션할지, 어떤 파일들을 같은 배치에 묶을지 알아야 하기 때문이다. 일부 언어와 코드베이스는 명시적인 매니페스트가 있어 수월하지만, 레거시 코드베이스나 C/C++, Python 같은 주요 언어들은 의존성을 직접 탐색하고 매핑해야 한다.
Claude Code는 에이전트를 배포해 이 맵을 생성하는 결정론적 스크립트를 만들고 실행할 수 있다. 마이그레이션 킷의 프롬프트는 리뷰-수정 루프를 생성하는 워크플로를 사용한다. 참고: 스타터 킷은 이 글에서 설명하는 프로세스를 일반화한 템플릿으로, 실제 포팅 작업에 사용된 것과는 다르다.
새로운 언어는 기존 언어와 다른 요구사항이 있으며, 이를 반드시 충족해야 한다. Zig에서 Rust로 전환할 때는 수동 메모리 관리가 차이점이었다(C와 C++도 동일하게 작동한다). 예를 들면:
Zig
fn readConfig(allocator: std.mem.Allocator) ![]u8 {
const buf = try allocator.alloc(u8, 1024);
// ...fill buf...
return buf; // caller must free this — but only the comment says so
}
// A caller that forgets 'defer allocator.free(buf)' still compiles — the leak only surfaces at runtime.Rust
fn read_config() -> Vec<u8> {
let buf = vec![0u8; 1024];
// ...fill buf...
buf // ownership moves to the caller; memory is freed automatically
}
// Use it after it's moved? Free it twice? Neither compiles.
// Forget to free it? There's no free call to forget — drop is automatic.Python에서 TypeScript로 전환할 때는 인터페이스와 계약(contract)이 갭이었다. Python은 어떤 형태의 객체를 받거나 반환하는지 선언하지 않아도 되지만, TypeScript는 필수다. 예를 들면:
Python
def register(handler):
handler.setup()
return handler.run({"retries": 3})
# Any object with .setup() and .run() works here. Which objects actually get passed in? Read the whole codebase to find out.
TypeScript
interface RunResult { ok: boolean }
interface Handler
{ setup(): void;
run(opts: { retries: number }): Promise<RunResult>;
}
function register(handler: Handler): Promise<RunResult> {
handler.setup();
return handler.run({ retries: 3 }); }
// The contract must be written down before this compilesJarred와 Mike 모두 이러한 암묵적 지식을 갭 인벤토리 파일로 정리했다. Jarred는 처음부터 갭을 미리 목록화했고(이 글에서 소개하는 방식), Mike는 먼저 번역하고 이후 감사를 통해 갭 인벤토리를 만드는 방식을 택했다. 경우에 따라서는 두 가지 모두 필요할 수 있다.
갭 인벤토리 파일을 만드는 Claude Code 프롬프트 예시도 참고하라.

이 단계는 본격적인 마이그레이션에 앞서 "시운전"을 겸하는 소규모 마이그레이션이다.
이 단계에서 Jarred는 에이전트 하나로 룰북을 적용해 파일 3개를 번역하고, 다른 에이전트 하나로 "시니어 Rust 엔지니어처럼" 파일 3개를 번역한 뒤, 세 번째 에이전트가 두 결과의 차이(diff)를 분석해 새로운 번역 규칙을 도출했다. 이 단계에서 전체 1,448개 파일에 적용됐다면 수많은 문제를 일으켰을 치명적인 이슈 2가지를 미리 발견했다.
프롬프트는 이 예시와 유사한 형태다.
이 유형의 스트레스 테스트는 구조를 유지하는 마이그레이션에만 적용 가능하다. 같은 파일의 두 번역본을 줄 단위로 비교할 수 있어야 하기 때문이다. Mike처럼 룰북이 재설계 문서인 경우, 이에 상응하는 테스트는 설계 문서에 직접 적대적 리뷰어를 투입한 뒤 일회성 엔드투엔드 실행으로 검증하는 것이다.
번역된 파일은 반드시 버려야 한다. 이 단계의 목적은 규칙을 다듬는 것이지, 점진적인 진행이 아니다.

이후 단계에서는 동일한 멀티에이전트 루프 구조를 반복한다: 구현(implement), 리뷰(review), 수정(fix).
구현 작업은 소형 모델에 위임하고, 리뷰어는 대형 모델로 유지할 수 있다. 예를 들어 Mike는 주요 마이그레이션에서 서브에이전트 12개를 분산 실행할 때 Claude Sonnet을 사용했다.
작업 큐는 기계적으로 운영돼야 한다. 배치 스크립트가 번역된 파일이 디스크에 존재하는지 확인해 완료 여부를 판단하고, 미처리 파일을 배치로 나눠 구현 에이전트에 할당한다. 큐가 매번 디스크 상태를 기준으로 재구성되므로, 마이그레이션은 구조적으로 언제든 재개할 수 있다.
이 단계에서 에이전트는 수행해야 할 작업량에 지나치게 보수적으로 접근하는 경향이 있다. 다음 단계에서 컴파일러가 오류를 잡아줄 테니 과감하게 진행하라고 직접적이고 명확한 프롬프트 지시를 주면 효과적이다.
번역기가 확신 없이 처리하는 부분은 // TODO(port): 으로 표시해 4단계에서 처리한다. 여기서부터 할 일 목록은 저절로 만들어진다. 컴파일러가 오류를 열거하고, 스모크 테스트가 크래시를 찾아내고, 테스트 스위트가 실패를 보고한다.
적대적 리뷰어 2명이 별도의 컨텍스트로 구현 에이전트의 작업을 평가하며, 두 리뷰어 간 의견 충돌은 세 번째 에이전트가 중재한다. 리뷰어가 여러 파일에서 같은 실수를 반복 발견한다면, 개별 파일을 수정하는 게 아니라 룰북에 규칙 한 문장을 추가하고 영향받은 배치를 재생성한다. 룰북은 이 단계 내내 계속 성장하며, 코드는 결코 수작업으로 수정하지 않는다.
이 단계에서 중요한 설계 결정 중 하나는 컴파일러의 위치다. Mike는 매 루프 안에서 TypeScript 컴파일러를 실행했는데, 유닛 하나를 수 초 만에 검사하기 때문이다. 반면 Jarred는 cargo가 수 분이 걸리기 때문에 루프 내 컴파일러 실행을 금지하고 다음 단계로 미뤘다.
이 단계에 이르면 핵심 작업의 대부분이 완료되며, 프롬프트는 점점 짧아지기 시작한다.

이 세 단계는 동일한 루프 구조를 공유하며 단계가 진행될수록 사람의 판단이 점점 덜 필요하므로, 함께 다룬다.
4단계는 언어와 마이그레이션 규모에 따라 3단계에 흡수되는 경우도 많다.
컴파일 단계의 규모와 난이도에 따라 에이전트가 이를 아예 실행하지 않을 수도 있다. Jarred는 오케스트레이터 스크립트로 전체 워크스페이스에 걸쳐 컴파일러를 한 번 실행했다. 그런 다음 "픽서 에이전트(fixer agent)"들이 적대적 리뷰와 함께 병렬로 오류 목록을 처리했다. 빌드를 다시 실행하고, 반복한다.
오류 목록을 검토하면 조정이 필요한 시스템적 문제를 파악하는 데 유용하다. 예를 들어 Jarred는 Zig의 지연 컴파일(lazy compilation)이 허용했던 순환 임포트를 수정하면서 수천 건의 Rust 모듈 오류가 발생하는 상황을 맞닥뜨렸다. 어떤 의존성을 삭제, 이동, 혹은 경계 재구성해야 하는지 분류하는 로직을 루프에 추가해 해결했다.
5단계에도 컴파일러 오류 목록과 유사한 기계적인 진실의 원천이 있다: 스모크 테스트에서 나오는 크래시다. 이번에도 루프 수정 방법은 문제를 카테고리로 묶는 것이었는데, 이 경우에는 근본 원인별로 분류하고 적대적 서브에이전트가 검토했다.
6단계는 이 여정의 마지막으로, 두 코드베이스의 동작을 비교하는 것이다.
파일들이 번역, 컴파일, 스모크 테스트를 거쳤다. 이제 이를 분할해 (사전 준비 단계에서 구축한) 테스트 스위트를 실행할 차례다. 실패는 두 코드베이스 모두에 대해 실패한 테스트를 검토하는 픽서 에이전트로 처리한다. 적대적 리뷰어가 수정 내용을 검토한다.
이 루프의 다음 단계는 빌드 데몬(build daemon)이다. 바이너리를 재빌드할 수 있는 유일한 프로세스다. 픽서가 패치를 작성하면 데몬이 이를 일괄 처리해 한 번에 재빌드하고, 영향받은 테스트를 재실행한 뒤 결과를 피드백한다. 이를 통해 여러 에이전트가 각자 빌드를 트리거하는 대신, 가장 비용이 큰 작업을 직렬화한다.
많은 테스트에서 동일한 실패가 반복되면, 수정은 업스트림으로 올라간다. 해당 버그를 만들어낸 규칙을 수정하고, 그 규칙이 적용된 파일만 재생성한다.
많은 개발자가 완성된 테스트 스위트를 갖추지 못했거나 포팅하기 어렵다는 점에서, Mike의 접근법이 중요하다. Mike는 Claude로 7가지 실제 시나리오를 새 포트와 Python 원본 코드베이스 모두에 실행하는 소규모 스크립트를 만들고, 결과를 비교했다. 실패한 시나리오마다 전담 수정 에이전트를 배정했으며, 7가지 모두 통과할 때까지 루프를 반복했다.
Mike는 한 발 더 나아갔다. Claude가 자체적으로 엔드투엔드 테스트 스위트를 설계하고 밤새 자율적으로 실행하며, 오류를 수정하고 4일 연속 재실행했다. 그 결과, 어떤 시나리오 목록으로도 예측하기 어려운 소소한 결함들까지 잡아낼 수 있었다.
여기서 얻을 수 있는 교훈은 테스트 스위트가 없어도 이 단계가 막히지 않는다는 것이다. 심판을 물려받을 수 없다면, Claude가 직접 만들게 하면 된다. 어떤 경우든 원본 코드베이스가 그라운드 트루스다.
매 실행마다 이전에 몰랐던 것을 배웠다. 다음 마이그레이션에서도 이 가이드가 담지 못한 것들을 배우게 될 것이다. 하지만 모든 프로젝트에서 일관되게 효과적이었던 몇 가지 실천 방법이 있다:
Jarred의 Bun 마이그레이션은 현재 프로덕션에서 운영 중이지만, 모든 마이그레이션에는 트레이드오프가 있다. 예를 들어 Rust 코드의 약 4%는 "unsafe" 블록 안에 있으며, 대부분 C/C++ 경계에서의 단일 라인 포인터 연산이다.
하지만 새 코드베이스는 측정 가능한 수준으로 개선됐다. 팀의 툴링으로 감지할 수 있는 모든 메모리 누수가 수정됐다. 2,000번 반복 빌드 벤치마크에서 메모리 사용량이 6,745MB에서 609MB로 줄었다. 바이너리는 Linux와 Windows에서 19% 작아졌다. 그리고 언어 간 최적화 덕분에 HTTP 서빙과 next build, tsc 같은 실제 워크로드에서 2~5% 더 빨라졌다.
오래 미뤄왔던 마이그레이션의 셈법을 다시 계산해볼 시점이 아닌지 생각해보라. 참아왔던 코드베이스를 하나 골라 Claude에게 마이그레이션 프로세스가 어떻게 될지 물어보라.
관련 자료