What Zig felt like, coming from Rust
Rust 개발자가 경험한 Zig
7년간 Rust를 사용한 개발자가 RFC 9535 기반 JSONPath 구현을 Zig로 다시 만들며 두 언어의 개발 경험을 비교합니다. Zig의 단순한 빌드 도구와 낮은 추상화는 긍정적으로 평가하지만, 수동 메모리 관리와 미성숙한 생태계에서는 Rust와 뚜렷한 차이를 확인합니다.
- 주제
AI 요약
Rust 개발자로 7년간 오픈소스 프로젝트를 만들어 온 글쓴이가 기존 Rust 프로젝트인 jsonpath-rust를 Zig로 재구현해 zig-jsonpath를 만들면서 두 언어의 차이를 살펴봅니다. 대상은 장난감 수준의 예제가 아니라 RFC 9535에 정의된 JSONPath 쿼리 언어 구현이며, 글쓴이는 Rust의 함수형 스타일과 생태계에 익숙한 상태에서 Zig를 처음 본다는 점을 전제로 경험을 기록합니다. 따라서 Zig의 정석적인 관용구를 설명하기보다는, Rust에서 넘어온 개발자가 실제 프로젝트를 진행하며 어떤 지점에서 사고방식을 바꾸게 되는지를 보여주는 비교에 가깝습니다.
■ IDE보다 명확했던 명령줄 중심 개발
가장 먼저 체감한 차이는 IDE 지원입니다. RustRover와 JetBrains 계열 IDE에 익숙했던 글쓴이에게 Zig의 지원은 문법 강조와 기본적인 자동 완성에 가까웠습니다. 처음에는 단점처럼 보였지만, 결과적으로 언어를 명령줄 도구 중심으로 익히게 됐고, IDE 없이도 개발 흐름이 단순해질 수 있다는 점을 다시 경험했다고 설명합니다. 특히 build.zig가 테스트와 검증 작업을 한곳에서 관리해 주는 점을 긍정적으로 평가합니다. 실제로 다음과 같은 명령을 구성했습니다.
zig build test — 전체 테스트 실행 zig build test -Dfilter="filter match function basic" — 특정 테스트 실행 zig build test -Ddebug-query=true — 디버그 출력을 포함한 전체 테스트 실행 zig build compliance — RFC 준수성 테스트 실행 zig build check — 단위 테스트와 준수성 테스트를 함께 실행
이 경험은 글쓴이가 전체 IDE에서 벗어나 helix, alacritty, zellij 조합으로 이동하는 계기가 되기도 했습니다.
■ Zig가 유도한 평평한 프로젝트 구조
Rust에서는 파일 크기와 디렉터리 깊이 사이의 균형을 찾기 위해 일찍부터 파일을 나누고 폴더 계층을 만드는 편이었지만, Zig에서는 그렇게 할 필요를 훨씬 늦게 느꼈다고 합니다. Zig에서도 파일과 폴더를 중첩할 수 있지만, import 과정에 마찰이 생기고 여러 파일로 나누었을 때 얻는 가독성 이점이 항상 분명하지 않았습니다. 관련된 코드를 하나의 큰 파일에 모은 뒤 섹션 단위로 탐색하는 방식이 오히려 실용적이었고, 모델과 짝을 이루는 코드가 필요하면 model_<companion>처럼 인접한 파일을 추가하는 정도로 해결했습니다.
Rust 프로젝트에서는 parser와 query 아래에 여러 모듈과 테스트 파일을 두었지만, Zig 프로젝트의 src 디렉터리는 root.zig, parser.zig, model.zig, model_query.zig, query.zig 정도의 평평한 구조로 유지됐습니다. 글쓴이는 이 방식이 매우 큰 프로젝트에도 계속 통할 것이라고 주장하지는 않습니다. 다만 첫날부터 폴더 계층을 만들고 싶은 충동이 실제 프로젝트의 규모 때문인지, 단순한 습관 때문인지 돌아보게 했으며, Zig에서는 계층 구조가 필요해지는 시점이 예상보다 훨씬 늦었다고 말합니다.
■ 테스트는 Rust가 더 편했지만, 원인은 메모리 관리에 있습니다
Rust에서 글쓴이는 코드 내부에 두는 인라인 단위 테스트와 소스 트리 밖의 integration test라는 두 가지 방식을 주로 사용했습니다. Zig도 같은 파일 안에 테스트를 작성할 수 있지만, 글쓴이는 테스트 코드가 장황하게 느껴졌습니다. 평평한 파일 구조를 택한 뒤에는 모델마다 별도의 model_test 파일을 만들거나 테스트를 모델 파일에 직접 넣어야 했고, 어느 쪽이든 파일 자체나 디렉터리가 복잡해졌습니다. 결국 테스트를 코드에 직접 넣고 build.zig에서 이를 명시적으로 구성했습니다. 설정이 끝난 뒤에는 깔끔하게 동작했지만, 전체적인 테스트 작성과 관리 경험은 Rust가 더 쉬웠다고 평가합니다.
다만 이 차이의 상당 부분은 테스트 프레임워크가 아니라 Zig의 수동 메모리 관리에서 비롯됐다고 설명합니다. 테스트가 메모리 누수와 오류 경로를 다루려면 초기화와 해제를 직접 추적해야 하기 때문입니다.
■ 함수형 추상화에서 직접 변이로
Rust는 기술적으로 명령형 언어지만 zero-cost iterator, 지연 평가, algebraic data type(ADT), 패턴 매칭, monadic type, trait, closure 등 함수형 프로그래밍 개념을 폭넓게 사용합니다. 글쓴이의 Rust JSONPath 구현도 Queryable과 관련 타입을 이용한 오류 제어, map·flat_map·reduce를 가진 Data<T>, 순수하고 불변인 변환, iterator combinator, closure, 선언적 매크로, sum type과 product type에 크게 의존합니다.
Zig에서는 sum type과 재귀 같은 요소는 어느 정도 유지할 수 있었습니다. 그러나 Rust의 trait 기반 처리와 달리 Zig에서는 anytype과 @hasDecl을 이용해 특정 타입에 query 메서드가 존재하는지 컴파일 타임에 확인하는 방식으로 구현했습니다. 글쓴이는 이를 명시적인 컴파일 타임 trait가 없는 duck typing에 가까운 형태로 설명합니다. 더 큰 차이는 데이터 변환 방식입니다. Rust에서는 새로운 Data를 반환하는 flat_map과 iterator combinator를 사용하지만, Zig에서는 allocator를 직접 다뤄야 하므로 반복적으로 새 구조를 만드는 방식이 메모리 비용과 수동 장부 관리 비용을 키울 수 있습니다. 그 결과 Zig 코드에서는 목록을 제자리에서 덮어쓰거나, while 루프를 돌며 조건에 맞지 않는 항목을 직접 제거하는 방식이 자연스러워졌습니다.
분기 처리에서도 Rust는 상태를 복제해 각 selector에 적용한 뒤 reduce하지만, Zig는 cursor 상태를 깊은 복사하는 fork를 만들고 한쪽 분기를 처리한 다음 폐기하는 식으로 작성했습니다. 글쓴이는 이런 선택이 각 언어의 설계 목표와 대상 영역을 반영한 합리적인 절충이라고 보면서도, 주관적으로는 결과적인 Zig 코드가 Rust 코드보다 덜 읽기 쉽다고 평가합니다. 이 글에서 말하는 핵심 차이는 불변 monad와 조합기 중심의 변환, 그리고 직접 변이와 루프 중심의 처리 사이에 있습니다.
■ allocator가 만드는 소유권 문제
Zig에서는 거의 모든 함수가 allocator를 인자로 받고, 많은 구조체가 allocator를 보관합니다. 할당이 명시적이라는 점은 흐름을 따라가기 쉽게 만들지만, 실제 코드는 init/deinit 관례를 계속 지켜야 하므로 번거롭습니다. 호출 스택이 길어질수록 누가 할당을 해제할 책임이 있는지, 오류가 발생했을 때 어느 경로를 정리해야 하는지를 사람이 직접 관리해야 합니다.
글쓴이는 std.testing.allocator의 TestAllocator와 FailingAllocator가 이런 문제를 검증하는 데 도움을 준다고 설명합니다. 예를 들어 iter.deinit()을 잊은 메모리 누수는 MemoryLeakDetected로 잡을 수 있고, 오류 경로에서 해제가 빠진 경우에는 특정 할당을 실패시키는 FailingAllocator로 확인할 수 있습니다. 그러나 테스트가 해당 실패 경로를 실제로 실행해야 한다는 조건이 있습니다.
글에서 특히 크게 다룬 사례는 네 가지입니다. 첫째, init 직후 defer iter.deinit()을 등록하지 않아 발생하는 기본적인 메모리 누수입니다. 둘째, 두 번째 append가 실패했을 때 errdefer iter.deinit()이 없어 전체 iterator가 누수되는 오류 경로입니다. 셋째, 함수가 반환한 결과를 cache에 얕은 복사로 넣은 뒤 원래 함수에서도 defer result.deinit()을 실행해 같은 메모리를 두 번 해제하는 문제입니다. 이 경우에는 소유권이 더 오래 살아 있는 cache 계층으로 넘어갔으므로, 값을 넘긴 뒤에는 호출자 쪽에서 다시 해제하지 않아야 합니다. 넷째, 문자열을 먼저 dupe한 다음 ArrayList에 넣는 과정에서 두 번째 할당이 실패하면, 배열에 연결되지 못한 문자열 할당이 고아가 되는 문제입니다. 이 경우에는 배열 추가 전에 errdefer allocator.free(duped)를 등록해야 합니다.
Rust에서는 scope가 끝날 때 Drop이 자동 실행되므로 첫 번째와 두 번째 형태의 누수가 일반적인 소유권 흐름에서는 발생하지 않습니다. 값을 cache에 넣는 동작도 move가 되기 때문에, 이동한 뒤 같은 바인딩으로 다시 해제하는 코드는 컴파일되지 않습니다. 글쓴이는 Zig가 C의 조용한 segmentation fault나 메모리 손상보다는 나아졌지만, Rust에서 넘어온 개발자에게는 여전히 규칙을 직접 집행해야 하는 언어라고 설명합니다.
■ 라이브러리 생태계와 표준 API의 변화
Zig 생태계는 아직 젊고 라이브러리가 부족합니다. 글쓴이는 정규식처럼 기본적인 기능도 완전히 성숙하지 않았다고 지적합니다. Zig의 정규식 엔진인 mvzr은 Unicode property escape인 \\p{...}를 지원하지 않았고, 이 문제는 RFC 9535의 filter function을 구현하는 과정에서 직접 드러났습니다. 또한 Zig 표준 라이브러리의 API가 버전별로 바뀌는 점도 언급합니다.
■ 결론
글쓴이는 Zig가 Rust와 다르지만, 단순하고 현대적이며 매우 빠르다는 점에서 좋은 인상을 남겼다고 정리합니다. C의 진정한 후계자가 될 잠재력이 있다고 보면서도, 언어의 형태가 아직 일부 미완성으로 느껴지고 앞으로 사용성 개선 기능과 문법적 편의성이 더해질 가능성이 있다고 말합니다. 이번 프로젝트를 계기로 Zig 생태계에 계속 기여하고 싶다는 의사도 밝혔습니다.
■ Hacker News 반응
• @gigatexal — “언어가 Rust와 다르다는 건 사실입니다. 누가 그럴 줄 몰랐겠습니까. 하지만 정말 좋은 인상을 남겼습니다. 단순하고 현대적이며 매우 빠릅니다. 저는 Zig가 C의 진정한 후계자가 될 가능성이 있다고 봅니다. 반면 아직 젊고, 그 점이 드러납니다. 언어 자체의 형태가 일부 미완성으로 느껴지고, 성숙해지면서 더 멋진 사용성 기능과 문법적 편의성이 추가될 것 같습니다. 저로서는 만들 만한 프로젝트를 발견할 때마다 생태계에 계속 기여하고 싶습니다.” 저는 어느 쪽도 충분히 잘 쓰지 못해서 여기에 손을 보탤 수는 없지만, 더 명령형적인 방식 때문에 이 모든 것을 잃는 것은 한 걸음 뒤로 물러나는 것처럼 느껴집니다. “함수형 패러다임 없음. Rust는 기술적으로 명령형 언어지만 zero-cost iterator, 지연 평가, ADT, 패턴 매칭, monadic type, trait, closure 같은 함수형 개념을 많이 끌어옵니다. Haskell과 Erlang도 경험했기 때문에 저는 함수형 스타일에 꽤 기울어 있고, 이 라이브러리에도 그 성향이 드러납니다. 이 라이브러리는 Queryable과 관련 타입을 이용한 monadic 오류 제어, map·flat_map·reduce 등을 가진 Data<T> 같은 monadic 스타일의 데이터 타입, 순수하고 불변인 변환, 루프 대신 iterator combinator, 지역 추상화를 위한 closure, 작은 embedded DSL로서의 선언적 매크로, sum type과 product type 같은 함수형 프로그래밍 관용구를 많이 사용합니다.”
• @api — Rust는 C++의 형태로 억지로 만들어진 함수형 언어라고 말하고 싶습니다. Rust는 ML에서도 많은 것을 끌어옵니다. 그 이유를 이해할 수 있고, 그렇기 때문에 Rust는 명백한 “더 깨끗하고 더 나은 방식으로 다시 만든 C++”라는 영역에 더 잘 맞습니다.
• @pjmlp — Modula-2와 Object Pascal 같은 언어를 뒤로하고 오면서 우리가 잃은 것, 그리고 새로운 세대가 스크립트 언어의 대안으로 C와 C++만이 컴파일되는 언어라고 생각하게 된 것을 떠올리면, 현대적이고 매우 빠른 언어라는 말이 반갑습니다.
• @touisteur — Ada로 CLI 도구, 웹 서버와 클라이언트, 그리고 물론 실시간 복합 분산 시스템을 작성하던 시절이 그립습니다. 단순하고 올바르며 빠르고 견고하게 느껴졌습니다. 코드는 오늘날에도 여전히 읽기 쉽고, 지금 유지보수해도 거의 노력이 들지 않습니다. 기업용 불필요한 요소를 뺀 깔끔한 Java가 프로그래밍 편의성에서는 근소한 차이로 2위였습니다. 보일러플레이트가 있더라도 말입니다. NVIDIA가 GPU를 프로그래밍할 방법을 찾아 셰이더의 수렁에서 우리를 꺼내 준 점은 기쁘지만, 꼭 C++이어야 했습니까?
• @jstimpfle — 이 이상한 허수아비 주장을 백만 번 반복한다고 해서 사실이 되지는 않습니다. 모두가 잘못하고 있었다는 것을 보여 주는 정말 흥미로운 프로젝트를 마침내 내놓아 사람들이 스스로 판단하고 설득될 수 있게 하는 것은 어떻습니까? 그 언어와 플랫폼에는 분명 어떤 진짜 마법이 있어야 합니다. 그래야 곳곳에 대문자를 쓰고 매우 긴 KEYWORD를 사용해야 하는 고통, 그리고 훨씬 적은 인지도와 훨씬 작은 소프트웨어 생태계를 가진 문화로 전환하는 비용을 상쇄할 수 있을 테니까요. 참고로 저는 실제로 오래된 Delphi 프로젝트에서 6개월 일한 적이 있습니다. 성능 작업이었고, 늘 그렇듯 주로 언어의 불편한 부분을 치워야 했습니다. 결국 작업을 끝내 100배에서 1000배의 속도 향상을 얻었지만, 이 생태계로 돌아가고 싶지는 않습니다. 라이선스 비용도 있고, 그쪽 언어에도 이상한 결함이 있으며, 느리게 움직이는 생태계이기 때문입니다. 결국 제가 필요한 것은 지시한 일을 안정적이고 빠르게 수행하면서 방해하지 않는 것입니다.
원문: besok.github.io / 번역·요약: Trawling