Ninja vs Make, 그리고 CMake·Meson: 무엇이 실제로 빌드를 빠르게 하는가
이 글의 핵심
Ninja가 Make보다 빠르다는 말은 반만 맞습니다. 컴파일이 대부분인 클린 빌드에서는 차이가 작고, 차이가 크게 나는 곳은 아무것도 안 바뀐 빌드와 파일 몇 개만 바뀐 증분 빌드입니다. 이 글은 그 이유(설계 차이), Make가 조용히 틀리는 지점(플래그 변경 미감지, 헤더 의존성, -j 기본값), CMake·Meson이 둘 사이에서 맡는 역할, 그리고 링크 단계 메모리 폭주를 막는 잡 풀 같은 실무 설정을 정리합니다.
들어가며: “Ninja로 바꾸면 빨라진다”는 말의 정확한 의미
C++ 프로젝트를 하다 보면 CMake, Make, Ninja, Meson을 한꺼번에 만나게 되고, 가장 자주 듣는 조언은 “Make 말고 Ninja 써라, 훨씬 빠르다”입니다. 틀린 말은 아니지만, 이 말을 그대로 믿고 전환한 뒤 “클린 빌드 시간이 거의 그대로인데?”라고 실망하는 경우를 여러 번 봤습니다. 저도 처음엔 그랬습니다. 빌드 시간의 대부분은 컴파일러가 쓰고, 빌드 도구가 바꿀 수 있는 부분은 그 바깥쪽뿐이기 때문입니다.
이 글은 도구 소개보다 어디서 차이가 나고 어디서는 안 나는지에 초점을 둡니다.
- 네 도구의 역할 구분 (빌드 도구 vs 빌드 파일 생성기)
- Ninja와 Make의 실제 차이: 속도, 정확성, 병렬 기본값, 출력, Windows
- 증분 빌드·병렬 실행·캐시의 내부 동작
- CMake·Meson에서 백엔드를 고르는 법과 흔한 함정
글에 속도 수치 표는 넣지 않았습니다. 빌드 시간은 코드베이스, 헤더 구조, 디스크, 코어 수에 따라 몇 배씩 달라져서 남의 벤치마크 숫자는 거의 참고가 되지 않습니다. 대신 자기 프로젝트에서 직접 재는 방법을 적었습니다.
역할 구분: 빌드 도구와 빌드 파일 생성기
빌드 시스템이 하는 일은 결국 네 가지입니다. 무엇이 무엇에 의존하는지 파악하고(의존성 그래프), 바뀐 것만 다시 만들고(증분 빌드), 서로 독립적인 작업을 동시에 돌리고(병렬), 운영체제·컴파일러 차이를 감추는 것(이식성)입니다.
flowchart LR
A[소스 코드] --> B[전처리]
B --> C[컴파일]
C --> D[링킹]
D --> E[실행 파일]
F[빌드 시스템] -.관리.-> A
F -.관리.-> B
F -.관리.-> C
F -.관리.-> D
이 네 가지를 한 도구가 다 하지 않는다는 점이 혼란의 원인입니다.
- 빌드 도구: 규칙 파일을 읽고 실제로 컴파일러를 호출합니다. Make, Ninja가 여기에 속합니다.
- 빌드 파일 생성기: 프로젝트 설명을 읽고 빌드 도구가 쓸 파일을 만듭니다. CMake, Meson이 여기에 속합니다.
CMakeLists.txt / meson.build (사람이 쓰는 프로젝트 설명)
↓ cmake / meson setup ← configure 단계
Makefile 또는 build.ninja (기계가 쓰는 빌드 규칙)
↓ make / ninja ← build 단계
실행 파일
그래서 “CMake vs Ninja”는 엄밀히 비교 대상이 아닙니다. 실무의 질문은 보통 두 개로 나뉩니다. 프로젝트 설명을 무엇으로 쓸 것인가(CMake, Meson, 아니면 손으로 쓴 Makefile)와 그걸 무엇으로 실행할 것인가(Make, Ninja, MSBuild)입니다.
Make
Make는 1976년 벨 연구소에서 나온, 지금도 쓰이는 가장 오래된 빌드 도구입니다. 실무에서 “Make”라고 하면 대개 GNU Make를 가리키며, 사람이 직접 읽고 쓸 수 있는 규칙 언어라는 점이 가장 큰 장점입니다.
# Makefile
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra -O2
# -MMD: 컴파일하면서 이 소스가 실제로 include한 헤더 목록(.d)을 생성
# -MP : 헤더가 삭제돼도 "규칙 없음" 에러가 나지 않도록 빈 규칙 추가
DEPFLAGS = -MMD -MP
SRCS = main.cpp utils.cpp
OBJS = $(SRCS:.cpp=.o)
all: main
main: $(OBJS)
$(CXX) $(CXXFLAGS) -o $@ $^
%.o: %.cpp
$(CXX) $(CXXFLAGS) $(DEPFLAGS) -c $< -o $@
-include $(OBJS:.o=.d)
clean:
rm -f $(OBJS) $(OBJS:.o=.d) main
.PHONY: all clean
처음 Makefile을 배울 때 흔히 보는 예제는 main.o: main.cpp utils.h처럼 헤더를 손으로 나열합니다. 이 방식은 헤더가 하나 늘 때마다 규칙을 고쳐야 하고, 빠뜨리면 헤더를 고쳐도 해당 오브젝트가 다시 컴파일되지 않습니다. 위처럼 -MMD -MP로 컴파일러가 만든 의존성 파일을 -include하는 것이 사실상 표준 패턴입니다.
make # 기본은 한 번에 작업 1개 (직렬)
make -j8 # 동시 작업 8개
make -j"$(nproc)" -O # GNU Make 4.0+: 병렬 출력이 섞이지 않게 작업 단위로 묶어서 출력
make -n # 실제로 실행하지 않고 실행할 명령만 출력
Make가 잘하는 것: 규칙이 단순하고 셸 명령을 그대로 쓸 수 있어서 C++ 빌드뿐 아니라 문서 생성, 배포 스크립트, 데이터 파이프라인 같은 “파일을 만드는 작업”을 엮는 데 여전히 편합니다. 유닉스 계열에는 거의 항상 설치되어 있습니다.
Make가 약한 것: 탭 문자로 시작해야 하는 레시피, 재귀 확장 변수(=)와 즉시 확장 변수(:=)의 차이, 셸마다 다른 동작 때문에 규모가 커지면 유지보수가 어려워집니다. Windows에서는 MinGW의 mingw32-make나 MSVC의 nmake를 써야 하는데, 레시피 안의 rm, mkdir -p 같은 유닉스 명령이 그대로 동작하지 않아서 “리눅스에선 되는데 Windows에선 안 되는 Makefile”이 흔합니다.
CMake
CMake는 C++ 생태계의 사실상 표준 빌드 파일 생성기입니다. 대부분의 오픈소스 C++ 라이브러리가 CMake 패키지 설정을 제공하고, Visual Studio·CLion·VS Code가 CMake 프로젝트를 직접 엽니다.
cmake_minimum_required(VERSION 3.20)
project(MyProject VERSION 1.0 LANGUAGES CXX)
add_executable(main main.cpp utils.cpp)
target_compile_features(main PRIVATE cxx_std_17)
target_include_directories(main PRIVATE include)
target_compile_options(main PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
find_package(Boost REQUIRED)
target_link_libraries(main PRIVATE Boost::headers)
경고 옵션을 -Wall -Wextra로 박아 두면 MSVC에서는 알 수 없는 옵션이 되므로, 위처럼 컴파일러별로 나누는 것이 크로스 플랫폼 CMake의 기본입니다. -O2 같은 최적화 옵션은 직접 넣기보다 CMAKE_BUILD_TYPE(Debug/Release/RelWithDebInfo)에 맡기는 편이 Debug 빌드에 최적화가 섞이는 사고를 막습니다.
# 생성기를 명시하지 않으면 리눅스·macOS는 Unix Makefiles,
# Windows는 설치된 Visual Studio가 기본값
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build # 생성기와 무관하게 같은 명령으로 빌드
cmake --build build -j 8 # 병렬도 지정 (CMake 3.12+)
매번 -G Ninja를 치기 귀찮다면 환경 변수 CMAKE_GENERATOR=Ninja(CMake 3.15+)를 설정하거나, CMakePresets.json에 생성기를 고정해 두면 팀 전체가 같은 설정을 씁니다. 기존 빌드 디렉터리의 생성기는 나중에 바꿀 수 없으므로, 생성기를 바꾸려면 새 빌드 디렉터리를 만들어야 합니다.
최근에 자주 만나는 함정: CMake 4.0(2025년 3월)부터 cmake_minimum_required(VERSION 3.5) 미만을 선언한 프로젝트와의 호환이 제거됐습니다. 오래된 서드파티 라이브러리를 add_subdirectory나 FetchContent로 가져오면 설정 단계에서 바로 에러가 납니다. 당장은 -DCMAKE_POLICY_VERSION_MINIMUM=3.5로 넘길 수 있지만, 근본적으로는 해당 라이브러리를 새 버전으로 올리는 것이 맞습니다.
CMake의 약점은 여전히 문법입니다. 변수·리스트·제너레이터 표현식이 섞이면 읽기 어렵고, 인터넷 예제의 절반은 include_directories() 같은 디렉터리 단위 옛 스타일이라 타겟 단위 스타일(target_*)과 섞이면 의도치 않은 옵션 전파가 생깁니다. 자주 나는 설정 에러는 CMake 에러 해결 가이드에 따로 정리했습니다.
flowchart LR
A[CMakeLists.txt] --> B[cmake configure]
B --> C{Generator}
C -->|Unix Makefiles| D[Makefile]
C -->|Ninja| E[build.ninja]
C -->|Visual Studio| F[.sln/.vcxproj]
D --> G[make]
E --> H[ninja]
F --> I[msbuild]
G --> J[실행 파일]
H --> J
I --> J
Ninja
Ninja는 Chromium 빌드를 위해 Evan Martin이 만든 빌드 도구입니다. 공식 매뉴얼이 설계 목표를 직접 밝히고 있는데, 요약하면 “고통스러울 만큼 단순해서 빠르다”와 “사람이 아니라 다른 프로그램이 입력 파일을 생성한다”입니다. 조건문, 와일드카드, 함수 같은 기능은 일부러 없고, 그런 결정은 전부 CMake나 Meson 같은 생성기가 미리 내려서 build.ninja에 평평하게 풀어 둡니다.
# build.ninja (보통 CMake·Meson이 생성)
cxx = g++
cxxflags = -std=c++17 -Wall -Wextra -O2
rule cxx
command = $cxx $cxxflags -MMD -MF $out.d -c $in -o $out
depfile = $out.d
deps = gcc
description = CXX $out
rule link
command = $cxx $in -o $out
description = LINK $out
build main.o: cxx main.cpp
build utils.o: cxx utils.cpp
build main: link main.o utils.o
deps = gcc가 핵심입니다. Ninja는 컴파일러가 만든 .d 파일을 읽은 뒤 자체 바이너리 데이터베이스(.ninja_deps)에 합쳐 넣고 .d 파일은 지웁니다. MSVC라면 deps = msvc로 /showIncludes 출력을 파싱합니다. 다음 빌드 때 수천 개의 .d 파일을 다시 파싱하지 않아도 되는 것이 증분 빌드가 빠른 이유 중 하나입니다.
ninja # 기본적으로 CPU 수 기준 병렬 실행
ninja -j 4 # 병렬도 제한
ninja -k 0 # 실패해도 가능한 작업은 끝까지 진행 (기본은 첫 실패에서 중단)
ninja -d explain # 각 타겟을 "왜" 다시 빌드하는지 출력
ninja -t compdb > compile_commands.json # clangd 등을 위한 컴파일 DB
ninja -t graph | dot -Tsvg > deps.svg # 의존성 그래프 시각화
ninja -d explain은 “아무것도 안 고쳤는데 매번 몇 개가 다시 빌드된다”는 문제를 추적할 때 가장 먼저 쓰는 옵션입니다. 대개 빌드할 때마다 타임스탬프를 갱신하는 코드 생성 스크립트가 원인으로 나옵니다.
Ninja vs Make: 실제로 무엇이 다른가
검색해서 이 글에 들어온 분들이 가장 궁금한 부분일 것 같아 따로 정리합니다.
속도: 차이가 나는 곳과 안 나는 곳
클린 빌드는 오브젝트 파일 수천 개를 컴파일하는 시간이 대부분이라, 두 도구에 같은 병렬도(make -j8 vs ninja -j8)를 주면 차이가 작습니다. 여기서 큰 차이가 난다면 거의 항상 Make를 -j 없이 쓰고 있었기 때문입니다. Make의 기본값은 직렬이고 Ninja의 기본값은 병렬이라, 아무 옵션 없이 비교하면 Ninja가 몇 배 빨라 보입니다.
차이가 진짜로 나는 곳은 빌드 도구 자체의 오버헤드가 드러나는 경우입니다.
- no-op 빌드(아무것도 안 바뀐 상태에서 다시 빌드): 규칙 파일을 읽고, 모든 파일의 상태를 확인하고, “할 일 없음”을 결정하는 시간. 타겟이 수만 개인 프로젝트에서 이 차이가 커집니다. Ninja가 탄생한 이유도 Chromium에서 이 시간이 개발 흐름을 끊을 만큼 길었기 때문입니다.
- 파일 한두 개만 바뀐 증분 빌드: 컴파일 자체는 몇 초인데 그 앞뒤의 판단 시간이 비중을 차지합니다.
- CMake가 생성한 Makefile: CMake의 Makefile 생성기는 디렉터리·타겟마다 재귀적으로
make를 호출하는 구조라, 손으로 쓴 단일 Makefile보다 오버헤드가 큽니다. “CMake+Make에서 CMake+Ninja로 바꿨더니 빨라졌다”는 경험의 상당 부분이 여기서 옵니다.
자기 프로젝트에서 재 보려면 이렇게 하면 됩니다.
cmake -S . -B build-make -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release
cmake -S . -B build-ninja -G Ninja -DCMAKE_BUILD_TYPE=Release
# 1) 클린 빌드: 반드시 같은 병렬도로 비교
time cmake --build build-make -j 8
time cmake --build build-ninja -j 8
# 2) no-op 빌드: 아무것도 고치지 않고 다시 실행
time cmake --build build-make
time cmake --build build-ninja
# 3) 증분 빌드: 자주 고치는 파일 하나를 touch
touch src/some_file.cpp
time cmake --build build-make -j 8
time cmake --build build-ninja -j 8
ccache를 쓰고 있다면 첫 번째 측정 전에 캐시를 비우거나 꺼야(CCACHE_DISABLE=1) 공정한 비교가 됩니다.
정확성: Make가 조용히 틀리는 지점
속도보다 제가 Ninja를 선호하는 진짜 이유는 이쪽입니다. GNU Make는 타겟을 만든 명령줄을 기억하지 않습니다. 수정 시간만 비교하기 때문에 CXXFLAGS에서 -O2를 -O0 -g로 바꿔도 소스가 그대로면 기존 오브젝트를 그대로 씁니다. 디버그 빌드로 바꿨다고 생각하고 gdb를 붙였는데 변수가 전부 <optimized out>으로 나와서 한참 헤맨 적이 있는데, 원인은 절반의 오브젝트가 예전 플래그로 만들어진 것이었습니다. -DENABLE_FEATURE처럼 매크로를 바꾼 경우는 더 위험해서, 서로 다른 설정으로 컴파일된 오브젝트가 링크되면 구조체 레이아웃이 어긋나 원인을 알기 어려운 크래시로 이어질 수 있습니다.
Ninja는 .ninja_log에 각 출력의 명령줄을 기록해 두고, 생성된 명령이 달라지면 해당 타겟을 다시 빌드합니다. 공식 매뉴얼 표현으로는 “출력은 그것을 만든 명령에 암묵적으로 의존한다”입니다. CMake가 생성한 Makefile은 flags.make 같은 파일을 의존성으로 걸어서 이 문제를 상당 부분 막아 주지만, 손으로 쓴 Makefile이라면 직접 대비해야 합니다. 흔한 방법은 플래그 문자열을 파일에 써 두고 내용이 바뀔 때만 그 파일을 갱신해, 모든 오브젝트의 선행조건으로 거는 것입니다.
병렬 기본값과 -j의 함정
| GNU Make | Ninja | |
|---|---|---|
| 옵션 없이 실행 | 직렬 (작업 1개) | CPU 수 기준 병렬 |
-j 숫자 없이 | 제한 없음 | (숫자 필수) |
| 출력 | 병렬 시 섞임, -O(4.0+)로 묶기 | 항상 작업 단위로 버퍼링 |
| 첫 실패 시 | 중단, -k로 계속 | 중단, -k 0으로 계속 |
make -j를 숫자 없이 쓰면 “코어 수만큼”이 아니라 동시 작업 수 무제한입니다. 큰 프로젝트에서 이렇게 돌리면 수백 개의 컴파일러 프로세스가 동시에 떠서 메모리가 바닥나고, 시스템이 스왑으로 멈춰 버립니다. 반대로 Ninja는 기본이 병렬이라 “옵션 안 줬는데 왜 노트북이 뜨거워지지”라는 반응이 나오기도 합니다.
에러를 찾을 때는 출력 방식 차이가 생각보다 큽니다. make -j16에서 에러가 나면 다른 파일의 경고와 에러 메시지가 한 줄씩 섞여서 어느 파일의 에러인지 읽기 어렵습니다. Ninja는 명령 출력을 항상 버퍼링했다가 작업이 끝나면 한꺼번에 찍기 때문에, 실패한 명령줄 바로 아래에 그 명령의 에러가 모여서 나옵니다.
메모리: 링크 단계와 잡 풀
Ninja 전환 후 가장 흔한 사고는 CI에서 링크 단계가 OOM으로 죽는 것입니다. 컴파일 작업 하나는 수백 MB로 끝나도, LTO를 켠 링크나 디버그 정보가 큰 바이너리의 링크는 작업 하나가 몇 GB를 쓸 수 있습니다. 테스트 실행 파일이 수십 개인 프로젝트에서 이런 링크가 기본 병렬도로 동시에 돌면 러너의 메모리를 금방 넘깁니다.
-j를 통째로 낮추면 컴파일까지 느려지므로, 링크만 제한하는 것이 낫습니다. Ninja의 pool 기능을 CMake에서 이렇게 씁니다.
# 링크 작업은 동시에 최대 2개만
set_property(GLOBAL PROPERTY JOB_POOLS link_pool=2)
set(CMAKE_JOB_POOL_LINK link_pool)
이 설정은 Ninja 계열 생성기에서만 동작하고 Makefile 생성기에서는 무시됩니다. Make에는 이런 작업 종류별 제한 기능이 없어서, 같은 문제를 Make로 풀려면 전체 -j를 낮추는 수밖에 없습니다.
jobserver: 빌드 안의 빌드
GNU Make의 jobserver는 최상위 make -j8이 하위 make들과 작업 토큰 8개를 나눠 쓰게 하는 장치입니다. 문제는 Make 빌드 안에서 Ninja를 호출하는 경우(예: 상위 Makefile이 서드파티 CMake 프로젝트를 빌드)였습니다. Ninja는 오랫동안 jobserver를 지원하지 않아서, 상위 Make가 8개로 제한해도 하위 Ninja가 따로 CPU 수만큼 작업을 띄워 전체 동시 작업 수가 폭증했습니다.
이 요청은 2016년에 이슈로 올라온 뒤 9년 가까이 열려 있다가 Ninja 1.13(2025년)에서 jobserver 클라이언트 지원이 들어갔습니다. 조건이 있는데, 명시적인 -j를 주지 않았을 때만 자동으로 동작하고, 리눅스·macOS에서는 GNU Make 4.4 이상의 FIFO 방식 jobserver가 필요합니다(Windows는 세마포어 방식). 배포판 패키지의 Ninja가 오래된 버전이라면 여전히 예전처럼 동작하니 ninja --version부터 확인하는 것이 좋습니다.
Windows
Ninja는 MSVC와 함께 Windows에서 네이티브로 동작하고, Visual Studio와 VS Code의 CMake 통합도 기본적으로 Ninja를 씁니다. 주의할 점은 Ninja가 Windows에서 명령을 셸 없이 직접 실행한다는 것입니다. 파이프나 리다이렉트가 필요한 커스텀 명령은 cmd /c로 감싸야 합니다(CMake가 생성한 규칙은 이미 처리되어 있습니다). Make를 Windows에서 쓰는 경우는 MinGW/MSYS2 환경이 아니면 드뭅니다.
그래서 무엇을 고르나
- CMake를 쓴다면: 특별한 이유가 없으면
-G Ninja. 정확성(명령줄 변경 감지), 출력 가독성, 잡 풀까지 대부분의 면에서 낫습니다. - Makefile을 손으로 쓴다면: Make를 유지하는 것이 맞습니다. Ninja 파일을 손으로 쓰는 것은 설계 의도에 맞지 않습니다. 대신
-MMD -MP의존성과 적절한-j, 플래그 변경 대응을 갖추세요. - 빌드가 아닌 작업 자동화(문서 생성, 배포 단계 등): Make가 여전히 편합니다.
Meson
Meson은 Python으로 구현된 빌드 파일 생성기입니다. 빌드 설명 언어는 Python처럼 생겼지만 Python은 아니며, 일부러 튜링 완전하지 않게 설계되어 사용자 정의 함수나 임의 코드 실행이 없습니다. 그만큼 CMake보다 “빌드 스크립트가 프로그램이 되어 버리는” 문제가 적습니다. 리눅스에서는 백엔드로 Ninja를 사용하며(Visual Studio, Xcode 백엔드도 있음), GNOME과 systemd 같은 프로젝트가 Meson을 씁니다.
# meson.build
project('myproject', 'cpp',
version: '1.0',
default_options: ['cpp_std=c++17', 'warning_level=2'])
boost_dep = dependency('boost')
executable('main',
'main.cpp', 'utils.cpp',
include_directories: include_directories('include'),
dependencies: boost_dep)
meson setup build --buildtype=release
meson compile -C build # 내부적으로 ninja 호출
meson test -C build
warning_level, buildtype처럼 흔한 옵션을 컴파일러 중립적인 이름으로 제공해서, CMake에서 컴파일러별로 옵션을 나누던 일을 Meson이 대신 해 줍니다. 크로스 컴파일도 cross file 하나로 정리되는 편입니다.
단점은 생태계입니다. 쓰려는 라이브러리가 Meson 빌드 정의나 pkg-config 파일을 제공하지 않으면 WrapDB에 있는지 찾거나 직접 래핑해야 합니다. 반대로 내가 만든 라이브러리를 CMake 사용자에게 배포하려면 CMake 패키지 설정 파일을 따로 신경 써야 합니다.
빌드 시스템 내부 동작 (심화)
도구별 문법을 넘어, Make·Ninja·CMake가 공통으로 다루는 문제는 “규칙 집합을 실행 가능한 순서로 정렬하고, 바뀐 것만 다시 빌드하며, 안전하게 병렬화하는 것”입니다. 증분 빌드가 이상하게 동작할 때 원인을 찾으려면 이 모델을 알아 두는 것이 도움이 됩니다.
의존성 그래프
빌드 스펙의 핵심은 방향 비순환 그래프(DAG)입니다. 정점은 파일(또는 논리적 타겟), 간선은 “이 출력을 만들려면 이 입력들이 필요하다”는 규칙입니다.
Make는 타겟: 선행조건으로 간선을 선언하고, 타겟을 갱신하기 전에 선행조건을 먼저 만족시키는 재귀 구조입니다. Makefile이 여러 파일로 나뉘어 include되면 그래프가 합쳐지는데, 같은 타겟에 레시피가 두 번 정의되면 GNU Make는 경고(“overriding recipe for target”)만 내고 뒤의 것을 씁니다. 대규모 Makefile에서 이런 경고를 무시하면 엉뚱한 명령으로 빌드되는 버그가 됩니다.
Ninja는 build 출력: 입력 | 암묵적입력 || 순서전용입력 형태로 간선을 명시합니다. || 뒤의 순서 전용 의존성은 “없으면 먼저 만들어야 하지만, 바뀌었다고 다시 빌드할 필요는 없는” 입력으로, 생성된 헤더가 있는 디렉터리나 코드 생성 단계에 씁니다.
CMake는 add_library/add_custom_command 등으로 추상 타겟 그래프를 만든 뒤, 선택한 생성기가 이를 Make/Ninja/IDE 프로젝트로 옮깁니다. 따라서 “CMake가 느리다”는 말은 종종 configure 단계의 비용을 가리키고, 이건 Ninja로 바꿔도 줄지 않습니다. configure가 느리다면 find_package 결과 캐시, 불필요한 file(GLOB_RECURSE), 설정 단계에서 도는 execute_process를 먼저 의심하세요.
flowchart TD
subgraph DAG["의존성 DAG (개념)"]
H[헤더/모듈 맵] --> O[오브젝트]
S[소스] --> O
O --> A[아카이브/DSO]
A --> X[실행 파일]
end
cmake --graphviz=deps.dot나 ninja -t graph로 그래프를 그려 보면 “왜 이 라이브러리가 저걸 기다리지?” 같은 구조 문제가 눈에 보입니다.
증분 빌드: 무엇을 다시 할지 정하는 법
Make와 Ninja 모두 기본은 수정 시간(mtime) 비교입니다. 출력이 있고 모든 입력보다 새것이면 건너뜁니다. 단순하지만 함정이 있습니다.
- 시계 차이: 빌드 머신과 NFS 서버의 시계가 어긋나면 방금 수정한 파일이 옛것으로 판정될 수 있습니다.
- 헤더 누락: 규칙에 헤더가 빠져 있으면 헤더만 바꿨을 때 재빌드가 안 됩니다. 컴파일러가 생성한 의존성(
-MMD,/showIncludes)을 쓰는 이유입니다. - 생성 파일의 타임스탬프: 코드 생성기가 내용이 같아도 파일을 매번 새로 쓰면, 그 파일에 의존하는 모든 것이 매번 다시 빌드됩니다. 생성기가 내용이 같을 때는 파일을 건드리지 않게 하거나, Ninja의
restat = 1(명령 실행 후 출력의 mtime이 안 바뀌었으면 하위 작업을 건너뜀)을 씁니다. CMake의add_custom_command는BYPRODUCTS등을 적절히 선언하면 Ninja에서 이 동작을 활용합니다.
Bazel 같은 도구는 파일 내용의 해시로 판정해서 이런 문제에 더 강합니다. Make/Ninja 환경에서는 보통 ccache 같은 외부 캐시로 “같은 입력이면 같은 결과를 재사용”하는 효과를 보완합니다.
병렬 실행
DAG에서 선행 작업이 모두 끝난 정점부터 실행하면 병렬성을 최대로 끌어낼 수 있지만, 현실에서는 제약이 있습니다.
- 작업 수는 코어 수가 정답이 아닐 수 있습니다. 메모리가 부족하거나 디스크 I/O가 병목이면 작업 수를 줄이는 편이 전체 시간이 짧습니다. 5.4절의 잡 풀이 이 문제의 정밀한 해법입니다.
- 크리티컬 패스: 거대한 라이브러리 하나를 모두가 기다리는 구조라면
-j를 올려도 소용이 없습니다. 타겟을 쪼개거나, 헤더 의존성을 줄여 그 라이브러리가 빨리 끝나게 하는 것이 구조적 해법입니다. - 토큰 밖 병렬성: 레시피 안에서 병렬 도구를 따로 띄우면 jobserver가 셀 수 없는 작업이 생겨 메모리 폭주로 이어집니다.
캐시
ccache/sccache는 전처리 결과, 컴파일러 인자, 컴파일러 식별 정보를 키로 오브젝트를 저장합니다. CMake에서는 규칙을 건드리지 않고 이렇게 붙입니다.
cmake -S . -B build -G Ninja -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
ccache -s # 적중률 확인
CI에서 캐시 디렉터리를 작업 사이에 보존하면 클린 체크아웃에서도 효과가 큽니다. 적중률이 이상하게 낮다면 빌드 경로가 매번 다르거나(__FILE__, 절대 경로 인클루드), 빌드 시각을 넣는 매크로(__DATE__, __TIME__)가 있는지 확인하세요. ccache의 base_dir 설정이 경로 문제를 완화합니다.
캐시는 빌드가 올바르다는 것을 증명하지 않습니다. 컴파일러를 올렸는데 캐시 키가 컴파일러 변화를 반영하지 못하는 구성이라면 이상한 결과를 볼 수 있으므로, 릴리스 빌드는 클린 빌드로 하거나 캐시 키에 툴체인 버전을 넣는 정책을 두는 것이 안전합니다.
프로덕션 빌드 패턴
cmake -S . -B build-release -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON
cmake --build build-release
- LTO(
CMAKE_INTERPROCEDURAL_OPTIMIZATION)는 런타임 성능을 올리지만 링크 시간과 메모리를 크게 늘립니다. 5.4절의 링크 풀과 같이 쓰세요. - 디버그 정보 분리: 배포 바이너리는 strip하고 디버그 정보는 별도로 보관해야 크래시 덤프를 분석할 수 있습니다.
RelWithDebInfo+ 심볼 분리가 흔한 조합입니다. - 재현 가능한 빌드: 같은 소스에서 같은 바이너리가 나오게 하려면 경로 정규화(
-ffile-prefix-map), 타임스탬프 고정(SOURCE_DATE_EPOCH)을 검토합니다.
한눈에 비교
| Make | CMake | Ninja | Meson | |
|---|---|---|---|---|
| 역할 | 빌드 도구 | 생성기 | 빌드 도구 | 생성기 |
| 사람이 직접 작성 | 예 | 예 | 아니오 (생성용) | 예 |
| Windows | MinGW/nmake, 셸 차이 큼 | 좋음 | 좋음 | 좋음 |
| 명령줄 변경 시 재빌드 | 안 함 (직접 대비) | 생성기에 따름 | 함 | Ninja 백엔드라 함 |
| 병렬 기본값 | 직렬 | 생성기에 따름 | CPU 수 기준 | Ninja 따름 |
| 생태계 | 범용 | C++ 사실상 표준 | CMake·Meson 백엔드 | 리눅스 시스템 SW 중심 |
| 주된 약점 | 규모가 커지면 유지보수 | 문법, 옛 스타일 혼재 | 수동 작성 불가 | 라이브러리 지원 폭 |
flowchart TD
A[프로젝트] --> B{손으로 쓴 Makefile이 이미 잘 돌아가나?}
B -->|예, 작은 프로젝트| C[Make 유지 + -MMD -MP, -j]
B -->|아니오 또는 새 프로젝트| D{남이 가져다 쓸 라이브러리인가?}
D -->|예| E[CMake + Ninja]
D -->|아니오| F{Meson 생태계와 가깝나?}
F -->|예| G[Meson + Ninja]
F -->|아니오| E
정리
- Make: 사람이 쓰는 규칙 언어. 작은 프로젝트나 빌드 외 작업 자동화에 여전히 좋지만, 기본이 직렬이고 명령줄 변경을 감지하지 않는다는 점을 알고 써야 합니다.
- CMake: C++에서 사실상의 표준 생성기. 백엔드는 특별한 이유가 없으면 Ninja로.
- Ninja: 생성기가 만들어 주는 파일을 빠르고 정확하게 실행하는 도구. 차이는 클린 빌드보다 no-op·증분 빌드와 정확성에서 납니다. 링크 풀로 메모리를 관리하세요.
- Meson: 더 간결하고 안전한 생성기. 생태계 폭을 확인하고 고르세요.
전환을 고민 중이라면 5.1절의 방법으로 자기 프로젝트에서 세 가지(클린·no-op·증분)를 재 보는 것이 어떤 글의 벤치마크보다 정확합니다.
더 읽을거리
- C++ Makefile 작성법
- CMake 입문
- CMake Presets
- CMake 에러 해결
- Visual Studio C++ 빌드 속도 개선
- C++ 빌드 시스템 비교: Bazel·패키지 매니저까지
- CI/CD와 빌드 자동화
공식 문서: Ninja 매뉴얼, GNU Make 매뉴얼, CMake 문서, Meson 문서