C++ CMake 고급 | 멀티 타겟·외부 라이브러리 관리 (대규모 프로젝트 빌드)
들어가며: 프로젝트가 커지면 빌드가 복잡해진다
실행 파일과 라이브러리가 여러 개인 프로젝트에서 소스 목록과 include 경로, 컴파일 옵션을 전역 변수로 관리하면, 새 라이브러리를 하나 추가할 때마다 엉뚱한 곳의 빌드가 깨지기 쉽습니다. 현대적인 CMake는 이 문제를 타겟 단위로 풉니다. add_library로 공통 코드를 라이브러리 타겟으로 만들고, 각 타겟에 include 경로·옵션·의존성을 붙인 뒤, target_link_libraries로 의존 관계만 선언하면 CMake가 필요한 설정을 사용하는 쪽으로 전파합니다.
이 글은 CMake 입문의 기본 사용법을 안다는 전제에서, 멀티 타겟 구성, 의존성 전파 규칙, Generator Expression, 외부 라이브러리 연동, 설치·패키징, 자주 나는 오류를 다룹니다. 예제는 CMake 3.16 이상(일부 기능은 버전을 따로 표시)과 GCC·Clang·MSVC를 기준으로 합니다.
같은 소스를 여러 타겟에 나열하는 문제
# 공통 소스를 모든 실행 파일에 반복 나열
add_executable(app1 main1.cpp util.cpp db.cpp)
add_executable(app2 main2.cpp util.cpp db.cpp)
add_executable(app3 main3.cpp util.cpp db.cpp)
이렇게 쓰면 util.cpp와 db.cpp가 타겟마다 따로 컴파일되어 세 번씩 빌드되고, 새 앱을 추가할 때마다 같은 목록을 복사해야 하며, db.cpp에만 필요한 include 경로나 정의를 세 곳에 맞춰 넣어야 합니다.
# 공통 코드를 라이브러리 타겟으로 분리
add_library(util STATIC util.cpp)
add_library(db STATIC db.cpp)
add_executable(app1 main1.cpp)
target_link_libraries(app1 PRIVATE util db)
add_executable(app2 main2.cpp)
target_link_libraries(app2 PRIVATE util db)
util과 db는 한 번만 컴파일되고, 각 실행 파일은 자기 main만 컴파일한 뒤 링크합니다. db가 필요로 하는 include 경로나 외부 라이브러리는 db 타겟에 한 번만 붙여 두면 링크하는 쪽으로 전파됩니다.
flowchart TB
subgraph apps[실행 파일]
app1[app1]
app2[app2]
app3[app3]
end
subgraph libs[라이브러리]
util[util]
db[db]
end
subgraph external[외부 의존성]
boost[Boost]
json[nlohmann_json]
end
app1 --> util
app1 --> db
app2 --> util
app2 --> db
app3 --> util
app3 --> db
db --> boost
app1 --> json
프로젝트 구조
권장 디렉터리 구조
루트 CMakeLists.txt에서는 프로젝트 전역 설정만 하고, add_subdirectory로 src·lib·tests·external을 나눕니다. 각 서브디렉터리의 CMakeLists.txt는 그 폴더의 타겟만 정의합니다. 이렇게 하면 어떤 모듈의 빌드 설정을 바꿀 때 그 폴더만 보면 됩니다.
myproject/
├── CMakeLists.txt # 루트
├── cmake/ # 공용 CMake 모듈
├── src/
│ ├── CMakeLists.txt
│ └── main.cpp
├── lib/
│ ├── CMakeLists.txt
│ └── mylib/
│ ├── CMakeLists.txt
│ ├── include/mylib/mylib.h
│ └── src/mylib.cpp
├── tests/
│ ├── CMakeLists.txt
│ └── test_mylib.cpp
└── external/
└── CMakeLists.txt
루트 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# 단일 구성 생성기(Makefile, Ninja)에서 빌드 타입을 안 주면 Release로
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
include(CTest) # BUILD_TESTING 옵션과 enable_testing()을 함께 제공
add_subdirectory(external)
add_subdirectory(lib)
add_subdirectory(src)
if(BUILD_TESTING)
add_subdirectory(tests)
endif()
빌드 타입 기본값은 CMAKE_BUILD_TYPE이 비어 있을 때만 의미가 있습니다. Visual Studio나 Ninja Multi-Config 같은 다중 구성 생성기는 CMAKE_BUILD_TYPE을 쓰지 않고 빌드할 때 --config Release로 구성을 고르므로, 위처럼 CMAKE_CONFIGURATION_TYPES가 없을 때만 설정합니다.
enable_testing()은 루트에서 호출해야 빌드 디렉터리 최상단에서 ctest를 실행했을 때 하위 디렉터리의 테스트까지 찾습니다. include(CTest)가 이를 대신해 줍니다.
타겟과 라이브러리
라이브러리 종류
STATIC:.a(Linux·macOS) 또는.lib(Windows) 정적 라이브러리. 링크할 때 필요한 오브젝트가 실행 파일 안으로 복사됩니다.SHARED:.so·.dylib·.dll동적 라이브러리. 실행할 때 로드됩니다.INTERFACE: 컴파일할 소스가 없는 타겟. 헤더 전용 라이브러리나, 컴파일 옵션 묶음을 전파하는 용도로 씁니다.
add_library(mylib STATIC src/mylib.cpp src/utils.cpp) # 정적
add_library(mylib_shared SHARED src/mylib.cpp src/utils.cpp) # 동적
add_library(header_only INTERFACE) # 헤더 전용
target_include_directories(header_only INTERFACE include/)
add_library(mylib src/mylib.cpp)처럼 종류를 생략하면 BUILD_SHARED_LIBS 옵션 값에 따라 정적 또는 동적으로 만들어집니다. 라이브러리를 배포할 때 사용자가 고를 수 있게 하려면 종류를 생략하는 편이 낫습니다.
타겟 속성 설정
add_library(mylib STATIC src/mylib.cpp)
target_include_directories(mylib
PUBLIC include/ # mylib를 쓰는 쪽도 필요
PRIVATE src/internal/ # mylib 내부에서만 사용
)
target_compile_options(mylib PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
target_link_libraries(mylib
PUBLIC dependency1 # mylib 공개 헤더가 dependency1 타입을 노출
PRIVATE dependency2 # 구현에서만 사용
)
target_include_directories의 PUBLIC 경로는 mylib 자신을 컴파일할 때도 쓰이고, mylib를 링크하는 타겟에도 전파됩니다. PRIVATE 경로는 mylib 컴파일에만 쓰입니다. 컴파일 옵션은 대개 PRIVATE으로 둡니다. -Werror 같은 옵션을 PUBLIC으로 걸면 그 라이브러리를 쓰는 모든 프로젝트의 경고가 에러로 바뀌기 때문입니다.
PUBLIC vs PRIVATE vs INTERFACE
add_library(A STATIC a.cpp)
add_library(B STATIC b.cpp)
add_library(C STATIC c.cpp)
target_link_libraries(B PUBLIC A) # B의 공개 헤더가 A의 타입을 씀
target_link_libraries(C PRIVATE B) # C는 B만 알면 됨
# → C를 컴파일할 때 A의 include 경로도 쓰이고, 최종 링크에 A도 포함됨
B PUBLIC A는 “B를 사용하는 쪽도 A가 필요하다”는 선언입니다. 그래서 C가 B만 링크해도 A의 사용 요구 사항(include 경로, 정의, 링크)이 C에 전달됩니다. B가 구현에서만 A를 쓴다면 PRIVATE으로 두어 C가 A의 헤더를 보지 못하게 합니다. 정적 라이브러리는 PRIVATE 의존성도 최종 실행 파일의 링크 줄에는 포함되는데, 정적 라이브러리 자체는 링크 단계가 없어 실행 파일이 대신 A를 링크해야 하기 때문입니다. INTERFACE는 B 자신은 A가 필요 없고 B를 쓰는 쪽만 필요할 때 씁니다.
타겟 속성 상세
주요 타겟 속성
| 속성 | 용도 | 설정 방법 |
|---|---|---|
INCLUDE_DIRECTORIES | include 경로 | target_include_directories |
COMPILE_OPTIONS | 컴파일 플래그 | target_compile_options |
COMPILE_DEFINITIONS | 매크로 정의 | target_compile_definitions |
LINK_LIBRARIES | 링크할 라이브러리 | target_link_libraries |
CXX_STANDARD | C++ 표준 | set_target_properties 또는 target_compile_features(t PUBLIC cxx_std_17) |
POSITION_INDEPENDENT_CODE | PIC (정적 라이브러리를 공유 라이브러리에 넣을 때 필요) | ON |
OUTPUT_NAME | 출력 파일명 | mylib → libmylib.a |
VERSION / SOVERSION | 공유 라이브러리 버전 | 1.2.3 / 1 |
get_target_property(inc_dirs mylib INCLUDE_DIRECTORIES)
message(STATUS "mylib include dirs: ${inc_dirs}")
set_target_properties(mylib PROPERTIES
POSITION_INDEPENDENT_CODE ON
VERSION 1.2.3
SOVERSION 1
)
C++ 표준은 target_compile_features(mylib PUBLIC cxx_std_17)로 지정하면 “이 라이브러리를 쓰려면 최소 C++17이 필요하다”는 요구 사항까지 사용자 쪽으로 전파됩니다.
Generator Expression
Generator Expression($<...>)은 configure 단계가 아니라 빌드 파일을 생성하는 단계에서 평가됩니다. 그래서 다중 구성 생성기에서 구성별로 다른 값을 주거나, 타겟의 최종 출력 경로처럼 configure 시점에는 아직 모르는 정보를 참조할 수 있습니다.
# Debug 구성에서만 정의
target_compile_definitions(mylib PRIVATE $<$<CONFIG:Debug>:DEBUG_MODE>)
# 빌드 트리 vs 설치 후 include 경로
target_include_directories(mylib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
# 구성별로 다른 라이브러리 링크
target_link_libraries(mylib PRIVATE
$<$<CONFIG:Debug>:debug_lib>
$<$<NOT:$<CONFIG:Debug>>:release_lib>
)
| 표현식 | 의미 |
|---|---|
$<CONFIG:Debug> | 현재 구성이 Debug이면 1 |
$<CXX_COMPILER_ID:GNU,Clang> | 컴파일러가 목록 중 하나이면 1 (목록 형식은 CMake 3.15+) |
$<BUILD_INTERFACE:...> / $<INSTALL_INTERFACE:...> | 빌드 트리에서 쓸 때 / 설치된 패키지에서 쓸 때의 값 |
$<TARGET_FILE:tgt> | 타겟 출력 파일의 전체 경로 |
$<AND:a,b> / $<OR:a,b> / $<NOT:a> | 조건 조합 |
$<조건:값> | 조건이 1이면 값, 0이면 빈 문자열 |
# 테스트에서 실행 파일 경로 참조
add_test(NAME myapp_test COMMAND $<TARGET_FILE:myapp> --test)
# 구성별 출력 디렉터리
set_target_properties(myapp PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_DEBUG ${CMAKE_BINARY_DIR}/bin/Debug
RUNTIME_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR}/bin/Release
)
공백이 들어간 값(-Wall -Wextra)을 generator expression 안에 쓰면 CMake가 이를 하나의 인자로 묶지 않고 나눠서 해석하는데, target_compile_options에서는 의도대로 동작하지만 다른 명령에서는 따옴표로 감싸야 할 수 있습니다. 결과를 확인하려면 file(GENERATE OUTPUT debug.txt CONTENT "$<...>")로 평가 결과를 파일에 써 보는 방법이 편합니다.
INTERFACE 라이브러리로 공통 옵션 전파
add_library(project_warnings INTERFACE)
target_compile_options(project_warnings INTERFACE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
add_library(mylib STATIC mylib.cpp)
target_link_libraries(mylib PRIVATE project_warnings)
경고 옵션을 전역 CMAKE_CXX_FLAGS에 넣으면 FetchContent로 가져온 외부 라이브러리에도 적용되어 남의 코드 경고가 쏟아집니다. INTERFACE 타겟을 내 타겟에만 PRIVATE으로 링크하면 적용 범위를 정확히 제한할 수 있습니다.
외부 라이브러리 관리
find_package
find_package(Boost 1.70 REQUIRED COMPONENTS filesystem)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE Boost::filesystem)
find_package는 이미 설치된 라이브러리를 찾습니다. 라이브러리가 제공하는 <Name>Config.cmake를 CMAKE_PREFIX_PATH와 시스템 경로에서 찾는 Config 모드와, CMake나 프로젝트가 가진 Find<Name>.cmake 스크립트(CMAKE_MODULE_PATH에서 검색)를 실행하는 Module 모드가 있습니다. 찾으면 Boost::filesystem 같은 imported 타겟이 생기고, 그것을 링크하면 include 경로와 라이브러리 경로가 함께 붙습니다. 타겟 이름은 라이브러리마다 다르므로 해당 문서를 확인합니다. REQUIRED를 주면 찾지 못했을 때 configure 단계에서 바로 실패합니다.
FetchContent (CMake 3.14+)
include(FetchContent)
FetchContent_Declare(
json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.2
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(json)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE nlohmann_json::nlohmann_json)
FetchContent는 configure 단계에서 소스를 내려받아 add_subdirectory처럼 내 빌드에 포함시킵니다. GIT_TAG로 버전을 고정하면 시스템에 무엇이 설치되어 있든 같은 버전으로 빌드되어 재현성이 좋아집니다. GIT_SHALLOW TRUE는 전체 히스토리 대신 해당 커밋만 받아 CI에서 다운로드 시간을 줄여 줍니다(태그나 브랜치 이름일 때만 동작하고, 커밋 해시에는 쓸 수 없습니다). 저장소가 크다면 Git 대신 릴리스 아카이브 URL과 URL_HASH를 쓰는 편이 더 빠르고 무결성 검증도 됩니다.
의존성의 옵션 바꾸기
FetchContent로 가져온 프로젝트의 옵션은 FetchContent_Declare에 CMAKE_ARGS로 넘길 수 없습니다(그 인자는 ExternalProject용이라 무시됩니다). 대신 FetchContent_MakeAvailable 전에 캐시 변수를 설정합니다.
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.1.1
GIT_SHALLOW TRUE
)
set(FMT_DOC OFF CACHE BOOL "" FORCE)
set(FMT_TEST OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(fmt)
하위 프로젝트가 option()으로 선언한 옵션을 일반 변수(set(FMT_TEST OFF))로 덮으려면 그 프로젝트가 정책 CMP0077(CMake 3.13+)을 NEW로 쓰고 있어야 합니다. 캐시 변수로 설정하면 정책과 무관하게 동작합니다.
find_package와 FetchContent 함께 쓰기
시스템에 설치된 패키지가 있으면 그것을 쓰고, 없으면 내려받는 패턴입니다.
include(FetchContent)
FetchContent_Declare(
json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.2
FIND_PACKAGE_ARGS 3.10 NAMES nlohmann_json # CMake 3.24+
)
FetchContent_MakeAvailable(json) # 먼저 find_package를 시도하고, 없으면 다운로드
target_link_libraries(myapp PRIVATE nlohmann_json::nlohmann_json)
CMake 3.24 미만이라면 find_package(nlohmann_json 3.10 QUIET) 후 if(NOT nlohmann_json_FOUND) 안에서 FetchContent를 호출하는 방식으로 같은 효과를 냅니다. 반대로 OVERRIDE_FIND_PACKAGE를 주면, 다른 하위 프로젝트가 find_package(json)을 호출할 때 시스템 패키지 대신 FetchContent로 받은 것을 쓰게 만들 수 있습니다.
ExternalProject
ExternalProject_Add는 configure가 아니라 빌드 단계에서 외부 프로젝트를 내려받아 별도 디렉터리에서 configure·빌드·설치합니다. CMake가 아닌 빌드 시스템(autotools, make)을 쓰는 라이브러리나, 내 빌드 옵션과 완전히 분리해 빌드해야 하는 의존성에 맞습니다. 대신 configure 시점에는 결과물이 없으므로 imported 타겟을 직접 만들어 연결해야 합니다.
include(ExternalProject)
set(SPDLOG_INSTALL_DIR ${CMAKE_BINARY_DIR}/external/spdlog)
ExternalProject_Add(spdlog_ext
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.12.0
GIT_SHALLOW TRUE
CMAKE_ARGS -DCMAKE_INSTALL_PREFIX=${SPDLOG_INSTALL_DIR}
-DCMAKE_INSTALL_LIBDIR=lib
-DCMAKE_BUILD_TYPE=Release
-DSPDLOG_BUILD_SHARED=OFF
-DSPDLOG_BUILD_EXAMPLE=OFF
-DSPDLOG_BUILD_TESTS=OFF
BUILD_BYPRODUCTS ${SPDLOG_INSTALL_DIR}/lib/libspdlog.a
)
# configure 시점에 존재하지 않는 경로를 INTERFACE_INCLUDE_DIRECTORIES에 넣으면 에러가 나므로 미리 생성
file(MAKE_DIRECTORY ${SPDLOG_INSTALL_DIR}/include)
add_library(spdlog::spdlog STATIC IMPORTED)
set_target_properties(spdlog::spdlog PROPERTIES
IMPORTED_LOCATION ${SPDLOG_INSTALL_DIR}/lib/libspdlog.a
INTERFACE_INCLUDE_DIRECTORIES ${SPDLOG_INSTALL_DIR}/include
INTERFACE_COMPILE_DEFINITIONS SPDLOG_COMPILED_LIB
)
add_dependencies(spdlog::spdlog spdlog_ext)
BUILD_BYPRODUCTS는 Ninja 생성기에서 특히 중요합니다. 이것이 없으면 Ninja는 libspdlog.a를 만드는 규칙을 모르기 때문에 “missing and no known rule to make it” 에러를 냅니다. CMAKE_INSTALL_LIBDIR=lib은 일부 배포판이 lib64에 설치해 경로가 어긋나는 것을 막습니다. 라이브러리 파일명은 플랫폼마다 다르므로(Windows는 spdlog.lib) 실제 프로젝트에서는 CMAKE_STATIC_LIBRARY_PREFIX·SUFFIX 변수로 경로를 만듭니다.
코드 생성: add_custom_command와 add_custom_target
Protocol Buffers나 IDL 컴파일러처럼 빌드 중에 소스를 생성해야 한다면 add_custom_command로 생성 규칙을 정의하고, 그 출력 파일을 어떤 타겟의 소스로 연결합니다.
find_package(Protobuf REQUIRED)
set(PROTO_FILES proto/msg.proto)
set(GENERATED_PROTOBUF_SRCS)
foreach(PROTO_FILE ${PROTO_FILES})
get_filename_component(ABS_PROTO ${PROTO_FILE} ABSOLUTE)
get_filename_component(PROTO_DIR ${ABS_PROTO} DIRECTORY)
get_filename_component(PROTO_NAME ${PROTO_FILE} NAME_WE)
set(GEN_PB_CC ${CMAKE_CURRENT_BINARY_DIR}/${PROTO_NAME}.pb.cc)
set(GEN_PB_H ${CMAKE_CURRENT_BINARY_DIR}/${PROTO_NAME}.pb.h)
list(APPEND GENERATED_PROTOBUF_SRCS ${GEN_PB_CC} ${GEN_PB_H})
add_custom_command(
OUTPUT ${GEN_PB_CC} ${GEN_PB_H}
COMMAND protobuf::protoc
ARGS --cpp_out=${CMAKE_CURRENT_BINARY_DIR} -I${PROTO_DIR} ${ABS_PROTO}
DEPENDS ${ABS_PROTO}
)
endforeach()
add_library(proto_lib ${GENERATED_PROTOBUF_SRCS})
target_include_directories(proto_lib PUBLIC ${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(proto_lib PUBLIC protobuf::libprotobuf)
.proto 파일이 바뀌면 DEPENDS 덕분에 다시 생성되고, 생성된 파일이 proto_lib의 소스이므로 빌드 순서도 자동으로 맞춰집니다. CMake의 FindProtobuf 모듈과 protobuf 패키지는 이 과정을 감싼 protobuf_generate() 함수도 제공합니다. protoc의 -I는 .proto 파일이 있는 디렉터리(또는 그 상위)를 가리켜야 하며, 그렇지 않으면 “File does not reside within any path specified using —proto_path” 에러가 납니다.
출력 파일이 없는 작업(문서 생성 등)은 add_custom_target으로 만듭니다. 이 타겟은 출력이 없으므로 빌드할 때마다 실행됩니다.
add_custom_target(generate_docs
COMMAND doxygen ${CMAKE_SOURCE_DIR}/Doxyfile
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Running documentation generator"
)
ALL을 주지 않으면 기본 빌드에 포함되지 않으므로 cmake --build build --target generate_docs로 따로 실행합니다. 다른 타겟에 add_dependencies(myapp generate_docs)를 걸면 myapp을 빌드할 때마다 문서 생성도 돌게 되므로, 꼭 필요할 때만 연결합니다.
configure_file로 버전 헤더 생성
project(MyProject VERSION 1.2.3)
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/version.h.in
${CMAKE_CURRENT_BINARY_DIR}/generated/version.h
@ONLY
)
target_include_directories(mylib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_BINARY_DIR}/generated>
)
// version.h.in
#define PROJECT_VERSION_MAJOR @MyProject_VERSION_MAJOR@
#define PROJECT_VERSION_MINOR @MyProject_VERSION_MINOR@
#define PROJECT_VERSION_PATCH @MyProject_VERSION_PATCH@
configure_file은 configure 단계에서 실행되어 @VAR@ 자리를 변수 값으로 바꿉니다. @ONLY를 주면 ${VAR} 형태는 치환하지 않아, 템플릿 안에 쉘 스크립트나 CMake 문법이 섞여 있어도 안전합니다.
빌드 옵션과 설정
옵션 정의
option(BUILD_EXAMPLES "Build examples" OFF)
option(ENABLE_LOGGING "Enable logging" ON)
if(BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
if(ENABLE_LOGGING)
target_compile_definitions(mylib PRIVATE ENABLE_LOGGING)
endif()
cmake -B build -DBUILD_EXAMPLES=ON처럼 configure할 때 값을 바꿉니다. 옵션은 캐시에 저장되므로, 한 번 바꾼 값은 다음 configure에도 유지됩니다. 기본값을 바꿨는데 반영되지 않는다면 build/CMakeCache.txt에 이전 값이 남아 있는 것입니다.
빌드 타입별 컴파일 플래그
CMake는 빌드 타입마다 기본 플래그를 정해 둡니다. GCC·Clang 기준으로 Debug는 -g, Release는 -O3 -DNDEBUG, RelWithDebInfo는 -O2 -g -DNDEBUG, MinSizeRel은 -Os -DNDEBUG입니다(MSVC는 각각 /Od, /O2 등 대응 플래그). RelWithDebInfo에도 NDEBUG가 정의되어 assert가 꺼진다는 점을 기억해 둘 만합니다.
이 기본값을 set(CMAKE_CXX_FLAGS_RELEASE "...")로 통째로 덮어쓰면 다른 컴파일러에서 맞지 않는 플래그가 들어가거나 NDEBUG가 빠지는 실수가 생기기 쉽습니다. 플래그를 추가하고 싶다면 generator expression으로 타겟에 붙이는 편이 안전합니다.
target_compile_options(mylib PRIVATE
$<$<AND:$<CONFIG:Release>,$<CXX_COMPILER_ID:GNU,Clang>>:-march=x86-64-v2>
)
플랫폼별 설정
find_package(Threads REQUIRED)
target_link_libraries(myapp PRIVATE Threads::Threads)
if(WIN32)
target_compile_definitions(myapp PRIVATE PLATFORM_WINDOWS)
target_link_libraries(myapp PRIVATE ws2_32)
elseif(APPLE)
target_compile_definitions(myapp PRIVATE PLATFORM_MACOS)
elseif(UNIX)
target_compile_definitions(myapp PRIVATE PLATFORM_LINUX)
endif()
스레드 라이브러리는 pthread를 직접 적기보다 find_package(Threads)가 만드는 Threads::Threads를 링크합니다. 플랫폼마다 필요한 플래그(-pthread 또는 아무것도 없음)를 CMake가 알아서 고릅니다. WIN32, APPLE, UNIX는 대상 플랫폼 변수이고, macOS에서는 APPLE과 UNIX가 모두 참이므로 APPLE을 먼저 검사합니다.
Sanitizer 옵션
option(ENABLE_ASAN "Enable AddressSanitizer" OFF)
if(ENABLE_ASAN AND CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_compile_options(-fsanitize=address -fno-omit-frame-pointer)
add_link_options(-fsanitize=address)
endif()
Sanitizer는 컴파일 옵션과 링크 옵션이 모두 필요하고, 최종 실행 파일을 링크할 때 런타임 라이브러리가 함께 들어가야 합니다. 정적 라이브러리 타겟에만 target_link_options(mylib PRIVATE -fsanitize=address)를 걸면, 정적 라이브러리는 링크 단계가 없어 그 옵션이 실행 파일에 전달되지 않고 undefined reference to __asan_... 에러가 납니다. 그래서 위처럼 add_subdirectory 전에 디렉터리 단위로 걸거나, INTERFACE 타겟에 INTERFACE 옵션으로 담아 실행 파일까지 전파되게 합니다.
자주 발생하는 오류와 해결법
”No such file or directory” (헤더를 못 찾음)
fatal error: mylib/mylib.h: No such file or directory
라이브러리에 target_include_directories를 설정하지 않았거나, 공개 헤더 경로를 PRIVATE으로 지정한 경우입니다. 라이브러리 A의 공개 헤더가 B의 헤더를 include하는데 A가 B를 PRIVATE으로 링크했다면, A를 쓰는 쪽에서 B의 헤더를 찾지 못합니다. 공개 헤더가 의존하는 라이브러리는 PUBLIC으로 링크합니다.
add_library(mylib STATIC src/mylib.cpp)
target_include_directories(mylib PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
”undefined reference” (링크 에러)
undefined reference to `mylib::doSomething()'
target_link_libraries에서 라이브러리를 빠뜨렸거나, 함수를 선언만 하고 정의한 .cpp를 어떤 타겟에도 넣지 않은 경우가 대부분입니다. target_link_libraries에 쓰는 타겟 이름은 generate 단계에서 해석되므로, 그 타겟을 정의하는 add_subdirectory가 뒤에 와도 문제가 되지 않습니다. 다만 타겟이 아닌 이름(오타 포함)을 적으면 CMake는 그것을 그냥 라이브러리 파일명으로 보고 -lmylibb를 넘기므로, configure는 통과하고 링크에서 실패합니다. 타겟 이름에 mylib::mylib처럼 ::를 넣어 두면, 존재하지 않는 이름일 때 configure 단계에서 바로 에러가 나 이런 실수를 빨리 잡을 수 있습니다.
add_library(mylib STATIC src/mylib.cpp)
add_library(mylib::mylib ALIAS mylib) # 사용하는 쪽은 mylib::mylib로 링크
add_executable(app1 main1.cpp)
target_link_libraries(app1 PRIVATE mylib::mylib)
”Could not find a package configuration file”
find_package(mylib)가 mylibConfig.cmake(또는 mylib-config.cmake)를 찾지 못한 경우입니다. 라이브러리를 설치했지만 Config 파일을 설치하지 않았거나, 설치 경로가 CMAKE_PREFIX_PATH에 없을 때 납니다. CMAKE_MODULE_PATH는 Find<Name>.cmake 스크립트를 찾는 경로라서 Config 파일 검색에는 쓰이지 않습니다.
cmake -B build -DCMAKE_PREFIX_PATH=/opt/mylib
# 특정 패키지의 Config 파일 위치를 직접 지정
cmake -B build -Dmylib_DIR=/opt/mylib/lib/cmake/mylib
# vcpkg
cmake -B build -DCMAKE_TOOLCHAIN_FILE=<vcpkg root>/scripts/buildsystems/vcpkg.cmake
# Conan 2 (conan install이 생성한 툴체인 파일, 경로는 레이아웃에 따라 다름)
cmake -B build -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake
”multiple definition” (중복 정의)
헤더에 함수 정의를 넣고 inline 없이 여러 .cpp에서 include하면, 각 번역 단위가 같은 함수를 정의해 링크 단계에서 충돌합니다.
// mylib.h
int add(int a, int b) { return a + b; } // 여러 .cpp에서 include하면 중복 정의
inline int add2(int a, int b) { return a + b; } // inline이면 ODR 예외로 허용
같은 .cpp를 두 라이브러리 타겟에 모두 넣고 둘 다 링크해도 같은 증상이 나므로, 공통 소스는 한 타겟에만 두고 링크로 공유합니다.
Windows DLL을 찾지 못하는 런타임 에러
공유 라이브러리를 만들면 DLL이 실행 파일과 다른 폴더에 생성되어 실행할 때 “DLL을 찾을 수 없습니다” 에러가 납니다. 출력 폴더를 맞춰 두면 해결됩니다.
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) # exe, dll
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # .so (Linux)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # .a, .lib(import library)
외부에서 받은 DLL까지 복사하려면 CMake 3.21부터 $<TARGET_RUNTIME_DLLS:myapp>로 필요한 DLL 목록을 얻어 빌드 후 단계에서 복사할 수 있습니다. MSVC로 공유 라이브러리를 만들 때는 심볼을 __declspec(dllexport)로 내보내지 않으면 .lib 가져오기 라이브러리가 생기지 않아 링크 에러가 나는데, GenerateExportHeader 모듈이 이 매크로를 만들어 줍니다.
add_custom_command의 출력이 생성되지 않음
add_custom_command(OUTPUT ...)는 그 출력 파일을 필요로 하는 타겟이 있어야 실행됩니다. 출력 파일을 어떤 타겟의 소스에도 넣지 않으면 CMake는 명령을 실행할 이유가 없다고 판단합니다. 출력을 add_library·add_executable의 소스로 넣거나, add_custom_target(proto_gen DEPENDS ${GENERATED_PROTOBUF_SRCS})를 만든 뒤 사용하는 타겟에 add_dependencies로 연결합니다. 이때 커스텀 명령과 이를 쓰는 타겟은 같은 디렉터리(CMakeLists.txt)에 있어야 의존성이 제대로 연결됩니다.
ExternalProject가 매번 다시 빌드되거나 Ninja가 규칙을 못 찾음
BUILD_BYPRODUCTS를 지정하지 않으면 Ninja는 ExternalProject가 만드는 라이브러리 파일의 생성 규칙을 모릅니다. 위 ExternalProject 예제처럼 imported 타겟이 참조하는 파일을 모두 BUILD_BYPRODUCTS에 적습니다.
실전 패턴
테스트 통합
# tests/CMakeLists.txt (루트에서 include(CTest)를 호출했다고 가정)
include(FetchContent)
FetchContent_Declare(
googletest
URL https://github.com/google/googletest/archive/refs/tags/v1.14.0.tar.gz
)
# Windows에서 MSVC 런타임 불일치 방지
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)
add_executable(mylib_test test_mylib.cpp)
target_link_libraries(mylib_test PRIVATE mylib GTest::gtest_main)
include(GoogleTest)
gtest_discover_tests(mylib_test)
gtest_discover_tests는 테스트 실행 파일을 빌드한 뒤 그 안의 테스트 케이스 목록을 읽어 CTest에 하나씩 등록합니다. add_test로 실행 파일 하나만 등록하면 CTest에는 테스트가 하나로 보이지만, 이렇게 하면 실패한 케이스 이름이 바로 보이고 ctest -R로 특정 케이스만 실행할 수 있습니다.
cmake -B build
cmake --build build
ctest --test-dir build --output-on-failure
설치 규칙과 find_package 지원
라이브러리를 다른 프로젝트에서 find_package(mylib 1.0 REQUIRED)로 쓸 수 있게 하려면, 타겟과 헤더를 설치하고 Config 파일 세트를 함께 설치합니다.
include(GNUInstallDirs)
include(CMakePackageConfigHelpers)
install(TARGETS mylib
EXPORT mylibTargets
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT mylibTargets
FILE mylibTargets.cmake
NAMESPACE mylib::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)
configure_package_config_file(
${CMAKE_CURRENT_SOURCE_DIR}/cmake/mylibConfig.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/mylibConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/mylibConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mylib
)
# cmake/mylibConfig.cmake.in
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(nlohmann_json 3.10) # mylib가 PUBLIC으로 의존하는 패키지
include("${CMAKE_CURRENT_LIST_DIR}/mylibTargets.cmake")
install(EXPORT)가 만드는 mylibTargets.cmake는 mylib::mylib imported 타겟을 정의하고, Config 파일은 그것을 불러오기 전에 mylib가 공개적으로 의존하는 패키지를 find_dependency로 먼저 찾습니다. 이 부분을 빠뜨리면 사용하는 쪽에서 “target nlohmann_json::nlohmann_json not found” 에러가 납니다. 설치한 뒤 다른 프로젝트에서는 CMAKE_PREFIX_PATH에 설치 경로를 주고 find_package(mylib 1.0 REQUIRED), target_link_libraries(myapp PRIVATE mylib::mylib)로 씁니다.
프리컴파일 헤더
target_precompile_headers(mylib PRIVATE
<vector>
<string>
<memory>
)
CMake 3.16부터 쓸 수 있습니다. PRIVATE이면 mylib 자신의 소스에만 적용됩니다. 여러 타겟이 같은 PCH를 공유하게 하려면 target_precompile_headers(other REUSE_FROM mylib)를 쓰는데, 두 타겟의 컴파일 옵션이 같아야 합니다.
공통 설정을 CMake 모듈로 분리
# cmake/CompilerWarnings.cmake
function(set_project_warnings target)
target_compile_options(${target} PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra -Wpedantic>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
endfunction()
# CMakeLists.txt
list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)
include(CompilerWarnings)
add_library(mylib STATIC mylib.cpp)
set_project_warnings(mylib)
빌드 흐름
sequenceDiagram
participant Dev as 개발자
participant CMake as CMake configure·generate
participant Fetch as FetchContent
participant Build as 빌드 도구
Dev->>CMake: cmake -B build
CMake->>Fetch: 외부 소스 다운로드
Fetch->>CMake: 하위 프로젝트 타겟 추가
CMake->>CMake: 타겟 의존성 그래프 구성
CMake->>Build: Makefile 또는 build.ninja 생성
Dev->>Build: cmake --build build
Build->>Build: 의존 순서대로 라이브러리와 실행 파일 빌드
configure 단계(cmake -B build)에서 CMakeLists.txt가 실행되고 FetchContent 다운로드, configure_file, find_package가 일어나며, generate 단계에서 generator expression이 평가되어 빌드 파일이 만들어집니다. ExternalProject와 add_custom_command는 그 뒤 빌드 단계(cmake --build)에서 실행됩니다. 어떤 문제가 어느 단계에서 나는지 구분하면 원인을 찾기가 훨씬 쉬워집니다. CMAKE_EXPORT_COMPILE_COMMANDS를 켜 두면 빌드 디렉터리에 compile_commands.json이 생겨 clangd 같은 도구가 실제 컴파일 옵션을 그대로 읽을 수 있습니다.