앞 글에서 CMake가 “어디서든 빌드된다”를 풀었다. 그러면 다음 질문은 무엇인가.
- 같은 입력이면 같은 결과가 나오는가
- 남이 이미 빌드한 것을 받아 쓸 수 있는가
Meson과 Bazel은 이 질문에 서로 다른 답을 낸다. 같은 textstat 프로젝트를 둘 다로 빌드해 보고, 각자 무엇을 겨냥하는지 본다.
Meson — 선택지를 줄여서 짧게
같은 프로젝트의 meson.build다. CMake 판 41줄, Makefile 44줄에 비해 27줄이다.
project('textstat', 'c',
version : '1.0.0',
default_options : ['c_std=c11', 'warning_level=2'])
conf = configuration_data()
conf.set('VERSION', meson.project_version())
configure_file(input : 'version.h.in', output : 'version.h', configuration : conf)
cc = meson.get_compiler('c')
m_dep = cc.find_library('m', required : false) # 확인한다
platform_src = host_machine.system() == 'windows' ? 'src/platform_win32.c' : 'src/platform_posix.c'
inc = include_directories('include')
textstat_lib = static_library('textstat', ['src/count.c', 'src/histogram.c', platform_src],
include_directories : inc, dependencies : m_dep)
textstat_dep = declare_dependency(link_with : textstat_lib, include_directories : inc)
executable('textstat', 'app/main.c', dependencies : textstat_dep)
test_count = executable('test_count', 'tests/test_count.c', dependencies : textstat_dep)
test('count', test_count)구성과 빌드는 이렇게 된다.
$ meson setup build-meson
Project name: textstat
C compiler for the host machine: cc (gcc 11.4.0)
Library m found: YES
Found ninja-1.10.1 at /usr/bin/ninja
$ meson compile -C build-meson
[8/8] Linking target test_count
INFO: autodetecting backend as ninja
$ meson test -C build-meson
1/1 count OK 0.00s
Ok: 1declare_dependency가 CMake의 PUBLIC 사용 요구사항과 같은 일을 한다. 라이브러리를 쓰는 쪽은 dependencies : textstat_dep 한 줄이면 헤더 경로와 링크가 따라온다.
Meson의 포커스 — 결정을 대신 내려 준다
Meson이 겨냥하는 건 선택의 제거다.
- 백엔드를 고르게 하지 않는다. 구성하면 알아서 Ninja를 찾아 쓴다(
autodetecting backend as ninja). CMake처럼 제너레이터를 고르고 그에 따라 단일/다중 구성이 갈리는 일이 없다 - 관례를 기본값으로 박아 둔다.
warning_level=2,c_std=c11처럼 흔히 쓰는 것을 옵션 이름으로 제공한다. 컴파일러별 플래그를 사람이 알 필요가 없다 compile_commands.json을 항상 만든다. 켜는 옵션조차 없다- 문법이 파이썬을 닮았지만 튜링 완전하지 않다. 임의 코드 실행을 막아 빌드 정의가 스크립트로 자라는 것을 구조적으로 억제한다
대가는 명확하다. CMake만큼 뭐든 되지는 않는다. 특이한 툴체인이나 생성 단계가 필요하면 우회로를 찾아야 하고, Visual Studio·Xcode 프로젝트 생성 지원은 CMake보다 약하다. GNOME·systemd·Mesa처럼 유닉스 중심 프로젝트에서 많이 쓰이는 것이 우연이 아니다.
Bazel — 선언한 것만 존재한다
Bazel은 파일이 둘이다. 모듈 선언과 빌드 규칙이 나뉜다.
# MODULE.bazel
module(name = "textstat", version = "1.0.0")
bazel_dep(name = "rules_cc", version = "0.1.1")# BUILD.bazel
load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test")
cc_library(
name = "textstat_lib",
srcs = ["src/count.c", "src/histogram.c", "src/platform_posix.c"],
hdrs = ["include/textstat.h"],
includes = ["include"],
linkopts = ["-lm"],
)
genrule(
name = "version_h",
srcs = ["version.h.in"],
outs = ["gen/version.h"],
cmd = "sed s/@VERSION@/1.0.0/ $< > $@",
)
cc_binary(
name = "textstat",
srcs = ["app/main.c", ":version_h"],
includes = ["gen"],
deps = [":textstat_lib"],
)
cc_test(name = "test_count", srcs = ["tests/test_count.c"], deps = [":textstat_lib"])$ bazel build //...
INFO: 20 processes: 10 internal, 10 linux-sandbox.
INFO: Build completed successfully, 20 total actions
$ bazel test //...
//:test_count PASSED in 0.0s
Executed 1 out of 1 test: 1 test passes.linux-sandbox라는 말에 이 도구의 성격이 다 들어 있다. 각 컴파일이 격리된 디렉터리에서 돌고, 그 안에는 내가 선언한 파일만 놓인다.
처음 쓰면 걸리는 것 둘
하나. 최신 Bazel에는 cc_library가 내장돼 있지 않다. 그냥 쓰면 이렇게 거절한다(Bazel 9.2.0).
This rule has been removed from Bazel. Please add a `load()` statement for it.
규칙 집합을 MODULE.bazel에서 의존성으로 선언하고 load()로 가져와야 한다. C/C++조차 외부 규칙으로 뺀 것은, 빌드 규칙을 언어별 패키지로 다루겠다는 방향이다.
둘. 넓은 include 경로를 아예 거부한다. CMake 판을 옮기면서 includes = ["."]를 썼더니 빌드가 아니라 분석 단계에서 멈췄다.
Error in fail: attribute includes: '.' resolves to the workspace root, which would
allow this rule and all of its transitive dependents to include any file in your
workspace. Please include only what you need
make도 CMake도 -I.에 아무 말을 하지 않는다. Bazel은 “그러면 이 규칙과 그 후손 전부가 워크스페이스의 어떤 파일이든 포함할 수 있게 된다”며 막는다. 그래서 생성 헤더를 gen/ 아래로 내리고 includes = ["gen"]으로 좁혔다.
밀폐성 — 선언하지 않은 것은 없는 셈이다
이 차이가 실제로 무엇을 잡아내는지 재 봤다. src/count.c에 어디에도 선언하지 않은 헤더를 몰래 포함했다.
#include "textstat.h"
#include "sneaky.h" /* BUILD.bazel의 hdrs에도, CMakeLists.txt에도 없다 */| 도구 | 결과 |
|---|---|
| CMake (Ninja) | 빌드 성공. -Iinclude가 열려 있으니 컴파일러가 찾아서 쓴다 |
| Bazel | 실패. src/count.c:2:10: fatal error: sneaky.h: No such file or directory |
Bazel이 못 찾은 이유는 파일이 없어서가 아니다. 샌드박스 안에 그 파일을 넣어 주지 않았기 때문이다. 선언과 실제 사용이 어긋난 순간 빌드가 깨진다.
이게 성가신 규율처럼 보이지만, 이 규율이 있어야 다음 단계가 성립한다. 입력이 정확히 무엇인지 알면 출력이 재현되고, 재현되면 캐시할 수 있다.
캐시 — 빌드를 빠르게 하는 게 아니라 안 하는 것
--disk_cache로 로컬에 캐시를 두고, 빌드 산출물을 통째로 지운 뒤 다시 빌드했다.
$ bazel build --disk_cache=~/bzl-cache //... # 한 번 채운다
$ bazel clean # 산출물 전부 삭제
$ bazel build --disk_cache=~/bzl-cache //...
INFO: 20 processes: 10 disk cache hit, 10 internal.
INFO: Elapsed time: 1.008sclean 직후인데 컴파일을 하나도 하지 않았다. 10개 액션이 전부 캐시 히트다. 이 캐시를 로컬 디스크가 아니라 팀이 공유하는 서버에 두는 것이 원격 캐시이고, 실행 자체를 남의 기계에 맡기는 것이 원격 실행이다. 수천 명이 한 저장소를 쓰는 규모에서 “내가 처음 빌드하는 커밋”이 드물다는 사실을 이득으로 바꾼다.
Bazel의 포커스 — 규모에서의 재현성
정리하면 Bazel이 겨냥하는 지점은 문법 편의가 아니다.
- 밀폐성 — 호스트에 무엇이 깔려 있든 결과가 같도록 입력을 못 박는다(샌드박스, 선언 강제, 넓은 경로 거부)
- 재현성 — 그 위에서 같은 입력이면 같은 출력이 보장된다
- 캐시와 원격 실행 — 재현되므로 남의 결과를 받아 써도 안전하다
- 규모 — 위 셋이 있어야 소스 수만 개, 사용자 수천 명에서 빌드 시간이 선형으로 늘지 않는다
대가는 이 글의 실측에 그대로 나온다. 아무것도 바뀌지 않았을 때조차 Bazel은 1초쯤 쓴다. 그래프를 검증하고 서버와 통신하는 비용이다.
재현성은 도구만으로 오지 않는다
밀폐성을 갖춘 도구를 써도, 소스와 플래그가 재현성을 깨뜨리면 소용없다. 도구와 무관한 층을 따로 재 봤다.
같은 소스를 두 번 빌드하면 바이너리가 같았다(Release, gcc 11.4).
r1: 3dabd0b5ef8383c8
r2: 3dabd0b5ef8383c8 → 같다
소스 경로를 바꾸면 깨진다. Debug 빌드에서 assert가 __FILE__을 박기 때문이다.
원본 경로 Debug: 6a75989ccc752b80
복사 경로 Debug: de6ea1b02246a8fc → 다르다
$ strings 원본/test_count | grep test_count.c
/home/ewanshin/textstat/tests/test_count.c
$ strings 복사/test_count | grep test_count.c
/tmp/tscopy2/tests/test_count.c
__DATE__·__TIME__을 쓰면 당연히 깨진다.
1회차: 62c3f061b592ec1d
2회차: 470559a057cc8316 → 다르다 (컴파일 시각이 박혔다)
그래서 재현 가능한 빌드를 원하면 도구를 고르는 것과 별개로 경로를 지우고(-ffile-prefix-map), 시각·호스트명 같은 것을 소스에 넣지 않고, 빌드 ID는 VCS 커밋 해시처럼 결정적인 값으로 만들어야 한다. Release 빌드에서 assert가 사라져 경로 문제가 안 보이는 것도 함정이다 — 디버그 빌드에서만 재현이 깨진다.
비용 — 아무것도 안 바뀌었을 때
같은 프로젝트에서 “할 일 없음”을 판정하는 데 걸리는 시간이다. 증분 빌드에서 사람이 가장 자주 기다리는 구간이다.
| 실행 방식 | no-op 소요 |
|---|---|
ninja (직접 호출) | 1~6 ms |
make (CMake 생성) | 44~48 ms |
meson compile | 123 ms |
bazel build | 900~1050 ms |
읽는 법에 주의가 필요하다. meson compile은 파이썬 래퍼가 Ninja를 부르는 구조라, 같은 빌드 디렉터리에 ninja를 직접 부르면 1 ms로 떨어진다. 즉 Meson의 실행 지연은 래퍼 비용이고 백엔드는 Ninja 그대로다. Bazel의 1초는 성격이 다르다 — 액션 그래프 전체를 검증하고 캐시 상태를 확인하는 값이다. 파일 여덟 개짜리 프로젝트에서는 순손실이고, 파일 수만 개에서는 그 검증이 곧 이득이 된다.
빌드 정의의 분량도 같이 재 뒀다.
| 파일 | 줄 수 |
|---|---|
Makefile | 44 |
CMakeLists.txt | 41 |
BUILD.bazel + MODULE.bazel | 29 + 2 |
meson.build | 27 |
줄 수는 큰 차이가 아니다. 차이는 그 줄들이 무엇을 적고 있는가다 — make는 실행할 명령을, CMake·Meson은 무엇이 무엇인지를, Bazel은 그 위에 “입력이 정확히 무엇인지”를 적는다.
그래서 무엇을 고르나
| 상황 | 고를 것 |
|---|---|
| 파일 몇 개, 한 플랫폼, 단발 자동화 | make. 규칙 세 줄로 시작한다 |
| 여러 OS + IDE(Visual Studio·Xcode)까지 | CMake. 사실상 표준이고 생성기 폭이 가장 넓다 |
| 유닉스 중심, 빌드 정의를 짧고 읽기 쉽게 | Meson. 결정을 대신 내려 주고 Ninja를 기본으로 쓴다 |
| 저장소가 크고 CI 캐시를 팀이 공유해야 함 | Bazel. 밀폐성을 강제하는 대가로 캐시를 얻는다 |
| 재현 가능한 릴리스 산출물이 필요 | 도구와 별개로 경로·시각을 소스에서 몰아내는 작업이 먼저 |
시리즈를 닫으며
1976년 make가 던진 질문은 “무엇을 다시 만들어야 하는가” 였다. 답은 타임스탬프였다.
Autotools는 여기에 “이 기계에서는 무엇이 되는가”를 더했고, CMake는 “어느 빌드 도구로도 뽑아낼 수 있게” 만들었다. Ninja는 판정 속도만 남겼고, Meson은 그 위에서 사람이 고를 것을 줄였다.
Bazel의 질문은 한 걸음 더 갔다. “이걸 이미 누가 만들지 않았나.” 그 질문이 성립하려면 입력이 무엇인지 정확히 알아야 하고, 그래서 밀폐성이 규율이 된다. 이 시리즈가 textstat 하나로 확인한 것은 결국 그 계보다 — 각 도구는 이전 도구가 남긴 질문에 답하려고 만들어졌다.
이 글의 모든 명령과 출력은 WSL2 우분투 22.04에서 직접 실행한 것이다. Bazel 9.2.0(bazelisk 1.29.0), Meson 1.7.0, Ninja 1.10.1, GNU Make 4.3, CMake 3.22.1, gcc 11.4.0. Bazel과 Meson은 sudo 없이 사용자 디렉터리에 내려받아 돌렸다. 원격 캐시·원격 실행은 서버가 필요해 직접 재지 못했고, 로컬
--disk_cache로 같은 메커니즘의 동작만 확인했다.
참고
- Bazel — Intro to Bazel — 샌드박스를 통한 재현성, 10만 개 이상 소스 규모, 이전 작업 캐싱에 대한 서술을 확인했다.
- Bazel — Remote caching — 액션 단위 캐시를 팀이 공유하는 구조와
--disk_cache의 관계를 확인했다. - Meson 문서 — 백엔드 자동 선택,
default_options로 제공하는 관례 값,declare_dependency의 역할을 확인했다. - rules_cc — 최신 Bazel에서
cc_*규칙이 내장이 아니라 외부 모듈로 제공된다는 점을 확인했다. - Reproducible Builds — 타임스탬프·경로·환경이 산출물에 새는 전형적 경로와 대처(
-ffile-prefix-map등)를 참고했다.