인프라 하다 보면 "언어 갈아엎자"는 소리를 한 번쯤 듣게 된다. 대부분은 회의실 농담으로 끝나는데, Roc 컴파일러 팀은 진짜로 했다. Rust로 짠 30만 줄짜리 컴파일러를 Zig로 통째로 다시 썼고, 그 기록을 직접 공개했다.
이게 왜 우리 같은 인프라/백엔드 엔지니어한테 흥미로운가. 단순히 "어떤 언어가 더 좋냐" 싸움이 아니라, 대규모 코드베이스를 재작성할 때의 판단 근거, 빌드 타임과 메모리 할당 전략, 재현 가능한 크로스 컴파일 같은 인프라 실무와 직결되는 결정들이 잔뜩 들어 있어서다. 최근 Bun 팀이 반대 방향(Zig → Rust)으로 옮긴 회고를 낸 시점과 맞물려서 더 화제가 됐다.
참고로 하나 짚고 가자. Bun은 약 50만 줄을 11일 만에 옮겼고, Roc은 30만 줄을 487일 걸렸다. 얼핏 보면 Roc이 40배 느린 것 같지만, Bun은 거의 그대로 옮기는 직역(port)이었고 Roc은 아키텍처 자체를 갈아엎는 재작성(rewrite)이었다. 성격이 완전히 다른 작업이라 이 숫자를 그대로 비교하면 안 된다. 원문 저자도 이 점을 분명히 못박는다.
1. 왜 Rust를 버리고 Zig를 선택했나: 구조적 결함의 실체
먼저 오해 방지. 저자(Richard Feldman)는 Rust 강사 출신이고 지금도 Zed에서 매일 Rust를 쓴다. Rust 안티가 아니다. 핵심 메시지는 이거다. "한 프로젝트에 맞는 언어가 다른 프로젝트엔 안 맞을 수 있다. 만능은 없다."
그럼 왜 갈아엎기로 했나. 재작성 결정의 방아쇠는 언어가 아니라 아키텍처 결함이었다. Roc은 람다 셋 특수화(lambda set specialization)를 통한 다형적 디펑셔널라이제이션(polymorphic defunctionalization)이라는 최적화를 쓴다. 쉽게 말하면 함수형 언어에서 클로저 캡처가 힙 할당을 안 하게 만드는 기법인데, 함수형 언어에서 이건 인라이닝처럼 후속 최적화를 줄줄이 터뜨리는 핵심이다.
문제는 이 시스템이 여러 컴파일러 단계에 걸쳐 있어서 버그가 계속 터졌다는 거다. Ayaz Hafiz가 OCaml로 새 아키텍처를 프로토타이핑하고 나서야 원인을 찾았는데, 결론이 "이거 고치려면 컴파일러 대부분을 다시 써야 함"이었다. 마침 여러 기여자가 각자 다른 이유로 컴파일러 여기저기를 다시 쓸 계획이었고, "어차피 거의 다 다시 쓸 거면 통째로 하자"가 된 거다.
여기서 실무 포인트 하나. 컴파일러는 스크래치 재작성이 정상인 특이한 도메인이다. 대부분의 메인스트림 컴파일러가 언젠가 한 번씩 밑바닥부터 다시 썼다(TypeScript가 Go로 옮긴 것도 그 사례). 하지만 여러분의 마이크로서비스는 컴파일러가 아니다. 이 "재작성 정상론"을 일반 백엔드 서비스에 그대로 적용하면 큰일 난다. 이건 뒤에서 다시 짚는다.
언어를 Zig로 정한 이유는 네 가지로 정리된다.
- 빌드 타임.
cargo build가 코드베이스 커질수록 증분 빌드조차 아팠다. 이게 실무에서 진짜 크다. 컴파일러 개발은 빌드-테스트 루프를 하루에 수백 번 도는데 여기가 막히면 생산성이 통째로 죽는다. - 메모리 제어. 이들은 컴파일 전 과정에서 여러 allocator(특히 arena)와 struct-of-arrays 레이아웃을 쓴다. Rust 생태계는 대체로 단일 글로벌 allocator를 가정하는데(
soa_rs조차), Zig 생태계는 세분화된 allocator가 기본 전제다. - 생태계 적합성. Rust 생태계가 전체적으로 훨씬 크지만, 이들이 원한 니치한 것들(LLVM C++ 라이브러리 래핑 없이 비트코드 뽑기 같은)은 Zig 쪽에 더 있었다.
- 메모리 언세이프 코드 지원. 이 부분이 핵심이다. 아래에서 별도로 본다.
Rust는 unsafe 코드를 드물게 격리시키고 miri나 Valgrind로 검증하라는 철학이다. 그런데 기계어를 뽑아내는 컴파일러는 메모리 언세이프한 짓이 업무의 본질이다. Roc의 Rust 코드 30만 줄 중 unsafe가 약 1,200군데였다. 비교하자면 rustc는 350만 줄에 unsafe가 약 4만 군데. Roc 입장에선 unsafe가 "드문 예외"가 아니라 일상이었고, 그 영역에서 Zig가 더 많은 도움을 준다고 판단한 거다.
2. 30만 줄 재작성 전략과 동작 원리
재작성 전략을 얘기할 때 항상 나오는 게 빅뱅 vs 점진적(Ship of Theseus, 부분 교체) 전환이다. Roc 팀은 결국 빅뱅에 가까운 스크래치 재작성을 택했다. 이유는 위에서 봤듯 결함이 아키텍처 전반에 퍼져 있어서 점진 교체로는 해결이 안 됐기 때문이다.
이 선택이 실제로 뭘 가져왔는지, 새 컴파일러가 뽑아낸 결과물로 보자. WASM-4 게임 Rocci Bird를 새 컴파일러로 빌드했더니:
# 사이즈 최적화 빌드
roc build --opt=size
# 결과: 31KB wasm 바이너리
# (기존 Rust 컴파일러는 두 배 이상 크기였다)
같은 게임 소스인데 바이너리가 절반 이하로 줄었다. 이게 재작성으로 얻은 구체적 성과 중 하나다. 다만 원문 기준으로 명확히 하면, 이건 피처 패리티(기존 기능 동등성) 달성 마일스톤이지 정식 릴리스가 아니다. 0.1.0은 그해 말 목표로 잡혀 있다.
여기서 대규모 재작성의 첫 번째 교훈. "피처 패리티"를 마일스톤으로 명확히 정의하라. 30만 줄짜리를 다시 쓸 때 "다 됐다"의 기준이 모호하면 끝이 안 난다. 이 팀은 "1000줄 미만 게임 하나가 새 컴파일러로 돌아간다"는 아주 구체적인 통과 기준을 잡았다. 작지만 컴파일러의 여러 기능을 실제로 요구하는 실전 예제를 게이트로 쓴 거다. 이건 우리 실무에서 마이그레이션할 때도 그대로 써먹을 수 있는 패턴이다. 추상적 완성도 대신 "이 대표 워크로드가 신 시스템에서 돈다"를 게이트로 삼는 것.
3. Zig가 가져다준 것들: 실제 동작 예시
핫 코드 로딩 + 크로스 컴파일
새 컴파일러의 킬러 피처는 개발 중 핫 코드 로딩이다. 서버를 띄운 채로 코드를 바꾸면, 다음 요청부터 자동으로 새 코드로 처리된다.
# 서버 실행
roc server.roc
# 이 상태에서 소스를 수정하고 저장하면
# 다음 요청부터 새 코드로 처리됨
Python 같은 인터프리터 언어에선 당연한 거지만, LLVM 최적화 바이너리를 뽑는 고성능 컴파일 언어에서 이걸 지원하는 건 흔치 않다. 배포할 땐 그냥 최적화된 자립 바이너리를 뽑는다.
인프라 관점에서 진짜 눈에 들어오는 건 재현 가능한 크로스 컴파일이다.
# Alpine Linux용 정적 바이너리 빌드
roc build --target=x64musl
# 핵심: 같은 소스 바이트 → 같은 출력 바이트
# Mac에서 돌리든 다른 시스템에서 돌리든 동일한 결과물
이게 왜 중요한가. CI/CD에서 빌드 재현성(reproducible build)은 공급망 보안과 캐시 효율의 핵심이다. "내 로컬에선 되는데 CI에선 다른 바이너리가 나온다" 같은 지옥을 겪어봤으면 안다. 원문도 "모든 컴파일러가 이걸 보장하진 않는다"고 콕 집는다. 크로스 컴파일이 이렇게 한 줄 플래그로 되는 건 Zig 툴체인의 강점을 그대로 물려받은 부분으로 보인다.
패턴 매칭 안의 문자열 보간
새로 들어간 언어 기능 중 인상적인 게 패턴 매칭 안에서의 문자열 보간이다. HTTP 라우팅 코드를 보자.
match (verb, path) {
("GET", "/users/${id}/${page}") = match page {
"" | "profile" = ok(id)
"settings" = ok(with_default(user_agent, id))
"posts/${post_id}" = ok("Post ID: ${post_id}")
_ = not_found
}
("GET", "/users/${id}") = ok(id)
("POST", "/posts/new") = created(with_default(…))
_ = not_found
}
여기서 "/users/${id}"는 런타임에 템플릿 문자열을 파싱하는 게 아니다. 컴파일 타임에 타입 안전하게 검사되는 언어 기능이고, 이 스니펫 전체가 힙 할당 제로다. 저자 말로는 핫 코드 로딩 되는 보통 언어라면 이런 코드가 줄당 1회 정도는 할당할 거라는데, Roc은 순수 함수의 컴파일 타임 실행을 써서 라우팅을 제로 할당으로 만들었다.
백엔드 하는 사람 입장에서 라우팅 경로 파싱이 할당 없이 타입 세이프하게 된다는 건 꽤 매력적이다. 고 트래픽 API 게이트웨이에서 요청당 할당 하나하나가 GC/RC 압력으로 쌓이는 걸 겪어봤다면 더 그렇다.
4. 실무 관점: 트레이드오프와 흔한 함정
메모리 안전성이라는 큰 트레이드오프
가장 큰 트레이드오프는 명백하다. Zig에는 Rust의 borrow checker가 없다. 원문도 인용하는 유명한 2019 Microsoft 자료에 따르면, 매년 보안 업데이트로 다루는 취약점의 약 70%가 메모리 안전성 문제다.
Roc 팀은 이 리스크를 감수하는 대신 Zig가 제공하는 다른 안전 장치들(디버그 빌드에서의 각종 런타임 체크 등)에 기댄다. 하지만 냉정하게 보자. borrow checker가 컴파일 타임에 잡아주던 걸, 이제 런타임 체크와 테스트, 퍼저(fuzzer), Valgrind 같은 도구로 사람이 메꿔야 한다. 원문 감사 인사에도 "퍼저 에러를 체계적으로 재현하고 조사한" 기여자가 등장한다. 즉, 재작성 후에도 메모리 관련 버그 사냥은 계속되고 있다는 뜻이다.
이걸 실무로 번역하면 이렇다. Zig를 택한다는 건 "언어가 못 막아주는 부분을 팀의 규율과 CI 파이프라인(퍼징, 새니타이저, ASAN/Valgrind)으로 막겠다"는 조직적 약속이다. 팀에 그런 문화와 인프라가 없으면 Rust가 컴파일 타임에 공짜로 주던 안전망을 잃기만 한다.
흔한 함정: Zig의 use-after-free는 컴파일러가 안 막는다
Rust 쓰다 Zig 오면 가장 먼저 데는 지점이다. 아래 같은 코드는 Rust면 컴파일 자체가 안 되지만, Zig에선 컴파일이 통과하고 런타임에 터진다.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const buf = try allocator.alloc(u8, 16);
allocator.free(buf);
buf[0] = 42; // 이미 free된 메모리에 접근
std.debug.print("{d}\n", .{buf[0]});
}
디버그 빌드로 돌리면 GeneralPurposeAllocator가 이런 식으로 잡아준다:
error(gpa): Double free detected. Allocation:
error(gpa): Use after free:
thread panic: incorrect alignment
Segmentation fault at address 0x0
여기서 핵심 함정 두 가지.
- 이건 런타임에, 그것도 그 코드 경로가 실제로 실행돼야만 잡힌다. Rust의 borrow checker는 실행 안 해도 컴파일 타임에 잡는다. 테스트 커버리지가 낮은 경로면 프로덕션에서 처음 만날 수도 있다.
- 릴리스 빌드(
-Doptimize=ReleaseFast)에선 GPA의 안전 체크가 꺼진다. 디버그에서 잘 돌던 게 릴리스에서 조용히 UB(정의되지 않은 동작)로 바뀐다. "디버그에선 되는데 릴리스에서만 이상함"의 전형적 원인이다. 그래서 CI에서 디버그/세이프 빌드로 테스트를 반드시 돌려야 한다.
또 다른 함정: 빌드 타임은 빨라도 학습 곡선과 생태계는 대가다
Zig가 빌드 빠르고 메모리 제어 좋은 건 맞지만, 공짜가 아니다.
- Zig는 아직 1.0 이전이다. 언어와 표준 라이브러리 API가 버전 사이에 깨질 수 있다. 실제로
std` 시그니처가 바뀌어서 빌드가 안 되는 상황을 만날 수 있다(정확한 호환성 정책은 공식 문서 확인 필요). 프로덕션 인프라의 핵심 컴포넌트로 넣을 거면 이 리스크를 계산에 넣어야 한다. - 생태계가 작다. Roc 팀은 "우리한테 필요한 니치한 건 오히려 Zig에 더 있었다"고 했지만, 그건 그들의 특수 상황이다. 일반적인 웹/백엔드 라이브러리(ORM, HTTP 프레임워크, 인증 등)는 Rust 생태계가 압도적으로 넓다.
대안
정리하면 선택지는 대략 이렇다.
- 메모리 안전성이 최우선이고 팀이 Rust에 익숙하다 → Rust 유지. borrow checker의 컴파일 타임 안전망은 진짜 자산이다.
- 커스텀 allocator/arena/SoA를 극한으로 다뤄야 하고, 빌드 타임이 병목이며, 팀에 퍼징·새니타이저 문화가 있다 → Zig 검토 가치 있음.
- 일반적인 백엔드 서비스 → 솔직히 둘 다 아닐 확률이 높다. Roc 팀도 "컴파일러라서 이런 결정이 말이 된다"고 여러 번 강조한다. 이 회고를 여러분의 CRUD API 재작성 명분으로 쓰지 마라.
5. 대규모 언어 마이그레이션, 실무 교훈 정리
한 줄 요약: 이건 "Zig가 Rust보다 좋다"는 얘기가 아니라, "특정 도메인(컴파일러)의 특정 요구(세분화된 메모리 제어, 빌드 타임, 잦은 unsafe)에는 Zig가 더 맞았다"는 케이스 스터디다.
재작성을 고민하는 실무자가 가져갈 것들:
- 재작성의 방아쇠는 언어가 아니라 아키텍처였다. Roc도 "언어가 싫어서"가 아니라 "결함이 여러 단계에 퍼져서 부분 수정이 불가능해서" 재작성했다. 여러분 시스템이 그 정도로 구조적으로 망가졌는지부터 냉정하게 봐라. 언어 취향은 재작성 명분이 될 수 없다.
- 487일이라는 숫자를 기억하라. 30만 줄 재작성은 1년 반이 걸렸고, 그것도 원저작자들이 붙어서다. 일정 산정할 때 낙관하지 마라.
- 피처 패리티 게이트를 구체적 워크로드로 정의하라. "다 됐다"의 기준을 실제 대표 예제가 신 시스템에서 도는 것으로 잡아라.
- 언어가 안 막아주는 건 파이프라인이 막아야 한다. borrow checker를 버렸으면 퍼징·새니타이저·재현 가능 빌드를 CI에 박아 넣어라. 안전은 언어 아니면 프로세스, 둘 중 하나는 반드시 담당해야 한다.
- 재현 가능한 크로스 컴파일은 그 자체로 인프라 자산이다.
--target=x64musl한 줄로 결정론적 정적 바이너리가 나오는 건, 컴파일러 얘기를 떠나 우리 빌드 파이프라인이 지향해야 할 이상향이다.
개인적으로 가장 와닿은 건 마지막 태도다. "만능 언어는 없다. 프로젝트마다 맞는 게 다르다." 인터넷 언어 전쟁에 시간 쓰지 말고, 우리 워크로드의 실제 병목(빌드 타임인지, 메모리 제어인지, 안전성인지, 생태계인지)을 먼저 정의한 다음 도구를 고르라는 거다. 지극히 당연한데 실무에선 자주 거꾸로 한다.
참고 자료
'Tech_News' 카테고리의 다른 글
| AWS가 17억 달러를 청구했다? 예상 청구(Estimated Billing) 데이터를 맹신하면 안 되는 이유 (0) | 2026.07.19 |
|---|---|
| GPU 없이 13년 된 Xeon에서 26B LLM 돌리기: CPU 추론의 현실과 함정 (0) | 2026.07.17 |
| Cloudflare Precursor: 체크포인트 봇 탐지를 버리고 전체 세션 행동을 스트림으로 보는 이유 (0) | 2026.07.16 |
| lobste.rs는 왜 MariaDB를 버리고 SQLite로 갔나 — DB를 줄여서 얻은 것들 (0) | 2026.07.15 |
| Claude Code가 프롬프트도 읽기 전에 33k 토큰을 태우는 이유 — OpenCode와 실측 비교 (0) | 2026.07.14 |
