Zig 언어 완전 가이드 | C를 대체하는 차세대 시스템 프로그래밍 언어
이 글의 핵심
C를 대체하는 현대적인 시스템 프로그래밍 언어 Zig. 명시적 메모리 관리로 안전하면서도 Rust보다 간단하며, C 라이브러리와 완벽히 상호 운용됩니다. Comptime으로 컴파일 타임 메타프로그래밍을 지원합니다.
이 글의 핵심
Zig은 C보다 안전하고 Rust보다 간단한 시스템 프로그래밍 언어입니다. 명시적 메모리 관리, 컴파일 타임 코드 실행(Comptime), C 라이브러리 직접 사용 등으로 현대적이면서도 실용적인 저수준 프로그래밍을 가능하게 합니다.
목차
- Zig이란?
- 핵심 철학: 숨겨진 제어 흐름이 없다
- 메모리 관리와 Allocator
- 에러 핸들링·Comptime 실전
- C·C++과의 구체적 비교
- 빌드 시스템: build.zig
- 크로스 컴파일
- 임베디드·시스템 프로그래밍 사례
- Rust와의 비교
- Zig vs C vs Rust 요약
- 학습 로드맵·리소스
- 설치·Hello World·문법
- C 상호 운용
- 핵심 정리
Zig이란?
Zig은 2016년 Andrew Kelley가 개발한 시스템 프로그래밍 언어입니다.
핵심 철학: 숨겨진 제어 흐름이 없다
Zig이 반복적으로 강조하는 문구는 “No hidden control flow”(숨겨진 제어 흐름이 없다) 입니다. 이는 다음을 의미합니다. 암시적 복사·암시적 new/소멸자 호출, 예외를 통한 보이지 않는 스택 언롤, 마법 같은 연산자 오버로딩이 기본 정책이 아닙니다. 힙 할당이 일어나면 호출한 쪽에서 Allocator를 넘기거나 그 결과를 볼 수 있게 설계하는 편이고, defer·errdefer는 스코프 내에서 보이는 자원 정리이지, 언어가 뒤에서 암시적으로 소멸자 체인을 돌리는 모델과는 다릅니다.
또 한 축은 comptime(컴파일 타임 실행) 입니다. C의 매크로나 템플릿 메타프로그래밍을 같은 언어로 풀어내는 쪽에 가깝고, comptime 식·comptime 매개변수로 타입과 값을 동시에 다루면서도 디버거와 에러 메시지가 C 전처리기만큼 끔찍해지지 않게 하려는 의도가 큽니다. 제 개인적인 평가로는, “매크로를 없앤 대신 comptime을 배워야 한다”는 점이 초반 학습비용이지만, 한번 익히면 제네릭·상수 끼워 넣기·빌드 타입 검증을 재현 가능한 코드로 남길 수 있어서 장기적으로는 비용이 정직하게 보입니다. 반대로, 런타임 GC나 예외에 익숙한 배경이면 “왜 이게 기본이 아니냐”는 마찰은 분명 있을 수 있습니다. 그 민감한 지점이 Zig이 지향하는 예측 가능한 비용과 제어권과 맞닿아 있습니다.
메모리 관리
Zig에서 메모리는 글로벌 malloc에 대한 낙인찍힌 기본 동작이 아니라, std.mem.Allocator 인터페이스에 alloc / free (및 resize 등) 를 위임하는 패턴이 중심입니다. 테스트·임베디드·끼워 넣는 커스텀 풀(arena, fixed buffer, bump allocator)을 동일한 API로 갈아끼울 수 있고, “이 함수는 힙을 쓴다”는 사실이 시그니처나 호출부에서 눈에 띄게 드러나는 쪽을 선호합니다.
Allocator 사용
const std = @import("std");
pub fn main() !void {
// 범용 할당자
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// 메모리 할당
const list = try allocator.alloc(i32, 10);
defer allocator.free(list); // 명시적 해제
// ArrayList (동적 배열)
var array = std.ArrayList(i32).init(allocator);
defer array.deinit();
try array.append(1);
try array.append(2);
try array.append(3);
std.debug.print("Array: {any}\n", .{array.items});
}
C와 비교하면, malloc이 실패해도 NULL만 돌아오는 세계를 그대로 두지 않고 try로 실패를 값으로 끌어올리는 쪽이 Zig 스타일에 가깝습니다. C++의 RAII와 겹쳐 보면, Zig은 소멸자에 숨지 않고 defer / errdefer로 “이 스코프를 빠져나갈 때 무엇을 할지”를 문맥 옆에 씁니다.
Arena Allocator (일괄 해제)
const std = @import("std");
pub fn processData() !void {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // 모든 할당 한 번에 해제
const allocator = arena.allocator();
const data1 = try allocator.alloc(u8, 100);
const data2 = try allocator.alloc(u8, 200);
const data3 = try allocator.alloc(u8, 300);
// defer arena.deinit()으로 모두 해제됨
}
요청/응답 한 사이클 안에서만 쓰는 임시 버퍼를 개별 free 없이 한 번에 정리하는 패턴은, 웹서버·게임 루프·파서처럼 “프레임 단위”로 경계를 나누기 쉬울 때 특히 잘 맞습니다. 임베디드에선 FixedBufferAllocator로 정적/스택 영역만으로 동작이 가능한지 설계·검증하는 데에도 쓰입니다.
에러 핸들링과 comptime 실전
에러 집합(error set)이 연산자 수준에서 합쳐지고, try / catch / if (expr) |v| / else |e|로 흐름이 드러난다는 점이 Zig의 실무 감촉을 좌우합니다. 아래는 파싱·검증을 comptime·런타임에 나누어 쓰는 뼈대 예제입니다. 실제 프로젝트에서는 std.json, 커스텀 DSL, 테이블 기반 설정을 comptime에 검산하는 식으로 확장하는 경우가 많습니다.
const std = @import("std");
const ParseError = error{ Empty, InvalidChar };
fn parsePositive(comptime s: []const u8) comptime_int {
if (s.len == 0) @compileError("empty");
var v: comptime_int = 0;
for (s) |c| {
if (c < '0' or c > '9') @compileError("non-digit");
v = v * 10 + (c - '0');
}
return v;
}
// 컴파일 타임에 상수가 확정됨(오타·형식 오류는 빌드 실패)
const buffer_cap = comptime parsePositive("4096");
fn parseU32(buf: []const u8) ParseError!u32 {
if (buf.len == 0) return error.Empty;
var out: u32 = 0;
for (buf) |c| {
if (c < '0' or c > '9') return error.InvalidChar;
out = try std.math.mul(u32, out, 10);
out = try std.math.add(u32, out, @as(u32, c - '0'));
}
return out;
}
pub fn main() !void {
const v = try parseU32("2026");
const stdout = std.io.getStdOut().writer();
try stdout.print("cap={d}, v={d}\n", .{ buffer_cap, v });
}
comptime으로 상수·테이블을 빌드에 박아 두는 식이면, 릴리스 바이너리에서 분기·체크를 줄이면서도, 소스는 여전히 읽을 수 있는 데이터로 남깁니다. C의 #define·enum 조합이 주던 “진실이 한곳에 없는” 느낌을 덜곤 합니다. 대신, 팀 규율이 없으면 comptime·런타임 경계를 혼용한 코드가 읽기 어려워질 수 있으니, “무엇을 언제까지 상수로 고정할지”는 코드 리뷰에서 눈여겨볼 지점입니다.
C·C++과의 구체적 비교
- 성능: Zig은 LLVM(또는 백엔드에 따라)을 통해 C·C++과 동일한 층의 최적화를 기대하는 편이 자연스럽습니다. “안전하다”는 이유로 필수적으로 런타임 검사를 넣는 모델이 기본이 아니라,
ReleaseFast·-fno-sanitize=…·바운드 체크 정책 등을 빌드·코드에서 정직하게 드러내는 쪽입니다. C와 비슷한 수의 할당·같은 알고리즘이면, 벤치마크는 대개 도구·환경·벡터화 여부에 좌우됩니다. C++이 템플릿·constexpr·RAII로 얻는 최적화와도 겹치지만, Zig은 빌드·링크·크로스까지 한 툴체인으로 끌고 가기 쉬운 점이 실무에서 체감됩니다. - 안전성: C의 미정의 동작(UB) 늪을 그대로 이어받지 않으려는 노력(예: 일부 래핑·정의된 동작을 선호)이 있으나, Zig = 메모리 안전 보장은 Rust 수준이 아닙니다. 대신 Optional·에러·테스트로 “실수하기 어려운” 방향이고, C보다 훨씬 조기에 잡히는 축이 있습니다. C++의
std::vector·스마트 포인터와 비교하면, “누가Allocator를 넘기는가”가 템플릿 인자·규칙 뒤에 숨지 않는 편이지만, 결국 책임은 프로그래머에게 남습니다. - 상호 운용성: C는 Zig의 한 부처럼 다루는 수준에 가깝습니다.
@cImport·extern·C ABI 그대로의 구조체 정렬, 링크 플래그는 한 번 익혀 두면 기존 라이브러리를 “그대로” 가져다 쓰기 좋습니다. C++는 이름 맹글링·템플릿·ABI 이슈로 C 래퍼가 필요한 경우가 많고, Zig은 그 C 면에 붙는 쪽이 안정적입니다.
빌드 시스템: build.zig
build.zig는 의존성·타깃·아티팩트(실행 파일·정적/동적 라이브러리·테스트)를 코드로 기술합니다. zig build 한 번에 크로스용 바이너리·zig build test·설치 경로를 맞출 수 있어, Make/CMake/Ninja를 이중으로 쓰는 대신, 소규모·중규모 프로젝트에서 반복을 줄이는 쪽이 현실적입니다. 아래는 실행 파일을 등록하고 zig build run으로 실행하는 축소 예시입니다(라이브러리 크레이트는 addTest 등으로 test 스텝에 연결. 버전·API는 사용 중인 Zig에 맞게 조정하십시오).
// 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 = "demo",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
if (b.option([]const u8, "arg", "args for demo")) |a| {
run_cmd.addArg(a);
}
const run_step = b.step("run", "앱 실행");
run_step.dependOn(&run_cmd.step);
}
주의: 위 스니펫은
std.BuildAPI가 버전마다 달라 그대로 복사해 빌드되지 않을 수 있습니다. 사용 중인 Zig의init-exe·init-lib로 생성한 템플릿을 기준으로 모듈 선언·addExecutable시그니처를 맞추는 것이 안전합니다. 핵심은 “빌드가 Zig 코드”라서 조건부 타깃·환경별 플래그·로컬 경로를 코드로 읽는다는 점입니다.
실무 팁으로는, build.zig에 C 소스/라이브러리를 linkSystemLibrary·addCSourceFile로 붙이고, Nix/CI에서는 동일 build.zig로 재현하는 패턴이 자주 쓰입니다. “IDE가 CMake를 못 찾는다”보다, Zig이 Zig을 빌드한다는 일관성이 팀 온보딩에 남는 경우가 있습니다.
크로스 컴파일
크로스 컴파일은 Zig를 처음 쓸 때 가장 인상적으로 남는 경험인 경우가 많습니다. zig는 표준 C 라이브러리·링커·타깃용 객체를 번들하는 방향(버전·환경에 따라 사용법이 달질 수 있으므로, 공식 문서의 Cross-compiling·Targets를 꼭 확인하십시오)이어서, “우분투에 ARM 툴체인 깔고, sysroot 맞추고, 링크 오류 200줄” 같은 서사가 짧아지는 편입니다.
개인적으로는, 동일 build.zig에 호스트와 타깃을 바꿔 zig build만 반복해보는 식으로 검증한 뒤, CI matrix에 aarch64-linux, x86_64-windows 등을 올리는 흐름이 가장 덜 지쳤습니다. 한계는 C++ ABI·Windows SDK·고유한 sysroot가 필요한 프로젝트는 여전히 “외부 힌트”가 필요하다는 점이고, “모든 것이 마법처럼 한 방”이 되지는 않습니다. 그때는 컨테이너·Nix·문서에 명시된 지원 타깃 범위 안에서 점진적으로 늘리는 쪽이 현실적입니다.
임베디드와 시스템 프로그래밍 사례
- 베어 메탈·RTOS: 고정
Allocator·no_libc(빌드·타깃에 따라)·링커 스크립트·벡터 테이블은 여전히 플랫폼 지식이 필요하지만, C 드라이버와 동일한 ABI로 모듈을 쪼갤 수 있다는 점이 매력적입니다. 부트스트랩·펌웨어 팀이 C에만 머물지 않을 점진적 도입으로 Zig 파일을 끼워 넣는 사례가 보입니다. - OS·커널·툴: 커스텀 커널, 부트로더, 시스템 데몬, 고성능 CLI에서 C와 동격의 제어권을 유지하면서 모던 문법·테스트·빌드를 한 세트로 가져가려는 시도가 있습니다. 생태계(크레이트 수준)는 Rust·C·C++보다 얇아서, “라이브러리가 있느냐”로 고르면 아직 불리한 경우가 있습니다.
- 이미 C가 있는 곳: 기존 정적/동적 C 라이브러리를 감싼 CLI·FFI·마이그레이션용 바이너리를 Zig로 쓰면, “새로운 런타임”을 들이지 않고도 점진적 교체를 논의하기 쉽습니다.
Rust와의 비교(장단점)
| 측면 | Zig | Rust |
|---|---|---|
| 메모리 모델 | 명시 Allocator + defer / 소유권·대여 없음 | 소유권·수명(불변·가변)으로 대부분의 UB 차단 |
| 학습 곡선(보통의 지표) | C·시스템 경험자는 상대적으로 완만 | 개념 밀도가 높고 컴파일러와 “협상”이 필요 |
| 생태계 | 작고 성장 중, C 통합이 강점 | 풍부한 순수 Rust 자산, 웹/백엔드 친화 |
| 빌드·크로스 | zig/build.zig가 한 축 | cargo·rustup이 강력 |
| 팀/채용 | 희소 | 상대적으로 넓음 |
솔직히 말하면, “안전”만 놓고 보면 Rust에 물릴 수밖에 없는 부분이 있고, Zig은 “내가 힙을 쓰는 경로를 다 본다”는 대가를 치르며 Rust만큼의 정적 메모리 보장을 약속하지는 않습니다. 대신, FFI·빌드·학습에서 마찰이 적은 프로젝트·팀엔 Zig이 설득력이 있습니다. 한 프로젝트에 둘 다 쓰는 것도, C ABI에 Rust 코어 + Zig 툴 같은 식으로 역할이 나뉠 수 있습니다.
Zig vs C vs Rust
| 기능 | Zig | C | Rust |
|---|---|---|---|
| 메모리 안전성 | ⚠️ 명시적 | ❌ 수동 | ✅ 자동 |
| 학습 곡선 | 🟡 중간 | 🟢 쉬움 | 🔴 어려움 |
| 성능 | ⚡⚡⚡ | ⚡⚡⚡ | ⚡⚡⚡ |
| C 호환 | ✅ 완벽 | ✅ 완벽 | ⚠️ FFI 필요 |
| 빌드 시스템 | ✅ 내장 | ❌ Make/CMake | ✅ Cargo |
| 크로스 컴파일 | ✅ 쉬움 | ⚠️ 복잡 | ✅ 쉬움 |
| 생태계 | 🌱 새로움 | 🌳 성숙 | 🌿 성장 중 |
학습 로드맵과 리소스
- 1~2주: 공식 문서의 Language Reference 를 읽기만 해도,
comptime·에러·defer뼈대가 잡힙니다.zig내장 learn / 튜토리얼(버전에 따라zig서브커맨드)로 작은 CLI를 2~3개 만듭니다. - 2~4주:
std의ArrayList·HashMap·파일/네트워트 API,test블록으로 단위 테스트 습관을 붙입니다. C 라이브러리 하나(예: zlib, curl)를 최소 래퍼로 감싸봅니다. - 1~2개월 이상:
build.zig로 멀티 타깃·CI, 필요하면 임베디드/특정 OS 문서, ZSF(Zig Software Foundation) 소식·이슈 트래커를 읽으며 실제 프로젝트 제약(ABI·라이선스)을 익힙니다.
추가로 Zig Learn, 공식 documentation, Zig GitHub, Discord는 변경이 잦은 버전에 정답을 맞출 때 효과가 큽니다. 한국어 자료는 상대적으로 적으므로, 팀 내에서는 용어표(Allocator, comptime, error set) 를 한 페이지로 못 박아 두면 온보딩 비용이 줄어듭니다.
🚀 핵심 특징(요약 예제)
1. 명시적 메모리 관리
// Zig: 명시적 할당자
const allocator = std.heap.page_allocator;
const list = try allocator.alloc(u32, 10);
defer allocator.free(list); // 명시적 해제
// C: malloc/free
// int* list = malloc(10 * sizeof(int));
// free(list);
// Rust: 자동 해제(소유권 규칙)
// let list = vec![0u32; 10];
2. Comptime (컴파일 타임 실행)
fn fibonacci(n: u32) u32 {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const result = comptime fibonacci(10); // 55 (컴파일 타임에 계산)
3. C와 완벽한 상호 운용
const c = @cImport({
@cInclude("stdio.h");
@cInclude("stdlib.h");
});
pub fn main() void {
c.printf("Hello from Zig!\n");
}
4. 에러 핸들링
fn divide(a: i32, b: i32) !i32 {
if (b == 0) return error.DivisionByZero;
return @divTrunc(a, b);
}
const result = divide(10, 2) catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
Zig 설치
macOS/Linux
# 설치(예: 공식 릴리스·버전은 ziglang.org에서 확인)
curl -L -o zig.tar.xz "$(curl -s https://ziglang.org/download/index.json | jq -r '.master | to_entries[0].value."x86_64-linux".tarball')"
tar xf zig.tar.xz
export PATH=$PATH:"$PWD/$(ls -d zig-linux-x86_64-* 2>/dev/null | head -1)"
Windows
# Scoop으로 설치
scoop install zig
버전 확인
zig version
Hello World
// hello.zig
const std = @import("std");
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Hello, {s}!\n", .{"Zig"});
}
# 컴파일 및 실행
zig run hello.zig
# 실행 파일 생성
zig build-exe hello.zig
# 최적화 빌드
zig build-exe hello.zig -O ReleaseFast
기본 문법
변수
const std = @import("std");
pub fn main() !void {
// const: 변경 불가
const x: i32 = 10;
// var: 변경 가능
var y: i32 = 20;
y = 30;
// 타입 추론
const z = 40; // comptime_int → 문맥에 따라 i32 등으로 확정
// 언더스코어 (가독성)
const million = 1_000_000;
_ = million;
_ = z;
_ = y;
_ = x;
}
함수
// 기본 함수
fn add(a: i32, b: i32) i32 {
return a + b;
}
// 에러 반환 가능
fn divide(a: i32, b: i32) !f32 {
if (b == 0) return error.DivisionByZero;
return @as(f32, @floatFromInt(a)) / @as(f32, @floatFromInt(b));
}
// 사용
// const _ = divide(10, 2) catch |err| {
// std.debug.print("Error: {}\n", .{err});
// return;
// };
// _ = add(1, 2);
구조체
const Point = struct {
x: f32,
y: f32,
pub fn distance(self: Point, other: Point) f32 {
const dx = self.x - other.x;
const dy = self.y - other.y;
return @sqrt(dx * dx + dy * dy);
}
};
// pub fn main() void {
// const p1 = Point{ .x = 0, .y = 0 };
// const p2 = Point{ .x = 3, .y = 4 };
// const dist = p1.distance(p2); // 5.0
// _ = dist;
// }
Comptime (컴파일 타임 실행)
제네릭 함수
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}
pub fn main() void {
const x = max(i32, 10, 20); // 20
const y = max(f32, 1.5, 2.5); // 2.5
_ = x;
_ = y;
}
Comptime 계산
const std = @import("std");
fn fibonacci(n: u32) u32 {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
const fib10 = comptime fibonacci(10); // 런타임 비용 0
pub fn main() void {
std.debug.print("Fibonacci(10) = {}\n", .{fib10});
}
C 상호 운용
C 라이브러리 사용
// curl.zig
const c = @cImport({
@cInclude("curl/curl.h");
});
pub fn main() !void {
const curl = c.curl_easy_init();
defer c.curl_easy_cleanup(curl);
_ = c.curl_easy_setopt(curl, c.CURLOPT_URL, "https://example.com");
_ = c.curl_easy_perform(curl);
}
# 컴파일 (curl 링크)
zig build-exe curl.zig -lc -lcurl
핵심 정리
✅ Zig의 장점
- 명시적 메모리 관리: 힙 경로·해제를 코드에서 추적하기 쉬움
- Comptime: C 매크로 대신, 같은 언어로 메타프로그래밍
- C 상호 운용: 기존 C 코드·빌드와의 점진적 연계
- 문법·개념 밀도: Rust 대비(주관적) 상대적으로 완만한 측면
- 빌드·크로스:
build.zig·크로스 컴파일을 한 흐름으로
솔직한 한계(개인적 평가)
- 1.0 이전/이후 정책·문법·표준 라이브러리 변화에 발이 걸릴 수 있으므로, 프로젝트 핀(버전 고정) 이 중요합니다.
- crates.io급 풍부한 순수 생태는 기대하기 어렵고, C·Rust와 같이 쓰는 마음가짐이 현실적입니다.
- “안전”을 최우선으로 밀기엔 Rust가 여전히 강하고, “제어”를 최우선으로 밀기엔 C·어셈이 남아 있습니다. Zig은 그 사이를 넓히는 도구에 가깝습니다.
🚀 다음 단계
- Zig 공식 문서에서 심화 학습
- Zig Learn에서 튜토리얼
- Zig Discord에서 커뮤니티 참여
시작하기: ziglang.org/download에서 Zig을 설치하고, 차세대 시스템 프로그래밍을 경험해보십시오.