Zig 완벽 가이드 — 시스템 프로그래밍 언어의 철학부터 CLI 실전까지

Zig 완벽 가이드 — 시스템 프로그래밍 언어의 철학부터 CLI 실전까지

이 글의 핵심

Zig는 C의 대체를 목표로 설계된 저수준 시스템 프로그래밍 언어입니다. 숨겨진 제어 흐름을 배제하고, 메모리 할당을 명시하며, 컴파일 타임 계산(comptime)으로 제네릭과 최적화를 동시에 다룹니다. 이 글에서는 Zig의 철학과 도구 체인, 할당자(allocator) 기반 메모리 모델, comptime, C 상호운용, 에러 집합(error set), build.zig 빌드 시스템, 그리고 실전 CLI까지 한 흐름으로 정리합니다. C·Rust·Go 경험이 있다면 비교하며 읽으면 이해 속도가 빨라집니다.


1. Zig의 정체와 설계 철학

1.1 Zig가 지향하는 것

Zig는 “간단함”, “예측 가능성”, “명시성”을 최우선으로 둡니다. C와 마찬지로 호출 규약과 메모리 레이아웃을 프로그래머가 통제할 수 있게 하되, 미정의 동작(undefined behavior)을 줄이기 위해 타입 시스템과 빌트인 검사를 강화합니다. 런타임에 숨은 할당이나 암묵적 예외 처리 같은 보이지 않는 비용을 피하는 것이 핵심 철학입니다.

1.2 다른 언어와의 거리감

  • C 대비: 포인터·구조체·전처리기 대신 더 안전한 대안(optional, error union, comptime)을 제공하되, C ABI와의 호환을 전제로 합니다.
  • Rust 대비: 소유권·수명 검사기(lifetime) 대신 할당 지점을 명시하고 런타임 비용을 직접 선택합니다. “안전 장치의 기본값”보다 “투명한 기본값”에 가깝습니다.
  • Go 대비: 가비지 컬렉션이 없고, 스레드·메모리 모델이 더 저수준에 가깝습니다.

1.3 “숨은 제어 흐름 없음”

Zig에서는 암묵적 함수 호출이나 연산자 오버로딩으로 제어 흐름이 숨지 않도록 설계되었습니다. 코드를 읽었을 때 어떤 함수가 언제 호출되는지가 텍스트만으로 드러나는 것을 중시합니다. 이는 대규모 시스템 코드와 FFI(외부 함수 인터페이스) 경계에서 디버깅 비용을 줄이는 데 도움이 됩니다.


2. 설치와 프로젝트 시작

공식 사이트(ziglang.org)에서 OS에 맞는 아카이브를 받거나 패키지 매니저를 사용합니다.

# macOS (Homebrew 예시)
brew install zig

zig version

프로젝트는 zig init으로 뼈대를 만들 수 있습니다. 이후 소스는 보통 src/main.zig에 두고, 빌드 정의는 build.zig에 둡니다(아래 빌드 시스템 절 참고).


3. 문법과 타입의 기본 축

Zig 소스 파일은 UTF-8이며, 진입점은 pub fn main() void 또는 pub fn main() !void 형태가 일반적입니다. !void에러 집합이 추론되는 에러 가능 함수임을 뜻합니다.

const std = @import("std");

pub fn main() void {
    std.debug.print("hello\n", .{});
}
  • const / var: 불변과 가변. Zig는 가변성을 최소화하는 편이 관용적입니다.
  • 배열과 슬라이스: [N]T는 길이 고정, []T는 슬라이스(포인터+길이). [:0]u8처럼 센티넬 종료 배열도 자주 씁니다.
  • optional: ?T — 값이 없을 수 있음을 타입으로 표현합니다.

문법 자체보다 이 글에서 강조할 것은 메모리와 에러를 타입으로 드러낸다는 점입니다. 다음 절부터가 Zig를 “시스템 언어”로 쓰는 핵심입니다.


4. 메모리 관리: Allocators

Zig에는 전역 기본 할당자가 강제되지 않습니다. 힙이 필요하면 어떤 std.mem.Allocator를 쓸지 호출 지점에서 결정합니다. 이로 인해 라이브러리는 “환경에 맞는 할당 전략”을 주입받을 수 있고, 테스트·임베디드·게임 엔진처럼 할당 정책이 다른 실행 환경에 동일한 코드를 실을 수 있습니다.

4.1 GeneralPurposeAllocator

개발·일반 프로그램에서 흔히 쓰는 범용 할당자입니다. 이중 해제(double free) 등 일부 오용을 탐지하는 모드가 있어 학습·디버깅에 유리합니다.

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const buf = try allocator.alloc(u8, 128);
    defer allocator.free(buf);

    @memset(buf, 0);
}

defer는 스코프 종료 시 실행됩니다. defer allocator.free 패턴으로 할당과 해제의 짝을 맞추는 것이 Zig 스타일의 기본입니다.

4.2 ArenaAllocator

많은 작은 할당이 한 번에 정리되면 되는 패턴(요청 처리 한 건, 파싱 한 번 등)에는 아레나가 효율적입니다. 상위 블록에서 deinit 한 번으로 하위 할당 전체를 되돌립니다.

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const allocator = arena.allocator();

4.3 고정 버퍼·페이지 할당자

임베디드나 할당 실패를 절대 허용하지 않는 경로에서는 FixedBufferAllocator나 미리 잡아 둔 버퍼 위에서만 동작하게 설계합니다. “실패 가능한 try alloc”을 설계 단계에서 제거할 수 있다는 뜻입니다.

4.4 실무에서의 선택 기준

  • 범용 애플리케이션: GPA + 명시적 free, 또는 상위에서 아레나 한 번.
  • 라이브러리: allocator: std.mem.Allocator를 인자로 받아 호출자가 정책 결정.
  • 핫 루프·실시간성: 사전 할당·풀·스택 버퍼로 힙 호출 자체를 줄이기.

Zig의 난이도는 상당 부분이 할당 책임을 어디에 둘지를 판단하는 데 있습니다. 이는 “편의”보다 성능과 예측 가능성을 택한 결과입니다.


5. 컴파일 타임 실행: comptime

comptime은 Zig의 제네릭·상수 폴딩·메타프로그래밍을 한 메커니즘으로 묶습니다. “컴파일에 알려진 값”은 comptime으로 계산해 런타임 비용을 제거할 수 있습니다.

5.1 타입을 인자로

fn largest(comptime T: type, a: T, b: T) T {
    return if (a > b) a else b;
}

const x = largest(u32, 3, 7);

T컴파일 타임에만 존재하는 타입 값입니다. C++ 템플릿과 비슷하지만, Zig는 하나의 문법(comptime)으로 통일한다는 점이 다릅니다.

5.2 컴파일 타임에 확정되는 값

comptime 블록 안에서는 컴파일 시점에만 의미 있는 계산을 수행합니다. 반복·분기 문법은 Zig 버전마다 미세하게 달라질 수 있으므로, 복잡한 메타프로그래밍은 해당 버전 문서의 comptime 예제를 그대로 따라가는 것이 안전합니다. 개념적으로는 런타임이 아니라 컴파일러가 결과를 상수로 박아 넣는다고 이해하면 됩니다.

5.3 실무적으로 comptime을 쓰는 이유

  • 제네릭 자료구조를 하나의 관용구로 작성.
  • 플랫폼별 상수·테이블을 생성 시점에 확정.
  • 불필요한 분기·할당을 제거해 바이너리 크기와 캐시 효율을 개선.

과도한 comptime 메타프로그래밍은 컴파일 시간 증가에러 메시지 난해함을 부를 수 있으므로, 팀 규모가 크면 “허용 패턴”을 문서화하는 편이 좋습니다.


6. 에러 처리: error set과 try / errdefer

Zig의 에러는 입니다. 함수는 ErrorSet!Payload 형태의 에러 유니온을 반환할 수 있습니다.

const FileError = error{ NotFound, AccessDenied };

fn openDemo(path: []const u8) FileError!void {
    if (path.len == 0) return error.NotFound;
    return {};
}

pub fn main() !void {
    openDemo("") catch |err| {
        std.debug.print("failed: {}\n", .{err});
        return err;
    };
}

6.1 errdefer

defer는 성공·실패와 관계없이 스코프 종료 시 실행되지만, errdefer는 함수가 에러로 반환될 때만 실행됩니다. 중간에 자원을 잡았다가 실패 시 롤백하는 패턴에 적합합니다.

fn example() !void {
    const r = try acquire();
    errdefer release(r);
    try step_that_may_fail();
    release(r);
}

실제 코드에서는 락·파일 디스크립터·할당된 버퍼 해제에 errdefer를 자주 씁니다.

6.2 try와 에러 전파

try exprexpr가 에러면 즉시 반환하고, 성공이면 페이로드를 풀어 줍니다. “예외”가 스택을 터뜨리지 않고 명시적 반환 경로로만 전파된다는 점이 디버깅에 유리합니다.


7. C 상호운용성

Zig는 C를 직접 임포트·링크하는 경로가 1급입니다. 기존 libc, OpenSSL, SQLite 같은 자산을 재사용하기 좋습니다.

7.1 @cImport로 헤더 사용

const c = @cImport({
    @cInclude("stdio.h");
});

pub fn main() void {
    _ = c.printf("hello from C stdio\n");
}

7.2 링크와 빌드

실제 프로젝트에서는 build.zig에서 시스템 라이브러리나 소스 트리의 C 파일을 객체로 컴파일해 링크합니다. 경로·플래그·크로스 컴파일 타깃은 모두 빌드 스크립트에서 한곳에 모을 수 있습니다.

7.3 cTranslateC와 점진적 이전

레거시 C 헤더를 Zig에서 쓰기 위해 자동 번역을 돕는 도구 흐름이 있습니다. 대규모 코드베이스는 한 모듈씩 Zig로 옮기며 링크 경계를 유지하는 전략이 현실적입니다.

C ABI를 유지해야 하는 플러그인·OS API·게임 엔진 모듈 경계에서 Zig의 강점이 두드러집니다.


8. 빌드 시스템: build.zig

Zig는 언어에 내장된 빌드 DSL로 실행 파일·라이브러리·테스트·크로스 컴파일을 기술합니다. build.zig의 핵심은 std.Build 객체를 받아 아티팩트(executable, static lib 등)를 등록하고, 의존 모듈·링크 옵션을 연결하는 것입니다.

8.1 전형적인 구조(개념)

아래는 Zig 0.13 전후에서 흔히 보던 형태입니다. 0.14부터는 root_modulecreateModule 등으로 모듈을 먼저 만들고 실행 파일에 연결하는 패턴이 늘었습니다. 필드 이름은 마이너 버전마다 바뀔 수 있으므로, 반드시 zig init으로 생성된 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 = "mytool",
        .root_source_file = b.path("src/main.zig"),
        .target = target,
        .optimize = optimize,
    });

    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);
}

8.2 크로스 컴파일

동일한 build.zig타깃 트리플만 바꿔 Windows·Linux·macOS용 바이너리를 뽑을 수 있습니다. CI에서 한 번의 스크립트로 멀티 아키텍처 산출물을 만드는 패턴과 잘 맞습니다.

8.3 모듈과 의존성

Zig 0.11 이후 std.Build.Module 기반으로 의존 그래프를 명시합니다. 내부 크레이트를 addModule로 노출하고, 실행 파일에 addImport로 연결하는 방식이 일반적입니다.


9. 실전: CLI 도구 개발

CLI는 인자 파싱, 파일 시스템, 표준 입출력, 종료 코드의 네 축으로 구성됩니다. Zig 표준 라이브러리만으로도 최소 도구는 작성 가능합니다.

9.1 인자 순회

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();

    const exe = args.next() orelse return error.NoExeName;
    _ = exe;

    const path = args.next() orelse {
        std.debug.print("usage: zigcat <file>\n", .{});
        return error.Usage;
    };

    var file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    const max_bytes: usize = 32 * 1024 * 1024;
    const data = try file.readToEndAlloc(allocator, max_bytes);
    defer allocator.free(data);

    try std.io.getStdOut().writeAll(data);
}

이 예제는 파일 전체를 메모리에 올리므로 대용량에는 부적합합니다. 실무에서는 std.io.bufferedReader로 스트리밍하거나 블록 단위로 복사합니다.

9.2 스트리밍으로 확장하는 방향

  • 입력: std.fs.File.reader() + 고정 크기 스택/힙 버퍼.
  • 출력: std.io.BufferedWriter로 시스템 콜 횟수 감소.
  • 에러 메시지: stderr로 분리해 파이프라인에서 진단만 분리 수집.

9.3 배포

zig build -Doptimize=ReleaseFast단일 정적 링크에 가까운 바이너리를 만들 수 있는 경우가 많아, 설치 스크립트 없이 바이너리 하나만 배포하는 운영 모델과 잘 맞습니다.


10. 모범 사항과 흔한 함정

  • 할당자 누수: defer free를 빼먹으면 메모리 누수가 납니다. 장기 실행 서버에서는 아레나·풀로 누수 가능성 자체를 줄이기도 합니다.
  • UB(미정의 동작): Zig도 저수준 연산이 있으므로 안전하다고 착각하지 말고 @panic·바운드·정렬을 명시적으로 다룹니다.
  • 버전 호환: Zig는 아직 안정화 전(pre-1.0) 이력이 있어, 마이너 버전마다 문법·std·build.zig API가 바뀔 수 있습니다. 프로젝트 고정 버전과 CI의 버전 핀(pin)이 중요합니다.
  • 과도한 comptime: 빌드 시간과 가독성 비용을 항상 함께 고려합니다.

11. 정리

Zig는 메모리·에러·빌드를 모두 “명시적 선택”으로 모아 시스템 소프트웨어에 필요한 통제력을 주는 언어입니다. 할당자를 주입 가능하게 설계한 점은 라이브러리·제품 양쪽에서 유연하고, comptime은 런타임 비용을 줄이는 레버가 됩니다. C 생태계와의 연결은 기존 자산을 끊지 않고 점진적 마이그레이션을 가능하게 합니다. 마지막으로, 공식 문서와 릴리스 노트를 버전에 묶어 두는 습관이 Zig 실무에서는 가장 큰 생산성 향상으로 이어집니다.


자주 묻는 질문

Q. Zig는 프로덕션에 써도 될까요?
A. 팀의 요구·라이브러리 성숙도·유지보수 역량에 따라 다릅니다. 언어·표준 라이브러리가 빠르게 진화하므로, 장기 서비스라면 컴파일러 버전 고정과 업그레이드 계획이 필수입니다.

Q. Rust 대신 Zig를 선택해야 할 때는?
A. C ABI와의 매끄러운 결합, GC 없는 단순한 배포 단위, 명시적 할당 정책이 최우선일 때 후보가 됩니다. 메모리 안전성을 컴파일러가 강하게 보장받는 것이 먼저라면 Rust가 유리한 경우가 많습니다.

Q. 학습 순서를 추천한다면?
A. 할당자·defer/errdefer → 에러 집합 → comptime 기초 → build.zig → C 연동 순으로 익히면, 이후 CLI·라이브러리 설계로 자연스럽게 확장됩니다.