- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 8개월, 1183커밋, 그리고 stdlib를 갈아엎은 릴리스
- Io를 인자로 넘긴다 — Allocator가 걸어간 길
- 구현체 목록을 읽는 법 — 무엇이 완성이고 무엇이 실험인가
- async는 무엇을 약속하고 무엇을 약속하지 않나
- 취소라는, 진짜 어려운 부분
- "Juicy Main" — main이 Io를 만든다
- 함수 색깔 문제는 풀렸나 — 릴리스 노트는 그 말을 하지 않는다
- 컴파일러 쪽 — 증분 컴파일과 새 ELF 링커
- 백엔드 현황 — x86은 기본값, aarch64는 멈춰 섰다
- 업그레이드 비용 — 무엇이 깨지나
- 그래서, 지금 Zig를 써야 하나
- 마치며
- 참고 자료
들어가며 — 8개월, 1183커밋, 그리고 stdlib를 갈아엎은 릴리스
Zig 0.16.0이 2026년 4월 14일에 발표됐습니다. 공식 공지는 짧습니다 — 8개월의 작업, 244명의 기여자, 1183개의 커밋. (다운로드 인덱스에 적힌 타르볼 날짜는 하루 앞선 2026-04-13입니다.) 릴리스 노트가 스스로 꼽는 헤드라인은 하나입니다. "I/O as an Interface".
한 문장으로 요약하면 이렇습니다. 릴리스 노트의 표현을 그대로 옮기면, Zig 0.16.0부터 모든 입출력 기능은 Io 인스턴스를 인자로 받아야 합니다. 파일을 읽든, 소켓에 쓰든, 잠을 자든, 표준 출력에 한 줄을 찍든 마찬가지입니다. 판단 기준도 노트에 적혀 있습니다 — 제어 흐름을 블록할 가능성이 있거나 비결정성을 끌어들이는 것은 대체로 I/O 인터페이스가 소유할 대상이라는 것입니다.
이 블로그는 얼마 전 Hashimoto가 Ghostty를 Zig로 만드는 이유를 다뤘습니다. 거기서 그는 0.15가 "출력 인터페이스, 그리고 무언가를 출력하는 모든 것"을 바꿨다고 말했고, 그 파괴적 변경을 오히려 반겼습니다. 0.16.0은 그 이야기의 다음 장이자, 훨씬 큰 장입니다. 이번엔 출력만이 아니라 입출력 전체입니다.
이 글은 홍보 문구가 아니라 릴리스 노트를 읽습니다. 무엇이 실제로 완성됐고, 무엇이 "실험적"이라고 적혀 있고, 어떤 숫자가 어떤 조건에서 측정된 것인지를 따라갑니다. 결론부터 말하면 — 설계는 흥미롭고, 청구서는 큽니다.
Io를 인자로 넘긴다 — Allocator가 걸어간 길
Zig를 조금이라도 써 본 사람에게 이 설계는 낯설지 않습니다. Zig에는 전역 할당자가 없습니다. 메모리가 필요한 함수는 Allocator를 인자로 받고, 그걸 실제로 무엇으로 채울지는 호출자가 정합니다. 라이브러리는 자기가 아레나 위에서 도는지 GPA 위에서 도는지 알 필요가 없습니다.
0.16.0은 같은 수를 I/O에 둡니다. I/O가 필요한 함수는 Io를 인자로 받습니다. 그리고 그게 스레드 풀인지, 이벤트 루프인지, io_uring인지는 호출자 — 정확히는 애플리케이션의 main — 가 정합니다.
릴리스 노트의 HTTP 예제가 이 설계의 야심을 잘 보여 줍니다. 도메인 하나에 HEAD 요청을 보내는 평범해 보이는 코드입니다.
const std = @import("std");
const Io = std.Io;
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const args = try init.minimal.args.toSlice(init.arena.allocator());
const host_name: Io.net.HostName = try .init(args[1]);
var http_client: std.http.Client = .{ .allocator = gpa, .io = io };
defer http_client.deinit();
var request = try http_client.request(.HEAD, .{
.scheme = "http",
.host = .{ .percent_encoded = host_name.bytes },
.port = 80,
.path = .{ .percent_encoded = "/" },
}, .{});
defer request.deinit();
try request.sendBodiless();
var redirect_buffer: [1024]u8 = undefined;
const response = try request.receiveHead(&redirect_buffer);
std.log.info("received {d} {s}", .{ response.head.status, response.head.reason });
}
노트는 이 코드가 — 코드를 한 줄도 더 쓰지 않았는데 — 다음 성질을 갖는다고 말합니다. 설정된 각 네임서버에 DNS 질의를 비동기로 보내고, 응답이 들어오는 대로 즉시 그 IP에 TCP 연결을 비동기로 시도하고, 첫 TCP 연결이 성공하는 순간 진행 중이던 다른 연결 시도들을 DNS 질의까지 포함해 전부 취소합니다. Windows에서는 이 모든 게 ws2_32.dll 의존성 없이 일어납니다.
여기서 진짜 흥미로운 문장은 그다음입니다. 노트는 이 코드가 -fsingle-threaded로 컴파일해도 동작한다고 적습니다 — 다만 그때는 연산들이 순차적으로 일어난다는 단서와 함께요. 같은 소스, 다른 실행 모델, 여전히 올바른 동작. 이게 이 설계가 팔고 있는 물건입니다.
구현체 목록을 읽는 법 — 무엇이 완성이고 무엇이 실험인가
인터페이스는 구현체가 있어야 의미가 있습니다. 그리고 이 목록이야말로 0.16.0에 대한 과장과 현실을 가르는 지점입니다. 노트에 적힌 수식어를 그대로 옮기면 이렇습니다.
Io.Threaded— 스레드 기반. 파일 시스템 연산이 read, write, open, close를 직접 호출합니다. 0.15.x에서 코드를 옮겨 올 때 동등한 동작을 주는 구현체입니다. 노트의 표현으로 기능이 완전하고 잘 테스트돼 있으며, 취소까지 지원합니다. "Juicy Main"이 현재 고르는 구현체이기도 합니다.Io.Evented— 노트의 표현 그대로 작업 중이고 실험적이며, 인터페이스의 진화 방향을 알아보기 위한 것입니다. 유저스페이스 스택 스위칭과 작업 훔치기 기반 — M:N 스레딩, 그린 스레드, 스택풀 코루틴이라 부르는 그것입니다.Io.Uring— 이번 릴리스 사이클의 초점이 아니었다고 노트는 밝힙니다. 개념 증명 수준이고, 네트워킹과 에러 처리와 테스트 커버리지가 빠져 있습니다.Io.Kqueue— 개념 증명 전용입니다.Io.Dispatch— macOS의 Grand Central Dispatch 기반.Io.failing— 아무 연산도 지원하지 않는 시스템을 시뮬레이션합니다.
이 목록을 정직하게 읽으면 결론은 하나입니다. 0.16.0이 프로덕션에 배달한 것은 스레드 기반 I/O 하나이고, 사람들이 이 릴리스를 이야기하며 흥분하는 대상 — io_uring 위에서 도는 Zig 비동기 — 은 아직 배달되지 않았습니다. 인터페이스는 그것을 담을 모양을 만들었을 뿐입니다.
이 구분은 냉소가 아닙니다. 오히려 Zig 팀이 릴리스 노트에서 스스로 이렇게 적어 두었다는 점이 인상적입니다. 많은 프로젝트가 개념 증명을 "지원"이라 부릅니다.
async는 무엇을 약속하고 무엇을 약속하지 않나
io.async는 Future(T)를 만듭니다. 여기서 T는 호출된 함수의 반환 타입입니다. 노트가 설명하는 의미론이 미묘하고 중요합니다.
io.async는 비동기성을 표현합니다 — 이 함수 호출이 다른 로직과 독립적이라는 사실을요. 그래서 이 태스크 생성은 실패할 수 없고, 동시성 메커니즘이 아예 없는 제한된 Io 구현체 위에서도 이식 가능합니다. 노트는 여기서 아주 솔직한 문장을 하나 덧붙입니다 — Io 구현체가 async 호출을 그냥 함수를 직접 호출한 뒤 반환하는 식으로 구현해도 합법이라는 것입니다.
이걸 곱씹어 볼 필요가 있습니다. io.async는 "동시에 실행하라"는 명령이 아닙니다. "이건 독립적이다"라는 선언입니다. 실제로 동시에 돌릴지는 구현체가 정합니다. 그래서 -fsingle-threaded 빌드에서도 코드가 컴파일되고 올바르게 동작하는 것입니다 — 그저 순차적으로 돌 뿐이죠.
진짜로 동시성이 필요할 때는 io.concurrent가 따로 있습니다. 노트의 표현으로는 이게 정확성을 위해 반드시 동시에 실행돼야 함을 전달합니다. 그리고 이건 필연적으로 메모리 할당을 요구하고 — 동시에 무언가를 하는 일의 본질이 그렇다고 노트는 설명합니다 — 따라서 실패할 수 있습니다. error.ConcurrencyUnavailable이 그것입니다.
즉 Zig는 "비동기여도 되는 것"과 "반드시 동시여야 하는 것"을 타입 시스템 수준에서 다른 함수로 갈라 놓았습니다. 전자는 무한히 이식 가능하고 실패하지 않으며, 후자는 자원을 요구하고 실패할 수 있습니다. 이건 대부분의 async 런타임이 뭉개는 구분이고, 개인적으로는 이 릴리스에서 가장 잘 설계된 부분이라고 생각합니다.
Future에는 두 메서드가 있습니다. await은 태스크가 끝날 때까지 논리적으로 제어 흐름을 블록하고 반환값을 줍니다. cancel은 await과 같되, 구현체에게 연산을 중단하고 error.Canceled를 반환하라고 요청합니다.
여러 태스크의 수명이 같을 때는 Group이 있고, 노트는 N개 태스크를 띄우는 데 O(1) 오버헤드라고 적습니다. 이 밖에 Queue(T), Select, Batch, 그리고 단위를 타입으로 강제하는 Clock, Duration, Timestamp, Timeout이 함께 들어왔습니다.
취소라는, 진짜 어려운 부분
비동기 런타임을 만들어 본 사람은 압니다. 어려운 건 태스크를 띄우는 게 아니라 죽이는 것입니다. 0.16.0은 여기에 지면을 꽤 씁니다.
가장 인상적인 구현 디테일은 이것입니다. 노트에 따르면 Io.Threaded조차 취소를 지원하는데, 방법은 스레드에 시그널을 보내 블로킹 시스템 콜이 EINTR을 반환하게 만들고, 그 에러 코드에 반응해 시스템 콜을 재시도하기 전에 취소 요청이 있었는지 확인하는 것입니다. 이벤트 루프도 없고 코루틴도 없는 평범한 스레드 풀에서 취소를 구현하는 방법으로는 꽤 영리합니다.
동시에 노트는 중요한 단서를 답니다 — 취소를 요청해도 그 요청이 받아들여질 수도, 받아들여지지 않을 수도 있습니다. 취소는 요청이지 명령이 아닙니다. 받아들여진 요청만 I/O 연산이 error.Canceled를 반환하게 만듭니다.
그리고 error.Canceled를 다루는 방법이 세 가지로 정리돼 있습니다. 흔한 순서대로 — 그냥 전파하거나, io.recancel()을 호출해 취소 요청을 재무장한 뒤 전파하지 않거나, io.swapCancelProtection()으로 도달 불가능하게 만들거나. 취소 요청을 스스로 한 로직만이 error.Canceled를 무시해도 안전하다는 규칙도 함께 적혀 있습니다.
자원 누수를 피하는 패턴도 노트가 직접 제시합니다.
var foo_future = io.async(foo, .{args});
defer if (foo_future.cancel(io)) |resource| resource.deinit() else |_| {}
var bar_future = io.async(bar, .{args});
defer if (bar_future.cancel(io)) |resource| resource.deinit() else |_| {}
const foo_result = try foo_future.await(io);
const bar_result = try bar_future.await(io);
cancel이 필수인 이유가 설명돼 있습니다 — 에러가 반환될 때(error.Canceled 포함) 비동기 태스크 자원을 해제하는 게 바로 이 호출이기 때문입니다. 즉 defer cancel은 취소를 위한 게 아니라 자원 회수를 위한 것이고, 이건 Zig를 처음 만지는 사람이 반드시 밟을 지뢰입니다.
"Juicy Main" — main이 Io를 만든다
Io가 인자라면, 그 첫 번째 Io는 누가 만드나요? 0.16.0의 답은 main입니다.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const ptr = try gpa.create(i32);
defer gpa.destroy(ptr);
try std.Io.File.stdout().writeStreamingAll(io, "Hello, world!\n");
}
main에 std.process.Init 파라미터를 붙이면 미리 초기화된 것들이 딸려 옵니다 — 프로세스 전체 수명의 아레나, 기본 선택된 범용 할당자, 타깃 설정에 맞춰 고른 기본 Io 구현체, 환경 변수 맵, 그리고 WASI용 preopens. Debug 모드에서는 가능한 경우 누수 검사까지 세팅해 준다고 노트는 적습니다.
main의 첫 파라미터는 세 가지 중 하나일 수 있습니다. 없거나(그러면 CLI 인자와 환경 변수에 접근할 수 없습니다), process.Init.Minimal이거나(argv와 environ만 날것으로), process.Init이거나.
환경 변수가 여기로 온 이유도 노트에 적혀 있고, 설득력이 있습니다. 환경이 전역 상태라는 건 아주 흔한 추상화지만 문제가 많다는 것입니다 — C에서 setenv 같은 함수를 스레드 맥락에서 호출하는 건 건전하지 않은데, environ이 아무 락 없이 직접 접근되는 일이 흔하기 때문입니다. 게다가 Zig 표준 라이브러리에는 큰 함정이 있었습니다. std.os.environ은 C의 environ과 동등할 의도였지만, libc를 링크하지 않는 라이브러리에서는 그걸 채울 방법이 없었습니다.
Io 인스턴스가 없는 곳에서 급하게 하나 만드는 방법도 노트가 알려 주는데, 함께 붙은 경고가 정직합니다.
var threaded: Io.Threaded = .init_single_threaded;
const io = threaded.io();
노트는 이게 태스크 수준 동시성이 필요 없는 한 동작하지만 이상적이지 않은 우회책이라고 말합니다. 비유가 좋습니다 — Allocator가 필요한데 없다고 std.heap.page_allocator를 집어 드는 것과 같다는 것입니다. 테스트에서는 std.testing.allocator처럼 std.testing.io를 쓰라고 권합니다.
함수 색깔 문제는 풀렸나 — 릴리스 노트는 그 말을 하지 않는다
이 릴리스를 둘러싼 요약 중 가장 많이 돌아다니는 문장이 "Zig가 함수 색깔 문제를 풀었다"입니다. 그래서 확인해 봤습니다. 0.16.0 릴리스 노트 전문에 "color"라는 단어는 한 번도 등장하지 않습니다.
그 문장의 출처는 따로 있습니다. Zig 코어 팀의 Loris Cro가 2025년 7월 13일에 쓴 Zig's New Async I/O입니다. 그는 거기서 세 단계로 주장을 올립니다 — 하나의 라이브러리가 동기 모드와 비동기 모드 양쪽에서 최적으로 동작할 수 있고, 예전에는 소스 코드가 async/await의 전염성에서 자유로웠어도 런타임에는 여전히 스택리스 코루틴이 강제됐지만 이제 io.async와 Future.await을 다양한 실행 모델과 함께 쓸 수 있으며, 그래서 — 그의 문장 그대로 — 이 마지막 개선으로 Zig는 함수 색깔 문제를 완전히 무찔렀다는 것입니다.
여기엔 단서를 달아야 합니다. 그 글은 0.16.0이 아니라 당시의 계획을 이야기한 2025년 7월의 글이고, 표준 라이브러리 재작성이 남아 있으며 0.15.0에는 그중 작은 일부만 들어간다고 스스로 적고 있습니다. 그리고 계획은 실제로 바뀌었습니다. Cro가 언급한 스택리스 코루틴은 0.16.0 노트에 한 번도 나오지 않고, 실제로 들어온 Io.Evented는 스택풀 코루틴 기반입니다.
반대편 주장도 있습니다. ivnj라는 필자가 같은 날인 2025년 7월 13일에 쓴 글의 논지는 이렇습니다 — 의미론적으로 모든 함수에 std.Io를 넘기는 것은 모든 Node.js 함수를 async로 만들어 프로미스를 반환하게 하는 것과 다르지 않고, Zig는 함수 색깔을 블로킹/논블로킹의 선택에서 io/non-io의 선택으로 옮겼을 뿐이라는 것입니다. I/O를 하는 함수는 std.Io 파라미터를 요구하고, 그런 함수는 std.Io를 받는 다른 함수에서만 호출될 수 있으니 파라미터가 콜 체인 전체로 번져 나간다는 지적이죠.
제 읽기는 이렇습니다. 두 주장 다 맞고, 서로 다른 걸 이야기하고 있습니다. 색깔의 전염성은 남아 있습니다 — Io는 콜 체인을 타고 올라갑니다. 사라진 건 색깔의 경직성입니다. Rust나 JavaScript에서 함수의 색깔은 컴파일 시점에 실행 모델을 못 박습니다. Zig에서 Io를 받는 함수는 실행 모델을 못 박지 않습니다 — 호출자가 나중에 고르고, 심지어 동시성이 없는 구현체를 골라도 코드가 여전히 맞습니다. 그리고 애초에 Allocator가 콜 체인을 타고 올라가는 걸 Zig 개발자들은 이미 받아들이고 살아 왔습니다. 색깔이 하나 더 늘어난 게 아니라, 이미 있던 색깔 옆에 같은 종류의 색깔이 하나 붙은 셈입니다.
다만 이건 공짜가 아닙니다. 노트의 "Lazy Field Analysis" 항목이 그 대가의 한 조각을 보여 줍니다 — I/O를 인터페이스로 만든 뒤 발견한 문제인데, std.Io.Writer를 어떤 식으로든 쓰면 std.Io의 vtable이 딸려 들어오고, 일부 경우에는 불필요한 코드 생성으로 이어져 바이너리를 부풀릴 수 있었다는 것입니다. 0.16.0은 타입 해석을 지연시켜 이걸 고쳤지만, 인터페이스에는 원래 vtable 값이 붙는다는 사실 자체는 남습니다.
컴파일러 쪽 — 증분 컴파일과 새 ELF 링커
I/O가 헤드라인을 가져갔지만, 컴파일러 쪽 변화가 실무에는 더 직접적일 수 있습니다.
증분 컴파일은 이번 사이클에 크게 좋아졌습니다. 노트가 드는 대표 사례가 인상적입니다 — Zig 컴파일러 자체에 증분 컴파일을 쓸 때, 예전에는 컴파일러 거의 전체를 재컴파일하던 변경이 이제 밀리초 단위로 끝난다는 것입니다. 공은 "Reworked Type Resolution"에 돌아갑니다. 타입 해석을 갈아엎으면서 컴파일러 내부 의존성 그래프가 (의존성 루프인 경우를 빼면) 비순환이 됐고, 그 덕에 "과잉 분석" — 필요한 것보다 많은 코드를 다시 빌드하는 일 — 이 대부분의 경우 사라졌습니다.
증분 빌드가 비증분 빌드에 없는 "dependency loop" 컴파일 에러를 뱉던 문제도 해결됐습니다. 노트는 이게 이전 릴리스들에서 증분과 비증분 사이의 가장 큰 불일치였다고 적습니다. LLVM 백엔드도 이제 증분 컴파일을 지원하는데, 여기엔 정직한 단서가 붙습니다 — 이게 "LLVM Emit Object" 단계를 빠르게 해 주지는 않습니다. 그 단계는 전적으로 LLVM의 몫이고 Zig가 할 수 있는 게 거의 없다는 것입니다. 대신 Zig 쪽에서 LLVM 비트코드를 만드는 과정이 빨라지고, 컴파일 에러가 있는 경우에는 — 에러가 있으면 Emit Object가 통째로 생략되므로 — LLVM 백엔드에서도 거의 즉각적인 피드백을 받습니다.
그리고 여기 이 릴리스에서 가장 솔직한 문장이 있습니다. 증분 컴파일에는 오컴파일을 포함한 알려진 버그가 아직 있고, 그래서 0.16.0에서도 기본값은 꺼짐입니다. 그런데 노트는 바로 다음 문장에서 그럼에도 켜기를 권합니다. 사용자들이 절약되는 시간에 자주 놀란다면서요. 켜는 법은 zig build -fincremental --watch입니다.
이 긴장을 어떻게 읽어야 할까요. 저는 이렇게 봅니다 — 컴파일 에러 피드백만 쓴다면 위험이 거의 없습니다. 오컴파일은 결국 잘못된 기계어를 만드는 문제이고, 컴파일이 실패하는 코드에는 기계어가 없으니까요. 반면 증분으로 만든 바이너리를 그대로 테스트하거나 배포한다면, 노트가 경고한 그 오컴파일을 정면으로 맞을 수 있습니다. "개발 루프에는 켜고, 나가는 아티팩트는 클린 빌드로" 정도가 이 릴리스에서 합리적인 선입니다.
새 ELF 링커도 들어왔습니다. -fnew-linker로 켜거나 빌드 스크립트에서 exe.use_new_linker = true로 켜고, -fincremental과 ELF 타깃 조합에서는 이미 기본값입니다. 노트가 제시한 성능 데이터 포인트는 Zig 컴파일러를 빌드한 뒤, 함수 하나에 한 줄짜리 변경을 가하고, 또 한 번 가한 시나리오입니다.
시나리오: Zig 컴파일러 빌드 -> 한 줄 변경 -> 다시 한 줄 변경
옛 링커: 14s, 194ms, 191ms
새 링커: 14s, 65ms, 64ms (66% 빠름)
링킹 아예 생략: 14s, 62ms, 62ms (68% 빠름)
이 표에서 진짜 재미있는 건 세 번째 줄입니다. 새 링커가 링킹을 통째로 건너뛰는 것과 3ms 차이밖에 나지 않습니다. 그래서 노트는 -Dno-bin 빌드 스텝을 노출할 이유가 이제 별로 없다고 말합니다 — 컴파일 속도 차이가 무시할 만하니 코드 생성과 링킹을 항상 켜 두는 편이 낫고, 그러면 끝에 실행 파일까지 덤으로 얻는다는 것이죠.
대가는 분명합니다. 노트는 이 새 링커가 옛 링커에 대해서도, LLD에 대해서도 기능이 완전하지 않다고 명시합니다. 예로 든 게 뼈아픕니다 — 이렇게 만들어진 실행 파일에는 DWARF 정보가 없습니다. 그래서 옛 링커와 LLD가 둘 다 여전히 남아 있고, 새 링커가 기능적으로 완전해지면 그때 옛 링커를 지우고 LLD를 의존성에서 제거하겠다는 게 계획입니다.
즉 66%라는 숫자는 "디버깅을 포기하면"이라는 조건을 달고 있습니다. 저 3ms 차이가 말해 주는 것도 같은 이야기입니다 — 링킹이 거의 공짜가 된 이유의 상당 부분은 링커가 하던 일 중 일부를 아직 안 하기 때문입니다.
백엔드 현황 — x86은 기본값, aarch64는 멈춰 섰다
자체 호스팅 백엔드 이야기는 릴리스마다 나오지만, 0.16.0의 현황은 냉정하게 볼 필요가 있습니다.
x86 백엔드는 이번에 버그 11개가 고쳐졌고, 상수 memcpy 코드를 더 잘 만듭니다. 노트가 LLVM 백엔드와 비교해 적은 문장을 그대로 옮기면 — 이 백엔드는 동작 테스트를 더 많이 통과하고, 컴파일 속도가 현저히 빠르며, 디버그 정보가 우수하고, 기계어 품질은 열등합니다. Debug 모드의 기본값으로 남아 있습니다.
이 네 항목이 왜 Debug 전용인지를 정확히 설명합니다. 개발 루프에서는 컴파일 속도와 디버그 정보가 전부고 기계어 품질은 거의 상관없습니다. 릴리스 빌드에서는 정확히 반대죠. 참고로 LLVM 백엔드는 동작 테스트 2010개 중 2004개를 통과합니다(노트는 이걸 100%로 반올림해 적습니다). x86 백엔드는 그보다 더 통과합니다.
aarch64 백엔드는 나쁜 소식입니다. 노트의 표현 그대로 — 여전히 작업 중이고, 이번 릴리스 사이클에는 "I/O as an Interface" 여파로 진행이 멈췄으며, 현재 동작 테스트를 돌리면 크래시합니다. 표준 라이브러리의 변동이 잦아들면 진행이 다시 붙을 것으로 기대한다고 적혀 있습니다.
이건 이 릴리스의 기회비용을 그대로 보여 줍니다. I/O 인터페이스는 공짜로 온 게 아니라 aarch64 자체 호스팅 백엔드의 한 사이클을 잡아먹고 왔습니다. Apple Silicon이나 ARM 서버에서 자체 호스팅 백엔드의 빠른 컴파일을 기다리던 사람에게 0.16.0은 진전이 아니라 정지입니다. WebAssembly 백엔드는 동작 테스트 1970개 중 1813개(92%)를 통과합니다.
툴체인 쪽에도 아픈 항목이 하나 있습니다. 0.16.0은 LLVM 21.1.0으로 올라갔는데, 루프 벡터화를 통째로 껐습니다. 이유가 심각합니다 — 그 회귀가 흔한 설정에서 컴파일러 자신을 오컴파일하기 때문입니다. 특정 CPU 기능을 끄는 우회는 너무 취약해서, 버그가 고쳐진 LLVM 버전으로 올라갈 때까지 벡터화를 아예 끄기로 했다는 것입니다. 노트는 이게 일부 경우 코드 생성을 나쁘게 만든다는 걸 인정하면서, 오컴파일보다는 낫다고 말합니다. 그리고 이 성능 회귀는 0.16.x만이 아니라 0.17.x까지 영향을 주고 0.18.x에서야 해소될 것으로 예상한다고 적혀 있습니다. 즉 0.16.0으로 올리면 이 페널티를 두 릴리스 동안 안고 가야 합니다.
밝은 소식도 있습니다. translate-c가 libclang 대신 arocc 기반으로 바뀌면서 컴파일러 소스 트리에서 C++ 5,940줄이 사라졌습니다(3,763줄 남음). LLVM에 대한 라이브러리 의존성을 Clang에 대한 프로세스 의존성으로 바꿔 가는 여정의 한 걸음입니다. 슬라이스를 도는 for 루프의 안전성 검사는 코드를 약 30% 덜 만들도록 개선됐고, 새로 들어온 deflate 압축은 기본 압축 레벨에서 zlib 대비 벽시계 시간이 9.7% ± 0.2% 빨랐습니다(다만 zlib이 압축률은 1.00% 더 좋고, 명령어 수는 오히려 18.9% 많습니다 — 캐시 미스와 분기 예측 실패가 줄어든 덕에 시간이 이긴 경우입니다).
업그레이드 비용 — 무엇이 깨지나
"모든 입출력이 Io를 받는다"는 문장은 업그레이드 관점에서 "입출력을 하는 모든 코드가 깨진다"와 같은 말입니다.
제거된 것부터 봅시다. Io.GenericWriter, Io.AnyWriter, Io.null_writer, Io.CountingReader가 사라졌습니다. SegmentedList, meta.declList, Thread.Mutex.Recursive도 없어졌습니다. 포맷팅 API는 fmt.format이 std.Io.Writer.print로, Formatter가 Alt로, FormatOptions가 Options로, bufPrintZ가 bufPrintSentinel로 바뀌었습니다.
에러 셋도 정리됐습니다. error.RenameAcrossMountPoints와 error.NotSameFileSystem이 error.CrossDevice로 합쳐지고, error.SharingViolation이 error.FileBusy로, error.EnvironmentVariableNotFound가 error.EnvironmentVariableMissing으로 바뀌었습니다. DynLib는 Windows 지원이 제거됐는데, 노트의 코멘트가 웃깁니다 — 이제 사용자가 LoadLibraryExW와 GetProcAddress를 직접 써야 하고, 어차피 다들 이미 그러고 있었을 거라는 것입니다.
언어 자체도 움직였습니다. @Type이 개별 타입 생성 빌트인들로 대체됐고, @cImport는 빌드 시스템으로 옮겨 가는 중이며(당분간 빌트인은 남지만 Aro가 뒤를 받칩니다), 런타임 벡터 인덱스가 금지됐고, packed struct와 union에서 포인터가 금지됐습니다. "Reworked Type Resolution"은 전반적으로 더 관대해졌지만 엄밀히 더 관대한 건 아니어서, 예전에 통과하던 일부 코드가 이제 의존성 루프 에러를 냅니다.
여기서 Ghostty 이야기가 다시 떠오릅니다. Hashimoto가 0.15에서 "무언가를 출력하는 모든 것"이 바뀌었다고 했을 때, 그건 끝이 아니라 시작이었습니다. 0.16.0은 그 writer 계열을 또 갈아엎었습니다. 그가 이걸 반긴 이유 — Andrew Kelley가 필요하다고 판단한 변경을 물러서지 않고 밀어붙인다는 것 — 이 여기서도 그대로 확인됩니다. 그리고 그 태도를 반길 수 있는지 여부가, 이 언어를 쓸 수 있는지를 가르는 진짜 기준입니다.
릴리스 노트에는 "This Release Contains Bugs"라는 제목의 절이 따로 있습니다. 이 사이클에 버그 리포트 345개가 닫혔지만, Zig에는 알려진 버그와 오컴파일과 회귀가 있다고 적습니다. 그리고 결정적인 문장 — 0.16.x에서도 Zig로 사소하지 않은 프로젝트를 하는 일은 개발 과정에 참여하는 것을 요구할 수 있습니다. 1.0.0에 도달하면 Tier 1 지원에 버그 정책이 추가 요건으로 붙을 거라는 말도 함께요. 뒤집으면, 지금은 그런 정책이 없다는 뜻입니다.
그래서, 지금 Zig를 써야 하나
정리하면 판단 기준은 이렇게 나뉩니다.
말이 되는 경우
- 도구나 인프라 코드를 만드는데, 툴체인의 파괴적 변경을 흡수할 여력이 팀에 있다. 업그레이드가 이벤트가 아니라 일상인 조직.
- I/O 추상화를 인자로 주입받는 설계가 실제로 필요하다 — 같은 라이브러리를 CLI에서는 단일 스레드로, 서버에서는 스레드 풀로 돌려야 하는 경우. 이건 0.16.0이 진짜로 배달한 물건입니다.
- 크로스 컴파일과 C 상호운용이 핵심 요구사항이다. 이 영역에서 Zig의 강점은 0.16.0의 소란과 무관하게 그대로입니다.
- 컴파일 속도가 개발 경험의 병목이고,
-fincremental --watch의 빠른 에러 피드백만으로도 값을 한다.
아직 아닌 경우
- 안정된 API가 필요하다. 0.15가 writer를 바꿨고, 0.16이 I/O 전체를 바꿨습니다. 0.17이 무엇을 바꿀지는 아무도 약속하지 않습니다. 1.0 전의 Zig에서 "이번이 마지막 큰 변경"이라는 기대는 근거가 없습니다.
- io_uring 위에서 도는 프로덕션 비동기 I/O가 지금 필요하다. 0.16.0이 준 건 개념 증명입니다 — 네트워킹도 에러 처리도 테스트 커버리지도 아직입니다.
- aarch64에서 자체 호스팅 백엔드의 빠른 컴파일이 필요하다. 이번 사이클엔 진행이 멈췄고, 지금은 동작 테스트에서 크래시합니다.
- 릴리스 빌드의 기계어 품질이 최우선이다. 자체 호스팅 x86 백엔드는 기계어 품질이 열등하다고 노트가 직접 적고 있고, 게다가 LLVM 루프 벡터화가 0.17.x까지 꺼져 있습니다.
- 팀이 "언어 버그를 만나면 업스트림에 참여한다"를 감당할 수 없다. 이건 제 평가가 아니라 릴리스 노트에 적힌 경고입니다.
마치며
0.16.0에서 제가 배운 건 언어에 대한 것보다 설계에 대한 것에 가깝습니다.
Allocator를 인자로 넘기는 Zig의 습관은 원래 메모리 이야기가 아니었습니다. "정책은 호출자가 정하고, 라이브러리는 메커니즘만 제공한다"는 이야기였죠. 0.16.0은 그 원칙을 I/O에 밀어붙였고, 그 과정에서 대부분의 async 런타임이 뭉개는 구분 — 비동기여도 되는 것과 반드시 동시여야 하는 것 — 을 io.async와 io.concurrent로 갈라 놓았습니다. 취소를 요청이지 명령이 아닌 것으로 다룬 것도, 스레드 풀에서 시그널과 EINTR로 취소를 구현한 것도 같은 결의 정직함입니다.
함수 색깔 문제를 "무찔렀는지"는 정의하기 나름이라고 생각합니다. Io는 여전히 콜 체인을 타고 번져 나가니까요. 다만 번져 나가는 게 실행 모델이 아니라 선택권이라는 점에서, 저는 이게 의미 있는 차이라고 봅니다. 그리고 릴리스 노트가 그 승리를 스스로 선언하지 않았다는 점도 — 그 문장은 코어 팀원의 개인 블로그에 있지 공식 노트에는 없습니다 — 이 팀에 대한 신뢰를 오히려 높입니다.
동시에 청구서는 실재합니다. 완성된 구현체는 하나이고, aarch64는 멈췄고, 증분 컴파일은 오컴파일 때문에 기본값이 꺼져 있고, 새 링커는 DWARF를 못 만들고, LLVM 벡터화는 두 릴리스 동안 꺼져 있고, I/O를 만지는 코드는 전부 다시 써야 합니다. 릴리스 노트는 이 중 무엇도 숨기지 않습니다 — 오히려 제가 이 글에 쓴 부정적인 사실은 거의 전부 노트에서 그대로 가져온 것입니다.
그게 아마 이 릴리스의 가장 정확한 요약일 겁니다. Zig는 여전히 "완성된 제품"이 아니라 "진행 중인 논증"이고, 릴리스 노트는 그 논증의 회의록입니다. 그 회의에 참여할 생각이 있다면 0.16.0은 흥미로운 릴리스입니다. 제품을 사러 온 거라면, 노트 맨 아래 "This Release Contains Bugs" 절을 먼저 읽으십시오. 그 절이 거기 있는 건 형식적인 면책이 아니라, 이 언어가 지금 어디에 서 있는지에 대한 정확한 진술입니다.
참고 자료
- Zig 0.16.0 Release Notes — 공식 릴리스 노트(이 글의 거의 모든 사실과 숫자의 출처)
- 0.16.0 Released — 공식 공지(2026년 4월 14일, 244명 기여자, 1183커밋)
- ziglang.org 다운로드 인덱스 — 버전별 배포 날짜(0.16.0: 2026-04-13)
- Zig's New Async I/O — Loris Cro, 2025년 7월 13일("함수 색깔을 완전히 무찔렀다"의 출처, 당시 계획 기준)
- Zig's new I/O: function coloring is inevitable? — ivnj, 2025년 7월 13일(반대편 논지)
- 터미널로 돌아온 창업자: Hashimoto의 Ghostty, Zig라는 베팅(관련 글)