Zig 빌드 시스템 내부 구조
원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기
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다.
예전에 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가 어디에 있는지 어떻게 알까?
Zig 설치 경로를 찾는 데 사용되는 API를 담은 src/introspect.zig 파일이 있다. 이 API는 현재 실행 파일이 있는 디렉터리에서 시작해 lib/zig/std/std.zig 혹은 lib/std/std.zig를 찾을 때까지 상위 디렉터리로 올라가며 탐색하는 방식으로 동작한다. zig build를 실행하면 zig 바이너리가 있는 디렉터리에서 시작해 이 파일들을 찾는다.
참고: 프로젝트에서 @import("std")가 해석되는 방식도 이와 같다. 구체적으로는 빌드 과정에서 zig CLI의 introspection API를 이용해 std 패키지가 미리 std.zig를 가리키도록 정의된다.
표준 라이브러리를 찾는 함수의 구현은 아래와 같다. 이 디렉터리로부터 표준 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는 단계가 정확히 한 번만 실행되도록 하며, 이후 실행은 아무 작업도 하지 않는다(noop).
이 지식을 바탕으로 커스텀 단계를 어떻게 만들 수 있을지 방향성은 분명해졌을 것이다. 내장된 단계들은 대부분의 프로젝트를 빌드하는 데 필요한 모든 기능을 제공한다.
선언형 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의 모든 파워를 활용할 수 있다.
나는 매일 사용하는 도구 아래에 있는 계층을 이해하는 것이 우리를 더 나은 도구 사용자로 만든다고 깊이 믿는다. 기계의 내부를 드러내 미스터리를 없애주며, 나는 그 내부가 항상 예상보다 단순하다는 것을 알게 된다. 다음에 빌드 시스템으로 무언가를 할 수 있을지, 혹은 왜 빌드 시스템이 원하는 대로 동작하지 않는지 궁금해질 때, 이 더 깊은 지식이 더 빨리 답을 찾는 데 도움이 되기를 바란다.
글을 무작위로 읽기
댓글
로그인하고 댓글 남기기