C++ 빌드 시스템 비교: CMake·Meson·Bazel·Makefile과 패키지 매니저 선택

C++에는 언어 표준이 정한 빌드 도구나 패키지 매니저가 없습니다. Rust의 Cargo나 Go 모듈처럼 빌드와 의존성 관리가 한 도구로 묶여 있는 언어와 달리, C++ 프로젝트는 빌드 시스템과 패키지 매니저를 따로 골라 조합해야 합니다. 다른 언어가 이 문제를 어떻게 푸는지는 Rust Cargo, Node 모듈, Go 모듈, Python pip·uv·Poetry와 비교해 보면 C++의 위치가 잘 보입니다.

이 글은 CMake, Meson, Bazel, Makefile을 비교하고, vcpkg와 Conan 중 무엇을 조합할지, 기존 프로젝트를 다른 빌드 시스템으로 옮길 때의 절차와 자주 만나는 오류를 다룹니다.


빌드 도구의 계층

빌드 도구는 역할에 따라 세 층으로 나눠 생각하면 이해하기 쉽습니다.

flowchart TB
    subgraph PM["패키지 매니저: 외부 라이브러리 확보"]
        vcpkg[vcpkg]
        Conan[Conan]
    end
    subgraph Meta["메타 빌드 시스템: 빌드 파일 생성"]
        CMake[CMake]
        Meson[Meson]
    end
    subgraph Native["네이티브 빌드 도구: 실제 컴파일 실행"]
        Make[Make]
        Ninja[Ninja]
        MSBuild[MSBuild]
    end
    Bazel["Bazel: 의존성 확보, 그래프 계산, 실행을 직접 수행"]
    vcpkg --> CMake
    Conan --> CMake
    Conan --> Meson
    CMake --> Make
    CMake --> Ninja
    CMake --> MSBuild
    Meson --> Ninja
    Meson --> MSBuild

Make, Ninja, MSBuild 같은 네이티브 빌드 도구는 “이 파일이 바뀌면 이 명령을 실행한다”는 규칙을 받아 실제로 컴파일러를 실행합니다. CMake와 Meson은 메타 빌드 시스템으로, 사람이 쓴 프로젝트 설명(CMakeLists.txt, meson.build)과 현재 플랫폼을 보고 네이티브 빌드 도구용 파일을 생성합니다. 그래서 CMake 프로젝트는 configure 단계(빌드 파일 생성)와 build 단계(컴파일)로 나뉩니다. Bazel은 이 층을 나누지 않고, 외부 의존성 다운로드와 의존성 그래프 계산, 명령 실행과 캐싱을 직접 합니다. vcpkg와 Conan은 외부 라이브러리를 받아 빌드하고, 메타 빌드 시스템이 그 라이브러리를 찾을 수 있도록 경로 정보를 넘겨줍니다.


네 가지 빌드 시스템 비교

항목CMakeMesonBazelMakefile
역할메타 빌드메타 빌드통합 빌드네이티브 빌드
설정 언어CMake 스크립트Meson DSL (파이썬과 비슷하지만 튜링 완전하지 않음)Starlark (파이썬 방언)Make 규칙
기본 백엔드Make, Ninja, Visual Studio, Xcode 등 선택Ninja (Visual Studio, Xcode 백엔드도 있음)자체 실행 엔진자체
크로스 플랫폼좋음좋음좋음플랫폼별 수작업 필요
IDE 지원Visual Studio, CLion, VS Code 등에서 기본 지원일부 IDE와 플러그인플러그인 중심제한적
서드파티 라이브러리대부분의 라이브러리가 CMake 설정 파일 제공pkg-config, CMake 설정 파일, WrapDBBazel Central Registry 또는 직접 BUILD 작성직접 경로 지정
재현성·원격 캐시외부 도구(ccache 등) 조합외부 도구 조합기본 설계 목표직접 구성

CMake

CMake는 C++ 생태계의 사실상 표준입니다. 대부분의 오픈소스 라이브러리가 CMake로 빌드되고 설치 시 find_package로 쓸 수 있는 설정 파일을 제공하며, 주요 IDE가 CMakeLists.txt를 프로젝트 파일로 바로 엽니다. vcpkg와 Conan도 CMake 연동을 가장 먼저 지원합니다.

cmake_minimum_required(VERSION 3.20)
project(MyApp VERSION 1.0.0 LANGUAGES CXX)

find_package(fmt REQUIRED)
find_package(spdlog REQUIRED)

add_executable(myapp src/main.cpp src/utils.cpp)
target_compile_features(myapp PRIVATE cxx_std_17)
target_link_libraries(myapp PRIVATE fmt::fmt spdlog::spdlog)
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build

단점은 언어 자체입니다. 모든 값이 문자열이고, 변수 범위 규칙이 직관적이지 않으며, 오래된 방식(전역 include_directories, 변수 기반 설정)과 현대적인 방식(타깃 기반 target_* 명령)이 공존해서 인터넷의 예제 품질이 들쭉날쭉합니다. 현대적인 CMake는 “타깃을 만들고, 그 타깃의 속성과 의존성을 선언한다”는 원칙만 지키면 꽤 읽기 쉬워집니다. cmake --build는 생성기에 맞게 병렬 빌드를 하므로 Ninja라면 -j를 따로 줄 필요가 없습니다.

Meson

Meson은 간결한 문법과 빠른 configure를 목표로 만들어졌습니다. 설정 언어가 일부러 튜링 완전하지 않게 설계되어 있어서(사용자 정의 함수가 없음), 빌드 스크립트가 복잡한 프로그램으로 변하는 것을 막아 줍니다. GNOME, systemd, Mesa 같은 대형 C/C++ 프로젝트가 Meson을 씁니다.

project('myapp', 'cpp',
  version : '1.0.0',
  default_options : ['cpp_std=c++17'])

fmt_dep = dependency('fmt')
spdlog_dep = dependency('spdlog')

executable('myapp', 'src/main.cpp', 'src/utils.cpp',
  dependencies : [fmt_dep, spdlog_dep])
meson setup build --buildtype=release
meson compile -C build

dependency()는 pkg-config, CMake 설정 파일, 시스템 라이브러리 순으로 찾고, 없으면 subprojects/의 wrap 파일로 소스를 받아 함께 빌드할 수 있습니다. WrapDB에 등록된 패키지는 meson wrap install fmt로 wrap 파일을 받을 수 있습니다. 약점은 CMake에 비해 IDE 지원과 예제 자료가 적다는 점, 그리고 CMake 프로젝트를 subproject로 쓸 수는 있지만 복잡한 CMake 라이브러리에서는 잘 안 되는 경우가 있다는 점입니다.

크로스 컴파일은 cross file로 지정합니다.

# cross-arm.txt
[binaries]
c = 'arm-linux-gnueabihf-gcc'
cpp = 'arm-linux-gnueabihf-g++'
ar = 'arm-linux-gnueabihf-ar'
strip = 'arm-linux-gnueabihf-strip'

[host_machine]
system = 'linux'
cpu_family = 'arm'
cpu = 'armv7'
endian = 'little'
meson setup build-arm --cross-file cross-arm.txt

Bazel

Bazel은 Google 내부 빌드 시스템 Blaze의 공개 버전입니다. 모든 빌드 동작의 입력(소스, 컴파일러, 플래그, 의존성)을 명시적으로 선언하게 하고, 같은 입력이면 같은 출력이 나온다는 전제(hermetic build) 위에서 결과를 캐시합니다. 이 캐시를 원격 서버에 두면 한 사람이 빌드한 결과를 팀 전체와 CI가 재사용할 수 있고, 원격 실행으로 컴파일을 여러 머신에 분산할 수도 있습니다. C++, Java, Go, Python 등이 섞인 모노레포를 하나의 도구로 빌드할 수 있다는 점도 강점입니다.

최근 Bazel은 외부 의존성을 MODULE.bazel(Bzlmod)로 관리합니다. 예전의 WORKSPACE 방식은 Bazel 8부터 기본으로 꺼져 있고 이후 제거될 예정입니다.

# MODULE.bazel
module(name = "myapp", version = "1.0.0")

bazel_dep(name = "fmt", version = "10.2.1")
bazel_dep(name = "spdlog", version = "1.13.0")
# BUILD.bazel
cc_binary(
    name = "myapp",
    srcs = ["src/main.cpp", "src/utils.cpp"],
    copts = ["-std=c++17"],
    deps = [
        "@fmt",
        "@spdlog",
    ],
)
bazel build //:myapp

위 버전은 예시이며, 실제로 쓸 수 있는 모듈과 버전은 Bazel Central Registry(BCR)에서 확인해야 합니다. BCR에 없는 라이브러리는 직접 cc_library 규칙을 써서 빌드 방법을 기술해야 하고, 이것이 Bazel 도입 비용의 상당 부분을 차지합니다. 의존성 하나하나의 빌드를 Bazel 규칙으로 다시 표현해야 하므로, 서드파티 라이브러리가 많고 팀이 작다면 얻는 것보다 들이는 노력이 클 수 있습니다.

Makefile

Make는 가장 오래된 빌드 도구이며 거의 모든 Unix 계열 시스템에 기본으로 있습니다. 작은 프로젝트에서는 규칙 몇 줄로 충분하지만, 헤더 의존성 추적, 플랫폼별 컴파일러 옵션, 외부 라이브러리 경로를 모두 직접 관리해야 합니다.

CXX      ?= g++
CXXFLAGS ?= -std=c++17 -Wall -O2
CPPFLAGS += -MMD -MP          # 헤더 의존성 파일(.d) 자동 생성
LDLIBS   += -lfmt -lspdlog

SRCS := src/main.cpp src/utils.cpp
OBJS := $(SRCS:.cpp=.o)

myapp: $(OBJS)
	$(CXX) $(LDFLAGS) $^ -o $@ $(LDLIBS)

%.o: %.cpp
	$(CXX) $(CPPFLAGS) $(CXXFLAGS) -c $< -o $@

-include $(OBJS:.o=.d)

clean:
	rm -f $(OBJS) $(OBJS:.o=.d) myapp
.PHONY: clean

-MMD -MP는 컴파일하면서 각 오브젝트가 의존하는 헤더 목록을 .d 파일로 남깁니다. 이것이 없으면 헤더만 바꿨을 때 다시 컴파일되지 않아 오래된 오브젝트가 링크되는 문제가 생깁니다. 라이브러리 플래그(-l...)는 LDLIBS에 넣어 오브젝트 파일 뒤에 오도록 해야 합니다. GNU ld는 링크 순서에 민감해서, 라이브러리가 오브젝트보다 앞에 있으면 심볼을 찾지 못합니다. 레시피 줄은 반드시 탭으로 시작해야 하며, 공백으로 들여쓰면 missing separator 오류가 납니다.


패키지 매니저: vcpkg와 Conan

빌드 시스템은 내 소스를 어떻게 컴파일할지를, 패키지 매니저는 외부 라이브러리를 어떻게 확보할지를 담당합니다. CMake 프로젝트라면 둘 다 toolchain 파일 하나로 연동됩니다.

// vcpkg.json
{
  "name": "myapp",
  "version": "1.0.0",
  "dependencies": ["fmt", "spdlog"],
  "builtin-baseline": "<vcpkg 저장소의 커밋 해시>"
}
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
cmake --build build
# conanfile.txt
[requires]
fmt/10.2.1
spdlog/1.13.0

[generators]
CMakeDeps
CMakeToolchain
conan install . --output-folder=build --build=missing
cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake -DCMAKE_BUILD_TYPE=Release
cmake --build build
항목vcpkgConan
설치Git 클론 후 bootstrap 스크립트 (Visual Studio에 포함되기도 함)pip install conan
의존성 선언vcpkg.jsonconanfile.txt / conanfile.py
버전 고정builtin-baseline 커밋 + overrides버전 명시 + conan.lock
빌드 환경 정의triplet (x64-windows, arm64-linux 등)프로필 (settings, options, conf)
바이너리 공유바이너리 캐시 (파일 시스템, NuGet, 클라우드 스토리지 등)Remote 서버에 바이너리 업로드
사내 패키지overlay port, 사설 registry사설 remote (Artifactory 등)
빌드 시스템 연동CMake 중심 (MSBuild 통합도 지원)CMake, Meson, MSBuild, Autotools 등 generator

vcpkg는 저장소의 한 커밋(baseline)이 모든 포트의 버전 묶음을 정하는 방식이라, baseline만 고정하면 의존성 버전이 함께 고정됩니다. 별도의 lockfile은 없고, 특정 패키지만 다른 버전으로 쓰고 싶을 때 overrides로 지정합니다. 시작이 간단하고 Visual Studio와의 통합이 좋아서 Windows 중심 팀에서 특히 편합니다.

Conan은 패키지마다 버전을 직접 정하고, 의존성 그래프의 해석 결과를 conan.lock으로 고정합니다. 프로필로 컴파일러와 옵션을 세밀하게 정의하고, 빌드된 바이너리를 사내 remote에 올려 팀이 공유하는 흐름이 잘 갖춰져 있어서, 여러 플랫폼과 크로스 컴파일 대상을 다루거나 사내 라이브러리를 패키지로 배포하는 조직에서 많이 씁니다.

한 프로젝트에서 둘을 함께 쓰는 것은 피하는 것이 좋습니다. 같은 라이브러리가 두 경로로 들어오면 서로 다른 빌드 설정의 바이너리가 섞이기 쉽고, CMAKE_TOOLCHAIN_FILE은 하나만 지정할 수 있어 연동도 번거로워집니다. 서로 다른 매니저를 쓰던 팀이 합쳐졌다면 프로젝트 단위로 하나를 정합니다.


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가 조용히 틀리는 지점

속도보다 더 중요한 차이는 정확성입니다. GNU Make는 타겟을 만든 명령줄을 기억하지 않습니다. 수정 시간만 비교하기 때문에 CXXFLAGS에서 -O2를 -O0 -g로 바꿔도 소스가 그대로면 기존 오브젝트를 그대로 씁니다. 디버그 빌드로 바꿨다고 생각하고 gdb를 붙였는데 일부 함수의 변수만 <optimized out>으로 나온다면, 그 오브젝트들이 예전 플래그로 만들어진 채 남아 있을 가능성이 큽니다. -DENABLE_FEATURE처럼 매크로를 바꾼 경우는 더 위험해서, 서로 다른 설정으로 컴파일된 오브젝트가 링크되면 구조체 레이아웃이 어긋나 원인을 알기 어려운 크래시로 이어질 수 있습니다.

Ninja는 .ninja_log에 각 출력의 명령줄을 기록해 두고, 생성된 명령이 달라지면 해당 타겟을 다시 빌드합니다. 공식 매뉴얼 표현으로는 “출력은 그것을 만든 명령에 암묵적으로 의존한다”입니다. CMake가 생성한 Makefile은 flags.make 같은 파일을 의존성으로 걸어서 이 문제를 상당 부분 막아 주지만, 손으로 쓴 Makefile이라면 직접 대비해야 합니다. 흔한 방법은 플래그 문자열을 파일에 써 두고 내용이 바뀔 때만 그 파일을 갱신해, 모든 오브젝트의 선행조건으로 거는 것입니다.

병렬 기본값과 -j의 함정

GNU MakeNinja
옵션 없이 실행직렬 (작업 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년에 이슈로 올라온 뒤 오래 열려 있다가 Ninja 1.13(2025년)에서 jobserver 클라이언트 지원이 들어갔습니다. 다만 조건이 있어서, 리눅스·macOS에서는 GNU Make 4.4 이상이 제공하는 FIFO 방식 jobserver만 지원하고(Windows는 세마포어 방식), 명시적으로 -j를 준 경우의 동작 등 세부 조건은 릴리스 노트를 확인하는 것이 좋습니다. 배포판 패키지의 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가 편합니다.

빌드 시스템 내부 동작 (심화)

도구별 문법을 넘어, 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가 병목이면 작업 수를 줄이는 편이 전체 시간이 짧습니다. 앞의 “메모리: 링크 단계와 잡 풀”에서 본 잡 풀이 이 문제의 정밀한 해법입니다.
  • 크리티컬 패스: 거대한 라이브러리 하나를 모두가 기다리는 구조라면 -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)는 런타임 성능을 올리지만 링크 시간과 메모리를 크게 늘립니다. 앞에서 본 링크 풀과 같이 쓰는 편이 안전합니다.
  • 디버그 정보 분리: 배포 바이너리는 strip하고 디버그 정보는 별도로 보관해야 크래시 덤프를 분석할 수 있습니다. RelWithDebInfo + 심볼 분리가 흔한 조합입니다.
  • 재현 가능한 빌드: 같은 소스에서 같은 바이너리가 나오게 하려면 경로 정규화(-ffile-prefix-map), 타임스탬프 고정(SOURCE_DATE_EPOCH)을 검토합니다.

무엇을 고를까

flowchart TD
    A[새 프로젝트인가?] -->|예| B{여러 언어 모노레포이고<br/>원격 캐시가 꼭 필요한가?}
    A -->|아니오| C{현재 빌드 시스템}
    B -->|아니오| D[CMake + vcpkg 또는 Conan]
    B -->|예| E[Bazel 검토]
    C -->|Makefile| F[CMake로 점진 이전 검토]
    C -->|CMake| G[유지. 빌드 속도는 Ninja·ccache로]

대부분의 C++ 프로젝트에는 CMake가 가장 무난합니다. 함께 일할 사람들이 이미 알고 있을 가능성이 가장 높고, 쓰려는 라이브러리가 CMake로 바로 연동될 가능성도 가장 높기 때문입니다. 패키지 매니저는 Windows와 Visual Studio 중심이고 빨리 시작하고 싶다면 vcpkg, 여러 플랫폼의 바이너리를 사내에서 관리하거나 크로스 컴파일 대상이 많다면 Conan이 잘 맞습니다.

Meson은 CMake의 문법에 지쳤고 의존성 대부분을 pkg-config나 WrapDB로 해결할 수 있는 프로젝트, 특히 Linux 중심의 C/C++ 프로젝트에서 좋은 선택입니다. Bazel은 여러 언어가 섞인 큰 모노레포에서 원격 캐시와 재현성이 핵심 요구사항일 때 검토할 가치가 있습니다.

빌드가 느려서 빌드 시스템을 바꾸려는 경우라면 먼저 원인을 확인해야 합니다. 시간 대부분은 컴파일러가 쓰므로, CMake라면 Ninja 생성기, ccache나 sccache, 미리 컴파일된 헤더(target_precompile_headers), 헤더 포함 관계 정리가 빌드 시스템 교체보다 효과가 큰 경우가 많습니다. Ninja는 -ftime-trace(Clang) 같은 컴파일러 옵션과 함께 쓰면 어느 파일이 오래 걸리는지 찾기 쉽습니다.


기존 프로젝트 옮기기

Makefile에서 CMake로

  1. 기존 Makefile에서 소스 목록, 컴파일 플래그(CXXFLAGS, CPPFLAGS), 링크 라이브러리(LDLIBS, LDFLAGS), 생성되는 결과물을 정리합니다.
  2. 결과물 하나당 타깃 하나로 CMakeLists.txt를 씁니다. 전역 변수 대신 target_compile_options, target_include_directories, target_link_libraries로 타깃에 붙입니다.
  3. 시스템 라이브러리는 find_package로 찾습니다. 예를 들어 -lpthread는 find_package(Threads REQUIRED)와 Threads::Threads로 바꿉니다.
  4. 두 빌드의 결과를 비교합니다. cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON으로 만든 compile_commands.json과 make -n의 출력을 비교하면 빠진 플래그나 정의를 찾을 수 있습니다.
  5. 한동안 두 빌드를 CI에서 함께 돌리다가, 결과가 같다고 확인되면 Makefile을 지웁니다.
cmake_minimum_required(VERSION 3.20)
project(MyApp LANGUAGES CXX)

find_package(Threads REQUIRED)
find_package(fmt REQUIRED)

add_executable(myapp src/main.cpp src/utils.cpp)
target_compile_features(myapp PRIVATE cxx_std_17)
target_compile_options(myapp PRIVATE -Wall)
target_link_libraries(myapp PRIVATE fmt::fmt Threads::Threads)

CMake에서 Meson으로

중간 규모 이하이고 CMake 스크립트가 지나치게 복잡해진 프로젝트에서 고려할 수 있습니다. 타깃 구조를 그대로 meson.build로 옮기고, 외부 의존성은 dependency()와 wrap으로 처리합니다. 다만 CMake로만 빌드되는 의존성이 많다면 Meson의 CMake subproject 지원으로 해결되지 않는 경우가 있으므로, 의존성 목록부터 확인하는 것이 좋습니다.

패키지 매니저 바꾸기

vcpkg에서 Conan으로 옮길 때는 vcpkg.json의 의존성을 conanfile.txt로 옮기고, CMake의 toolchain 파일을 conan_toolchain.cmake로 바꾸고, CI 스크립트에서 vcpkg 단계를 conan install로 교체합니다. 반대 방향도 같은 순서입니다. 두 매니저가 같은 라이브러리에 대해 다른 CMake 타깃 이름을 쓰는 경우가 가끔 있으므로, target_link_libraries의 타깃 이름을 확인해야 합니다.


재현 가능한 빌드와 CI

재현 가능한 빌드에는 세 가지가 필요합니다. 의존성 버전을 고정하고, 빌드 설정을 파일로 남기고, 빌드 환경(컴파일러 버전)을 고정합니다.

의존성 버전은 vcpkg라면 builtin-baseline을, Conan이라면 conan.lock을 저장소에 커밋해 고정합니다.

# Conan: lockfile 생성 후 그 lockfile로 설치
conan lock create . --lockfile-out=conan.lock
conan install . --output-folder=build --build=missing --lockfile=conan.lock

빌드 설정은 CMakePresets.json에 담으면 개발자와 CI가 같은 옵션으로 configure합니다.

{
  "version": 3,
  "configurePresets": [
    {
      "name": "release",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/release",
      "cacheVariables": {
        "CMAKE_BUILD_TYPE": "Release",
        "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
      }
    }
  ],
  "buildPresets": [
    { "name": "release", "configurePreset": "release" }
  ]
}
cmake --preset release
cmake --build --preset release

CI에서는 패키지 매니저가 빌드한 바이너리를 캐시해야 매번 의존성을 다시 빌드하지 않습니다. vcpkg의 기본 바이너리 캐시는 Linux와 macOS에서 ~/.cache/vcpkg/archives, Windows에서 %LOCALAPPDATA%\vcpkg\archives이고, Conan은 ~/.conan2/p에 패키지를 둡니다. 캐시 키에는 의존성 선언 파일과 lockfile 또는 baseline, 컴파일러 버전을 포함시켜야 설정이 바뀌었을 때 오래된 바이너리를 쓰지 않습니다.

- uses: actions/cache@v4
  with:
    path: ~/.cache/vcpkg/archives
    key: vcpkg-${{ runner.os }}-${{ hashFiles('vcpkg.json') }}

컴파일러 버전까지 고정하려면 Docker 이미지로 빌드 환경을 만듭니다. vcpkg는 bootstrap과 포트 빌드에 curl, zip, unzip, tar, pkg-config가 필요합니다.

FROM ubuntu:24.04
RUN apt-get update && apt-get install -y \
      build-essential cmake ninja-build git curl zip unzip tar pkg-config
RUN git clone https://github.com/microsoft/vcpkg.git /vcpkg && /vcpkg/bootstrap-vcpkg.sh -disableMetrics
ENV VCPKG_ROOT=/vcpkg
WORKDIR /src
COPY . .
RUN cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_TOOLCHAIN_FILE=/vcpkg/scripts/buildsystems/vcpkg.cmake \
 && cmake --build build

자주 만나는 오류

Could not find a package configuration file provided by "fmt"는 CMake가 fmt의 설정 파일을 찾지 못했다는 뜻입니다. 패키지 매니저의 toolchain 파일을 CMAKE_TOOLCHAIN_FILE로 지정했는지, Conan이라면 conan install을 먼저 실행했는지 확인합니다. toolchain 파일은 첫 configure 때만 적용되므로, 빌드 디렉터리를 이미 만든 뒤에 지정했다면 빌드 디렉터리를 지우고 다시 configure해야 합니다.

undefined reference to ...(MSVC는 LNK2019)는 대개 target_link_libraries에 라이브러리 타깃을 빠뜨렸거나, Debug와 Release 바이너리를 섞어서 생깁니다. 패키지 매니저 쪽 빌드 타입(Conan의 -s build_type, vcpkg의 triplet 구성)과 CMake의 CMAKE_BUILD_TYPE이 같은지 확인합니다.

fatal error: mylib.h: No such file or directory는 헤더 경로가 전파되지 않은 경우입니다. 라이브러리 타깃이 target_include_directories(mylib PUBLIC include)처럼 공개 헤더 경로를 PUBLIC으로 선언해야, 그 라이브러리를 링크하는 타깃에 경로가 전달됩니다.

Conan의 Version conflict는 의존성 그래프에서 같은 패키지가 다른 버전으로 요구될 때 납니다. 소비자 conanfile.py에서 self.requires("fmt/10.2.1", force=True)처럼 쓸 버전을 정합니다. vcpkg는 각 의존성이 요구한 최소 버전 중 가장 높은 것을 고르므로 충돌이 드물고, 특정 버전으로 고정하고 싶다면 overrides를 씁니다.

Meson의 Dependency "fmt" not found는 pkg-config나 CMake 설정 파일로 fmt를 찾지 못한 경우입니다. 시스템 패키지(libfmt-dev 등)를 설치하거나 meson wrap install fmt로 wrap 파일을 받아 subproject로 빌드합니다.

이전 글: CMakePresets.json으로 빌드 설정 통일하기


같이 보면 좋은 글