앞 글에서 textstat을 make로 빌드했다. 잘 돌았지만 두 가지를 남겼다. 플랫폼 분기가 전부 추측이었고(-lm이 필요한지 확인하지 않고 OS 이름으로 짐작했다), 윈도우의 nmake는 GNU 문법 첫 줄부터 거부했다.

같은 프로젝트를 CMake로 옮긴다. 무엇이 사라지는지, 그리고 CMake가 대신 무엇을 요구하는지 본다.

CMakeLists.txt 한 장

먼저 결과물이다. 이 파일 하나로 리눅스 Makefile, Ninja, Visual Studio 프로젝트가 전부 나온다.

cmake_minimum_required(VERSION 3.16)
project(textstat VERSION 1.0.0 LANGUAGES C)
 
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
 
# 1) 생성 파일 — sed 규칙 대신
set(VERSION ${PROJECT_VERSION})
configure_file(version.h.in ${CMAKE_CURRENT_BINARY_DIR}/version.h @ONLY)
 
# 2) 플랫폼별 소스 — ifeq/uname 대신
if(WIN32)
  set(PLATFORM_SRC src/platform_win32.c)
else()
  set(PLATFORM_SRC src/platform_posix.c)
endif()
 
# 3) 라이브러리
add_library(textstat_lib STATIC src/count.c src/histogram.c ${PLATFORM_SRC})
target_include_directories(textstat_lib
  PUBLIC  ${CMAKE_CURRENT_SOURCE_DIR}/include      # 쓰는 쪽에도 전파된다
  PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
 
# 4) libm이 필요한지 '확인'한다 — 짐작하지 않는다
include(CheckLibraryExists)
check_library_exists(m sqrt "" HAVE_LIBM)
if(HAVE_LIBM)
  target_link_libraries(textstat_lib PUBLIC m)
endif()
 
# 5) 실행 파일
add_executable(textstat app/main.c)
target_link_libraries(textstat PRIVATE textstat_lib)
target_include_directories(textstat PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
 
# 6) 테스트
enable_testing()
add_executable(test_count tests/test_count.c)
target_link_libraries(test_count PRIVATE textstat_lib)
add_test(NAME count COMMAND test_count)

make 판보다 짧지도 않다. 줄 수로는 비슷하다. 달라진 건 적는 내용의 성격이다 — 무엇을 실행할지가 아니라 무엇이 무엇인지를 적는다.

단계가 하나 늘어난다 — 구성(configure)

make는 make 한 번이면 끝이었다. CMake는 두 단계다.

$ cmake -S . -B build-make -DCMAKE_BUILD_TYPE=Release   # 구성: 빌드 파일을 만든다
-- Check for working C compiler: /usr/bin/cc - skipped
-- Detecting C compile features - done
-- Looking for sqrt in m
-- Looking for sqrt in m - found
-- Configuring done
-- Generating done
-- Build files have been written to: .../build-make
 
$ cmake --build build-make                               # 빌드: 만들어진 것을 돌린다
[ 50%] Linking C static library libtextstat_lib.a
[ 75%] Linking C executable textstat
[100%] Linking C executable test_count

-B build-make소스 밖 빌드다. 산출물이 소스 트리를 어지럽히지 않고, 구성별로 디렉터리를 나눠 병행할 수 있다. make에서 build/$(BUILD)/ 경로를 손으로 조립하던 일이 기본값이 된다.

cmake --build도 눈여겨볼 만하다. 뒤에 뭐가 있든(make든 ninja든 MSBuild든) 호출하는 명령은 하나다. CI 스크립트가 플랫폼별로 갈라지지 않는다.

짐작하지 않고 확인한다

이 글에서 가장 중요한 두 줄이다.

include(CheckLibraryExists)
check_library_exists(m sqrt "" HAVE_LIBM)

CMake는 이 지점에서 실제로 시험 링크를 해 본다. 같은 파일을 두 OS에서 구성하면 결과가 갈린다.

# 리눅스
-- Looking for sqrt in m - found
 
# 윈도우 (MSVC)
-- Looking for sqrt in m - not found

그래서 리눅스 링크 라인에는 -lm이 붙고 윈도우에서는 붙지 않는다. make 판에서 ifeq ($(UNAME_S),Linux)로 짐작하던 것을, 1991년에 Autotools가 하던 방식 그대로 확인해서 정한다. 다만 M4와 셸 스크립트가 아니라 CMake 함수 두 줄이다.

macOS는 libm이 libSystem에 있으니 -lm이 없어도 된다 같은 주석도 이제 필요 없다. 확인 결과가 답을 준다.

헤더 의존성은 그냥 된다

make에서는 -MMD -MP-include $(DEP)를 손으로 엮어야 했다. CMake에서는 아무것도 하지 않는다.

$ touch include/textstat.h && cmake --build build-make
[ 12%] Building C object CMakeFiles/textstat_lib.dir/src/count.c.o
[ 25%] Building C object CMakeFiles/textstat_lib.dir/src/histogram.c.o
[ 37%] Building C object CMakeFiles/textstat_lib.dir/src/platform_posix.c.o
[ 50%] Linking C static library libtextstat_lib.a
[ 62%] Building C object CMakeFiles/textstat.dir/app/main.c.o
[ 75%] Linking C executable textstat

생성 헤더(version.h)의 순서 문제도 사라진다. make에서 순서 전용 의존성(| version.h)을 몰라 첫 빌드가 깨졌던 그 문제는, configure_file구성 단계에서 파일을 만들기 때문에 애초에 생기지 않는다.

대신 다른 함정을 만났다. version.h.in@VERSION@은 CMake 변수 VERSION을 찾는데, project(... VERSION 1.0.0)이 채우는 건 PROJECT_VERSION이다. 그대로 두면 조용히 빈 문자열이 들어가 textstat --version이 버전 없이 출력된다. set(VERSION ${PROJECT_VERSION}) 한 줄로 해결했다. 치환은 실패해도 오류를 내지 않으니 결과를 눈으로 확인해야 한다.

타깃과 사용 요구사항

target_include_directories(textstat_lib PUBLIC include)PUBLIC이 모던 CMake의 핵심이다.

  • PRIVATE — 나를 빌드할 때만 쓴다
  • PUBLIC — 나를 빌드할 때도 쓰고, 나를 링크하는 쪽에도 전파된다
  • INTERFACE — 나는 안 쓰지만 나를 쓰는 쪽에 전파된다(헤더 전용 라이브러리)

그래서 textstat 실행 파일 쪽에는 include/ 경로를 적지 않았다. textstat_lib을 링크하는 것만으로 따라온다. -lmPUBLIC으로 걸어 뒀으니 라이브러리를 쓰는 쪽이 알 필요가 없다.

이게 make와 갈리는 지점이다. Makefile에서는 라이브러리를 쓰는 쪽이 그 라이브러리에 필요한 플래그를 직접 알아내 다시 적어야 했다. 의존성이 늘수록 그 중복이 곱해진다.

같은 파일, 네 가지 빌드

같은 CMakeLists.txt로 실제로 만들어 본 것들이다.

명령결과
cmake -B build-makeMakefilemake로 빌드
cmake -B build-ninja -G Ninjabuild.ninja — 8개 작업으로 병렬 빌드
cmake -B build-vs -G "Visual Studio 18 2026"textstat.slnx + textstat.vcxproj, test_count.vcxproj, ALL_BUILD, RUN_TESTS
vcvars 안에서 -G NinjaMSVC 컴파일러로 도는 Ninja 빌드

윈도우 쪽 실제 출력이다.

$ cmake --build build-vs --config Release
  textstat_lib.vcxproj -> ...\build-vs\Release\textstat_lib.lib
  test_count.vcxproj   -> ...\build-vs\Release\test_count.exe
  textstat.vcxproj     -> ...\build-vs\Release\textstat.exe
 
$ .\build-vs\Release\textstat.exe --version
textstat 1.0.0

ALL_BUILDRUN_TESTS 프로젝트는 CMake가 넣어 준 것이다. Visual Studio를 열면 솔루션 탐색기에서 평소처럼 F5로 돌릴 수 있다. 윈도우 개발자에게 “이제부터 make를 쓰라”고 요구하지 않는다 — 2편에서 nmake가 막았던 벽이 이렇게 없어진다.

테스트는 CTest가 받는다

make에서는 test: 타겟을 만들고 ./run_tests를 직접 호출했다. CMake에서는 테스트가 빌드 시스템이 아는 대상이 된다.

# 리눅스
$ ctest --output-on-failure
    Start 1: count
1/1 Test #1: count ............................   Passed    0.00 sec
100% tests passed, 0 tests failed out of 1
 
# 윈도우
$ ctest -C Release
1/1 Test #1: count ............................   Passed    0.13 sec
100% tests passed, 0 tests failed out of 1

테스트가 여러 개가 되면 -j로 병렬 실행하고, -R로 골라 돌리고, --rerun-failed로 실패한 것만 다시 돌릴 수 있다. 이런 걸 Makefile에 직접 만들어 넣을 이유는 없다.

구성(Debug/Release)이 진짜로 갈린다

플래그를 손으로 관리하지 않는다. 구성 이름만 주면 된다.

$ cmake -B out-dbg -G Ninja -DCMAKE_BUILD_TYPE=Debug
$ cmake -B out-rel -G Ninja -DCMAKE_BUILD_TYPE=Release
 
Debug   컴파일 플래그: -g
Release 컴파일 플래그: -O3 -DNDEBUG

여기서 제너레이터에 따라 방식이 갈리니 알아 둘 필요가 있다.

  • 단일 구성(Unix Makefiles, Ninja) — 구성 단계에서 -DCMAKE_BUILD_TYPE=으로 정한다. 디버그와 릴리즈를 함께 두려면 빌드 디렉터리를 둘 만든다
  • 다중 구성(Visual Studio, Xcode, Ninja Multi-Config) — 구성 단계에서는 정하지 않고 빌드할 때 --config Release로 고른다

같은 CMakeLists.txt인데 사용법이 다르다. CI 스크립트를 쓸 때 자주 걸리는 자리다.

프리셋 — 명령줄 인자를 파일로

-G Ninja -DCMAKE_BUILD_TYPE=Debug -B build/dev …를 매번 치는 대신, CMakePresets.json에 적어 둔다(CMake 3.19+).

{
  "version": 3,
  "configurePresets": [
    {
      "name": "dev",
      "generator": "Ninja",
      "binaryDir": "build/dev",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
    },
    {
      "name": "release",
      "generator": "Ninja",
      "binaryDir": "build/release",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" }
    }
  ],
  "buildPresets": [
    { "name": "dev", "configurePreset": "dev" },
    { "name": "release", "configurePreset": "release" }
  ]
}
$ cmake --list-presets
Available configure presets:
  "dev"
  "release"
 
$ cmake --preset dev && cmake --build --preset dev

팀원과 CI가 같은 명령을 쓰게 만드는 장치다. “내 로컬에서는 되는데”의 상당수가 서로 다른 구성 인자에서 온다.

덤으로 따라오는 것 둘

빌드 파일이 스스로를 갱신한다. CMakeLists.txt를 고치고 빌드만 해도 구성 단계가 다시 돈다.

$ touch CMakeLists.txt && cmake --build build/dev
[0/1] Re-running CMake...
-- Configuring done
-- Generating done

에디터가 컴파일 옵션을 알게 된다. CMAKE_EXPORT_COMPILE_COMMANDS ONcompile_commands.json을 만들고, clangd·VS Code·CLion이 이걸 읽어 정확한 자동완성과 진단을 준다.

{
  "directory": ".../build-make",
  "command": "/usr/bin/cc -I.../include -I.../build-make -O3 -DNDEBUG -std=gnu11 -o ... -c .../src/count.c",
  "file": ".../src/count.c"
}

Makefile로는 이걸 얻으려고 bear 같은 도구로 빌드를 가로채야 했다.

CMake가 대신 요구하는 것

공짜는 아니다. 정직하게 적으면 이렇다.

  • 구성 단계와 캐시가 생긴다. CMakeCache.txt에 한 번 정해진 값(컴파일러, 옵션)은 계속 남는다. 컴파일러를 바꾸려면 캐시를 지우거나 빌드 디렉터리를 새로 만들어야 한다. “왜 옛 설정으로 도는가”의 답은 대개 캐시다
  • 언어가 하나 더 늘어난다. CMake 스크립트 언어는 변수 확장과 리스트 처리에 독특한 규칙이 있고, 오류 메시지가 친절하지 않다
  • 옛 예제가 섞여 든다. include_directories·add_definitions 같은 디렉터리 전역 명령이 아직도 검색 상위에 나온다. 타깃 방식과 섞으면 어디서 온 플래그인지 추적하기 어려워진다
  • 제너레이터마다 세부가 다르다. 위의 단일/다중 구성 차이가 대표적이다

그럼에도 남는 계산은 분명하다. 2편에서 손으로 짜던 조건 분기·헤더 의존성·테스트 실행·구성 관리가 전부 없어졌고, 윈도우가 1급 시민이 됐다.

정리

make 판에서 손으로 하던 것CMake 판
sedversion.h 생성 + 순서 전용 의존성configure_file (구성 단계에서 생성)
ifeq ($(UNAME_S),Linux)if(WIN32) / check_library_exists확인
-MMD -MP + -include $(DEP)자동
라이브러리 쓰는 쪽이 플래그 재기입PUBLIC 사용 요구사항으로 전파
test: 타겟에서 직접 실행add_test + CTest
BUILD=debug 분기와 디렉터리 조립CMAKE_BUILD_TYPE / --config
윈도우는 별도 Makefile 필요같은 파일로 .slnx + .vcxproj 생성

다음 글 Meson과 Bazel은 무엇을 노리는가에서는 최신 도구들이 여기서 무엇을 더 노리는지 본다. CMake가 “어디서든 빌드된다”를 풀었다면, 그다음 질문은 “같은 입력이면 같은 결과가 나오는가, 그리고 남이 빌드한 걸 받아 쓸 수 있는가” 다.

이 글의 모든 명령과 출력은 직접 실행한 것이다. 리눅스는 WSL2 우분투 22.04(CMake 3.22.1, GNU Make 4.3, Ninja 1.10.1), 윈도우는 CMake 4.2.1 + Visual Studio 2026(MSVC 14.51) + Ninja 1.12.0이다.

참고