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은 외부 라이브러리를 받아 빌드하고, 메타 빌드 시스템이 그 라이브러리를 찾을 수 있도록 경로 정보를 넘겨줍니다.
네 가지 빌드 시스템 비교
| 항목 | CMake | Meson | Bazel | Makefile |
|---|---|---|---|---|
| 역할 | 메타 빌드 | 메타 빌드 | 통합 빌드 | 네이티브 빌드 |
| 설정 언어 | 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 설정 파일, WrapDB | Bazel 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
| 항목 | vcpkg | Conan |
|---|---|---|
| 설치 | Git 클론 후 bootstrap 스크립트 (Visual Studio에 포함되기도 함) | pip install conan |
| 의존성 선언 | vcpkg.json | conanfile.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은 하나만 지정할 수 있어 연동도 번거로워집니다. 서로 다른 매니저를 쓰던 팀이 합쳐졌다면 프로젝트 단위로 하나를 정합니다.
무엇을 고를까
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로
- 기존 Makefile에서 소스 목록, 컴파일 플래그(
CXXFLAGS,CPPFLAGS), 링크 라이브러리(LDLIBS,LDFLAGS), 생성되는 결과물을 정리합니다. - 결과물 하나당 타깃 하나로
CMakeLists.txt를 씁니다. 전역 변수 대신target_compile_options,target_include_directories,target_link_libraries로 타깃에 붙입니다. - 시스템 라이브러리는
find_package로 찾습니다. 예를 들어-lpthread는find_package(Threads REQUIRED)와Threads::Threads로 바꿉니다. - 두 빌드의 결과를 비교합니다.
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON으로 만든compile_commands.json과make -n의 출력을 비교하면 빠진 플래그나 정의를 찾을 수 있습니다. - 한동안 두 빌드를 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으로 빌드 설정 통일하기
같이 보면 좋은 글
- CMake 입문 | 수십 개 파일 컴파일할 때 필요한 빌드 자동화 (CMakeLists.txt 기초)
- CMake 3.28+ 프리셋과 모듈로 크로스 플랫폼 빌드 구성하기
- Makefile 직접 작성하기
- Conan 기초
- C++ 의존성 관리 비교
- C++ vcpkg 고급 활용 | Manifest·Triplet·오버레이·바이너리 캐시 가이드
- vcpkg 기초