Zig 입문: 숨은 제어 흐름 없는 설계, 명시적 Allocator, defer, comptime, build.zig
이 글의 핵심
Zig는 숨겨진 제어 흐름을 없애고 메모리 할당을 호출하는 쪽이 결정하게 한다는 원칙이 문법 전반에 드러나는 시스템 언어입니다. Bun과 TigerBeetle처럼 프로덕션 채택 사례가 있지만, 버전을 올릴 때 코드가 깨지거나 라이브러리가 부족한 현실적인 문제도 있습니다. comptime_int 타입 오류 같은 흔한 막힘과 Rust와 비교한 선택 기준까지 짚습니다.
Zig의 설계 철학
Zig 공식 사이트는 Zig를 “견고하고(robust), 최적이며(optimal), 재사용 가능한(reusable) 소프트웨어를 유지보수하기 위한 범용 언어이자 툴체인”으로 소개하고, 언어 개요에서 다음 특징을 앞세웁니다.
- Robust: 버그를 재현 가능하게 (UB 최소화·디버그 검증)
- Optimal: 최고 성능을 얻을 수 있어야
- Reusable: 한 번 쓴 코드를 어디서나
- Maintainable: 쉽게 읽고 수정
- “No hidden control flow”: 숨겨진 함수 호출·할당·예외 없음
숨겨진 제어 흐름 없음이 Zig의 가장 독특한 특징입니다.
- 연산자 오버로딩 없음 (
a + b는 정말 덧셈) - 예외(exceptions) 없음 → 오류는 값으로
- 숨은 heap allocation 없음 →
allocator를 인자로 명시 - 생성자/소멸자 없음 (defer로 정리)
이 원칙은 코드를 읽는 사람을 위한 설계입니다. C++에서 a = b + c; 한 줄은 연산자 오버로딩, 복사 생성자, 암묵 변환, 힙 할당, 예외 전파를 모두 숨길 수 있어서, 이 줄이 실패할 수 있는지 얼마나 비싼지를 알려면 관련 타입의 정의를 모두 찾아봐야 합니다. Zig에서는 함수 호출은 호출처럼 보이고, 실패할 수 있는 호출에는 반드시 try나 catch가 붙고, 메모리를 할당하는 함수는 인자로 allocator를 받습니다. 대가는 코드가 길어진다는 것입니다. 같은 기능을 C++나 Rust보다 더 많은 줄로 쓰게 되는 경우가 많고, 이를 “장황하다”고 느끼는지 “명확하다”고 느끼는지가 Zig에 대한 호불호를 가르는 지점입니다.
설치
# macOS
brew install zig
# Linux (공식 바이너리)
wget https://ziglang.org/download/0.14.0/zig-linux-x86_64-0.14.0.tar.xz
tar -xf zig-linux-x86_64-0.14.0.tar.xz
export PATH=$PWD/zig-linux-x86_64-0.14.0:$PATH
# Windows
winget install zig.zig
zig version
Zig는 1.0 전이라 버전마다 표준 라이브러리 API가 바뀝니다. 인터넷에서 찾은 예제가 no member named ... 같은 오류로 컴파일되지 않는다면, 코드가 작성된 Zig 버전과 설치된 버전이 다른 경우가 대부분입니다. 이 글의 예제는 0.14 기준이며, 0.15에서는 std.io의 Reader/Writer가 버퍼를 명시적으로 받는 새 인터페이스로 바뀌어 아래 Hello World의 getStdOut().writer() 형태부터 달라졌습니다. 여러 프로젝트를 오가야 한다면 zigup이나 mise 같은 버전 관리 도구로 프로젝트별 버전을 고정하는 것이 사실상 필수입니다. 배포판 패키지 매니저의 Zig는 오래된 버전인 경우가 많다는 점도 주의하세요.
Hello, World
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Hello, {s}!\n", .{"Zig"});
}
!void= 오류를 반환할 수 있는 voidtry는 오류를 상위로 전파 (Rust의?와 유사).{"Zig"}는 익명 구조체 — 포맷 인자 튜플
포맷 문자열은 컴파일 타임에 검사됩니다. {s} 자리에 정수를 넘기거나 인자 개수가 맞지 않으면 런타임이 아니라 컴파일 오류가 나며, 이것이 comptime의 첫 번째 실용적 예입니다. print는 포맷 문자열을 comptime 매개변수로 받아 컴파일 시점에 파싱하고, 그 결과로 인자 타입에 맞게 특수화된 출력 코드를 생성합니다. std.debug.print는 표준 에러로 출력하며 오류를 반환하지 않아 try 없이 쓸 수 있으므로, 디버깅 출력에는 이쪽이 간편합니다.
컴파일·실행:
zig run hello.zig
zig build-exe hello.zig -O ReleaseFast
기본 문법
변수
const x: i32 = 10; // 불변
var y: u64 = 100; // 가변
y += 1;
const arr = [_]u8{ 1, 2, 3 }; // 길이 추론
const slice: []const u8 = "hello";
제어 흐름
const n: i32 = 5;
if (n > 0) {
std.debug.print("positive\n", .{});
} else {
std.debug.print("non-positive\n", .{});
}
for (arr, 0..) |item, i| { // 0.11부터 인덱스는 범위 0..를 함께 순회해 얻음
std.debug.print("{d}: {d}\n", .{ i, item });
}
var i: usize = 0;
while (i < 10) : (i += 1) {
// ...
}
const msg = switch (n) {
0 => "zero",
1...9 => "single digit",
else => "large",
};
switch는 모든 경우를 다뤄야 컴파일됩니다. 정수에 대한 switch에서 else를 빼면 처리하지 않은 값이 있다는 컴파일 오류가 나고, 열거형이라면 모든 태그를 나열하거나 else를 둬야 합니다. 열거형에 값이 추가되었을 때 이를 처리하지 않은 switch를 컴파일러가 모두 찾아 주므로 리팩터링이 안전해집니다. while의 : (i += 1) 부분은 “계속 식(continue expression)“으로, 루프 본문에서 continue를 해도 반드시 실행된다는 점이 C의 for 증감식과 같은 역할을 합니다.
Optional·Error Union
// null 가능한 타입
var maybe: ?i32 = null;
maybe = 42;
if (maybe) |v| {
std.debug.print("value: {d}\n", .{v});
} else {
std.debug.print("nothing\n", .{});
}
// 오류 유니언
const ParseError = error{ Invalid, TooLong };
fn parseNumber(s: []const u8) ParseError!i32 {
if (s.len > 10) return ParseError.TooLong;
return std.fmt.parseInt(i32, s, 10) catch ParseError.Invalid;
}
pub fn main() !void {
const n = try parseNumber("42");
_ = n;
}
_ = n;이 필요한 이유는 Zig가 사용하지 않는 지역 변수를 컴파일 오류로 처리하기 때문입니다. 경고가 아니라 오류라서 처음에는 불편하지만, 값을 버리려면 _ =로 명시해야 하므로 “실수로 결과를 무시했다”는 버그가 사라집니다. 오류 반환값도 마찬가지여서 parseNumber("42");처럼 결과를 그냥 버리면 컴파일되지 않습니다.
오류 유니언은 C의 오류 코드와 예외의 중간쯤입니다. 오류는 평범한 반환값이라 비용이 예측 가능하고, try는 catch |err| return err의 축약일 뿐입니다. 그런데도 디버그 빌드에서는 오류가 어느 함수들을 거쳐 전파됐는지 보여 주는 error return trace가 출력되므로, 예외의 스택 트레이스에 해당하는 정보를 얻을 수 있습니다. 반환 타입을 !i32처럼 오류 집합 없이 쓰면 컴파일러가 함수가 반환할 수 있는 오류를 추론하지만, 라이브러리의 공개 함수는 이 예제처럼 오류 집합을 명시해야 호출자가 어떤 오류를 처리해야 하는지 알 수 있습니다. 위 parseNumber는 parseInt의 Overflow와 InvalidCharacter를 모두 Invalid 하나로 뭉개고 있는데, 호출자에게 원인을 구분해 알려야 한다면 오류 집합을 합치는 || 연산자로 두 집합을 모두 노출할 수 있습니다.
Allocator: 메모리의 명시성
Zig의 가장 독특한 설계. 표준 라이브러리의 어떤 함수도 할당자 없이 힙을 쓰지 않습니다.
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var list = std.ArrayList(i32).init(allocator);
defer list.deinit();
try list.append(1);
try list.append(2);
try list.append(3);
std.debug.print("{any}\n", .{list.items});
}
GeneralPurposeAllocator: 누수·UAF 탐지 기능이 있는 범용 할당자 (0.14에서DebugAllocator로 이름이 바뀌고 옛 이름은 별칭으로 남음)FixedBufferAllocator: 스택 또는 정적 버퍼 위에서 할당ArenaAllocator: 라이프타임 동안만 살아있고 한 번에 해제page_allocator: 시스템 페이지 단위
사용자가 어디서 메모리가 할당되는지 항상 아는 것이 Zig의 원칙입니다. 라이브러리가 “마음대로 heap을 쓰는” 일이 없습니다.
이 설계의 실질적인 이점은 할당 전략을 호출하는 쪽에서 바꿀 수 있다는 것입니다. 같은 JSON 파서라도 서버에서는 요청마다 ArenaAllocator를 넘겨 요청이 끝날 때 한 번에 해제하고, 임베디드에서는 FixedBufferAllocator로 힙 없이 정적 버퍼만 쓰고, 테스트에서는 std.testing.allocator로 누수를 검사할 수 있습니다. 라이브러리 코드는 전혀 바꿀 필요가 없습니다. 또 할당은 항상 실패할 수 있는 연산(error.OutOfMemory)으로 취급되므로, try list.append(1)처럼 메모리 부족을 무시할 수 없는 구조가 됩니다. 대신 allocator를 함수마다 인자로 넘겨야 해서 호출 체인이 깊어지면 번거롭고, 구조체에 allocator를 저장할지 매번 받을지 같은 설계 결정이 늘어납니다. 0.15부터 std.ArrayList가 allocator를 저장하지 않고 메서드 호출마다 받는 형태를 기본으로 바꾼 것도 이 트레이드오프에 대한 고민의 결과입니다.
defer
fn writeFile(allocator: std.mem.Allocator, path: []const u8, data: []const u8) !void {
const file = try std.fs.cwd().createFile(path, .{});
defer file.close();
try file.writeAll(data);
const backup = try allocator.alloc(u8, data.len);
defer allocator.free(backup);
@memcpy(backup, data);
}
defer는 스코프 종료 시 LIFO로 실행되어 소멸자 없이도 안전한 정리가 가능합니다.
defer와 짝을 이루는 것이 errdefer입니다. errdefer는 스코프가 오류로 빠져나갈 때만 실행되므로, “만든 자원을 성공하면 호출자에게 넘기고 실패하면 정리한다”는 패턴을 깔끔하게 표현합니다. 예를 들어 버퍼를 할당해 반환하는 함수에서 const buf = try allocator.alloc(u8, n); errdefer allocator.free(buf);라고 써 두면, 이후 단계에서 오류가 나 try로 빠져나갈 때만 버퍼가 해제되고 성공하면 그대로 반환됩니다. 여기서 defer를 쓰면 성공 경로에서도 해제되어 호출자가 해제된 메모리를 받게 됩니다. 처음 Zig를 쓸 때 가장 흔히 저지르는 실수가 이 둘을 헷갈리는 것이며, GeneralPurposeAllocator의 디버그 검사가 이중 해제나 해제 후 사용으로 잡아 주는 경우가 많습니다.
defer는 블록 스코프 기준이라는 점도 기억해야 합니다. 루프 본문 안에서 defer를 쓰면 매 반복이 끝날 때 실행되고, 함수가 끝날 때 한꺼번에 실행되지 않습니다. 이는 Go의 defer가 함수 종료 시점까지 쌓이는 것과 다른 동작이라, Go에서 넘어온 개발자가 헷갈리기 쉽습니다.
comptime: 컴파일 타임 실행
Zig의 제네릭·메타프로그래밍은 comptime으로 이뤄집니다.
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
const big = max(i32, 10, 20);
const bigF = max(f64, 1.5, 2.5);
// 컴파일 타임 반복
fn tupleToArray(comptime tuple: anytype) [tuple.len]@TypeOf(tuple[0]) {
var result: [tuple.len]@TypeOf(tuple[0]) = undefined;
inline for (tuple, 0..) |item, i| {
result[i] = item;
}
return result;
}
컴파일 타임에 타입을 생성·검사하며, 런타임에는 완전히 특수화된 코드만 남습니다. C++ 템플릿·Rust 제네릭의 장점을 더 직관적인 문법으로 제공.
직관적인 이유는 별도의 템플릿 언어가 없기 때문입니다. 타입은 type이라는 값이고, 제네릭 함수는 타입을 comptime 인자로 받는 평범한 함수이며, std.ArrayList(i32)는 타입을 반환하는 함수 호출입니다. if, for, 함수 호출 같은 일반 문법이 컴파일 타임에도 그대로 쓰입니다. 대신 트레이드오프도 C++ 템플릿과 비슷합니다. max에 >를 지원하지 않는 구조체를 넘기면 오류가 함수 본문 안쪽에서 발생하고, Rust의 트레이트 경계처럼 “이 타입이 무엇을 지원해야 하는지”를 시그니처에 선언하는 방법이 없습니다. 제약을 명시하고 싶다면 함수 앞부분에서 @typeInfo나 @hasDecl로 검사하고 @compileError로 읽기 쉬운 오류를 내는 것이 관례입니다. 또 comptime에 무거운 계산을 하면 컴파일 시간이 늘어나고, 기본 분기 할당량을 넘으면 evaluation exceeded 1000 backwards branches 오류가 나서 @setEvalBranchQuota로 한도를 올려야 합니다.
구조체와 메소드
const Point = struct {
x: f64,
y: f64,
pub fn init(x: f64, y: f64) Point {
return .{ .x = x, .y = y };
}
pub fn distance(self: Point, other: Point) f64 {
const dx = self.x - other.x;
const dy = self.y - other.y;
return std.math.sqrt(dx * dx + dy * dy);
}
};
const p1 = Point.init(0, 0);
const p2 = Point.init(3, 4);
std.debug.print("{d}\n", .{p1.distance(p2)}); // 5
- 상속 없음 → 합성 선호
- 인터페이스는
fn(ptr: *anyopaque, ...)스타일의 vtable로 표현 가능
테스트
test "addition" {
try std.testing.expectEqual(@as(i32, 3), 1 + 2);
}
test "allocator no leak" {
const allocator = std.testing.allocator;
const buf = try allocator.alloc(u8, 10);
defer allocator.free(buf);
@memset(buf, 0);
}
zig test src/main.zig
std.testing.allocator는 누수가 있으면 테스트 실패시키는 특수 할당자.
빌드 시스템 (build.zig)
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "myapp",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// C 라이브러리 링크
exe.linkLibC();
exe.linkSystemLibrary("sqlite3");
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
const unit_tests = b.addTest(.{
.root_source_file = b.path("src/main.zig"),
});
const test_step = b.step("test", "Run tests");
test_step.dependOn(&b.addRunArtifact(unit_tests).step);
}
zig build # 기본
zig build -Doptimize=ReleaseFast
zig build run
zig build test
CMake·Meson·Makefile 같은 별도 도구 없이 Zig 코드로 빌드 그래프를 선언합니다.
build.zig는 빌드를 직접 실행하는 스크립트가 아니라 단계(step)의 의존 그래프를 선언하는 코드라는 점을 이해하면 읽기 쉬워집니다. addExecutable은 컴파일 단계를 만들 뿐이고, installArtifact가 기본 install 단계에 연결해야 zig build가 실제로 바이너리를 zig-out/bin에 만듭니다. run 단계도 마찬가지로 dependOn으로 연결했기 때문에 zig build run에서만 실행됩니다. 가장 흔한 함정은 빌드 API가 버전마다 바뀐다는 것입니다. 예전 튜토리얼의 .root_source_file = .{ .path = "src/main.zig" }는 b.path(...)로 바뀌었고, 0.15에서는 root_module을 따로 만들어 넘기는 방식이 권장되는 식이라, 빌드 파일이 컴파일되지 않으면 먼저 버전을 확인해야 합니다. 위 예제에서 unit_tests에 target과 optimize를 넘기지 않은 것도 눈여겨볼 부분인데, 크로스컴파일 타깃을 지정한 상태에서 테스트를 호스트에서 돌리려면 이렇게 기본값(호스트)을 쓰는 편이 맞습니다.
크로스컴파일 (Zig의 슈퍼파워)
# macOS에서 Windows x64 바이너리 빌드
zig build-exe main.zig -target x86_64-windows-gnu
# Linux arm64
zig build-exe main.zig -target aarch64-linux-gnu
# macOS arm64
zig build-exe main.zig -target aarch64-macos
# WASI
zig build-exe main.zig -target wasm32-wasi
단일 호스트에서 모든 OS/아키텍처 바이너리를 만들 수 있습니다. 이 기능만으로도 Zig를 빌드 도구로 채택하는 프로젝트가 있습니다.
이것이 가능한 이유는 Zig 배포판이 여러 타깃의 libc(musl, glibc 헤더와 심볼 정보, MinGW-w64)를 소스나 압축된 형태로 함께 가지고 있다가, 필요한 타깃용을 그 자리에서 빌드하기 때문입니다. 다른 툴체인에서 크로스컴파일이 어려운 이유가 “타깃용 sysroot와 libc를 따로 구해야 해서”라는 점을 생각하면 큰 차이입니다. glibc 타깃은 -target x86_64-linux-gnu.2.28처럼 버전을 붙여 오래된 배포판과 호환되는 바이너리를 만들 수도 있습니다. 한계도 있습니다. macOS 타깃은 시스템 프레임워크(Cocoa 등)를 링크해야 하는 경우 Apple SDK가 필요하고, 타깃 시스템에 설치된 서드파티 라이브러리(linkSystemLibrary("sqlite3")처럼)는 그 플랫폼용 라이브러리 파일이 없으면 링크할 수 없으므로, 이런 의존성은 소스로 함께 빌드하는 구성이 크로스컴파일에 유리합니다.
zig cc — C 컴파일러로서의 Zig
# Zig가 Clang을 감싼 C 컴파일러로 동작
zig cc -O2 -o hello hello.c
# 크로스컴파일도 지원
zig cc -target aarch64-linux-musl -o hello_arm hello.c
cgo를 쓰는 Go 프로젝트에서 CC="zig cc -target ..."로 지정하면 C 크로스컴파일러를 따로 설치하지 않아도 되어 크로스 빌드가 간소화되며, Uber가 Bazel용 hermetic C/C++ 툴체인으로 zig cc를 도입한 사례가 잘 알려져 있습니다. zig cc는 Clang에 Zig의 libc와 타깃 처리 로직을 얹은 것이라 대부분의 Clang 옵션을 그대로 받지만, 일부 링커 옵션이나 특이한 플래그는 지원하지 않아 기존 빌드 스크립트를 그대로 옮기면 실패하는 경우가 있습니다.
C 인터옵
Zig에서 C 헤더 import
const c = @cImport({
@cInclude("stdio.h");
@cInclude("math.h");
});
pub fn main() !void {
_ = c.printf("sqrt(2) = %f\n", c.sqrt(2.0));
}
C 함수·타입·매크로를 자연스럽게 사용. 바인딩 생성 단계 없음.
@cImport는 내부적으로 C 헤더를 Zig 코드로 번역(translate-c)해 가져옵니다. 대부분의 함수와 구조체는 문제없이 번역되지만, 복잡한 함수형 매크로나 비트필드는 번역되지 않거나 @compileError로 표시되는 경우가 있어서, 그런 부분은 작은 C 래퍼 함수를 만들어 감싸는 것이 일반적입니다. C의 포인터는 null일 수도 있고 배열일 수도 있어서 Zig는 이를 [*c] 같은 “C 포인터” 타입으로 가져오는데, 이 타입은 Zig의 null 안전성을 우회하므로 받자마자 ?*T나 슬라이스로 변환해 쓰는 편이 안전합니다. 위 예제의 printf처럼 C의 가변 인자 함수를 호출하려면 libc를 링크해야 하므로 zig run main.zig -lc처럼 실행해야 합니다.
C에서 Zig 호출
export fn add(a: c_int, b: c_int) c_int {
return a + b;
}
zig build-lib add.zig -dynamic
생성된 .so/.dll/.dylib를 C 프로그램이 링크.
실전 예제: 간단한 HTTP 서버
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const addr = try std.net.Address.parseIp("0.0.0.0", 8080);
var server = try addr.listen(.{ .reuse_address = true });
defer server.deinit();
std.debug.print("listening on :8080\n", .{});
while (true) {
const conn = try server.accept();
handleConn(allocator, conn) catch |err| {
std.debug.print("error: {s}\n", .{@errorName(err)});
};
}
}
fn handleConn(allocator: std.mem.Allocator, conn: std.net.Server.Connection) !void {
defer conn.stream.close();
var buf: [4096]u8 = undefined;
const n = try conn.stream.read(&buf);
_ = n;
_ = allocator;
const response =
"HTTP/1.1 200 OK\r\n" ++
"Content-Type: text/plain\r\n" ++
"Content-Length: 13\r\n\r\n" ++
"Hello, Zig!\r\n";
try conn.stream.writeAll(response);
}
이 서버는 구조를 보여 주기 위한 최소 예제라서 실제 서비스에 쓰기에는 부족한 점이 분명합니다. 요청을 한 번 read로 읽고 내용은 확인하지 않으며, 연결을 하나씩 순서대로 처리하므로 느린 클라이언트 하나가 다른 모든 요청을 막습니다. handleConn이 오류를 반환해도 catch로 로그만 남기고 계속 도는 부분은 의도적인 선택입니다. 한 연결의 오류(클라이언트가 먼저 연결을 끊은 error.ConnectionResetByPeer 등)로 서버 전체가 종료되면 안 되기 때문입니다. HTTP 파싱이 필요하다면 표준 라이브러리의 std.http.Server를 쓰거나, 연결마다 스레드를 띄우는 std.Thread.spawn 또는 std.Thread.Pool로 동시 처리를 추가하는 것이 다음 단계입니다. Content-Length를 손으로 세는 것도 버그의 원천이므로, 본문 길이는 response_body.len처럼 코드에서 계산하는 편이 안전합니다.
사용 사례
| 프로젝트 | 사용처 |
|---|---|
| Bun | JavaScript 런타임 (엔진인 JavaScriptCore는 C++) |
| TigerBeetle | 금융 트랜잭션 DB |
| Uber | Bazel용 hermetic C/C++ 툴체인 (zig cc) |
| 임베디드 | 베어메탈 타깃 (microzig 등 커뮤니티 프로젝트) |
언제 Zig를 선택할지
- 단일 바이너리 + 크로스컴파일이 핵심 요구사항
- C 라이브러리와 깊이 통합하는 시스템 프로그래밍
- C의 단순성은 좋지만 안전성을 조금 더 원할 때
- 컴파일 타임 메타프로그래밍을 C++ 템플릿보다 깔끔하게
- 언어 변경을 감당할 수 있는 팀 (아직 1.0 전)
언제 Zig를 피할지
- 정적 메모리 안전이 필수 → Rust
- 방대한 C++ 라이브러리 생태계 필요 → C++
- 생산성 중심 앱 → Go, Rust, TypeScript
- JVM·에코시스템 의존 → Kotlin, Java
- 장기 안정적 ABI 필요 → 아직 Zig는 1.0 전
Rust와의 비교 요약
| 관점 | Zig | Rust |
|---|---|---|
| 메모리 안전 | 런타임 + 명시적 allocator | 컴파일 타임 borrow checker |
| 학습 곡선 | 중간 | 높음 |
| 컴파일 속도 | 빠름 | 보통 |
| C 인터옵 | 최고 수준(헤더 직접 import) | bindgen 필요 |
| 생태계 | 초기 | 성숙 |
| 크로스컴파일 | 최고 | 좋음 |
| 안정성 | pre-1.0 | 안정 |
| 표현력 | 의도적으로 작음 | 풍부함 |
트러블슈팅
정수 타입 불일치 오류
Zig는 매우 엄격한 타입 검사. @as(u32, 10)·@intCast로 명시 캐스팅.
정수 리터럴은 comptime_int라는 크기 없는 타입이라 대개 알아서 맞는 타입으로 바뀌지만, var x = 10;처럼 런타임 변수를 타입 없이 선언하면 variable of type 'comptime_int' must be const or comptime 오류가 납니다. var x: u32 = 10;처럼 타입을 적어 주면 해결됩니다. 서로 다른 정수 타입끼리의 연산도 암묵적으로 변환되지 않습니다. 넓은 타입에서 좁은 타입으로 줄일 때는 @intCast를 써야 하며, 디버그 빌드에서는 값이 범위를 벗어나면 런타임 패닉이 납니다. 0.11부터 @intCast가 목적지 타입을 결과 위치에서 추론하도록 바뀌었으므로(const y: u8 = @intCast(x);), 옛 예제의 @intCast(u8, x) 형태는 컴파일되지 않습니다.
메모리 누수
GeneralPurposeAllocator의 deinit() 반환값이 .leak 이면 누수. defer _ = gpa.deinit()으로 체크.
빌드 속도 저하
-O Debug 기본은 빠르지만 최적화는 느림. 반복 개발에서는 -Doptimize=Debug 유지.
릴리스 모드도 목적에 따라 고릅니다. ReleaseSafe는 최적화하면서 오버플로·경계 검사 같은 안전 검사를 유지하고, ReleaseFast는 검사를 빼서 속도를 얻는 대신 해당 상황이 정의되지 않은 동작이 되며, ReleaseSmall은 바이너리 크기를 우선합니다. 운영 환경에서 “디버그에서는 패닉으로 잡히던 버그”가 조용한 메모리 오염으로 바뀌는 것을 원하지 않는다면 ReleaseSafe가 기본 선택으로 무난합니다.
업그레이드 깨짐
Zig는 pre-1.0이라 마이너 릴리스가 코드 변경을 요구할 수 있음. 릴리스 노트·zig fmt·커뮤니티의 upgrade script 참고.
라이브러리 부재
표준 라이브러리 외에는 아직 제한적. zig fetch로 크레이트처럼 dependency 설치 가능하지만 생태계 크기는 Rust·Go 대비 작음.
체크리스트
- Zig 안정 버전 고정 (0.14.x 등)
-
GeneralPurposeAllocator로 기본 개발, 누수 체크 -
zig fmtCI 통과 -
build.zig로 명확한 빌드 그래프 - 크로스컴파일 타깃 자동화
-
std.testing.allocator로 leak-safe 테스트 - C 라이브러리 필요 시
@cImport로 직접 바인딩 - 언어/표준 라이브러리 변경에 대응하는 팀 규약
마무리
Zig는 “C의 영혼을 유지한 채 단점을 고친다”는 야심 찬 목표를 꾸준히 실현해나가는 언어입니다. 정적 메모리 안전에서는 Rust에 못 미치지만, 명시성·단순성·크로스컴파일·comptime이라는 독자적 강점으로 고유한 자리를 확보했습니다. Bun·TigerBeetle 같은 성공적 프로덕션 사례가 Zig의 성능·실용성을 증명하고 있으며, zig cc는 이미 많은 C/C++ 프로젝트의 빌드 인프라를 단순화하고 있습니다. 2026년 현재 1.0 전 단계이므로 신중한 선택이 필요하지만, 시스템 프로그래밍에 관심 있는 개발자라면 반드시 한 번은 다뤄볼 가치가 있는 언어입니다.
자주 묻는 질문 (FAQ)
Q. GeneralPurposeAllocator와 ArenaAllocator는 어떻게 골라 쓰나요?
A. GeneralPurposeAllocator는 할당마다 개별로 해제하는 범용 할당자이고, 디버그 빌드에서 누수와 해제 후 사용을 탐지해 주므로 기본 선택으로 적합합니다. ArenaAllocator는 요청 하나를 처리하는 동안처럼 수명이 같은 할당을 모아 두었다가 한 번에 해제할 때 편하며, 개별 free를 신경 쓰지 않아도 됩니다. 어느 쪽이든 defer로 deinit을 스코프 끝에 걸어 두면 오류 경로에서도 정리가 누락되지 않습니다.