Zig 빌드 시스템 내부 구조
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)에 접근할 수 있습니다. 이 API는 무엇을 어떤 순서로 실행할지 빌드 시스템이 판단하는 데 사용하는, 대부분 선언적인 API입니다.
zig build로 Zig 프로젝트를 빌드해 본 적이 있다면, 때때로 두 번 컴파일되는 것처럼 보이는 현상을 눈치챘을지도 모릅니다. 의미 분석과 코드 생성 출력이 두 번 보이는 것입니다. 캐시되지 않은 프로젝트에서는 실제로 두 번 컴파일되기 때문입니다. 먼저 빌드 바이너리를 컴파일한 뒤, 그 빌드 바이너리가 다시 프로젝트 코드를 컴파일합니다.
방금 언급한 대로, zig build CLI는 먼저 build.zig 파일을 현재 시스템용 빌드 바이너리로 컴파일한 뒤, 이 바이너리를 자식 프로세스로 실행해 실제 빌드를 수행합니다. 빌드 바이너리는 Zig 캐시 시스템의 혜택을 받으므로, build.zig(혹은 임포트한 파일)가 수정되지 않는 한 이후의 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를 임포트합니다. 이는 cmdBuild가 여러분의 build.zig 파일을 가리키도록 정의한 특수 패키지입니다. 빌드 러너가 결국 여러분의 build.zig 파일에 있는 build 함수를 실행하는 방식이 바로 이것입니다.
최종 빌드 바이너리는 어디에도 설치되지 않습니다. Zig 캐시 디렉터리(일반적으로 build.zig 파일 기준 zig-cache)에 저장됩니다. 다음처럼 직접 찾아보면 빌드 바이너리가 실제로 존재함을 확인할 수 있습니다.
$ find ./zig-cache -type f -name 'build'
./zig-cache/o/c4c75a71df444bff10945728759e174c/build
“build_runner.zig” 찾기
zig build는 시스템에서 lib/std/special/build_runner.zig의 위치를 어떻게 알 수 있을까요?
src/introspect.zig라는 파일에 Zig 설치 경로를 찾는 데 사용되는 API가 있습니다. 현재 실행 파일이 있는 디렉터리에서 시작해 상위로 탐색하면서 lib/zig/std/std.zig 혹은 lib/std/std.zig를 찾을 때까지 올라가는 방식입니다. zig build를 실행하면 zig 바이너리가 있는 디렉터리에서 시작해 이 파일들을 찾기 시작합니다.
참고: 프로젝트에서 @import("std")가 해석되는 방식도 이와 같습니다. 구체적으로, 빌드 과정에서 zig CLI의 introspection API를 이용해 std 패키지가 미리 std.zig를 가리키도록 정의됩니다.
stdlib를 찾는 함수의 구현은 아래와 같습니다. 이 디렉터리를 알면 표준 Zig 설치 내의 어떤 파일이든 가져올 수 있습니다.
fn 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가 설치되어 있어야 합니다(가급적 체크아웃한 소스로 직접 컴파일한 버전이 이상적이지만, 최근 버전이라면 어떤 것이든 동작합니다). 다음으로 체크아웃한 디렉터리에서 빌드 바이너리를 빌드할 수 있습니다.
$ 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는 자식 프로세스를 생성해 즉시 실행합니다.
바이너리는 다음 순서대로 네 개의 위치 인자를 기대합니다.
- Zig 컴파일러 경로
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의 내부 동작을 설명하기 위해 빌드 바이너리를 수동으로 실행해 보는 것뿐입니다.
$ ./custom-builder $(which zig) . ./cache ./global-cache
예시로 Zig 컴파일러를 사용했지만, 이 패턴은 Zig 빌드 시스템을 사용하는 어떤 프로젝트에서도 동일하게 동작합니다.
지원하는 인자와 플래그
빌드 바이너리는 zig build가 지원하는 거의 모든 플래그를 지원합니다. 사실 zig build는 빌드 바이너리 빌드 과정을 직접 제어하는 일부 플래그를 제외하고, 모든 인자를 그대로 빌드 바이너리 자식 프로세스에 복사해 전달합니다.
네 개의 필수 위치 인자와 함께 --help 플래그를 지정해 빌드 바이너리를 실행하면 이를 직접 확인할 수 있습니다. 이전 절에서 빌드 바이너리를 직접 빌드했다면 지금 바로 시도해 볼 수 있습니다.
$ ./custom-builder $(which zig) . ./cache ./global-cache --help
네 개의 위치 인자는 zig가 PATH에 있고 현재 작업 디렉터리가 build.zig 파일이 있는 빌드 루트라고 가정합니다.
도움말은 zig build --help와 거의 동일하게 보일 것입니다. 당연합니다! zig build --help 역시 먼저 빌드 바이너리를 빌드한 뒤 --help 플래그를 자식 프로세스에 전달하기 때문입니다. 출력은 정확히 일치해야 합니다.
빌드 함수 호출하기
제가 특히 흥미롭게 본 부분은 빌드 러너가 build.zig 파일의 build 함수를 호출하는 방식이었습니다. 빌드 러너는 Zig의 컴파일타임(comptime) 기능을 이용해 build 함수의 시그니처를 introspection하여 여러 함수 시그니처를 지원합니다.
동작 방식을 보여주기 위해 build_runner.zig의 runBuild 함수를 아래에 재현했습니다.
fn 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 인자는 많은 기능을 가지고 있지만, 핵심 목표는 스텝들의 집합을 구성하는 것입니다.
‘최상위 스텝(top level step)’은 이름으로 호출할 수 있는 스텝에 대한 특별한 구분입니다. 즉, zig build <name> 형태로 호출할 수 있습니다. 미리 정의된 최상위 스텝은 ‘install’과 ‘uninstall’ 두 가지가 있습니다. 추가 최상위 스텝은 step 함수로 만들 수 있습니다. 호출 가능한 이름이 지정된다는 점을 제외하면 최상위 스텝은 다른 스텝과 기능적으로 동일합니다. Builder는 최상위 스텝들의 집합을 ArrayList로 유지합니다.
모든 스텝은 Step에서 dependOn 함수를 호출해 지정하는 0개 이상의 의존성을 가질 수 있습니다. 이 의존성들은 단순한 ArrayList로 유지됩니다.
최상위 스텝 호출하기
하나 이상의 최상위 스텝은 Builder의 make 함수를 호출해 실행됩니다. 이 함수는 내부적으로 단일 최상위 스텝을 만드는 makeOneStep을 호출합니다. makeOneStep은 매우 단순하며 전체 구현은 아래와 같습니다.
fn 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 함수 포인터에 캡슐화되어 있고, 나머지 필드는 공유 상태입니다.
pub 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).
이러한 지식을 갖추면 커스텀 스텝을 만드는 방법이 방향성 측면에서는 꽤 명확해져야 합니다. 내장 스텝들은 대부분의 프로젝트를 빌드하는 데 필요한 모든 기능을 제공합니다.
선언적 vs. 명령적
build 함수는 스텝들의 집합과 그 의존성을 정의하지만, 실행하지는 않습니다. 이를 표현하는 한 가지 방법은 build.zig 파일이 빌드 스텝을 선언적으로 정의한다고 말하는 것입니다. 이는 Zig를 포함한 빌드 시스템 전반에서 사람들이 흔히 저지르는 실수를 피하기 위해 이해해야 할 중요한 개념입니다.
스텝이 선언적으로 정의되므로, 로직이 실제로 언제 어디서 발생하는지 신중하게 이해해야 합니다. 이전 스텝에서 생성된 파일을 읽는 스텝이 있다면, 이는 makeFn을 가진 커스텀 스텝을 이용해 수행해야 합니다. 스텝을 생성한 뒤 build 함수 안에서 파일을 읽을 수는 없습니다. 아직 실행되지 않았기 때문입니다.
반면, 빌드 스텝이 실행되기 전에 이미 존재하는 파일들에 대해 일련의 스텝을 프로그래밍 방식으로 생성하려는 경우라면, 스텝이 완전히 정의되도록 해당 작업을 build 함수 안에서 직접 수행할 수 있고, 또 그렇게 해야 합니다. 빌드 실행 시점에 스텝을 동적으로 정의할 수는 없습니다.
심화 참고: 실행 시점에 기존 스텝 그래프에 스텝을 동적으로 정의할 수는 없습니다. 하지만 다른 스텝들을 동적으로 생성해 직접 실행하는 커스텀 스텝을 만들 수는 있습니다.
컴파일 스텝
이제 build.zig가 스텝을 어떻게 정의하고, 그 스텝들이 어떻게 구성되며 어떻게 호출되는지 이해했습니다. Zig의 Builder 구조체에는 실행 파일, 오브젝트 등을 빌드하기 위한 고수준 헬퍼가 있습니다. 그 동작 방식을 좀 더 자세히 살펴보겠습니다.
컴파일과 관련된 모든 기능은 LibExeObjStep이라는 단일 구현을 공유합니다. 스텝 구조체의 필드 값에 따라 실행 파일, 라이브러리 혹은 다른 오브젝트 타입이 빌드됩니다. 이 세부 사항은 보통 addExecutable이나 addSharedLibrary 같은 헬퍼 뒤에 숨겨져 있습니다.
LibExeObjStep의 구현은 lib/std/build.zig에서 찾을 수 있습니다. 이 스텝은 기능이 풍부하고 빌드 과정에서 매우 중요한 부분이므로, 코드 양이 비교적 많지만 전체 스텝을 공부해 보시길 적극 권합니다.
스텝 구현은 zig 컴파일러를 다시 자식 프로세스로 호출하는 방식으로 동작합니다. make 구현은 스텝에 설정된 구성을 가져와 커맨드라인 인자 집합을 만들고, 그 뒤 zig build-exe나 zig build-obj 혹은 다른 Zig 명령을 호출합니다. Zig CLI 경로는 빌드 바이너리에 필요한 첫 번째 위치 인자였다는 점을 기억하시기 바랍니다. 이것이 그 인자의 주된 사용처입니다.
중요한 점은, 이는 빌드 바이너리가 전체 Zig 컴파일러를 내장하지 않는다는 의미라는 것입니다. 더 나아가 원한다면 빌드 바이너리가 다른 버전의 Zig를 가리키도록 할 수도 있습니다!
마무리
Zig 빌드 시스템의 내부를 공부하며 얻은 저의 가장 큰 깨달음은 결국 그냥 Zig라는 점입니다. build.zig 파일은 현재 시스템을 위한 완전한 실행 파일로 컴파일되므로, 그 안에서 원하는 무엇이든 할 수 있습니다. Zig는 나름의 구조와 일련의 내장 스텝을 제공하지만, 프로젝트를 빌드하는 데 Zig의 모든 파워를 활용할 수 있습니다.
저는 우리가 매일 사용하는 도구 아래에 있는 레이어를 이해하면 더 나은 도구 사용자가 될 수 있다고 깊이 믿습니다. 기계의 내부 동작을 드러내면 어떤 신비도 사라지며, 저는 내부 동작이 언제나 예상보다 단순하다는 것을 알게 되었습니다. 다음에 빌드 시스템으로 무언가를 할 수 있을지 고민되거나 빌드 시스템이 원하는 대로 동작하지 않아 이유가 궁금할 때, 이번에 얻은 더 깊은 지식이 더 빠르게 답을 찾는 데 도움이 되기를 바랍니다.
글을 무작위로 읽기