Zig의 내장 빌드 시스템이 `zig build`를 실행할 때 실제로 어떤 과정을 거쳐 빌드 바이너리를 만들고, 그 바이너리가 빌드 단계를 정의·의존성 순서대로 실행하며, 다시 Zig 컴파일러를 서브프로세스로 호출해 산출물을 생성하는지 내부 구현 관점에서 살펴본다.
2022년 2월 24일
목차
Zig에는 프로젝트를 빌드하기 위한 내장 빌드 시스템이 있다. 이 시스템은 Zig가 지원하는 모든 플랫폼에서 동작하며, 단순한 실행 파일과 라이브러리부터 복잡한 다중 산출물·다중 단계 프로젝트까지 모두 빌드할 수 있다. 이 글에서는 Zig 빌드 시스템의 내부가 어떻게 동작하는지 깊게 파고들어 본다.
빌드 시스템은 어떤 소프트웨어 프로젝트에서도 대단히 중요한 요소다. 잘 동작할 때는 마치 마법 같다. 명령 하나를 실행하면, 잠재적으로 복잡한 일련의 단계들을 거친 뒤 정상 동작하는 바이너리(또는 다른 산출물)가 만들어진다! 반대로 잘 동작하지 않을 때는 이해하기 어렵고 투명하지 않은 장애물처럼 느껴져서, 존재하지 않았으면 좋겠다는 생각이 들기도 한다. 강력한 도구라면 보통 그렇다. 어떤 날, 어떤 작업이냐에 따라 마법이 되기도 하고 골칫거리가 되기도 한다.
빌드 시스템 내부를 이해하는 것은 그 “마법”을 걷어내고 일을 더 쉽게 만드는 방법이다. 이 글은 한 단계 더 깊이 들어가 Zig 사용자들이 zig build가 어떻게 동작하는지 이해하도록 돕고, 그 결과 매일 더 생산적으로 작업할 수 있기를 목표로 한다.
참고: 이 글은 Zig 빌드 시스템 자체의 입문서가 아니라, 빌드 시스템이 동작하는 _내부 구현_에 대한 소개다. 빌드 시스템 사용법 입문은 공식 문서, Zig 빌드 시스템에 대한 훌륭한 블로그 시리즈, 또는 기존 C/C++ 프로젝트가 이 빌드 시스템을 활용하는 방법에 대한 글을 참고하라.
세부로 들어가기 전에, 먼저 확대해서(줌 아웃한 관점에서) Zig 빌드 시스템을 설명하겠다.
Zig 빌드 시스템은 설정을 위해 build.zig 파일을 사용하고, 실행을 위해 zig build CLI를 사용한다. build.zig 파일 안에서 개발자는 빌드 함수를 구현해야 한다(예: fn build() void). 이 빌드 함수는 단계, 의존성, 산출물 등을 정의하는 빌더 API(std.build.Builder)에 접근할 수 있다. 이것은 대부분 선언형(declarative) API이며, 빌드 시스템은 이를 이용해 무엇을 어떤 순서로 실행할지 결정한다.
Zig 프로젝트를 zig build로 빌드해 본 적이 있다면, 때때로 컴파일이 두 번 일어나는 것처럼 보였을 것이다. 의미 분석(semantic analysis)과 코드 생성(codegen) 출력이 두 번 나타난다. 캐시가 없는 프로젝트에서는 실제로 두 번 컴파일되기 때문이다. 먼저 빌드 바이너리를 컴파일하고, 그 빌드 바이너리가 다시 프로젝트 코드를 컴파일한다.
앞서 언급했듯이, zig build CLI는 먼저 build.zig 파일을 현재 실행 중인 시스템용 _빌드 바이너리(build binary)_로 컴파일한 다음, 실제 빌드를 수행하기 위해 그 바이너리를 서브프로세스로 실행한다. 빌드 바이너리는 Zig 캐시 시스템의 이점을 누리므로 build.zig(또는 그 import들)가 변경되지 않는 한 이후 zig build 실행에서는 재컴파일 비용이 들지 않는다.
그 다음 빌드 바이너리가 실행되며, 프로젝트를 빌드하기 위해 필요한 단계들을 결정하고 실행한다. 빌드 바이너리는 Zig 컴파일러를 내장하지 않는다. 대신 필요에 따라 개별 산출물을 만들기 위해 Zig 컴파일러를 다시 서브프로세스로 호출한다.
상위 수준 단계들을 나타낸 다이어그램은 아래와 같다:
┌────────────────────┐
│ │
│ zig build │
│ │
└────────────────────┘
│
┌────────────────────┐ │
│ │ ▼
│ build.zig │──┐ ┌────────────────────┐
│ │ │ │ │
└────────────────────┘ ├─▶│ build binary │
┌────────────────────┐ │ │ │
│ lib/std/special/ │ │ └────────────────────┘
│ build_runner.zig │──┘ │
│ │ │
└────────────────────┘ ▼
┌────────────────────┐ ┌────────────────────┐┌───────┐
│ │ │ ││ │
│ source files │────▶│ zig build-exe ││ ... │
│ │ │ ││ │
└────────────────────┘ └────────────────────┘└───────┘
│
│
▼
┌────────────────────┐
│ artifact(s) │
│ (exe, libs, etc.) │
│ │
└────────────────────┘
zig build 과정은 단 두 가지를 한다: (1) _빌드 바이너리_를 빌드하고 (2) 빌드 바이너리를 자식 프로세스로 실행한다. 첫 번째 단계—빌드 바이너리 빌드—는 build.zig 파일을 입력으로 사용하지만, 아직 build 함수를 실행 하지는 않는다.
zig build의 소스는 src/main.zig에서 찾을 수 있으며 cmdBuild 함수로 구현되어 있다. 그렇게 복잡한 함수는 아니므로 한 번 읽어보기를 권한다. 초반 블록은 빌드 바이너리를 만들고, 그 다음 로직은 그 바이너리를 서브프로세스로 실행한다.
빌드 바이너리는 표준 라이브러리의 lib/std/special/build_runner.zig를 빌드의 메인 엔트리포인트로 사용한다. 이 파일을 보면 pub fn main이 있다. 이것이 실제 빌드 바이너리의 메인 엔트리포인트다.
이 엔트리포인트 파일은 @build를 import한다. 이것은 cmdBuild가 여러분의 build.zig 파일을 가리키도록 정의하는 특수 패키지다. 이 덕분에 빌드 러너가 최종적으로 여러분의 build.zig 파일 안에 있는 build 함수를 실행할 수 있다.
최종 빌드 바이너리는 어디에도 설치되지 않는다. Zig 캐시 디렉터리(보통 build.zig 파일 기준 상대경로의 zig-cache)에 저장된다. 빌드 바이너리가 존재함을 직접 확인하려면 다음처럼 찾아보면 된다:
shell$ find ./zig-cache -type f -name 'build' ./zig-cache/o/c4c75a71df444bff10945728759e174c/build
zig build는 시스템에서 lib/std/special/build_runner.zig가 어디 있는지 어떻게 알까?
src/introspect.zig에 Zig 설치 위치를 찾는 데 사용되는 API가 있다. 이 API는 현재 실행 파일의 디렉터리에서 시작해, 위로 거슬러 올라가며 lib/zig/std/std.zig 또는 lib/std/std.zig를 찾을 때까지 탐색한다. zig build를 실행하면 zig 바이너리가 있는 디렉터리에서 시작해 이 파일들을 찾기 시작한다.
참고: 이것은 프로젝트에서 @import("std")가 해결되는 방식이기도 하다. 구체적으로, std 패키지는 빌드 과정에서 zig CLI가 introspection API를 사용해 std.zig를 가리키도록 미리 정의된다.
stdlib을 찾는 함수 구현은 아래와 같다. 이 디렉터리만 찾을 수 있으면 표준 Zig 설치 내의 어떤 파일이든 얻을 수 있다.
zigfn testZigInstallPrefix(base_dir: fs.Dir) ?Compilation.Directory { const test_index_file = "std" ++ fs.path.sep_str ++ "std.zig"; zig_dir: { // Try lib/zig/std/std.zig const lib_zig = "lib" ++ fs.path.sep_str ++ "zig"; var test_zig_dir = base_dir.openDir(lib_zig, .{}) catch break :zig_dir; const file = test_zig_dir.openFile(test_index_file, .{}) catch { test_zig_dir.close(); break :zig_dir; }; file.close(); return Compilation.Directory{ .handle = test_zig_dir, .path = lib_zig }; } // Try lib/std/std.zig var test_zig_dir = base_dir.openDir("lib", .{}) catch return null; const file = test_zig_dir.openFile(test_index_file, .{}) catch { test_zig_dir.close(); return null; }; file.close(); return Compilation.Directory{ .handle = test_zig_dir, .path = "lib" }; }
마법을 더 걷어내기 위해, 빌드 바이너리를 수동으로 빌드해 보자. 이 글의 뒷부분에서 이 바이너리를 수동으로 실행도 해볼 텐데, 우선은 빌드만 해보겠다.
예제로는 Zig 컴파일러 자체의 build.zig 파일을 빌드하겠다. Zig 소스 코드를 클론하고 zig를 설치하라(이상적으로는 체크아웃한 소스로부터 컴파일한 버전이 좋지만, 최신에 가까운 버전이면 어떤 것이든 괜찮다). 그리고 체크아웃 디렉터리에서 다음 명령으로 빌드 바이너리를 만들 수 있다:
shell$ zig build-exe \ --pkg-begin '@build' build.zig \ --pkg-end \ -femit-bin=custom-builder \ lib/std/special/build_runner.zig
끝이다! 명령을 실행하고 나면 custom-builder 실행 파일이 생성되어 있어야 한다. 이것은 zig build를 호출했을 때 생성되는 실행 파일과 동일하다.
이 커맨드라인 호출을 보면, 우리의 메인 패키지가 표준 라이브러리의 build_runner.zig 파일이고, build.zig 파일이 @build 패키지로 노출된다는 점이 아주 분명해진다.
이것이 zig build가 내부에서 하는 일의 절반이다.
빌드 바이너리가 빌드된 후, zig build는 자식 프로세스를 만들고 즉시 이를 실행한다.
이 바이너리는 아래 순서대로 4개의 위치 인자(positional argument)를 요구한다:
build.zig가 있는 빌드 루트 경로 — 다만 중요한 점은 이제 더 이상 build.zig 파일 자체에 접근할 필요가 없다 는 것이다. 이 경로는, 이미 바이너리로 컴파일된 build.zig 코드에서 “빌드 루트 기준 상대 경로”를 해석하는 데에만 사용된다.zig build 구현은 이 인자들을 자동으로 채우고, 추가 인자들은 빌드 바이너리에 그대로 전달한 뒤 실행한다. 이 바이너리가 실제 프로젝트 빌드를 수행한다.
다시 상기하자면, 빌드 바이너리의 main 엔트리포인트 함수는 lib/std/special/build_runner.zig에 정의되어 있다. 복잡하지 않으므로 파일을 읽어보길 권한다. 실제 빌더 API(std.build.Builder)는 더 복잡해지므로, 먼저 build_runner.zig를 읽어 상위 수준의 제어 흐름을 이해하는 것부터 시작하는 게 좋다.
이전 섹션에서 custom-builder 바이너리를 만들었다면, 이제 이를 수동으로 호출해 Zig 컴파일러 전체 빌드를 수행할 수 있다.
다시 말하지만 실제로는 이렇게 할 필요가 전혀 없다. zig build가 이 작업을 수행하기 때문이다. 여기서는 zig build가 내부적으로 어떻게 동작하는지 보여주기 위해 수동으로 실행하는 것뿐이다.
shell$ ./custom-builder $(which zig) . ./cache ./global-cache
예제로 Zig 컴파일러를 사용했지만, Zig 빌드 시스템을 사용하는 어떤 프로젝트에도 동일한 패턴이 적용된다.
빌드 바이너리는 zig build가 지원하는 거의 모든 플래그를 지원한다. 사실 zig build는, 빌드 바이너리 자체를 빌드하는 과정을 직접 제어하는 일부 제한된 플래그를 제외하면, 모든 인자를 자식 프로세스인 빌드 바이너리에 그대로 복사해서 전달한다.
이는 --help 플래그로 빌드 바이너리를 실행해 보면 확인할 수 있다(4개의 필수 위치 인자와 함께). 이전 섹션에서 빌드 바이너리를 수동으로 만들었다면 지금 시도해볼 수 있다:
shell$ ./custom-builder $(which zig) . ./cache ./global-cache --help
이 4개의 위치 인자는 zig가 PATH에 있고, 현재 작업 디렉터리가 build.zig 파일이 존재하는 빌드 루트라고 가정한다.
도움말 출력은 zig build --help와 거의 동일할 텐데, 실제로 그렇기 때문이다. zig build --help는 먼저 빌드 바이너리를 빌드한 다음 --help 플래그를 자식 프로세스에 전달한다. 출력은 정확히 일치해야 한다.
흥미로웠던 점 중 하나는, 빌드 러너가 build.zig 파일 안의 build 함수를 어떻게 호출하느냐였다. 빌드 러너는 Zig의 comptime 기능을 이용해 build 함수의 시그니처를 introspection하여 여러 함수 시그니처를 지원한다.
build_runner.zig의 runBuild 함수를 아래에 그대로 옮겨, 이것이 어떻게 동작하는지 보여준다:
zigfn runBuild(builder: *Builder) anyerror!void { switch (@typeInfo(@typeInfo(@TypeOf(root.build)).Fn.return_type.?)) { .Void => root.build(builder), .ErrorUnion => try root.build(builder), else => @compileError("expected return type of build to be 'void' or '!void'"), } }
이 덕분에 build 함수 시그니처는 다음 둘 모두가 될 수 있다:
fn build(*std.build.Builder) voidfn build(*std.build.Builder) !void (!void에 주목)더 구체적으로, 에러 유니온 케이스(두 번째 케이스)는 어떤 에러 유니온이든 될 수 있다. 위 목록처럼 추론된 에러 유니온일 수도 있고, 명시적으로 정의된 에러 유니온일 수도 있다.
빌드 시스템이 어떻게 동작하는지 이해하는 관점에서 이는 아주 중요한 디테일은 아니지만, 도구 구현을 공부하다 보면 발견할 수 있는 멋진 내부 동작의 예시다. 동시에 꽤 괜찮은 comptime 사용 사례를 보여주기도 했다.
우리는 zig build가 build.zig 파일을 사용해 전용 빌드 바이너리를 만드는 방법과, 그 빌드 바이너리가 실행되어 프로젝트를 빌드할 수 있다는 점을 보았다. 그렇다면 빌드 바이너리는 실제로 무엇을 하고, build.zig 파일과는 어떤 관계일까?
빌드 바이너리는 build.zig 파일에 사용자가 정의한 build 함수를 호출한다. 이 빌드 함수는 std.build.Builder에 대한 포인터를 인자로 받는데, 이를 이용해 사용 가능한 플래그, 타깃, 타깃 의존성 등 빌드의 요소를 선언형으로 정의한다. 마지막으로 빌드 러너(build_runner.zig)가 지정된 타깃에 대해 의존성 순서대로 단계들을 실행한다.
Builder 인자는 많은 기능을 제공하지만, 핵심 목표는 단계(step)들의 집합을 구성하는 것이다.
“최상위 단계(top level step)”는 이름으로 호출될 수 있는 단계에 부여되는 특별한 구분이다. 즉 zig build <name>처럼 호출할 수 있다. 미리 정의된 최상위 단계는 “install”과 “uninstall” 두 가지가 있다. 추가 최상위 단계는 step 함수로 만들 수 있다. 호출 가능한 이름이 지정된다는 점을 제외하면, 최상위 단계는 기능적으로 다른 어떤 단계와도 동일하다. Builder는 최상위 단계들의 집합을 ArrayList에 유지한다.
모든 단계는 Step의 dependOn 함수를 호출해 의존성을 0개 이상 지정할 수 있다. 의존성 역시 단순한 ArrayList로 유지된다.
하나 이상의 최상위 단계는 Builder의 make 함수를 호출하여 실행된다. 이 함수는 내부적으로 단일 최상위 단계를 실행하는 makeOneStep을 호출한다. makeOneStep은 매우 단순하며 전체 구현은 아래와 같다:
zigfn makeOneStep(self: *Builder, s: *Step) anyerror!void { if (s.loop_flag) { warn("Dependency loop detected:\n {s}\n", .{s.name}); return error.DependencyLoopDetected; } s.loop_flag = true; for (s.dependencies.items) |dep| { self.makeOneStep(dep) catch |err| { if (err == error.DependencyLoopDetected) { warn(" {s}\n", .{s.name}); } return err; }; } s.loop_flag = false; try s.make(); }
makeOneStep은 추가된 순서대로 의존성을 하나씩 순회하면서 재귀적으로 makeOneStep을 호출한다. 각 단계의 loop_flag는 순환(사이클)을 감지하는 데 사용된다(그래프 구조를 만들거나 더 복잡한 무언가를 쓰는 대신). 마지막으로 make를 통해 그 단계 자체를 실행한다.
Step 구조체는 상태가 붙어 있는 비교적 단순한 인터페이스 같은 구조다. 단계별 고유 로직은 makeFn 함수 포인터에 캡슐화되어 있고, 나머지 필드들은 공통 상태다.
zigpub const Step = struct { id: Id, name: []const u8, makeFn: fn (self: *Step) anyerror!void, dependencies: ArrayList(*Step), loop_flag: bool, done_flag: bool, // ... };
name은 이 단계가 최상위 단계가 아닌 한 디버깅 용도로만 쓰인다. loop_flag와 dependencies는 앞에서 다뤘다. done_flag는 단계가 정확히 한 번만 실행되도록 한다. 이후 실행은 no-op이다.
이 지식을 바탕으로 커스텀 단계를 만드는 방법이 대략적으로는 명확해졌을 것이다. 내장 단계들은 대부분의 프로젝트를 빌드하기에 필요한 기능을 모두 제공한다.
build 함수는 단계들과 그 의존성의 집합을 정의하지만, 이를 실행하지는 않는다. 이를 설명하는 한 가지 방식은 build.zig 파일이 빌드 단계를 선언적으로 정의한다고 말하는 것이다. 이는 일반적으로 빌드 시스템에서(그리고 Zig에서도) 사람들이 자주 겪는 함정을 피하기 위해 이해해야 하는 중요한 개념이다.
단계들이 선언적으로 정의되기 때문에, 로직이 언제 어디에서 실제로 발생하는지 이해하려면 신중해야 한다. 이전 단계가 생성한 파일을 읽는 단계가 있다면, 그것은 makeFn을 가진 커스텀 단계를 사용해서 구현해야 한다. build 함수 안에서 단계를 만들고 곧바로 파일을 읽을 수는 없다. 아직 실행되지 않았기 때문이다.
반대로, 빌드 단계가 실행되기 이전 에 존재하는 파일 집합에 대해 일련의 단계를 프로그램적으로 생성하려 한다면, build 함수 안에서 직접 하는 것이 가능하고(그리고 대부분의 경우 그래야 한다). 단계가 완전히 정의되도록 말이다. 빌드 실행 시점에 동적으로 단계를 정의할 수는 없다.
고급 참고: 실행 시점에 기존 step 그래프 안으로 동적으로 단계를 정의할 수는 없다. 하지만, 동적으로 다른 단계들을 만들고 그것들을 직접 실행하는 커스텀 단계를 만드는 것은 가능하다.
이제 build.zig가 단계를 정의하는 방법, 단계의 구조, 그리고 호출 방법을 이해했다. Zig의 Builder 구조체는 실행 파일, 오브젝트 등을 빌드하기 위한 고수준 헬퍼를 제공한다. 이것이 어떻게 동작하는지 더 깊게 살펴보자.
컴파일 관련 기능은 모두 LibExeObjStep이라는 단일 구현을 공유한다. 실행 파일, 라이브러리, 기타 오브젝트 타입 중 무엇을 빌드할지는 step 구조체의 필드 값들에 따라 결정된다. 이러한 디테일은 보통 addExecutable이나 addSharedLibrary 같은 헬퍼 뒤에 숨겨져 있다.
LibExeObjStep의 구현은 lib/std/build.zig에서 찾을 수 있다. 이 step은 기능이 매우 풍부하고 빌드 과정에서 너무 중요한 부분이므로, 코드 줄 수가 비교적 많긴 하지만 전체 step을 공부해보는 것을 강력히 추천한다.
이 step의 구현은 다시 zig 컴파일러를 서브프로세스로 호출하는 방식으로 동작한다. make 구현은 step에 설정된 구성을 바탕으로 커맨드라인 인자들을 구성한 다음, zig build-exe나 zig build-obj 또는 다른 Zig 명령을 호출한다. 빌드 바이너리의 첫 번째 필수 위치 인자가 Zig CLI의 경로라는 점을 떠올려보라. 이것이 그 인자의 주된 사용 사례다.
중요한 점은, 이는 빌드 바이너리가 전체 Zig 컴파일러를 내장하지 않는다는 뜻이다. 더 나아가, 원한다면 빌드 바이너리가 서로 다른 Zig 버전을 가리키게 할 수도 있다!
Zig 빌드 시스템의 내부를 공부하면서 얻은 가장 큰 결론은, _그냥 Zig라는 것_이다. build.zig 파일은 현재 시스템을 위한 완전한 실행 파일로 컴파일되므로, 그 안에서 원하는 무엇이든 할 수 있다. Zig는 의견이 반영된 구조와 내장 단계들의 집합을 제공하지만, 프로젝트를 빌드하는 데에는 Zig의 모든 능력을 사용할 수 있다.
나는 우리가 매일 사용하는 도구의 한 층 아래를 이해하는 것이 더 나은 도구 사용자로 만들어 준다고 굳게 믿는다. 기계의 내부 동작을 드러내어 모든 신비를 걷어내며, 그리고 내부 동작은 대개 내가 예상했던 것보다 항상 더 단순하다는 사실을 자주 발견한다. 다음에 빌드 시스템으로 무언가를 할 수 있을지 고민하거나, 빌드 시스템이 원하는 대로 동작하지 않는 이유가 궁금해질 때, 이 더 깊은 지식이 더 빠르게 답에 도달하는 데 도움이 되길 바란다.
2022년 2월 24일
© 2026 Mitchell Hashimoto.