OCaml 런타임을 C에서 Rust로 라인별 번역하기
요약
본 글은 OCaml 런타임을 C에서 Rust로 포팅한 경험을 공유합니다. 이 과정은 OCaml 컴파일러의 테스트 스위트를 통과하며, 성능 향상과 함께 성공적으로 완료되었습니다. 특히 Claude Code를 활용하여 대규모 코드베이스를 재작성하는 방법론적 사례 연구가 포함되어 있습니다.
핵심 포인트
- OCaml 런타임을 C에서 Rust로 포팅하여 성공적으로 작동시켰습니다.
- 포팅 과정은 OCaml 컴파일러의 업스트림 테스트 스위트를 통과했습니다.
- Claude Code를 활용한 대규모 코드 재작성(rewrite) 경험 보고서입니다.
- 파일별 토글을 사용하여 점진적이고 안전하게 포팅 작업을 진행했습니다.
Rust 커뮤니티는 OCaml 커뮤니티에 신세 진 것이 있습니다. 최초의 Rust 컴파일러가 OCaml로 작성되었기 때문입니다. 이 게시물에서는 OCaml 런타임을 Rust로 다시 작성했습니다. 이것을 빚을 갚기 위한 저희의 계약금이라고 생각해주십시오. 
No Marks Shinwell were harmed[1]
더 진지하게
OCaml 런타임은 C로 작성되어 있습니다. 저는 이를 C에서 Rust로 포팅했습니다. 작동합니다. OCaml 컴파일러의 테스트 스위트를 통과합니다. 이 테스트 스위트는 업스트림(upstream)의 것이며 수정되지 않았습니다. Rust 런타임을 가진 OCaml 컴파일러는 스스로 빌드할 수 있고, dune을 빌드하고, opam switch를 설치하며, 바이트코드와 네이티브 모두에서 임의의 OCaml 프로그램을 빌드할 수 있습니다. 런타임에 남아있는 C 코드는 없습니다. 성능은... 놀라울 정도입니다! 가장 재미있었던 부분은 이것이 저에게 OCaml에 대해 무엇을 가르쳐 주었는지였습니다.
포크(fork)는 여기 https://github.com/mbacarella/rustcaml에 있으니 확인해 보십시오. 저장소를 클론하고, rust-runtime 브랜치에 있는지 확인한 후 일반적인 빌드 지침을 실행하기만 하면 됩니다. Rust의 cargo가 설치되어 있는지 확인하십시오. 또한 인터프리터를 더 빠르게 실행하는 rust-runtime-nightly 브랜치도 있습니다. 이에 대해서는 아래에서 더 설명하겠습니다.
이 게시물의 나머지 부분은 성숙하고 정교한 코드베이스인 OCaml 런타임을 인간 주도의 AI 구동 재작성(rewrite)에 대한 경험 보고서이자 예비 사례 연구로 간주해 주십시오.
이 작업 방식은
이 작업 방식은 이전 버전의 컴파일러를 이용한 바이트코드 빌드입니다. OCaml을 소스에서 빌드하려면, 먼저 OCaml 바이트코드를 실행하는 방법을 아는 ocamlrun이라는 C 프로그램을 만듭니다. 그런 다음 현재 소스를 네이티브 ocamlc로 컴파일하기 위해 ocamlrun과 boot/ocamlc를 사용합니다. 오직 OCaml로 런타임을 작성하지 않을까요? 할 수는 있지만, 그 이유 중 하나는 지원하려는 모든 아키텍처와 OS에 대해 영원히 미리 빌드된 바이너리를 배포해야 한다는 것입니다. 다른 좋은 이유들도 있으며, 이는 아래에서 탐구될 것입니다.
방법론에 대한 이야기
저는 x86-64 Linux 환경의 Opus 4.7[2] 모델을 사용하는 Claude Code를 사용했습니다. Claude가 대부분의 전술적인 작업을 수행했습니다: 코딩과 다음에 어디로 갈지 선택 메뉴를 제시하는 것, 그리고 테스트 실행이었습니다. 저는 방향을 설정하고 약간의 격려(아래에서 더 논의됨)를 했습니다.
Bun 프로젝트는 최근 960k(!) 라인의 Zig를 Rust로 포팅했다고 보고했으며, 이는 상당한… 흥분을 불러일으켰습니다. 저는
계획은 간단했지만 명시할 가치가 있다고 생각합니다. 컴파일러가 실행되지 않는 상태가 절대 없도록 만드세요. 빌드에 파일별 토글을 넣습니다: 이를 전환하면 링커는 C 버전 대신 해당 파일의 Rust 버전을 선택하게 됩니다. 이렇게 함으로써 한 번에 하나의 파일을 포팅하고, 각 플립(flip) 후에 전체 테스트 스위트를 실행한 다음, 진행하기 전에 알려진 정상 상태를 커밋할 수 있었습니다.
각 .c 파일은 OCaml에서 업스트림 변경 사항을 추적하고 통합하는 것이 가장 쉬울 것이기 때문에 효과적으로 라인별로 .rs 파일로 번역되었습니다. 이 포팅 작업은 이전 형제격인 OCaml과 함께 성장할 수 있습니다.
각 포팅 후의 git 커밋 목적은, 만약 우리가 가까운 미래든 먼 미래든 정말 막히는 상황에 처하게 되어, 예를 들어 극도로 모호한 버그를 유발하는 애플리케이션을 발견한다면, 포팅 노력 중 어느 시점에서 불일치가 도입되었는지 알아내기 위해 특정 시점의 모든 지점으로 이분 탐색(bisect)할 수 있도록 하기 위함이었습니다. OCaml 런타임은 Rust로 2%, 50% 또는 99%가 포팅되었든 상관없이, 실행되지 않는 상태에 놓인 적이 한 번도 없었습니다.
결국 우리는 두 개의 브랜치를 갖게 되었습니다. 하나는 Rust stable을 유지하는 rust-runtime 브랜치였고, 다른 하나는 일부 성능 실험을 위해 Rust nightly 기능을 추가한 rust-runtime-nightly였습니다.
벤치마크 결과
좋습니다. 충분히 기다리게 했네요. 모두 궁금해하셨죠. 제가 예상했던 바는 Rust 기반 네이티브 실행 파일은 약 1020% 느리고, 바이트코드 인터프리터는 2030% 느릴 것이라는 것이었습니다.
이는 포팅을 어떻게 제한했는지(관용적인 Rust 사용 금지, C와 최대한 라인별로 일치시키기, Rust stable 기능에 고수하기, 계산된 gotos 사용하지 않기)를 고려했을 때의 기대치였습니다.
헤드라인 결과는 “음, 거의 동등한 수준입니다.” 입니다.
| 런타임 | 바이트코드 (사이클 vs C) | 네이티브 (사이클 vs C) |
|---|---|---|
| C (trunk) | 1.00x: (기준선) | 1.00x (기준선) |
| Rust (stable) | 1.44x, C보다 느림 | ~1.05x (범위: 0.87-1.13) |
| Rust (nightly) (ETCs) | 0.91x, C보다 빠름 | Rust stable과 동일 |
일부 벤치마크 테스트에 따르면 네이티브 실행 파일은 거의 동등한 수준입니다. 반면 바이트코드 인터프리터는 누락된 computed gotos 때문에 Rust stable에서 약 2배 느립니다. 만약 Rust nightly로 전환하고 명시적인 꼬리 호출(explicit tail calls, ETCs)을 추가하면 C 런타임과 같거나 심지어 약간 더 빠릅니다.
저는 비관용적인 번역이 언어를 활용하기보다는 싸우는 것이기 때문에 전반적으로 느릴 것으로 예상했습니다. 하지만 결국 C와 유사한 Rust(C-like-Rust)는 몇 가지 단점에도 불구하고 C만큼 잘 작동하는 것으로 나타났습니다.
전체 벤치마크 표는 마지막에 있습니다.
2015 unsafe
s
ocaml/runtime % rg unsafe *.rs | wc -l
2015
이것은 Rust에 있다는 것이 더 안전하다는 의미가 아닙니다. 이 코드는 어쩌면 C보다 약간 덜 안전할 수도 있습니다. 하지만 약 2015개의 unsafe 코드가 번역 실패를 의미하는 것은 아닙니다. Rust는 이미 존재했던 unsafe 코드를 단지 더 읽기 쉽게 만들 뿐입니다.
이 숫자가 크게 낮아질 수 없는 세 가지 이유가 있으며, 이 중 가장 제거하기 어려운 것이 먼저 나열됩니다:
- 타입 소거(Type erasure). 컴파일 후 모든 값은 태그가 지정된 하나의 기계어 단어인 정수형 또는 힙 포인터이며, 런타임은 평생 동안 타입이 지정되지 않은 단어를 읽고 쓰는 데 사용됩니다. 이 수준에서는 Rust가 확인할 수 있는 정적 타입이 없습니다. 모든 필드 접근은 ~원시 포인터 역참조(raw pointer deref)와 같습니다. 따라서 소유권 검사기(borrow checker)는 이것을 다룰 수 없습니다.
- 런타임 자체가 GC입니다. 이는 가비지 컬렉션(GC) 중에 활성 포인터를 변경하고 이동시킵니다. 이는 정확히 소유권 검사기가 금지하기 위해 존재하는 것들입니다. 언어 런타임을 Rust로 포팅하는 것은 인간이 작성하든 기계가 작성하든 어려움을 겪을 것입니다!
- OCaml 런타임의 ABI(Application Binary Interface)이기 때문에 C ABI를 사용해야 합니다. 게다가 Rust는 자체적으로 안정적인 FFI(Foreign Function Interface)가 없기 때문에, `extern
네 번째 이유는 저에게 달려 있습니다: 상위 호환성을 추적하기 위해 Claude가 관용적인 방식이 아닌 C 코드를 라인별로 번역하도록 했습니다. 따라서 이 중 일부는 C 대응 관계를 유지하고 있으며, 프로젝트에 제약이 없어지면 나중에 캡슐화될 수 있을 것입니다.
많은 unsafe 부분이 있습니다.
이를 제거하면 현재의 OCaml을 깨뜨릴 수 있기 때문에 그대로 둘 수밖에 없습니다. 이런 의미에서, unsafe를 도입하는 것은 런타임 내부에서 얼마나 많은 것이 신중하게 관리되는 안전하지 않은(unsafety)지 드러내는 것과 같습니다.
그럼에도 불구하고, 이 프로젝트가 OCaml과의 빌드 및 바이너리 호환성에서 벗어난다면 전반적인 안전성을 높이는 데 좋은 기반이 될 수 있습니다.
추가 생각들
제가 처음 Claude에게 이 프로젝트를 인벤토리화하고 포팅 방안을 제안해 달라고 요청했을 때, 그것은 상당히 미쳤고 성공하기 어려울 것이라고 생각했습니다. 어느 시점에서 그것은 미묘한 오류들이 스며들어 거의 되돌릴 수 없는 상태가 될 것이며, 그때까지 우리가 흥미를 잃지 않는다면 완성하는 데 2~3년이 걸릴 것이라고 예측했습니다.
저는 일종의 격려 연설을 해주고, 우리가 어떻게 가볍게 진행하면서 자신감을 높게 유지할 수 있는지에 대해 설명해야 했습니다. 그것은 어떤 면에서 인간 개발자들과 함께 작업하는 것을 상기시켜 주었습니다.
물론 잘못 조정된 것은 아니었습니다! OCaml 런타임은 C를 한계까지 밀어붙입니다! 개발자 경험을 관리하기 위해 매우 영리한 매크로들을 많이 사용합니다. 멀티코어 시스템은 강력한 보장을 합니다. 외부 함수 인터페이스(FFI)는 C ABI 호환성에 의존하며, Rust 자체에 표준 ABI가 없기 때문에 이는 영원히 유지되어야 합니다. 가끔씩 그것은 파일을 포팅하고, 테스트 진전을 이루지 못해 낙담하여 되돌리고 지금은 다른 것을 시도해보자고 제안하곤 했습니다.
여기 실제 작업 공간(meat space)에서 전체 프로젝트는 약 7일이 걸렸습니다. 대부분의 시간은 제가 평소 생활을 하는 동안 명령어 승인을 기다리는 데 쓰였습니다 (Claude Code를 모바일 앱으로 직접 사용할 수는 있지만 여전히 상당히 불안정하고 자주 멈춥니다). Claude C Compiler 실험과는 달리, 저는 “Claude Teams”나 다른 종류의 다중 에이전트 오케스트레이션(multi-agent orchestration)을 사용하지 않았습니다. 단지 하나의 에이전트만 작동시켰습니다. 또한 71개의 runtime/*.c 파일을 .rs로 포팅할 때마다 테스트 스위트를 재실행하기 위해 많은 시간을 보냈습니다. 오류를 반복적으로 확인하고 격리하며 수정하는 데는 한 번에 수많은 시간이 걸렸습니다.
워크플로우와 관련하여, 저는 코드 게이트키핑(gatekeeping)을 거의 하지 않았습니다. 제 경험상, 라인별 번역은 LLM이 탁월한 능력을 보이는 분야입니다. 또한 제가 약 40,000줄의 코드를 검토할 수 있었을 리 없습니다. 대신, 저는 파일 단위 수준에서 시간을 더 많이 보내 어떤 파일을 어떤 순서로 포팅할지 결정하고, 이 파일들이 테스트에서 실행되도록 보장하는 데 집중했습니다. 주로 우리가 생각할 수 있는 모든 테스트를 통과시킨 다음 모호한 애플리케이션을 실행하여 이상한 힙 손상 버그(heap corruption bug)가 발생했는데 어디서부터 시작해야 할지 전혀 모르는 상황이 얼마나 끔찍할지 상상하며 이를 염두에 두는 것이 LLM의 행동을 어느 정도 제어했습니다.
Claude의 신중함은 좋았지만 완벽하지는 않았다
컴파일러 테스트 스위트와 벤치마크(sandmark)가 우리의 지침이 되었고, Claude는 진전을 위해 이를 해킹하려는 시도를 단 한 번도 하지 않았습니다. 하지만 가끔 혼란스러워하기도 했습니다. 초기에 한 테스트가 세그폴트(segfault)를 유발했고 Claude는 계속 진행하며 더 많은 파일을 포팅했습니다. 제가 왜 이 세그폴트를 조사하지 않았는지 되물었을 때, 그것은 그 세그폴트가 트렁크 버전에서도 항상 존재한다고 가정했으며 확인할 생각을 하지 않았다고 말했습니다.
사실 이런 일이 몇 번 있었습니다. 특히 컨텍스트가 압축된 장시간 세션에서는 테스트의 '골든 조건(gold condition)'을 잃어버리고 현재의 테스트 실패가 자신의 변경 사항에 의해 발생한 것이 아니라고 스스로를 설득할 수 있습니다. 제가 트렁크(trunk) 버전에서 테스트 스위트(testsuite)를 실행하여 모든 것이 녹색(green)으로 돌아오는 것을 보여주며 그 핵심을 확실히 상기시켜야 했습니다.
LLM이 라인별 번역에 능하다는 말을 했고, 실제로 그렇습니다. 인간보다 더 잘하지만 완벽하지는 않습니다. 한 번의 세그폴트(segfault)를 무시하는 것 외에도, 0부터 다른 인덱스에서 시작하는 경우처럼 큰 열거형(enums)을 완벽하게 재현하지 못하는 실수도 했습니다. 초기화 시퀀서에서 마법 숫자(magic numbers) 순서를 거꾸로 하기도 합니다 (예: { a=2, b=1 } 대신 { a=1, b=2 }). 수동으로 전사해야 할 내용이 많으면 sed/awk 스크립트를 작성하여 번역하는 것이 영리했지만, 항상 그런 것은 아니었습니다. 일반적으로 이러한 실수는 테스트에서 빠르게 나타났지만, 발견하고 수정하기까지 많은 시간이 걸렸습니다.
Claude는 종종 스스로를 의심했지만, 간단한 자극만 주면 작업을 수행하고 놀라울 정도로 성공했습니다. 다른 때에는 독립적일 수 있음에도 불구하고 여러 가지를 묶어 처리하는 경향이 있었습니다. 예를 들어, 우리가 계속해서 하나씩 포팅(porting)하는 전략을 따르고 있었음에도 불구하고 전체 GC를 한 번에 포팅해야 한다고 결정했고, 네 가지 모두를 한꺼번에 시도하여 매우 복잡한 Rust 코드 5000줄을 디버깅하려고 준비했습니다. 하지만 우리가 계속 파일별 전략을 따르고 있었다는 사실과 왜 여기서 작동하지 않는지 이유를 물어보자, 그것은 할 수 있다고 인정하고 실제로 수행했습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Lobste.rs ML의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기