Zig의 comptime을 사용해 플랫폼/기능/빌드 구성에 따라 코드를 컴파일 단계에서 제외(비활성화)하는 패턴과 주의점을 설명한다.
2024년 9월 12일
이 글은 Zig comptime 활용 사례 시리즈의 일부입니다.
Zig에는 comptime이라는 매우 강력한 기능이 있습니다. Comptime을 사용하면 컴파일 시간에 Zig 코드를 실행할 수 있습니다. 이는 특별한 매크로 언어나 AST 조작이 아니라, 말 그대로 표준 Zig 코드가 컴파일 시간에 실행되는 것입니다. 유일한 실질적 제약은 comptime 코드는 부작용을 가질 수 없다는 점입니다(시스템 콜, IO 등 불가).
솔직히 말해, 제가 처음 Zig를 살펴보기 시작했을 때는 이게 ‘요란한 장난감’처럼 느껴졌습니다. “도대체 컴파일 시간에 뭘 그렇게까지 진짜로 하고 싶겠어?”라고 생각했죠. 하지만 만만치 않은 프로젝트에 Zig를 2년 정도 쓰고 나니, comptime은 곳곳에 사용되고 있으며 Zig에서 가장 좋아하는 기능이 되었습니다.
Comptime 자체는 큰 주제입니다. 그래서 comptime의 모든 것을 파고들기보다는, 특히 유용한 패턴 하나를 보여드리려고 합니다: comptime으로 코드를 조건부로 비활성화(제외)하기.
코드를 조건부로 비활성화하는 것은 소프트웨어 개발에서 흔한 패턴입니다. 아래는 여러분이 코드를 조건부로 비활성화하고 싶어질 만한 아주 흔한 이유들입니다.
Python이나 JavaScript 같은 동적 언어에서는 보통 런타임의 단순한 if 문으로 올바른 코드 경로를 선택할 수 있습니다. 동적 언어는 코드가 런타임에만 평가되므로, 동작하지 않을 수 있는 경로를 실제로 밟지 않게 할 수 있습니다.
하지만 컴파일 언어에서는, 런타임에 선택될 “가능성이 있는” 모든 코드 경로를 컴파일러가 컴파일하고 링크해야 하므로 if 문만으로는 코드를 조건부로 비활성화할 수 없습니다.
컴파일이 되게 만드는 것 외에도, 컴파일 시간에 코드를 아예 제외하면 바이너리 크기를 줄일 수 있고, 런타임 분기(조건문)를 피해서 성능 비용을 줄일 수도 있습니다.
다른 컴파일 언어들이 이 문제를 어떻게 다루는지 살펴보겠습니다. 이 부분에 관심이 없다면 다음 섹션으로 건너뛰어 Zig의 comptime으로 이 문제를 해결하는 방법을 보셔도 됩니다.
C 계열 언어에서는 전처리기를 이용해 코드를 조건부로 비활성화하는 경우가 많습니다. 예를 들면:
c#define FLAG void my_function() { #ifdef FLAG // FLAG가 정의되어 있을 때만 이 코드가 포함됩니다. #else // 그렇지 않으면 이 코드가 포함됩니다. #endif }
이는 사실상 컴파일 시간에 코드를 템플릿처럼 조립하는 것입니다. 전처리기가 먼저 실행되어(텍스트 수준에서) 코드를 변환한 뒤, 컴파일러가 그 결과를 컴파일합니다. 첫 번째 큰 단점은 전처리기가 자체 문법과 규칙을 가진 별도의 언어라는 점입니다.
두 번째 큰 단점은 전처리기가 매우 제한적이라는 점입니다. C 프로그래머들은 보통 “올바른 전처리기 정의를 생성하는” 능력 있는 빌드 시스템에 의존합니다. 그리고 그 빌드 시스템은 보통 이를 생성하기 위해 또 다른 언어를 사용합니다.
Go에서는 컴파일 시간 코드 제외를 파일 단위로 빌드 태그(build tag)로 수행합니다. 예를 들면:
go// +build mytag func myFunction() { // 이 코드는 파일이 "mytag" 빌드 태그로 빌드될 때만 포함됩니다. }
플랫폼별 코드의 경우 파일명 접미사도 사용할 수 있습니다. Go 튜토리얼이 아니므로 자세히는 다루지 않지만, 핵심은 조건부 컴파일이 파일 수준에서 이뤄진다는 점입니다.
이 접근에 대해서는 주관적 의견이 다양합니다. 제가 보기에 이 방식의 객관적으로 나쁜 부분은, 태그 포함 여부를 결정하기 위해 여전히 어떤 전처리 언어를 써야 하고, 이 결정이 대개 빌드 시스템, Makefile 등에 위임된다는 점입니다.
Zig의 comptime을 사용하면 Zig 자체로 코드를 조건부로 비활성화할 수 있습니다. 가장 단순한 플랫폼별 코드 예시는 다음과 같습니다.
zigconst builtin = @import("builtin"); fn myFunction() void { if (comptime builtin.os.tag == .macos) { // 이 코드는 타깃 OS가 macOS일 때만 포함됩니다. return; } // 이 코드는 그 외 모든 운영체제에 대해 포함됩니다. }
가장 먼저 눈에 띄는 점은 이 코드가 표준 Zig라는 것입니다. 전처리 언어나 특별한 문법이 없습니다. 두 번째로, Zig 컴파일러는 if 문이 탈출 조건(여기서는 return)으로 끝난다는 것을 알 수 있으므로, 조건이 자명하게 참이라면 다른 부분을 컴파일할 필요가 없다는 것도 압니다. 이 경우 타깃 OS가 macOS라면 컴파일러는 첫 번째 블록만 컴파일합니다.
comptime 키워드에 대한 참고: 위 예시에서 comptime 키워드는 사실 필요 없습니다. Zig는 조건에 필요한 모든 정보가 컴파일 시간에 알려져 있으면 자동으로 조건을 컴파일 시간에 평가합니다. builtin은 전부 컴파일 시간 상수이므로 comptime은 중복입니다. 다만 예시를 더 명확히 하기 위해 넣었습니다.
다음은 제가 작업 중인 실제 프로젝트에서 가져온 더 복잡한 예시입니다.
zigif ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&config) and adwaita.versionAtLeast(1, 4, 0)) { // 이 코드는 빌드 시점에 libadwaita를 사용할 수 있고 // (버전이 1.4.0 이상이며), 런타임에도 버전이 1.4.0 이상일 때만 // 포함됩니다. }
이 예시는 comptime이 표준 Zig라는 점이 얼마나 강력한지 보여줍니다. 여기서는 comptime 조건과 비-comptime(런타임) 조건을 섞고 있습니다. 첫 번째 조건은 comptime이고 나머지는 런타임 조건입니다.
첫 번째 comptime 조건은 빌드 구성이 libadwaita를 활성화했는지, 그리고 빌드 시점에 확보한 버전이 1.4.0 이상인지 확인합니다. 이것이 거짓이라면 Zig 컴파일러는 나머지 조건들이 어떤 값이더라도 블록이 포함될 수 없다는 것을 알게 되므로, 블록의 나머지 부분을 아예 컴파일하지 않습니다.
중요한 점은, 이 덕분에 런타임 조건과 코드 블록 안에서 adwaita 라이브러리를 사용해도, 라이브러리를 사용할 수 없거나 버전이 맞지 않거나(혹은 최신 버전에만 존재하는 타입을 참조하더라도) 컴파일 오류가 나지 않는다는 점입니다.
왜 adwaita.versionAtLeast(1, 4, 0)를 두 번 호출하나요? 첫 번째 호출은 빌드 시점에 libadwaita가 उपलब्ध(사용 가능)하고 버전이 1.4.0 이상인지 확인합니다. 두 번째 호출은 동적 링크된 라이브러리가 런타임에 최소 1.4.0인지 확인합니다. 이렇게 하면 실행 환경의 라이브러리 버전이 빌드한 시스템보다 낮을 수 있는 경우 등, 여러 조합을 다룰 수 있습니다.
이 점을 확실히 하기 위해 C에서 이를 어떻게 했을지 생각해 봅시다. C에서는 아마 CMake 같은 빌드 시스템으로 일회성 프로그램을 만들고, 이를 컴파일해 보면서 전처리기 플래그를 정의할지 말지를 결정했을 겁니다. 으.
comptime으로 코드를 조건부로 비활성화할 때 알아둬야 할 함정이 있습니다. 첫째, comptime 키워드를 사용하지 않으면 comptime은 함수 경계를 넘지 않습니다. 예를 들어 앞의 조건을 함수로 뽑아내면 동작하지 않습니다.
zigfn myFunction() void { if (hasFeature()) { // 기능 전용 코드. } else { // 기본 코드. } } fn hasFeature() bool { return false; }
위 예시에서 hasFeature는 자명하게 false를 반환하지만, 컴파일러는 조건문의 양쪽 브랜치 코드를 모두 빌드합니다. 이를 고치려면 함수 호출 앞에 comptime을 붙이면 됩니다.
zigfn myFunction() void { if (comptime hasFeature()) { // 기능 전용 코드. } else { // 기본 코드. } }
불행히도 hasFeature가 comptime 조건과 비-comptime 조건을 섞는다면 이 방법은 동작하지 않습니다. 그런 경우에는 함수를 인라인(inline)해야 합니다.
zigfn myFunction() void { if (hasFeature()) { // 기능 전용 코드. } else { // 기본 코드. } } inline fn hasFeature() bool { return (comptime comptimeCheck()) and runtimeCheck(); }
이제 기대한 대로 동작합니다. comptime 체크가 false이면 컴파일러는 기능 전용 코드를 포함하지 않습니다.
이 미묘한 동작은 놓치기 쉽습니다. 저는 이런 comptime 체크가 있을 때는 항상 CI에 양쪽 브랜치를 모두 추가해, 두 환경에서 모두 빌드가 되는지 확인합니다.
저는 comptime을 정말 좋아합니다. 갖기 전에는 필요하다는 걸 몰랐고, 다른 언어로 작업할 때는 그리워지는 기능입니다. 이 글에서는 comptime의 특정 사용 사례 하나만 보여줬지만, 설령 comptime이 이 용도 하나만으로도 가치가 있었다고 해도 그것만으로도 ‘킬러 기능’일 겁니다.
comptime을 이용한 조건부 컴파일 덕분에 저는 동일한 파일과 많은 공통 코드를 공유하면서도 크로스 플랫폼 코드를 작성할 수 있습니다. 플랫폼별 코드를 관리하기 위해 복잡한 빌드 시스템이나 외부 도구가 필요하지 않습니다. 그저 Zig 코드를 작성하면, 나머지는 컴파일러가 처리합니다.
다시 말하지만 comptime으로 할 수 있는 일은 훨씬 더 많고, comptime에는 강력한 성질이 더 많이 있습니다. 예를 들어 comptime에서의 타입은 타깃 시스템과 일치합니다(예: 포인터 크기가 정확함). 더 많은 내용이 있습니다! Zig 문서와 다른 블로그 글도 살펴보고, Zig를 한 번 써보시길 권합니다.
2024년 9월 12일
© 2026 Mitchell Hashimoto.