몇 년간 멀리했던 Zig로 돌아온 지 약 네 달이 됐다. 프로젝트를 만들고 지역 커뮤니티에도 참여하면서 느낀 장점과 아쉬움을 Rust와 비교해 정리했다.

프로젝트와 Zig Day

Zig로 처음 시작한 프로젝트는 snurl이다. Gemini, Finger, Gopher, Spartan 같은 smallnet과 상호작용하는 라이브러리로, curl의 smallnet용 대안을 만드는 것이 목표다. 규모는 더 작지만 curl과 비슷한 테스트 및 퍼징 요구사항을 갖추고, 프로젝트에 맞는 범위에서 TigerStyle을 따르려 했다.

초기에 퍼저가 실제로 작동하지 않는다는 점을 알게 됐다. 지난 글에서 퍼징에 기대를 보였을 때는 이 기능이 아직 완성되지 않았다는 사실을 몰랐다. Zig의 개발 상태를 고려하면 놀랄 일은 아니지만, zig init이 생성하는 main.zig 파일에서 퍼저를 시연하기 때문에 실망스러웠다.

그 뒤로 아직 공개하지 않은 프로그램 두 개를 썼다. 하나는 간단한 TUI 타자 경주 게임으로, 최소 기능 버전을 만드는 일이 예상보다 쉬웠다. 다른 하나는 raylib를 사용한 항성 진화를 포함하는 N체 시뮬레이터다.

지난 몇 달 동안 가장 반가웠던 일은 지역 Zig Day에 참석한 것이다. 8월 20일에 열리는 Zig Day Seattle 소식을 Lobsters에서 봤다. 가까운 곳에 행사가 있다는 사실에 기뻤고, 앤드루 켈리(Andrew Kelley)도 참석한다는 것을 알고 더 기대하게 됐다.

Zig Day는 Zig에 관심 있는 사람들이 모여 각자의 프로젝트를 함께 작업하고 Zig에 관해 이야기하는 지역 모임이다. 이번 행사에서는 snurl을 작업하기로 했다. 친구 에빈 율로(Evin Yulo)도 참석했다.

행사는 Bothell 도서관에서 열렸다. 신청자가 많아 Bellevue 도서관의 수용 인원을 넘어서면서 장소를 옮겼다. Zig를 만든 사람이 참석하면 그럴 수 있다.

지역 단체가 주최하는 Zig Day는 커뮤니티를 더 긴밀하게 연결하고, Zig에 관심 있는 사람들과 직접 만날 기회를 준다. 다른 프로그래머들과 프로젝트에 관해 이야기했고, 서로의 생각을 나눈 일이 각자의 프로젝트를 더 나아지게 했기를 바란다. 에빈과 snurl을 함께 작업했고, 에빈은 Zig 표준 라이브러리의 TLS 구현을 대체 백엔드로 통합하기 시작했다.

앤드루 켈리와도 잠깐 이야기했다. Zig에 내장된 퍼징 기능이 조만간 고쳐지기를 바란다. 그날 행사에 관한 자세한 내용은 라이언 헨드릭슨(Ryan Hendrickson)이 쓴 후기에서 확인할 수 있다.

앞으로도 Zig Day Seattle에 참석할 생각이다. 다음 지역 행사가 열리기 전에 포틀랜드 행사에 갈 수도 있다. Zig와 그 가치를 공유하는 사람들을 만나는 기회를 즐긴다.

마음에 든 점

anyzig

anyzig이 마음에 든다. 프로젝트에 따라 zig master와 0.16.0을 써야 했는데, anyzig 덕분에 이를 처리할 수 있었다.

다만 build.zig.zon이 없는 디렉터리에서는 작동하지 않는다. zig 명령이 필요한 도구를 프로젝트 디렉터리 안에서만 쓸 수 있다는 뜻이다. 그래서 단발성 Zig 스크립트의 자동 서식 지정이 깨진다. 그런 경우에 사용할 기본 Zig 버전을 제공하면 좋겠다.

입출력과 할당자

Zig에서 함수에 입출력과 할당자 매개변수를 전달해야 하는 점이 불편한 사람도 있겠지만, 나는 이 방식이 좋다.

할당자는 Zig에서 더 오래 사용된 기능이다. Zig 함수는 명시적인 할당자 매개변수를 받아야 한다. 기본 할당자를 사용하고 전역에서 바꾸거나, Odin처럼 지역 context를 바꾸는 대부분의 언어와 다르다.

코드의 각 부분에 서로 다른 할당자를 사용할 수 있다는 점이 좋다. 어떤 작업에는 아레나 할당자를 쓰고 다른 작업에는 범용 할당자가 필요할 수 있다. 때로는 작업에 FixedBufferAllocator만으로 충분하다. Zig 방식에서는 이런 선택을 명확하게 처리할 수 있다.

암묵적인 context를 쓰는 Odin보다 Zig 방식이 낫다고 본다. Zig에서는 함수가 메모리를 할당하는지 분명하고, 잘못된 할당자를 실수로 전달하기도 어렵다. 메모리가 부족할 때 패닉을 일으키는 Rust의 숨겨진 비실패 할당보다도 이 방식이 낫다. Rust는 명시적 할당을 추가하려고 하지만, 언어의 토대에 포함된 기능이 아니라 나중에 덧붙이는 기능이 될 것이다. Zig에서는 메모리를 할당하는 프로그램을 훨씬 쉽게 추론할 수 있다.

입출력 매개변수는 더 최근에 도입됐지만, 더 강력하다. 이를 위해 필요한 호환성 파괴 변경은 충분히 감수할 만하다. 나는 0.15.0에서 0.16.0으로의 전환을 건너뛰기는 했다. Zig에서는 운영체제와 상호작용하는 모든 함수가 몇 가지 예외를 제외하고 IO 매개변수를 받아야 한다. 이 매개변수가 입력과 출력 등 모든 작업의 맥락을 제공한다. Zig를 새 운영체제에 이식하는 일은 대부분 입출력 부분을 이식하는 일이다.

테스트할 때도 편리하다. 동작을 시험하려면 필요한 방식으로 작동하는 테스트용 입출력 인스턴스를 넣으면 된다. 입력 스트림을 원하는 대로 바꾸고, 쓰기 작업을 실패하게 하는 등 여러 상황을 만들 수 있다. 할당자와 마찬가지로 입출력 인수를 전달하면 어떤 프로그램이 순수하고 어떤 프로그램이 외부 상태와 상호작용하는지 드러난다. 프로그램이 외부 요소를 바꾸는지도 한눈에 알 수 있다.

Zig는 단순함을 유지하면서 강력한 기능을 더하는 데 좋은 성과를 내고 있다. 서로 조금씩 다른 비슷한 기능을 100개씩 추가하기보다, 여러 용도를 지원하는 주요 기능 하나를 제공하는 경향이 있다. 배우기는 쉽고 활용 범위는 넓다.

comptime과 빌드 시스템

comptime이 좋다. 메타프로그래밍과 사전 계산을 매끄럽게 한데 제공한다. 더 자세한 설명은 다른 사람들이 이미 썼으므로 넘어가겠다.

Zig의 빌드 시스템은 매우 뛰어나다. Uber 같은 회사가 다른 언어로 작성한 소프트웨어에도 이를 채택할 정도다. Cargo가 불평하는 방식으로도 다른 도구를 빌드 시스템에 매끄럽게 통합할 수 있다. 코드 생성이 포함된 내 프로젝트 하나에서 특히 중요하다.

의존성 문화

Zig의 의존성 문화는 훌륭하다. 기본 원칙은 간단하다. 의존성을 피한다. 필요하지 않으면 추가하지 않는다. 필요할 때는 충분히 검증된 라이브러리를 쓰는 경우가 많고, Zig는 translate-c로 이를 손쉽게 통합할 수 있다. 외부 의존성을 저장소에 직접 포함하고 코드를 감사하기도 쉽다.

내 Zig 프로그램 대부분은 직접 의존성이 한두 개 정도이고, 간접 의존성은 없다. 덕분에 코드베이스를 감사하고 통제하기가 훨씬 쉽다. 가장 작은 프로젝트에도 직접 의존성이 몇 개, 간접 의존성이 수십 개씩 들어가는 Rust와는 대조적이다.

이는 Zig의 의존성 문화뿐 아니라 반영, 난수 생성, 깔끔한 오류 처리, JSON 파싱 같은 필수 기능이 별도 라이브러리로 유지되지 않고 언어나 표준 라이브러리에 포함된 덕분이기도 하다. 모든 프로그램에 serde나 syn을 의존성으로 추가할 필요가 없다. Zig는 의존성을 추가하기 전에 신중히 따져보도록 하며, 그 점이 좋다.

오류 처리

Zig는 기본적으로 프로그래머가 오류를 올바르게 처리하도록 이끈다. 가장 간단한 방법은 try 키워드를 쓰고 함수 반환 형식에 !를 붙이는 것이다.

Rust에서는 프로토타입을 만들 때 패닉을 넣는 편이 더 쉬워, 그 코드가 필요 이상으로 오래 남곤 한다. 적절한 오류를 반환하지 않고 패닉을 일으키는 Rust 프로덕션 코드를 사용해 본 적도 있다.

Rust에서는 ?를 사용할 수 있다. unwrap보다 빠르지만, Result<Type, Box<dyn std::error::Error>> 같은 형식을 써야 한다. 상당히 길다. 대신 anyhow 크레이트를 사용해 anyhow::Result를 반환할 수 있고, 유용한 헬퍼 메서드도 제공한다. 하지만 적절한 오류 처리를 위해 의존성을 추가해야 한다. Zig 방식의 조합 가능한 오류 처리를 제공하는 라이브러리도 있지만, 이 역시 별도 라이브러리가 필요하다.

증분 컴파일

Zig 핵심 팀은 증분 컴파일을 크게 개선하고 있다. zig build --watch -fincremental을 실행하면 컴파일러가 변경 사항을 감시하고, 바뀐 부분만 확인한 뒤 링크 단계까지 변경된 부분만 수정한다. 필요한 작업만 하므로 대규모 코드베이스에서도 사람의 인지 속도보다 빠르다. Zig는 처음부터 이런 동작을 지원하도록 설계됐다.

이 방식은 작업 흐름을 좋게 만든다. 텍스트 편집기 옆에서 컴파일러를 증분 모드로 실행하면 저장할 때마다 코드 업데이트를 확인할 수 있다. 다른 일에 정신이 팔릴 틈이 없다. Rust 프로젝트에서는 작은 프로젝트에서도 증분 컴파일에 10초 이상 걸리는 일이 흔했다. 다른 일을 시작하기엔 짧지만 집중력을 잃기에는 충분한 시간이다. Zig는 이런 불편한 공백을 없앤다.

이 점에서 Rust는 불리하다. 안정된 언어이고 하위 호환성을 유지해야 하므로 컴파일 속도를 높이기 위해 큰 폭으로 변경하기 어렵다. 반면 Zig를 개선할 수 있다면 유지관리자들은 최종 결과를 완성하기 위해 거의 무엇이든 바꿀 의향이 있다.

현재 증분 컴파일은 Linux와 macOS의 x86_64에서만 지원된다. 다른 운영체제나 아키텍처를 쓰면 아직 이 기능을 이용할 수 없다. 그렇지 않은 환경이라면 앞으로 지켜볼 만한 기능이다. 그래도 Zig를 조금 더 즐겁게 만들어주는 기능이다.

더 바라는 점

안정성 향상

Zig 사용자라면 누구나 그렇듯, 표준 라이브러리가 더 안정되기를 바란다. 1.0 전에 해결해야 할 여러 과제 중 하나라는 것은 알고 있지만, 생태계가 계속 호환성을 깨뜨리는 상황은 답답하다. 내가 이해하기로는 이것이 0.17.0 출시가 오래 걸린 이유다.

앤드루 켈리는 Zig Day에서 누구보다 1.0 출시를 바란다고 말했다. 그 말을 믿기에 인내심을 갖고 최종 버전이 훌륭할 것이라고 기대하겠다.

일부 프로젝트에서 여전히 Rust를 쓰는 이유는 안정성이다. 안정된 언어와 생태계가 필요한 경우가 있다. 하지만 요즘 Zig로 인상적인 소프트웨어가 만들어지는 것을 보면 안정성이 전부는 아니다. Zig의 장점이 불안정성을 감수할 만하게 만드는 프로젝트도 있다. 1.0이 기다려진다.

수명 지원

Zig 컴파일러가 수명(lifetime)을 더 잘 지원했으면 한다. 구체적으로는 지역 변수를 반환하는 코드를 디버그 모드에서 실패하게 만들고 싶다. 앤드루 켈리가 구현하고 싶다고 언급한 내장 린트 기능으로 처리하거나, 함수가 반환된 뒤 해당 메모리를 실제로 무효화할 수 있다. 그러면 새 사용자는 그런 패턴을 빠르게 피하는 법을 배울 수 있다. 현재는 수명을 이해한다고 생각해도 실수로 코드를 깨뜨리기 쉽다.

이와 관련해 컴파일러는 앞으로 문제가 될 코드를 현재 허용한다. 변수의 수명을 블록 범위가 아니라 전체 범위로 취급한다. 따라서 스택에 할당한 버퍼를 블록에서 반환하면 무효화되어야 하지만, 현재는 그렇지 않다. 내가 이해하기로는 향후 Zig 버전에서 조용히 문제가 생길 수 있다.

// This works but will be invalid.
const foo = blk: {
 var buf: [1024]u8 = undefined;
 const n = try writeToBuf(&buf);
 break :blk buf[0..n];
};

문서 보강

Zig 문서는 부족하다. 시작하는 데 필요한 내용은 있지만, 그다음부터는 드문 문서 주석에 담긴 의도를 추론해야 한다. 예제도 드물다. 나와 친구는 snurl에서 Zig 표준 라이브러리의 TLS를 설정하는 방법을 알아내지 못해 아직 작동시키지 못했다. 버퍼를 어떻게 구성해야 하는지 알기 어렵다.

문서가 부족해 견고한 Zig 프로그램을 만드는 지식이 웹 곳곳에 흩어져 있다. Zig 커뮤니티와 긴밀히 연결돼 있고 기존 문서를 읽고 발표를 시청하고 코드를 살펴본 사람은 해낼 수 있다. 그렇지 않다면 효과적으로 작업하기가 어려울 수 있다.

이런 상황은 Zig 커뮤니티에 참여하도록 장려한다. 예를 들어 나는 Matrix 방의 관리자다. 대규모 언어 모델(LLM)이 사람 간 협업을 무너뜨리려는 지금, 사람끼리 협력하는 일이 그 어느 때보다 중요하다. 그렇다고 좋은 문서를 함께 갖출 수 없는 것은 아니다. 문서가 잘 정리돼 있어도 사람들은 질문을 한다. 좋은 커뮤니티라면 단순히 “RTFM”이라고 하지 않고 유용한 자료를 안내하는 법을 안다.

Zig와 리터러트 프로그래밍이 만나는 지점도 궁금하다. 지금 내가 쓰는 리터러트 프로그래밍 도구는 Rust로 작성했지만, 바뀔 수도 있다.

comptime 할당자

컴파일 시점에 JSON을 파싱하고 싶다. 내 프로젝트 하나에는 실행 파일 안에 저장되는 JSON 파일이 있다. 정적인 데이터이므로 컴파일 시점에 구조체 모음으로 파싱할 수 있다.

하지만 표준 라이브러리의 JSON 파서는 할당자를 요구한다. 컴파일 시점에 작동하는 할당자가 없으므로 JSON 파싱을 실행 시점에 해야 한다. 그래서 프로젝트에서도 실행 시점에 파싱했다.

JSON 문자열에 데이터가 이미 있으므로 할당자가 필요 없는 JSON 파서를 추가하려는 계획이 있다고 알고 있다. 그러면 당면한 문제는 해결되지만, 더 일반적인 경우에는 할당자가 필요할 수도 있다. 예전에는 FixedBufferAllocator가 컴파일 시점에 작동해 이런 목적에 쓰였던 것으로 안다. 다시 그렇게 쓸 수 있으면 좋겠다.

부트스트랩 개선

Zig의 부트스트랩 방식은 인상적이다. C++ 컴파일러를 없애고 작은 WASM 바이너리와 WASM 인터프리터로 대체했다. 이 WASM 파일은 전체 Zig 컴파일러를 C로 컴파일하는 축소판 컴파일러이며, 생성된 C를 시스템 C 컴파일러가 컴파일한다. 흥미로운 접근 방식이므로 자세한 내용은 연결한 글을 참고하면 된다.

한 가지 단점은 컴파일된 바이너리 파일을 소스 코드 저장소에 포함해야 한다는 것이다. 이전 전체 컴파일러를 요구하는 Rust 방식보다는 낫지만, 부트스트랩의 모든 단계에 필요한 전체 소스를 항상 제공하는 방식만큼 좋지는 않다. 악의적인 행위자가 바이너리에 무언가를 몰래 넣을 가능성도 있다. 더 중요한 문제는 Debian 같은 여러 배포판이 현재 지침을 준수하는 방식으로 Zig를 패키징할 수 없다는 점이다.

당초 계획은 컴파일러의 C 백엔드로 컴파일러의 C 버전을 생성한 뒤 별도로 유지관리하는 것이었다. 그렇게 하면 소스 버전이 두 개가 된다. 하지만 이 방식을 유지하기가 너무 어렵다고 판단해 포기했다.

따라서 누군가 다른 언어, 아마도 C로 Zig 컴파일러를 만들어 부트스트랩 과정을 개선해야 한다. Zig Software Foundation은 현재 이를 개발할 계획이 없다고 명시했다. 지금은 Zig를 소스만으로 완전히 부트스트랩할 방법이 없다.

이는 이론적인 문제에 그치지 않는다. Zig를 Debian에 패키징할 수 없어서, 프로젝트 하나에서는 더 널리 사용할 수 있도록 다른 언어를 선택해야 했다.

다행히 이 문제는 일시적일 것이다. Zig가 안정되면 대체 컴파일러를 만드는 일이 수월해질 것이다. Zig는 사람이 대체 구현을 만들 수 있을 만큼 단순하게 설계됐다. 다른 언어와는 다르다. 현재는 불안정성 때문에 대체 컴파일러가 계속 따라잡아야 하므로, 핵심 팀이 빠르게 변경을 진행하는 동안 이를 관리하려 하지 않는 이유도 여기에 있을 것이다.

이 문제도 1.0까지 기다려야 할 수 있다. 조만간 나오기를 바란다.

앞으로

계속 Zig를 사용할 생각이다. Zig가 나아가는 방향이 기대되고 커뮤니티에 계속 참여하고 싶다. 단점이 있어도 지금까지 Zig보다 더 선호하는 프로그래밍 언어는 없었다. 버전이 바뀔 때마다 큰 변경을 하지 않아도 될 만큼은 안정적이다. 대부분의 프로젝트에서 계속 주로 사용할 언어가 될 것이다.

그중 하나는 ReTangled이다. 이전에는 Rust로 작성했지만, 방대한 의존성 트리에 지쳤고 Zig 빌드 시스템을 충분히 활용하고 싶어졌다.

Zig 프로젝트에 직접 더 기여하는 방법도 찾고 있다. 학생으로서 시간이 제한돼 있고, 이미 기여자가 많으며 문서도 부족해 쉽지는 않을 것이다. 그래도 프로젝트에 보탬이 되고 영향력을 발휘하며 더 나은 소프트웨어 엔지니어가 되고 싶다. 다행히 앤드루 켈리는 앞으로 기여자 온보딩을 개선하는 일을 우선순위로 두고 있는 듯하다.

앞으로 열리는 Zig Days Seattle 행사에도 참석할 예정이다. 함께 만나 Zig 프로젝트를 작업하고 싶다면 행사에서 볼 수 있다.