Roc 컴파일러의 Rust→Zig 재작성 진행기
요약
Roc 컴파일러 팀이 기존 Rust 기반 코드를 Zig로 재작성하여 기능 동등성에 도달했습니다. 새 컴파일러는 핫 코드 로딩, 재현 가능한 크로스 컴파일 등 개발 경험을 크게 개선했으며, WASM 크기 절반 이하로 줄이는 성과를 거두었습니다.
핵심 포인트
- Rust 30만 줄 코드를 Zig로 재작성하여 기능 동등성에 도달함.
- 핫 코드 로딩 및 LLVM 최적화된 독립 실행형 바이너리 생성이 가능해짐.
- 패턴 매칭 내 문자열 보간을 지원하며, HTTP 라우팅에서 힙 할당을 제거함.
- Zig 선택의 이유로 빌드 시간과 메모리 비안전성 검사 등을 언급함.
- 기존 구현의
구조적 결함을 해결하기 위해 Rust 30만 줄을 Zig로 다시 작성했으며, 487일 만에 기능 동등성에 도달해 올해 후반 첫 정식 번호 릴리스인 0.1.0을 목표로 함 - 새 컴파일러는
핫 코드 로딩과 재현 가능한 크로스 컴파일, 패턴 매칭 안의 문자열 보간, HTTP 라우팅의 힙 할당 제거를 지원하며Rocci Bird
의 wasm 크기도 절반 이하인 31KB로 줄임 - Zig를 선택한 핵심 이유는 빌드 시간, 세분화된 할당자와 데이터 배치 제어, 컴파일러 개발에 적합한 생태계, 메모리 비안전 코드 검사였으며 Zig 0.17.0의 증분 빌드는 약 46만 줄을
35ms에 다시 빌드함 - 실제 버그 분류에서 Rust 컴파일러는 메모리 손상 21건, Zig 컴파일러는 10건이었지만 대부분 잘못된 코드 생성 때문이었고, Zig 컴파일러 자체의 메모리 안전성 오류는 파일명을 깨뜨린
use-after-free 2건이었음 - Zig는 포인터 없는 데이터 구조, 무파싱 역직렬화, LLVM bitcode 직렬화기 재사용에 잘 맞았지만 테스트의 자동 메모리 해제, 다형성, 비공개 구조체 필드, 죽은 코드 탐지, 릴리스 간 호환성에서는 Rust의 개발 경험이 더 나았음
기능 동등성에 도달한 재작성
- Roc 컴파일러 팀은 약 1년 반 동안
Rust 30만 줄을 Zig로 재작성해 기존 컴파일러와 기능 동등성에 도달함 - 기능 동등성은 중요한 이정표지만 정식 릴리스는 아니며, 새 컴파일러의
0.1.0은 올해 후반을 목표로 함 - 2024년 WASM-4 게임 Rocci Bird를 새 컴파일러로 빌드할 수 있게 됨 - 홈페이지에서는 2.5MB WebAssembly 컴파일러로 기본 Roc 프로그램을 브라우저에서 작성하고 실행할 수 있음
- 재작성에는
487일이 걸려 Bun이 Zig 약 50만 줄을 Rust로 옮긴 11일보다 476일 길었음- Bun은 직접 포팅했지만 Roc은 여러 컴파일러 구조와 기능을 크게 바꾸는 재작성에 가까웠음 - 따라서 두 코드베이스의 규모나 기간을 Rust와 Zig만으로 직접 비교하기는 어려움
새 컴파일러가 제공하는 개발 경험
핫 코드 로딩과 크로스 컴파일
roc server.roc
로 서버를 실행한 뒤 코드를 수정하면 다음 요청부터새 코드가 자동 적용됨- Python 같은 인터프리터 언어에서는 일반적이지만 Roc 같은 고성능 컴파일 언어에서는 흔하지 않은 동작임
- 웹 서버뿐 아니라 간단한 2D 게임에서도 같은 방식으로 작동함
- 배포할 때는
roc build server.roc
가 LLVM 최적화를 적용한독립 실행형 바이너리를 생성함 roc build --target=x64musl
로 Alpine Linux용 정적 바이너리를 크로스 컴파일할 수 있음- 동일한 소스 바이트를 입력하면 Mac을 포함한 어느 빌드 시스템에서도
동일한 출력 바이트를 생성함
패턴 매칭 안의 문자열 보간
"/users/${id}"
같은 문자열 보간을패턴 매칭안에서 지원함- Express식 라우팅처럼 실행 중 템플릿 문자열을 파싱하지 않고 컴파일러가 직접 처리함
- HTTP 메서드와 경로를 함께 매칭하고, 중첩 경로나 기본값도 패턴으로 분기할 수 있음
- 예시의 전체 HTTP 요청 처리 코드는 컴파일 시 타입 안전성을 검사하면서
힙 할당을 전혀 수행하지 않음 - 순수 함수의 컴파일 시 실행을 이용해 HTTP 요청 라우팅을 무할당으로 구현했으며, 홈페이지의 WebAssembly 컴파일러에서 문법을 시험할 수 있음
처음부터 다시 작성한 이유
-
Roc은 Rust·C·Zig와 달리 시스템 언어가 아니며,
참조 카운팅 기반 자동 메모리 관리를 사용함- 추적식 가비지 컬렉터의 일시 중지를 피함 -
Perceus 최적화와 Koka식 기회적 변경을 활용함
-
일반적인 비시스템 언어처럼 클로저 캡처마다 힙을 할당하지 않고, 람다 집합 특수화를 통한 다형적 비함수화를 사용함
-
비함수화는 함수형 언어에서 인라이닝처럼 여러 후속 최적화를 가능하게 하지만, 기존 구현을 정확히 만드는 일은 매우 어려웠음
-
Ayaz Hafiz의 OCaml 프로토타입을 통해 문제가 여러 컴파일러 단계에 걸친
구조적 결함이며, 해결하려면 컴파일러 대부분을 다시 작성해야 한다는 점을 확인함 - 다른 기여자들도 각기 다른 이유로 여러 부분을 다시 작성할 계획이어서, 거의 전부를 점진적으로 교체하는 테세우스의 배 방식 대신 전체 재작성을 검토하게 됨 -
성공한 컴파일러 프로젝트에서는 자체 호스팅 등을 위해 처음부터 재작성하는 사례가 흔하며, 자체 언어가 아닌 Go로 옮긴 TypeScript 재작성 사례도 있음
-
Roc 컴파일러는 자체 호스팅하지 않는다는 방침이지만, 이번에는 재작성으로 얻는 이점이 알려진 비용보다 크다고 판단함
다시 Rust가 아닌 Zig를 선택한 기준
- 팀은 이미 표준 라이브러리의 여러 기본 기능에 Zig를 사용하고 있었고, 충분히 잘 아는 시스템 언어가
Rust와 Zig뿐이어서 두 언어만 진지하게 검토함 - 프로젝트마다 적합한 언어가 다르며, Rust를 다른 업무에서 계속 사용하는 것과 Roc에 Zig를 선택하는 일은 모순되지 않음
빌드 시간
- Rust의
cargo
빌드는 증분 빌드까지 오래 걸렸으며 코드베이스가 커질수록 주요 불편으로 남았음 - Zig로 옮기면 빌드가 훨씬 빨라질 것으로 예상함
- Rust의
메모리와 데이터 배치 제어
-
컴파일 단계마다 다양한 할당자, 특히
아레나 할당자를 사용하고 구조체 배열(SoA) 배치를 광범위하게 활용함 - Rust 생태계는 대체로 하나의 전역 할당자를 가정하지만, Zig 생태계는 세분화된 할당자 전달을 기본으로 삼고 SoA 지원도 표준으로 제공함 -
컴파일 단계마다 다양한 할당자, 특히
필요한 생태계의 범위
-
Rust 전체 생태계는 Zig보다 크지만, Roc의 특수한 요구와 관련된 패키지는 두 생태계 모두 많지 않았음
-
LLVM C++ 라이브러리를 감싸는 대신 빠르게 bitcode를 생성하는 코드처럼 필요한 틈새 기능은 Zig 쪽에 더 많았음
메모리 비안전 코드 지원
- 기존 Rust 컴파일러 30만 줄에는 약
1,200개의이 있었음unsafe
사용 - 비교 대상으로 Rust 컴파일러 자체는 350만 줄에서 약 4만 개의
unsafe
를 사용함 - 기계어를 생성하는 컴파일러에서는 메모리 비안전 작업이 중요한 업무 일부임
- Rust는 드문
unsafe
블록을 격리하고 Miri나 Valgrind로 검사하는 모델이지만, Roc에서는unsafe
가 드물지 않았음 - Zig는 메모리 비안전 코드를 올바르게 작동시키기 위한 기능을 더 제공했고, 팀은 바로 이 영역에서 가장 많은 지원을 원했음
- 기존 Rust 컴파일러 30만 줄에는 약
borrow checker 없이 얻은 메모리 안전성
Rust와 Zig의 검사 범위
- Microsoft의 2019년 자료에 따르면 매년 보안 업데이트로 처리되는 취약점의 약
70%가 메모리 안전성 문제였음 - 해당 자료의 2018년 분류를 Rust와 Zig 관점에서 나누면 다음과 같음 - 83.6%는 범위 밖 읽기·쓰기, 안전하지 않은 캐스트, 초기화되지 않은 읽기, 스택 오버플로, 비메모리 안전성 문제로, Rust와 Zig 선택에 영향을 받지 않는 범주임
- 16.4%는 use-after-free이며 Rust의 borrow checker, Zig
ReleaseSafe
, Fil-C 방식 검사로 잡을 수 있는 범주임 - Zig의
ReleaseSafe
는 해제된 메모리를 사용하면 실행 중 패닉을 발생시킴- Rust의 안전한 부분집합보다 검사가 덜 포괄적임
- 실행 비용이 있고 프로그램이 패닉할 수 있음
ReleaseFast
는 프로덕션에서 검사를 생략하지만 디버그 빌드와 테스트에서는 유지함- 모든 실제 실행 경로를 테스트한다면
ReleaseSafe
와 같은 안전성을 얻을 수 있지만, 그런 테스트 범위는 일반적으로 현실적이지 않음
- Microsoft의 2019년 자료에 따르면 매년 보안 업데이트로 처리되는 취약점의 약
다른 Zig 프로젝트와 Rust의
unsafe
- TigerBeetle는
ReleaseSafe
를 사용하며, 정밀한 Jepsen 검증에서 안전성 버그 2건이 발견됐지만 둘 다 메모리 안전성과 무관했음 ReleaseFast
를 사용하는 Ghostty와 Zig 컴파일러도 Zig 코드의 메모리 비안전성으로 발생한 CVE가 없음- Rust 프로그램도 의존성 내부의
unsafe
를 통해 메모리 안전성 공백이 생길 수 있음 - Unsafe Rust에는
ReleaseFast
Zig와 같은 위험이 있지만, 개발 중 문제를 잡아내는 Zig의 실행 검사에 해당하는 기능은 없음 - Miri와 Valgrind가 도움을 줄 수 있지만 이를 사용하는 Rust 프로젝트는 많지 않음
- 대신
unsafe
를 드물게 사용하고 더 엄격히 검토하는 문화가 실무에서 Rust의 강한 메모리 안전성 평판을 만듦 - Rust 기반 프로젝트에서도
unsafe
관련 취약점이 발생한 사례가 있음- Deno에는 범위 밖 읽기와 use-after-free CVE가 있었음
- Rocket에는 use-after-free CVE가 있었음
- Actix는
unsafe
사용량이 비정상적으로 높던 시기에 여러 메모리 비안전성 CVE가 발생함
-
Roc은 할당 대부분이 수명이 단순한 아레나에서 일어나므로 use-after-free를 큰 위험으로 보지 않았고, Zig의 추가 검사가 본질적으로 비안전한 코드에 더 유용하다고 판단함
-
TigerBeetle는
재작성 후 확인한 메모리 손상 버그
- Claude Opus 4.8로 Roc 이슈 트래커를 분류한 결과는 다음과 같음
| 컴파일러 | 메모리 손상 발생 | 메모리 손상 없음 | 전체 |
|---|---|---|---|
| Rust | 21 | 2,575 | 2,596 |
| Zig | 10 | 421 | 431 |
-
Rust 컴파일러의 메모리 손상 21건은 컴파일러 내부 로직의 손상이 아니었음
-
borrow checker는 의도한 역할을 수행함
-
잘못 생성된 기계어가 컴파일된 프로그램에서 메모리를 손상시킨
오컴파일버그였음 -
Zig 컴파일러의 메모리 손상 10건 중 8건도 오컴파일이었음
-
나머지 2건은 오류 보고 코드의 use-after-free였음
roc check
와roc bundle
의 오류 메시지에서 파일명이 U+FFFD 대체 문자로 깨졌음- Rust borrow checker였다면 두 버그를 모두 잡을 수 있었음
- 다른 도구 선택이 실제 사용자에게 미쳤을 결과는 다음과 같음
| 도구 선택 | 실제 메모리 안전성 영향 |
|---|---|
Zig ReleaseFast | |
| 일부 오류 메시지에서 파일명이 렌더링되지 않는 버그 2건 | |
Zig ReleaseSafe | |
| 일부 오류가 패닉하고 파일명을 렌더링하지 못하는 버그 2건 | |
| Rust borrow checker | 두 버그 모두 방지 |
- 18개월, 수십만 줄, 수백 건의 버그 보고를 고려하면 세 선택지의 차이는 프로젝트에 실질적으로 크지 않았음
- Bun은 JavaScript의 추적식 가비지 컬렉션 값과 수동 관리 값을 함께 다루면서 use-after-free, double-free, 해제 누락이 큰 비중을 차지했음
- Roc 컴파일러는 JavaScript나 다른 추적식 가비지 컬렉터와 연동하지 않으므로 같은 수명 관리 문제를 겪지 않음
- Roc에는 컴파일러 내부 메모리 오류보다
생성된 출력 코드의 메모리 오류를 찾는 도구가 더 필요하며, 후자는 borrow checker의 범위 밖임
빌드 시간의 실제 결과
zig build --watch -fincremental
은 현재 약 45만~46만 줄의 Zig 코드에서 변경 사항을 약35ms에 다시 빌드함- 안정 버전 Zig 0.16.0에는 Roc 코드베이스에서
-fincremental
을 깨뜨리는 버그가 있음- 수정 사항은 반영됐지만 이를 사용하려면 호환성이 깨지는 Zig 0.17.0 사전 빌드로 옮겨야 함
-
관련 의존성도 함께 벤더링하고 0.17.0으로 업그레이드해야 하므로 다음 안정 릴리스를 기다리기로 함
-
Intel 데스크톱과 Ubuntu 26에서 측정한 결과는 다음과 같음
| Roc 컴파일러 | 코드 규모 | 콜드 빌드 | 증분 빌드 |
|---|---|---|---|
| Rust 1.85.0 기반 원본 | 354K | 32.4초 | 10.0초 |
| Rust 1.97.0 기반 원본 | 354K | 25.4초 | 3.4초 |
| Zig 0.16.0 기능 동등성 시점 | 320K | 39.6초 | 8.6초 |
| Zig 0.17.0 현재 재작성본 | 464K | 32.1초 | 0.035초 |
-
기능 동등성 시점에는 거의 변하지 않는 산출물도 매번 다시 빌드했지만, 현재는 필요할 때만 생성함
-
이 변경으로 코드가 약 50% 늘었는데도 콜드 빌드는 더 빨라짐
-
Rust 1.85.0에서 1.97.0으로 넘어가며 증분 빌드는 10초에서 3.4초로 줄어들어 18개월 동안 약 3분의 2가 개선됨
-
Zig의 35ms는 3.4초의 약
100분의 1이며, Rust 측정 코드보다 약 50% 큰 코드베이스에서 나온 결과임 - 현재
-fincremental
은 x86-64에서만 작동하고 많은 기여자가 ARM 기반 Mac을 사용하므로 아직 빌드 시간 이점을 온전히 활용하지 못함
포인터 없는 구조와 무파싱 역직렬화
-
새 디스크 캐시는 Zig 컴파일러와 게임 개발에서 쓰이는 방식처럼 실행에 효율적인 메모리 배치를 그대로 디스크 형식으로 사용함
-
모든 컴파일러 데이터 구조는 포인터 대신
32비트 인덱스 배열로 표현되며, 여러 곳에서 구조체 배열 형식을 사용함- 메모리 사용량을 줄이고 실행 속도를 높임 -
별도 직렬화 형식으로 바꾸지 않고 데이터 구조를 디스크에 직접 기록할 수 있음
-
역직렬화할 때는 디스크 바이트를 파싱하지 않음
-
바이트를 메모리에 읽음
-
기존 데이터 구조가 새 배열을 가리키도록 일부 재배치함
-
이후 즉시 사용할 수 있음
-
속도는 사실상 디스크에서 메모리로 바이트를 읽는 I/O 속도로 제한됨
-
운영체제 디스크 캐시에 데이터가 있으면 대략
memcpy
속도로 이전 빌드 결과를 불러옴
- 운영체제 디스크 캐시에 데이터가 있으면 대략
roc check
는 첫 실행에서 파싱·타입 검사 등의 결과를 디스크에 저장함- 입력 소스가 바뀌지 않았다면 두 번째 실행에서는 데이터 구조를 디스크에서 메모리로 바로 옮김
roc test
는 결정적인 순수 함수 테스트 결과도 캐시함- 캐시는 파일 단위로 동작하므로 한 파일을 바꾸면 해당 파일과 의존 파일만 다시 처리함
- 이 방식은 컴파일러 전체가 포인터 대신 인덱스를 사용하기 때문에 가능하며, 일반적인 포인터 중심 구조에서는
무파싱 역직렬화가 불가능함 -
인덱스 기반 구조의 안전성 한계
- 포인터가 잘못된 주소를 가리킬 수 있듯 인덱스도 잘못된 배열을 조회해 임의의 바이트를 읽을 수 있음
- Rust borrow checker는 포인터 수명을 다루지만 어떤 인덱스가 어떤 배열에 속하는지는 검사하지 않음
- 필요한 배열 수를 사전에 안다면 Rust의
compact_arena
가 매크로로 타입 태그를 만들어 잘못된 배열 조회를 막을 수 있음 - Roc처럼 모듈 수에 따라 배열 수가 달라지면 이 기법을 적용할 수 없어
compact_arena
도SmallArena::new
를unsafe
로 표시함 - 빈 아레나 생성 자체는 위험을 만들지 않지만 실제 위험은 매우 자주 수행하는 배열 인덱싱에 있음
- Safe Rust는
unsafe
가 작고 격리돼 있다는 가정에서 효과적이지만, Roc처럼unsafe
가 광범위하면 이 가정이 성립하지 않음
Roc에 맞았던 Zig 생태계
- Bun은 JavaScript와 수동 메모리의 상호 운용 때문에 정리 코드를 한 번만 실행하는 Rust의
Drop
이 유용했음 - Roc은 반대로 모듈과 컴파일 단계마다
별도 아레나를 사용하려 하므로, 전역 할당자와 암묵적Drop
을 전제로 한 Rust 패키지가 불편했음 - Zig 생태계는 할당자를 명시적으로 전달하는 API가 일반적이어서 Roc의 메모리 관리 방식과 잘 맞음
- Rust 생태계는 Bun이 원하는 구조에, Zig 생태계는 Roc이 원하는 구조에 각각 더 적합했음
LLVM bitcode 직렬화기 재사용
- LLVM은 Roc 최적화기의 핵심 의존성이며, Roc 자체 최적화 뒤에 추가 최적화를 수행함
- LLVM은 주요 API 호환성을 자주 깨뜨려 버전 업그레이드에 상당한 시간과 비용이 필요했음
- LLVM의 직렬화된
bitcode 형식은 안정적이고 하위 호환되므로, 자체 직렬화기를 사용하면 C++ API 변경에서 벗어날 수 있음 - 이를 위해서는 LLVM C++ 라이브러리와 분리된 수작업 bitcode 직렬화기가 필요함 - 기존에 알려진 구현은 Zig 컴파일러에 있었고, Roc의 새 컴파일러는 해당 Zig 코드를 재사용함
- Roc이 Zig 생태계에서 얻는 가장 큰 의존성 원천은 일반 패키지보다
Zig 컴파일러 자체임
Zig에서 그리운 Rust 기능
- 컴파일러 구현에서는 명시적 할당 제어가 필요하지만 테스트에서는 Rust의
자동 할당·해제가 더 편리했음- Zig 테스트 할당자는 메모리 누수를 찾아내고 컴파일된 Roc 코드의 누수도 감지할 수 있음 - 대신 각 테스트에서
init
과defer deinit
을 정확히 작성해야 하며, 하나라도 잘못되면 누수로 테스트가 실패함
- Zig의
comptime
이 매개변수적·임시 다형성과 겹치지만 두 형태의 다형성이 그리움- Rust의
Allocator
trait은self
를 받을 수 있음 - Zig의
ArenaAllocator
같은 구현은anyopaque
포인터를 받은 뒤 자기 타입으로 캐스팅해야 함
-
Rust의
비공개 구조체 필드가 없어 직접 접근하면 안 되는 필드를 컴파일 오류로 막을 수 없음- 코드 차이 검토에서는 필드 접근만 보이고 원래 구조체의 문서는 보이지 않으므로 매번 별도로 확인해야 함 -
함수·변수·상수가 모두
snake_case
를 사용하는 Rust의 일관성이 때때로 그리움 unsafe
와 borrow checker는 비용도 있었지만 특정 문제를unsafe
블록 안에서만 걱정하게 해주는 안정감이 있었음- Zig에 같은 기능을 추가해야 한다는 입장은 아님
- Zig에서는 Rust보다
죽은 코드를 뒤늦게 발견하는 일이 많았음- Zig 내장 도구와 TigerBeetle의
tidy.zig
도 일부 죽은 코드를 잡지 못함 - 죽은 코드는 바이너리에 생성되지 않아 사용자에게 영향을 주지 않지만 코드베이스 관리에는 불리함
- Zig 내장 도구와 TigerBeetle의
- Rust는 소수 버전 업그레이드와 에디션 변경이 대부분 수월했음
- 현재 Zig는 하위 호환성을 목표로 하지 않으며 예상했던 조건이라 큰 문제는 아니었지만, Rust의 간단한 업그레이드 경험은 더 나았음
Zig에서 만족한 부분
- Zig는 함수형 프로그래밍처럼 익숙한 도구를 줄이는 대신 다른 속성을 얻는
감산적 설계의 매력이 있음 - 매크로가 없으며, 매개변수적 다형성을 포함한 많은 문제를
comptime
이나 일반 함수로 해결할 수 있음 - 데이터 배치를 세밀하게 제어할 수 있음
u7
,u5
처럼 2의 거듭제곱이 아닌 정수 타입을 별도 비트 처리 없이 사용함- packed struct를 기본 지원함
-
선언 위치가 아니라 호출 위치에서 함수를 인라인할 수 있음
-
Rust에서는 매크로 기반 크레이트가 필요한 기능을 추가 의존성 없이 이용함
-
Zig 빌드 도구 체인은 Alpine Linux와 WebAssembly용
독립 실행형 바이너리생성에 잘 맞았음- Roc 표준 라이브러리에 해당하는 builtins를 불투명한 바이너리 블롭으로 컴파일해 최종 실행 파일에 포함하는 특수한 빌드도 처리함 -
Uber도 Zig 언어를 사용하지 않으면서 빌드 인프라에 Zig 도구 체인을 사용함
-
Zig의 오류 처리에서는 실패한 힙 할당도 일반 사용자 공간 오류로 다룸
-
Roc도 익명 합 타입과 페이로드를 이용해 오류가 자연스럽게 누적되는 유사한 전략을 사용함
-
Rust의
anyhow
,thiserror
, 기본Result
기반 처리보다 Zig와 Roc의 방식을 선호함 - 오류 전파 문법은 Zig의
try
보다 Rust의 후위?
를 선호해 Roc에도후위를 채택함?
연산자 - 세분화된 할당자 API와 고성능 컴파일러용 재사용 코드를 포함해, 프로젝트 전체로는 Zig 선택에 매우 만족함
Roc의 다음 단계
- 새 컴파일러의
0.1.0을 올해 후반에 출시할 계획이며, Roc 최초로 번호가 붙은 릴리스가 됨 - 출시 전에도 Nightly 빌드를 시험할 수 있지만 현재는 여러 버그, 미완성 기능, 불완전한 문서가 남아 있음 - Roc Programming Language Foundation은 미국의 501(c)(3) 비영리단체이며, 기부금은 주로 기여자 보상에 사용함
- 향후 개발 진행과 질문은 Roc Zulip에서 확인할 수 있음
AI 자동 생성 콘텐츠
본 콘텐츠는 GeekNews의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기