GitHub Copilot 런타임을 Rust로 마이그레이션하는 방법: Copilot 활용
요약
GitHub Copilot 에이전트 런타임을 Node.js/TypeScript에서 Rust로 마이그레이션한 사례를 다룹니다. 이 런타임은 여러 Microsoft 및 GitHub 솔루션의 핵심 엔진 역할을 하며, 재작성 과정을 통해 성능을 수십 배 향상시키고 개발 기간을 크게 단축했습니다.
핵심 포인트
- Copilot 에이전트 런타임을 Rust로 마이그레이션하여 성능을 대폭 개선함.
- AI가 코드를 작성하고 풀 리퀘스트를 통해 점진적으로 배포하는 방식의 효율성을 보여줌.
- 공유 런타임 구조는 여러 제품군(CLI, 앱 등)에 일관된 지능과 신뢰성을 제공함.
Stephen Toub은 Microsoft의 Distinguished Engineer입니다.
GitHub Copilot CLI, GitHub Copilot 앱, 그리고 GitHub Copilot SDK는 모두 Copilot 에이전트 런타임(Copilot agent runtime)에 의해 지원됩니다. 이 런타임은 애플리케이션과 서비스에 임베드할 수 있는 에이전틱 하네스(agentic harness)입니다. 원래는 Node.js의 TypeScript와 V8 JavaScript 엔진으로 작성되었으며, 현재 GitHub Copilot 클라우드 에이전트(CCA)가 사용하는 런타임도 이 스택을 유지하며 런타임과 그 기능들이 빠르게 성장했습니다.
하지만 이제 상황이 바뀌었습니다. GitHub Copilot 앱과 Copilot CLI를 사용하여 우리는 런타임을 80만 줄 이상의 프로덕션 Rust 코드로 완전히 재작성했습니다. AI 에이전트가 대부분의 코드를 작성했으며, 이는 main 브랜치에 반영된 128개의 풀 리퀘스트(pull requests)에 걸쳐 점진적으로 배포되었고, 마지막에 한 번에 전환하는 방식은 아니었습니다. 과정 중에 발생한 몇 가지 불가피한 회귀(regressions)는 빠르게 발견되고 수정되었으며, 동시에 런타임의 성능은 수십 배 향상되었습니다. 에이전트가 등장하기 전에는 개발자 팀 전체가 1년 또는 2년 정도 걸렸을 프로젝트가 이제는 단일 개발자에 의해 몇 달 만에 완료되었고, 나머지 팀원들은 이 기간 동안 계속해서 런타임의 기능과 도달 범위를 크게 확장할 수 있었습니다.
포팅이 필요했던 이유
Copilot 에이전트 런타임은 Copilot CLI의 배후 엔진일 뿐만 아닙니다. 이는 Microsoft, GitHub 및 생태계 솔루션들의 증가하는 세트를 지원하며, 이들 각각에 대해 AI 지원은 구조적으로 동일한 런타임을 중심으로 구축되고 그 솔루션이 필요로 하는 사용자 정의 기능들이 추가된 형태입니다. 여기에는 GitHub Copilot CLI와 GitHub Copilot 앱뿐만 아니라, VS Code, Visual Studio, CCA, Copilot Code Review (CCR), Copilot Cowork, Copilot Studio의 최신 릴리스, 그리고 Excel, Outlook, PowerPoint, Word 등도 포함됩니다.
이들은 매우 다른 제품들이며, 그 어떤 것도 프로덕션 에이전트 하네스에 들어가는 모든 것을 구현할 필요가 있거나 그렇게 해서는 안 됩니다. 이들은 지능, 보안, 신뢰성, 성능을 모두 원하며, 이러한 요소들이 공유되어 한 곳에서의 수정이 모든 곳에서 수정되기를 바랍니다. 이전 단락에 나열된 대부분의 제품들은 처음에 자체 에이전트 루프를 구현했지만, 이후 GitHub Copilot SDK로 대체했습니다. 이는 Copilot 에이전트 런타임으로 들어가는 진입점입니다. 이렇게 함으로써 이들은 핵심 비즈니스 가치에 집중하고 세부 사항은 런타임에 맡길 수 있습니다. 특히 업계의 속도를 고려할 때, 그리고 경쟁이 치열한 상황에서 에이전트 루프가 항상 최고 수준을 유지해야 할 필요성을 생각하면 이는 더욱 중요합니다.
따라서 공유 런타임이라니 좋습니다. 문제는 공유되는 대상의 본질적인 특성이었습니다.
CLI를 살펴보면, 논리적으로는 에이전트 루프 위에 있는 터미널 UI(TUI)입니다. 우연히도 전체 스택은 Node.js를 프레임워크로, V8을 실행 엔진으로 사용하여 TypeScript로 구현되었습니다. 이는 TUI 애플리케이션에 대해 괜찮은 선택입니다. TypeScript와 Node.js는 광범위하게 접근 가능하며 매우 빠른 애플리케이션 개발을 가능하게 합니다. 그리고 콘솔 애플리케이션의 요구 사항에 대해서도 시작 시간, 반응성, 처리량, 메모리 소비 측면에서 성능 영향이 합리적입니다. 하지만 이 구현이 다른 환경, 다른 제약 조건, 낮은 메모리 오버헤드로 인한 빠른 시작과 우수한 서버 밀도 같은 요구 사항을 가진 상황에서 사용된다고 생각하면, 안타깝게도 훨씬 덜 합리적이 됩니다.
CLI와 런타임의 아키텍처 또한 여기서 어려움을 야기했습니다. 전체 산업이 매우 빠르게 움직이고 있으며, 이러한 상황에서 정말 뛰어난 인재들은 배포 속도와 시장 도달 범위를 위해 결정을 내립니다. Copilot CLI는 초기에 빠르고 간편하게 작성되어 출시되었고, 그 과정에서 TUI(Text User Interface)와 런타임이 분리된 계층이라기보다는 상당히 얽혀 있었습니다. 그러다가 해당 런타임에 대한 프로그래밍 방식 접근을 위한 SDK가 필요해지자, 논리적으로는 반대 아키텍처를 예상할 수 있음에도 불구하고, 명확한 계층 분리가 없다는 실용적인 결정이 내려져 CLI 위에 SDK를 쌓게 되었습니다. CLI는 단순히 사용자가 명령줄에서 제공하는 명령어들을 통해서만 접근 가능하기보다는, 헤드리스(headless) 모드로 실행되어 stdin으로부터 유사한 명령을 읽고 stdout으로 응답을 쓰는 방식으로 업데이트되었습니다. 그러면 JSON-RPC 프로토콜을 사용하여 외부 프로세스로부터 CLI로의 함수 호출을 마샬링할 수 있게 되었습니다. 이후 SDK는 임의의 소비 프로그램에 내장될 수 있었는데, 이 프로그램은 CLI 프로세스를 스폰하여 에이전트 루프를 out-of-process로 호스팅하고, SDK가 이 JSON-RPC 메커니즘을 통해 원격 프로세스에서 함수들을 호출하게 했습니다. 훌륭했습니다. 빠르게 시장에 출시할 수 있었습니다. 유연했습니다. 하지만 이러한 소비 애플리케이션의 성능(시작 시간, 메모리, 처리량)과 신뢰성 측면에서는 좋지 않았습니다. SDK로부터 새로운 CopilotClient를 생성한다는 것은 또 다른 프로세스를 스폰하는 것을 의미했습니다:
const client = new CopilotClient();
await client.start(); // CLI를 서브프로세스로 스폰합니다
const session = await client.createSession({
...
이 과정은 Node와 V8을 실행하고 호스팅해야 한다는 것을 의미했습니다. 이는 CLI에서 TypeScript 코드로 생성된 상당한 양의 JavaScript를 파싱하고, 이를 위한 바이트코드를 생성하며, 나중에 JIT 계층에서 핫 코드를 최적화하는 것을 포함한다는 뜻이었습니다. 이는 V8과 관련된 모든 메모리 오버헤드를 의미했습니다. 또한 Node의 스레딩 모델을 상속받아야 한다는 것도 의미했는데, 이 모델은 기본적으로 모든 CPU 바운드 작업이 직렬화되는 방향으로 우리를 밀어붙입니다. 그리고 함수 호출을 위해서만 프로세스 외부 통신(out-of-process communication)이 강제된다는 것을 의미했습니다. 이는 모든 SDK 소비자(모든 언어에서)가 Node.js 또는 V8을 포함하는 번들된 바이너리를 배포해야 한다는 뜻이었습니다. 즉, C#, Python, Go, Java, Rust SDK 모두 클라이언트당 전체 두 번째 언어 런타임에 비용을 지불해야 했으며, 이는 최소 100MB의 작업 집합(working set) 크기였고, 이 런타임은 애플리케이션이 원래 필요로 하지 않는 것이었습니다. 또한 모든 이벤트, 모든 메시지, 그리고 추상화된 세션 파일 시스템 읽기와 쓰기가 프로세스 경계를 넘어 전달되어야 했습니다. 이는 Node에서 충돌이 발생하면 세션 전체가 함께 사라진다는 것을 의미했습니다. 마지막으로, 이 시스템을 배포하는 사람은 최소한 두 개의 프로세스를 감독하고, 모니터링하며, 디버깅해야 한다는 뜻이었습니다.
대신, 우리는 다음과 같은 런타임을 원했습니다:
- TUI를 포함하지 않으며, TUI와 다른 애플리케이션 및 서비스가 그 위에 깔끔하게 계층화될 수 있는 자체 라이브러리 형태여야 합니다.
- 최소한의 의존성과 최소한의 오버헤드를 가진 언어로 구현되어야 합니다.
- 프로세스 외부로 강제되는 것이 아니라, 인프로세스(in-process)로 깨끗하게 임베드될 수 있는 방식으로 구현되어야 합니다.
- 성능, 확장성, 신뢰성에 대한 최고 특성을 가진 언어로 구현되어야 합니다.
- 모든 6가지 Copilot SDK 언어 버전(C#, TypeScript, Python, Rust, Go, Java)가 해당 스택의 외부 함수 인터페이스(FFI) 메커니즘을 사용하여 깨끗하게 사용할 수 있도록 상호운용성(interop)에 뛰어난 언어로 구현되어야 합니다.
- 공급망 위험이 적고 '구축 시 올바름(correct-by-construction)' 코드를 더 많이 지원하며, 보다 현대적인 보안 자세를 제공하는 툴체인으로 구현되어야 합니다.
이러한 모든 이유들뿐만 아니라 팀의 경험이나 산업 방향과 같은 부드러운(soft) 이유들로도 우리는 Rust를 선택했습니다. 이것은 모든 대규모 TypeScript 프로그램이 Rust가 되어야 한다는 주장은 결코 아닙니다. 우리의 요구사항은 C ABI를 통한 임베딩, 낮은 시작 및 정상 상태 오버헤드, 그리고 예측 가능한 리소스 사용에 중점을 두었습니다. Rust는 이러한 목표들을 다른 복잡성(complications)을 희생하면서 가능하게 했습니다. 예를 들어, 우리는 수명 주기(lifetimes)와 공유 상태(shared state)를 명시적으로 표현해야 했고 (나중에 논의될 라이프사이클 회귀(lifecycle regressions)가 그것의 함의를 강조합니다). 적절한 대상 언어는 애플리케이션마다 다릅니다.
그 후 두 가지 핵심 관련 작업이 수행되었습니다:
- TUI 전용 코드를 런타임으로부터 분리하여, 전자가 후자에 엄격하게 계층화되고, 더 구체적으로 SDK의 공개 표면 영역(public surface area) 위에 엄격하게 계층화되도록 했습니다. 오늘날에도 CLI는 여러 곳에서 여전히 런타임 내부 구조를 직접 호출하며, 이를 완전히 SDK의 표면 영역으로 옮기는 작업은 진행 중입니다.
- 해당 런타임 레이어를 100% Rust로 포팅하여, 모든 언어 프론트엔드(language front-ends)가 인프로세스(in-process)에서 소비할 수 있는 C ABI를 노출하는 순수 네이티브 바이너리(pure native binary)와, 아웃오브프로세스(out-of-process)가 여전히 필요한 경우에 대비한 stdin/stdout 기반 또는 소켓 기반 서버를 만드는 것입니다.
본 게시물은 주로 두 번째 작업, 즉 런타임을 Rust로 포팅하는 것에 대해 다룹니다.
이전 모습
- TUI 레이어에 여전히 묶여 있던 Pieces들이 런타임 레이어로 밀려 내려오고 있었습니다. 초기 추정치에서 무시되었던 전체 컴포넌트와 상당한 비율의 코드가 나중에 포팅하는 데 관련성이 있는 것으로 간주되었습니다.
- 새로운 TypeScript를 상당량 기여하는 풀 리퀘스트(Pull requests)들은 지속적으로 레포지토리 내 TypeScript 양을 증가시키고 있었습니다. 매주 수백 개의 풀 리퀘스트를 병합하는 수십 명의 에이전트 지원 개발자들이었습니다.
모든 요소를 고려했을 때, 약 430,000 라인의 프로덕션 TypeScript가 포팅 과정을 거친 것으로 추정됩니다. 이러한 요소들은 또한 진행 과정에서 진척도를 파악하기 어렵게 만들기도 했습니다. 포팅이 들어오는 작업 속도에 맞춰 진행되면서, 끝날 무렵까지는 프로덕션 TypeScript 양이 비교적 안정적으로 유지되거나 약간 증가하는 것처럼 보였습니다.
여기에 게다가 포팅과는 별개로 해당 기간 동안 유입되는 Rust 코드도 있었기 때문에 상황은 더욱 복잡했습니다. 포팅 초반에는 들어오는 코드가 TypeScript에 의해 지배될 가능성이 높았지만, 후반으로 갈수록 Rust에 의해 지배될 가능성이 높아졌습니다.
포팅 과정 동안 런타임은 약 300,000 라인의 프로덕션 TypeScript를 받아들이고 약 430,000 라인을 제거했으며, 약 1,200,000 라인의 프로덕션 Rust가 유입되고 약 365,000 라인이 나갔습니다. 다시 말해, 위의 그래프에서 보이는 TypeScript 라인의 겉보기 안정성은 실제로는 상당한 양의 TypeScript 변화(churn)를 숨기고 있었던 것입니다.
제자리 포팅 전략 (In-place porting strategy)
이 차트는 또한 포팅이 어떻게 이루어졌는지에 대한 중요한 측면, 즉 '제자리'에서 진행되었다는 점을 강조합니다.
이 규모의 리라이트(rewrite)에는 두 가지 주요 접근 방식이 있습니다:
빅뱅 (Big bang). 새로운 Rust 런타임은 완벽한 대안으로 개발된 다음, 준비가 되면 한 번에 모두 교체됩니다. 이러한 빅뱅 전환(cutover)에는 두 가지 변형이 있습니다.
a.월드 정지 (Stop the world). 리라이트가 진행되는 동안 모든 사람이 main 브랜치에서 다른 작업을 중단합니다.
. b.병렬 개발(Parallel development). 리라이트는 피처 브랜치(feature branch)에서 진행되는 동안 메인 브랜치(main branch)에서는 작업이 계속되며, 리라이트 과정은 메인 브랜치의 변경 사항을 지속적으로 따라잡고 병합하는 것을 시도합니다.제자리(In place). 이는 런타임을 한 조각씩 점진적으로 재작성하는 컴포넌트별 포팅 방식으로 이루어집니다. 이러한 제자리 접근 방식에는 두 가지 변형이 있습니다. a.원자적 교체(Atomic replacement). 각 부분이 TypeScript에서 Rust로 원자적으로 플립되며, 남아있는 TypeScript와 새로운 Rust 간의 상호 운용성(interop)이 연속성을 제공합니다. 시간이 지남에 따라 프로덕션 런타임 중 TypeScript 비중은 점점 줄어들고 Rust 비중은 점점 늘어나다가, 어느 날에는 더 이상 TypeScript가 없고 오직 Rust만 남게 됩니다. b.A/B. 컴포넌트를 포팅하면서 삭제하는 대신, TypeScript와 Rust 컴포넌트 모두를 핫스왑 가능한 옵션으로 유지하며, 자신감이 충분히 쌓인 후에야 TypeScript가 삭제됩니다.
저희는 여러 가지 이유로 옵션 2a를 선택했습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 HN OpenAI Codex의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기