CMake 입문: CMakeLists.txt로 C++ 프로젝트를 크로스플랫폼 빌드하기

[C++ 실전 가이드 #4] CMake 입문

소스 파일이 한두 개일 때는 g++ hello.cpp -o hello 한 줄이면 충분합니다. 파일이 늘고, 팀원마다 OS가 다르고, 외부 라이브러리를 붙이기 시작하면 이 한 줄을 손으로 관리하기가 어려워집니다. CMake는 이 문제를 푸는 빌드 시스템 생성기입니다. 직접 컴파일하지 않고, CMakeLists.txt에 적힌 선언을 읽어 플랫폼에 맞는 Makefile, Ninja 빌드 파일, Visual Studio 솔루션을 만들어 줍니다. 실제 컴파일은 그렇게 만들어진 빌드 도구가 합니다.

다른 언어와 비교하면, Rust의 Cargo나 Node.js의 npm처럼 빌드와 패키지 관리를 한 도구가 맡는 구조는 아닙니다. CMake는 빌드 기술만 맡고, 라이브러리 설치는 Conan이나 vcpkg 같은 별도 패키지 매니저가 맡습니다. Makefile과의 관계는 Makefile 가이드, 프리셋과 모듈 같은 최신 기능은 CMake 3.28+ 프리셋과 모듈로 크로스 플랫폼 빌드 구성하기, 다른 빌드 도구와의 비교는 빌드 시스템 비교에서 이어서 다룹니다.

flowchart LR
  A[CMakeLists.txt] --> B[cmake configure]
  B --> C[Makefile / .sln 등]
  C --> D[빌드]
  D --> E[실행파일·라이브러리]

이전 글 C++ 실전 가이드 #3: VS Code C++ 개발 환경 설정에서는 IntelliSense, 빌드 작업, 디버깅 설정을 다뤘습니다. 이 글의 예제는 CMake 3.15 이상과 g++/Clang 또는 MSVC가 있으면 따라 할 수 있습니다. Linux에서는 cmake와 build-essential, macOS에서는 Xcode Command Line Tools와 CMake, Windows에서는 Visual Studio(또는 Build Tools)와 CMake를 설치합니다.

수동 빌드가 막히는 지점

소스 파일이 여러 개이고 외부 라이브러리를 쓰는 프로젝트라면 컴파일 명령은 다음처럼 됩니다.

g++ -std=c++20 -I./include \
    src/main.cpp src/utils.cpp src/database.cpp src/network.cpp \
    -L./lib -lsqlite3 -lpthread -o myapp

-std=c++20은 표준 버전, -I./include는 헤더 검색 경로, -L./lib는 라이브러리 검색 경로, -lsqlite3 -lpthread는 링크할 라이브러리입니다. 이 방식에는 네 가지 문제가 있습니다.

첫째, 파일을 추가할 때마다 명령을 고쳐야 하고, 하나를 빠뜨리면 링크 단계에서 undefined reference가 납니다. 둘째, Windows의 MSVC(cl)는 옵션 체계가 완전히 달라서 build.sh와 build.bat을 따로 유지하게 되고, 한쪽만 고치고 잊는 일이 생깁니다. 셋째, 이 명령은 매번 모든 파일을 다시 컴파일합니다. 수정된 파일만 다시 컴파일하는 증분 빌드(incremental build)가 안 되므로 프로젝트가 커질수록 기다리는 시간이 늘어납니다. 넷째, 헤더를 고쳤을 때 그 헤더를 include하는 .cpp가 무엇인지 사람이 기억해야 합니다.

CMake로 옮기면 “무엇을 빌드할지”만 선언하고, “어떤 명령으로 빌드할지”는 CMake가 플랫폼에 맞게 생성합니다. 생성된 Makefile이나 Ninja 파일은 컴파일러가 출력한 헤더 의존성 정보를 이용하므로, 헤더를 고치면 그 헤더를 쓰는 소스만 다시 컴파일됩니다.

flowchart TB
    subgraph 수동["수동 빌드 (g++ 직접 호출)"]
        M1[파일 추가] --> M2[명령어 수동 수정]
        M2 --> M3[전체 재컴파일]
        M3 --> M4[OS별 스크립트 별도 유지]
    end
    subgraph cmake[CMake 사용]
        C1[CMakeLists.txt에 파일 추가] --> C2[cmake --build]
        C2 --> C3[증분 빌드: 변경된 파일만]
        C3 --> C4[단일 설정으로 모든 OS 지원]
    end
    style 수동 fill:#ffcccc
    style cmake fill:#ccffcc

configure·generate·build 단계

CMake를 쓰는 빌드는 두 단계로 나뉩니다. cmake를 실행하는 구성(configure)·생성(generate) 단계에서는 CMakeLists.txt를 읽고 컴파일러를 탐지해 빌드 파일을 만듭니다. 이어지는 cmake --build 단계에서 생성된 빌드 도구가 실제 컴파일과 링크를 합니다.

sequenceDiagram
    participant Dev as 개발자
    participant CMake as CMake
    participant Gen as Generator
    participant Build as 빌드 도구
    Dev->>CMake: cmake ..
    CMake->>CMake: CMakeLists.txt 파싱
    CMake->>CMake: 플랫폼/컴파일러 탐지
    CMake->>Gen: Makefile/Ninja/VS 프로젝트 생성
    Gen->>Dev: 빌드 파일 생성 완료
    Dev->>Build: cmake --build .
    Build->>Build: 소스 컴파일
    Build->>Build: 링크
    Build->>Dev: 실행 파일/라이브러리 출력

빌드는 보통 프로젝트 루트가 아니라 build/ 같은 별도 디렉터리에서 합니다(out-of-source build). 생성된 Makefile, 오브젝트 파일, 실행 파일이 소스와 섞이지 않으므로 build/만 지우면 깨끗한 상태로 돌아가고, Debug와 Release를 build-debug, build-release처럼 다른 디렉터리에서 따로 유지할 수도 있습니다.


CMake 설치와 Generator 개념

운영체제별 설치

# Windows (Chocolatey)
choco install cmake
# macOS
brew install cmake
# Linux (Ubuntu/Debian)
sudo apt install cmake
# Linux (배포판 패키지가 너무 오래되었을 때)
sudo snap install cmake --classic
# 확인
cmake --version

설치가 되었다면 다음과 비슷한 출력이 나옵니다.

cmake version 3.28.1
CMake suite maintained and supported by Kitware (kitware.com/cmake).

기본 용어

  • CMakeLists.txt: 무엇을 빌드할지(실행 파일, 라이브러리)와 어떤 소스·헤더를 쓰는지 적는 설정 파일입니다.
  • 빌드 디렉터리: 생성된 빌드 파일과 결과물이 들어가는 곳(build/)입니다.
  • Generator: CMake가 어떤 형식의 빌드 파일을 만들지 정합니다(Unix Makefiles, Ninja, Visual Studio 등).
  • Target: 빌드 결과물 하나(실행 파일 하나, 라이브러리 하나)를 가리키는 단위입니다. 현대적인 CMake는 설정을 전역 변수보다 타겟에 붙이는 방식을 권장합니다.

Make와 Ninja

Linux와 macOS에서 -G를 지정하지 않으면 Unix Makefiles 생성기가 쓰입니다. Ninja는 빌드 파일을 기계가 읽기 위한 형식으로 단순하게 만들어서, 특히 “바뀐 것이 거의 없을 때” 무엇을 다시 빌드할지 판단하는 오버헤드가 작고, 별도 옵션 없이도 CPU 코어 수에 맞춰 병렬로 빌드합니다(make는 -j를 주지 않으면 한 번에 하나씩 컴파일합니다). 그래서 CI와 큰 프로젝트에서 Ninja를 많이 씁니다. Make는 거의 모든 Unix 계열 환경에 이미 설치되어 있다는 장점이 있습니다. 차이가 얼마나 나는지는 프로젝트 구조에 따라 크게 다르므로, 궁금하다면 같은 프로젝트를 두 생성기로 빌드해 직접 비교해 보는 것이 정확합니다.

cmake -G Ninja ..
cmake --build .

생성기는 빌드 디렉터리를 처음 구성할 때 정해지며, 이미 Makefiles로 구성한 디렉터리에 -G Ninja를 주면 오류가 납니다. 이때는 빌드 디렉터리를 새로 만들어야 합니다.


첫 CMakeLists.txt 작성과 빌드

Hello World CMakeLists.txt

가장 단순한 구성은 실행 파일 하나만 만드는 것입니다.

main.cpp:

#include <iostream>
int main() {
    std::cout << "Hello, CMake!" << std::endl;
    return 0;
}

CMakeLists.txt:

cmake_minimum_required(VERSION 3.15)
project(HelloCMake VERSION 1.0)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(hello main.cpp)

한 줄씩 보면:

  • cmake_minimum_required(VERSION 3.15): 이 파일을 처리하는 데 필요한 최소 CMake 버전입니다. 이보다 낮은 버전으로 실행하면 오류가 납니다. 이 값은 해당 버전의 정책(policy) 동작도 함께 켜므로, 단순한 버전 검사 이상의 의미가 있습니다. 3.15는 2019년에 나온 버전이라 대부분의 환경에서 사용할 수 있습니다.
  • project(HelloCMake VERSION 1.0): 프로젝트 이름과 버전을 정하고, PROJECT_NAME, PROJECT_VERSION 같은 변수를 설정합니다. 이 시점에 컴파일러 탐지도 일어납니다.
  • set(CMAKE_CXX_STANDARD 20): C++20으로 컴파일하도록 표준 플래그를 붙입니다. 주의할 점은 CMAKE_CXX_EXTENSIONS의 기본값이 ON이라서 GCC·Clang에서는 -std=c++20이 아니라 GNU 확장이 켜진 -std=gnu++20이 붙는다는 것입니다. 이식성을 확인하려면 set(CMAKE_CXX_EXTENSIONS OFF)를 함께 둡니다.
  • set(CMAKE_CXX_STANDARD_REQUIRED ON): 컴파일러가 C++20을 지원하지 않으면 오류를 냅니다. OFF이면 지원되는 더 낮은 표준으로 조용히 내려가므로 권하지 않습니다.
  • add_executable(hello main.cpp): main.cpp로 hello라는 실행 파일 타겟을 만듭니다. 소스가 여러 개면 add_executable(hello main.cpp utils.cpp)처럼 나열합니다.

build 디렉터리에서 빌드하기

mkdir build && cd build
cmake ..
cmake --build .
./hello

cmake ..는 상위 디렉터리의 CMakeLists.txt를 읽고 빌드 파일만 생성합니다. 이 단계에서는 컴파일하지 않으며, 끝나면 -- Configuring done, -- Generating done, -- Build files have been written to: ... 같은 메시지가 나옵니다. cmake --build .는 생성된 빌드 도구(make, ninja, MSBuild 등)를 호출해 실제로 컴파일하므로, 플랫폼과 상관없이 같은 명령을 쓸 수 있습니다. 병렬 빌드는 cmake --build . -j 8처럼 지정합니다(CMake 3.12 이상).

Windows에서 Visual Studio 생성기를 쓰면 실행 파일이 구성별 하위 디렉터리에 생기므로 .\Debug\hello.exe처럼 실행합니다.

Debug와 Release 빌드

개발 중에는 디버그 정보가 필요하고, 배포할 때는 최적화된 바이너리가 필요합니다. Makefiles·Ninja 같은 단일 구성 생성기에서는 CMAKE_BUILD_TYPE으로 이를 고릅니다.

# Debug 빌드 (디버깅용)
cmake -DCMAKE_BUILD_TYPE=Debug ..
# Release 빌드 (최적화, 배포용)
cmake -DCMAKE_BUILD_TYPE=Release ..

GCC·Clang 기준으로 각 빌드 타입이 붙이는 기본 플래그는 다음과 같습니다(MSVC는 /Od, /O2 등 대응하는 옵션을 씁니다).

빌드 타입기본 플래그용도
Debug-g개발, 디버깅
Release-O3 -DNDEBUG배포, 성능 측정
RelWithDebInfo-O2 -g -DNDEBUG프로파일링, 배포 바이너리 디버깅
MinSizeRel-Os -DNDEBUG크기가 중요한 배포

Release 계열은 -DNDEBUG가 붙어 assert가 사라진다는 점도 기억해 둘 만합니다.

빌드 타입을 지정하지 않으면 Debug가 되는 것이 아니라 빈 값이 되어, Makefile·Ninja 생성기에서는 -g도 -O 옵션도 붙지 않습니다. 그래서 기본값을 두고 싶다면 CMakeLists.txt에서 “비어 있을 때만” 채워야 합니다.

if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()

여기서 흔한 함정은 그냥 set(CMAKE_BUILD_TYPE Release)라고 적는 것입니다. 일반 변수가 캐시 변수를 가려서 사용자가 -DCMAKE_BUILD_TYPE=Debug를 넘겨도 무시되고 항상 Release로 빌드되며, “디버거에서 브레이크포인트가 안 걸린다”는 증상으로 나타납니다. 또 Visual Studio나 Xcode 같은 멀티 구성 생성기는 CMAKE_BUILD_TYPE을 쓰지 않고 cmake --build . --config Release로 빌드할 때 구성을 고르므로, 위 조건에 CMAKE_CONFIGURATION_TYPES 검사를 함께 넣었습니다. option(BUILD_TESTS "Build tests" ON)처럼 만든 옵션도 캐시 변수라서, 한 번 -DBUILD_TESTS=OFF로 구성한 빌드 디렉터리에서는 CMakeLists.txt의 기본값을 바꿔도 반영되지 않습니다. 이럴 때는 -D로 다시 넘기거나 빌드 디렉터리를 지우고 새로 구성합니다.


여러 소스 파일로 된 프로젝트

프로젝트 구조

calculator/
├── CMakeLists.txt
├── main.cpp
├── src/
│   ├── operations.cpp
│   └── utils.cpp
└── include/
    ├── operations.h
    └── utils.h

소스 코드

include/operations.h:

#pragma once
// 덧셈, 뺄셈, 곱셈, 나눗셈 함수 선언
int add(int a, int b);
int subtract(int a, int b);
int multiply(int a, int b);
double divide(int a, int b);

include/utils.h:

#pragma once
// 입력 검증 유틸리티
bool is_valid_number(const char* str);
void print_result(const char* op, double result);

src/operations.cpp:

#include "operations.h"
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
double divide(int a, int b) {
    if (b == 0) return 0.0;  // 예제 단순화를 위해 0으로 나누면 0을 반환
    return static_cast<double>(a) / b;
}

src/utils.cpp:

#include "utils.h"
#include <cstdlib>
#include <iostream>
bool is_valid_number(const char* str) {
    if (!str || !*str) return false;
    char* end;
    std::strtol(str, &end, 10);
    return *end == '\0';
}
void print_result(const char* op, double result) {
    std::cout << op << " 결과: " << result << std::endl;
}

main.cpp:

#include "operations.h"
#include "utils.h"
#include <cstdlib>
#include <iostream>
#include <string>
int main(int argc, char* argv[]) {
    if (argc != 4) {
        std::cerr << "사용법: calculator <a> <op> <b>\n";
        return 1;
    }
    if (!is_valid_number(argv[1]) || !is_valid_number(argv[3])) {
        std::cerr << "잘못된 숫자입니다.\n";
        return 1;
    }
    int a = std::atoi(argv[1]);
    int b = std::atoi(argv[3]);
    std::string op = argv[2];
    if (op == "+") print_result("덧셈", add(a, b));
    else if (op == "-") print_result("뺄셈", subtract(a, b));
    else if (op == "*") print_result("곱셈", multiply(a, b));
    else if (op == "/") print_result("나눗셈", divide(a, b));
    else { std::cerr << "알 수 없는 연산: " << op << "\n"; return 1; }
    return 0;
}

셸에서 *는 글롭 문자이므로 곱셈을 시험할 때는 ./calculator 3 '*' 4처럼 따옴표로 감싸야 합니다.

라이브러리 타겟으로 나눈 CMakeLists.txt

여러 .cpp를 정적 라이브러리로 묶고, 실행 파일은 그 라이브러리를 링크하는 구성이 좋습니다. target_include_directories로 헤더 경로를 라이브러리에 붙이고, target_link_libraries로 실행 파일이 라이브러리를 쓰도록 연결합니다. 이렇게 하면 의존 관계가 타겟 단위로 드러나고, 같은 라이브러리를 테스트 실행 파일에서도 재사용할 수 있습니다.

cmake_minimum_required(VERSION 3.15)
project(Calculator VERSION 1.0)
set(CMAKE_CXX_STANDARD 20)
# 라이브러리 생성
add_library(calculator_lib STATIC
    src/operations.cpp
    src/utils.cpp
)
# 헤더 경로 설정
target_include_directories(calculator_lib PUBLIC include)
# 실행 파일 생성 및 링크
add_executable(calculator main.cpp)
target_link_libraries(calculator PRIVATE calculator_lib)

add_library(calculator_lib STATIC ...)는 나열한 소스를 컴파일해 정적 라이브러리 하나로 묶습니다. 결과 파일은 Linux·macOS에서 libcalculator_lib.a, Windows MSVC에서 calculator_lib.lib입니다. STATIC 대신 SHARED를 쓰면 동적 라이브러리(.so, .dylib, .dll)가 됩니다. 정적 라이브러리는 링크할 때 필요한 코드가 실행 파일에 복사되어 배포가 단순하고, 동적 라이브러리는 실행 시점에 로드되므로 여러 프로그램이 공유할 수 있지만 라이브러리 파일을 함께 배포해야 합니다.

소스 목록을 file(GLOB SOURCES "src/*.cpp")로 자동 수집하고 싶어지지만, GLOB은 구성 시점에 한 번만 실행됩니다. 새 .cpp를 추가하고 빌드만 다시 돌리면 새 파일이 포함되지 않아 그 안의 함수가 “undefined reference”로 나오고, cmake ..를 다시 실행해야 풀립니다. CMake 3.12의 CONFIGURE_DEPENDS 옵션이 이를 완화하지만 공식 문서도 모든 생성기에서 안정적으로 동작하지는 않는다고 적고 있으므로, 파일을 명시적으로 나열하는 편이 안전하고 추가·삭제가 diff로도 드러납니다.

target_include_directories(calculator_lib PUBLIC include)의 PUBLIC은 이 경로를 라이브러리 자신의 컴파일에도 쓰고, 이 라이브러리를 링크하는 타겟에도 전파한다는 뜻입니다. PRIVATE이면 라이브러리 자신만 쓰고, INTERFACE이면 자신은 쓰지 않고 사용자에게만 전파합니다. 그래서 calculator가 calculator_lib를 링크하는 것만으로 main.cpp의 #include "operations.h"가 include/operations.h를 찾습니다. target_link_libraries(calculator PRIVATE calculator_lib)의 PRIVATE은 calculator에 의존하는 다른 타겟이 없으니 전파할 필요가 없다는 의미입니다.

빌드가 실제로 하는 일은 다음 순서입니다.

  1. operations.cpp, utils.cpp를 컴파일해 libcalculator_lib.a를 만듭니다.
  2. main.cpp를 컴파일해 오브젝트 파일을 만듭니다.
  3. 두 결과를 링크해 calculator 실행 파일을 만듭니다.
flowchart TB
    subgraph 소스
        main[main.cpp]
        ops[operations.cpp]
        utils[utils.cpp]
    end
    subgraph 헤더
        ops_h[operations.h]
        utils_h[utils.h]
    end
    subgraph 빌드
        lib[libcalculator_lib.a]
        exe[calculator]
    end
    ops --> ops_h
    utils --> utils_h
    ops --> lib
    utils --> lib
    main --> ops_h
    main --> utils_h
    main --> exe
    lib --> exe

빌드 및 실행

mkdir build && cd build
cmake ..
cmake --build .
./calculator 10 + 5
덧셈 결과: 15

이 상태에서 src/utils.cpp만 고치고 cmake --build .를 다시 실행하면 utils.cpp만 다시 컴파일되고 라이브러리와 실행 파일이 다시 링크됩니다. include/operations.h를 고치면 그 헤더를 include하는 operations.cpp와 main.cpp가 다시 컴파일됩니다. 수동 빌드에서 사람이 기억해야 했던 부분입니다.


find_package로 외부 라이브러리 연동

nlohmann/json 설치

# Ubuntu
sudo apt install nlohmann-json3-dev
# macOS
brew install nlohmann-json
# Windows (vcpkg)
vcpkg install nlohmann-json

시스템이나 패키지 매니저로 설치한 라이브러리는 find_package로 찾습니다. REQUIRED를 붙이면 찾지 못했을 때 구성 단계에서 바로 실패하고, 찾은 뒤 제공되는 imported 타겟(nlohmann_json::nlohmann_json)을 target_link_libraries에 넣으면 include 경로와 링크 설정이 함께 붙습니다.

cmake_minimum_required(VERSION 3.15)
project(JsonExample)
set(CMAKE_CXX_STANDARD 20)
# 최소 버전 3.11.0 이상을 요구
find_package(nlohmann_json 3.11.0 REQUIRED)
add_executable(json_app main.cpp)
target_link_libraries(json_app PRIVATE nlohmann_json::nlohmann_json)

vcpkg로 설치했다면 CMakeLists.txt를 고치지 말고 첫 구성 때 툴체인 파일을 넘깁니다. CMAKE_TOOLCHAIN_FILE은 첫 project() 호출 전에 정해져야 하므로, project() 뒤에서 set()하면 효과가 없습니다.

cmake -B build -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"

JSON을 쓰는 main.cpp

#include <nlohmann/json.hpp>
#include <fstream>
#include <iostream>
using json = nlohmann::json;
int main() {
    json j;
    j["name"] = "CMake 예제";
    j["version"] = 1.0;
    j["tags"] = {"C++", "CMake", "JSON"};
    std::cout << j.dump(2) << std::endl;
    std::ofstream out("output.json");
    out << j.dump(2);
    return 0;
}

find_package 옵션

# 필수 라이브러리와 컴포넌트
find_package(Boost 1.70 REQUIRED COMPONENTS filesystem)
target_link_libraries(myapp PRIVATE Boost::filesystem)
# 선택적 라이브러리: 없으면 조용히 넘어가고 OpenCV_FOUND로 확인
find_package(OpenCV QUIET)
if(OpenCV_FOUND)
    message(STATUS "OpenCV found")
    target_link_libraries(myapp PRIVATE opencv_core)
endif()
# 최소 버전 지정 (1.2 이상)
find_package(ZLIB 1.2 REQUIRED)
target_link_libraries(myapp PRIVATE ZLIB::ZLIB)

find_package가 패키지를 찾는 방식

find_package에는 두 가지 모드가 있습니다. 모듈 모드는 CMAKE_MODULE_PATH와 CMake에 내장된 Find<Package>.cmake 스크립트를 찾아 실행합니다(FindZLIB, FindBoost 등). 설정(Config) 모드는 패키지가 설치하면서 함께 넣어 둔 <Package>Config.cmake 또는 <package>-config.cmake 파일을 찾습니다. 기본적으로는 모듈 모드를 먼저 시도하고 실패하면 설정 모드로 넘어갑니다.

설정 파일을 찾을 때는 <Package>_DIR 변수, CMAKE_PREFIX_PATH, 환경 변수 PATH에서 유추한 접두 경로, /usr·/usr/local 같은 시스템 경로 순으로 살펴봅니다(정확한 순서는 CMake 문서의 find_package 항목에 길게 정리되어 있습니다). vcpkg 툴체인 파일은 이 검색 경로에 vcpkg 설치 디렉터리를 추가하는 방식으로 동작합니다.


VS Code CMake Tools 확장으로 빌드·디버깅

VS Code 확장 탭(Ctrl+Shift+X)에서 “CMake Tools”를 설치하면 상태 표시줄에서 구성, 빌드, 실행을 할 수 있습니다. 처음에는 명령 팔레트(Ctrl+Shift+P)에서 “CMake: Select a Kit”으로 컴파일러를 고르고, “CMake: Configure”로 구성합니다. 이후에는 F7로 빌드하고, Shift+F5로 디버거 없이 실행합니다.

.vscode/settings.json

{
    "cmake.configureOnOpen": true,
    "cmake.buildDirectory": "${workspaceFolder}/build",
    "cmake.generator": "Ninja",
    "cmake.buildBeforeRun": true,
    "cmake.parallelJobs": 8
}

.vscode/launch.json (디버깅)

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "CMake 디버그",
            "type": "cppdbg",
            "request": "launch",
            "program": "${command:cmake.launchTargetPath}",
            "args": ["10", "+", "5"],
            "cwd": "${workspaceFolder}"
        }
    ]
}

program에 실행 파일 경로를 직접 적어도 되지만, ${command:cmake.launchTargetPath}를 쓰면 CMake Tools에서 선택한 실행 타겟의 경로가 들어가므로 생성기나 빌드 디렉터리를 바꿔도 고칠 필요가 없습니다. 이 구성은 디버깅 전에 빌드를 하지 않으므로, 먼저 F7로 빌드하거나 CMake Tools의 디버그 명령을 사용합니다.


자주 만나는 오류

cmake: command not found

bash: cmake: command not found

CMake가 설치되지 않았거나 PATH에 없습니다. 위의 설치 명령으로 설치한 뒤 cmake --version으로 확인합니다. macOS의 Apple Silicon에서 Homebrew로 설치했는데 인식되지 않는다면 /opt/homebrew/bin이 PATH에 있는지 확인합니다. cmake_minimum_required보다 설치된 버전이 낮다는 오류가 나면 brew upgrade cmake나 sudo snap install cmake --classic으로 새 버전을 받습니다.

라이브러리를 찾지 못함

Could not find a package configuration file provided by "nlohmann_json"

먼저 개발용 패키지가 설치되었는지 확인합니다. 표준 위치가 아닌 곳에 설치했다면 CMakeLists.txt를 고치기보다 명령줄로 경로를 알려 주는 편이 다른 사람의 환경을 깨뜨리지 않습니다.

# 설치 접두 경로를 알려 주기
cmake -B build -DCMAKE_PREFIX_PATH=/opt/mylibs
# 설정 파일이 있는 디렉터리를 직접 지정
cmake -B build -Dnlohmann_json_DIR=/opt/mylibs/share/cmake/nlohmann_json

헤더 파일을 찾지 못함

fatal error: operations.h: No such file or directory
fatal error: nlohmann/json.hpp: No such file or directory

헤더 검색 경로가 설정되지 않은 경우입니다. 자체 헤더라면 해당 타겟(또는 그 타겟이 링크하는 라이브러리)에 target_include_directories를 붙이고, find_package로 찾은 패키지라면 imported 타겟을 target_link_libraries에 넣으면 대부분 해결됩니다.

target_include_directories(myapp PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_link_libraries(myapp PRIVATE nlohmann_json::nlohmann_json)

undefined reference (링크 오류)

undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status

함수의 선언(헤더)은 보이지만 정의가 들어 있는 오브젝트 파일이나 라이브러리가 링크되지 않았다는 뜻입니다. 자체 코드라면 해당 .cpp가 add_executable이나 add_library 목록에 빠졌는지, 그 라이브러리를 target_link_libraries로 연결했는지 확인합니다. 외부 라이브러리라면 find_package만 하고 target_link_libraries를 빠뜨린 경우가 대부분입니다.

flowchart TD
    A[undefined reference 에러] --> B{어떤 심볼?}
    B --> C[자체 함수/클래스]
    B --> D[외부 라이브러리]
    C --> E[.cpp가 타겟 소스 목록에 있는가?]
    E --> F[target_link_libraries 추가]
    D --> G[find_package 호출했는가?]
    G --> H["target_link_libraries에 imported 타겟 추가"]
    F --> I[해결]
    H --> I

target_include_directories는 헤더 검색 경로만 추가할 뿐입니다. 헤더를 찾았다고 해서 구현 코드가 링크되는 것은 아니므로, 컴파일은 되는데 링크에서 실패한다면 target_link_libraries를 먼저 의심합니다.

캐시 때문에 설정이 바뀌지 않음

CMake는 구성 결과를 build/CMakeCache.txt에 저장합니다. 컴파일러를 바꾸었거나 캐시 변수에 잘못된 값이 들어갔다면 빌드 디렉터리를 지우고 다시 구성하는 것이 가장 확실합니다. CMake 3.24 이상에서는 cmake --fresh ..로 캐시만 버리고 다시 구성할 수도 있습니다.

rm -rf build
cmake -B build

빌드 속도: 병렬 빌드와 ccache

# CPU 코어 수만큼 병렬 빌드 (Linux)
cmake --build . -j$(nproc)
# macOS
cmake --build . -j$(sysctl -n hw.ncpu)

ccache는 컴파일러 호출을 가로채 입력(전처리된 소스, 컴파일 옵션 등)이 같으면 이전 결과를 재사용합니다. 증분 빌드는 원래 바뀐 파일만 다시 컴파일하므로, ccache가 효과를 보는 경우는 빌드 디렉터리를 지우고 다시 빌드할 때, 브랜치를 오가며 같은 파일을 다시 컴파일할 때, CI에서 캐시 디렉터리를 보존할 때입니다.

# Ubuntu
sudo apt install ccache
# macOS
brew install ccache
# CMake에서 ccache 사용
cmake -DCMAKE_CXX_COMPILER_LAUNCHER=ccache ..
cmake --build .

헤더가 무거운 프로젝트에서는 target_precompile_headers()(CMake 3.16 이상)로 미리 컴파일된 헤더를 쓰면 반복되는 헤더 파싱 시간을 줄일 수 있습니다.


add_subdirectory로 나눈 프로젝트 구조

프로젝트가 커지면 루트 CMakeLists.txt가 하위 디렉터리를 add_subdirectory로 포함하고, 각 디렉터리는 자기 타겟만 정의하는 구조를 씁니다.

myapp/
├── CMakeLists.txt              # 루트: 프로젝트 설정, 서브디렉터리 포함
├── src/
│   ├── CMakeLists.txt          # 메인 앱 빌드
│   ├── main.cpp
│   └── core/
│       ├── CMakeLists.txt      # core 라이브러리
│       ├── core.cpp
│       └── core.h
├── libs/
│   └── utils/
│       ├── CMakeLists.txt      # utils 라이브러리
│       ├── utils.cpp
│       └── utils.h
└── tests/
    └── CMakeLists.txt          # 테스트 타겟

루트 CMakeLists.txt:

cmake_minimum_required(VERSION 3.15)
project(MyApp VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_subdirectory(libs/utils)
add_subdirectory(src/core)
add_subdirectory(src)
add_subdirectory(tests)

하위 디렉터리 쪽 파일:

# libs/utils/CMakeLists.txt
add_library(utils STATIC utils.cpp)
target_include_directories(utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# src/core/CMakeLists.txt
add_library(core STATIC core.cpp)
target_include_directories(core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(core PRIVATE utils)  # core.h가 utils.h를 include한다면 PUBLIC

# src/CMakeLists.txt
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE core)

target_link_libraries에 적은 타겟 이름은 생성 단계에서 해석되므로, add_subdirectory 순서가 링크 관계를 좌우하지는 않습니다. 다만 if(TARGET utils)처럼 구성 중에 타겟이 이미 있는지 검사하는 코드가 있다면 순서가 영향을 줍니다. core가 utils를 PRIVATE으로 링크하면 myapp은 utils의 include 경로를 받지 못하므로, core.h가 utils.h를 include한다면 PUBLIC으로 바꿔야 합니다.

flowchart TB
    subgraph 루트[루트 CMakeLists.txt]
        R[project, C++ 표준]
    end
    subgraph 라이브러리[라이브러리 계층]
        U[utils]
        C[core]
    end
    subgraph 앱[애플리케이션]
        A[myapp]
    end
    subgraph 테스트[테스트]
        T[tests]
    end
    R --> U
    R --> C
    R --> A
    R --> T
    C --> U
    A --> C
    T --> C

CI에서는 위에서 다룬 Ninja와 ccache를 함께 쓰는 구성이 흔합니다.

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
cmake --build build

라이브러리를 다른 프로젝트에 배포하려면 install(TARGETS ...), install(DIRECTORY include/ ...)로 설치 규칙을 정의하고 Config.cmake를 생성해, 사용하는 쪽에서 find_package로 찾을 수 있게 합니다. 자세한 방법은 CMake 공식 문서의 Importing and Exporting Guide를 참고하세요.

다음 글 C++ 실전 가이드 #5: 컴파일 과정 분석에서는 CMake가 호출하는 컴파일러가 소스를 실행 파일로 만드는 과정을 전처리, 컴파일, 어셈블, 링크 단계로 나눠 살펴봅니다.

참고 자료


같이 보면 좋은 글