CMake 마스터하기: 링킹, 의존성 관리 및 테스트 자동화 실전 가이드

링킹의 메커니즘과 라이브러리 구성

C++ 빌드 파이프라인에서 소스 코드가 오브젝트 파일로 컴파일된 후, 링커는 이러한 파일들을 결합하여 실행 가능한 바이너리를 생성합니다. ELF 포맷의 오브젝트 파일은 .text(머신 코드), .data(초기화된 전역 변수), .bss(초기화되지 않은 변수) 등의 섹션으로 구성됩니다. 링커는 여러 오브젝트 파일의 동일한 섹션을 병합하고, 메모리 주소를 재배치(Relocation)하며, 외부 심볼 참조를 해결합니다.

라이브러리 유형과 CMake 설정

CMake에서는 add_library() 명령어를 사용하여 정적, 공유, 모듈 라이브러리를 생성합니다.

# 정적 라이브러리 (아카이브)
add_library(core_engine STATIC engine.cpp)

# 공유 라이브러리 (동적 링크)
add_library(network_io SHARED io.cpp)

# 공유 모듈 (런타임 플러그인)
add_library(audio_plugin MODULE plugin.cpp)

공유 라이브러리와 모듈은 메모리의 임의의 위치에 로드될 수 있으므로 위치 독립 코드(PIC)로 컴파일되어야 합니다. CMake는 이를 자동으로 처리하지만, 정적 라이브러리가 공유 라이브러리에 링크될 경우에는 명시적으로 설정해야 합니다.

set_target_properties(core_engine PROPERTIES POSITION_INDEPENDENT_CODE ON)

ODR과 링크 순서 문제

C++의 단일 정의 규칙(ODR, One Definition Rule)은 전역 변수나 비인라인 함수가 여러 번역 단원에서 중복 정의되는 것을 금지합니다. 동적 라이브러리의 경우 링커가 중복 심볼을 허용하기도 하지만, 이는 링크 순서에 따라 예기치 않은 동작을 유발할 수 있습니다.

또한, 정적 라이브러리 링크 시 undefined reference 오류가 발생한다면 링크 순서를 의심해야 합니다. GNU 링커는 기본적으로 왼쪽에서 오른쪽으로 한 번만 스캔하므로, 의존성을 가진 라이브러리가 의존하는 라이브러리보다 먼저 와야 합니다.

# core_engine이 math_utils에 의존하는 경우
# 잘못된 순서: target_link_libraries(app math_utils core_engine)
# 올바른 순서
target_link_libraries(app core_engine math_utils)

외부 의존성 탐색 및 통합

프로젝트가 커지면 외부 라이브러리를 관리해야 합니다. 시스템에 미리 설치된 패키지를 찾거나, 소스 코드를 직접 빌드하여 통합하는 방법이 있습니다.

find_package와 PkgConfig

대부분의 현대 C++ 라이브러리는 CMake 설정 파일(*Config.cmake)을 제공합니다. find_package()를 사용하면 임포트된 타겟을 쉽게 가져올 수 있습니다.

find_package(Boost 1.75 REQUIRED COMPONENTS filesystem system)
target_link_libraries(app PRIVATE Boost::filesystem Boost::system)

CMake를 공식적으로 지원하지 않는 레거시 라이브러리의 경우 pkg-config를 활용할 수 있습니다.

find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBXML2 REQUIRED IMPORTED_TARGET libxml-2.0)
target_link_libraries(app PRIVATE PkgConfig::LIBXML2)

FetchContent를 활용한 소스 레벨 종속성

시스템에 라이브러리가 설치되어 있지 않다면 FetchContent 모듈을 사용하여 빌드 시점에 소스 코드를 다운로드하고 통합할 수 있습니다. ExternalProject와 달리 FetchContent는 구성(Configure) 단계에서 타겟을 가져오므로 메인 프로젝트에서 즉시 링크할 수 있습니다.

include(FetchContent)
FetchContent_Declare(
  spdlog
  GIT_REPOSITORY https://github.com/gabime/spdlog.git
  GIT_TAG        v1.12.0
)
FetchContent_MakeAvailable(spdlog)

target_link_libraries(app PRIVATE spdlog::spdlog)

CTest와 테스트 프레임워크를 활용한 품질 보증

견고한 소프트웨어를 위해서는 자동화된 테스트가 필수적입니다. CMake는 CTest를 통해 테스트 실행을 표준화하며, 다양한 테스트 프레임워크와 원활하게 통합됩니다.

테스트 가능한 아키텍처 설계

테스트 코드와 프로덕션 코드 간의 이진수 일관성을 유지하기 위해, main() 함수를 분리하고 핵심 비즈니스 로직을 라이브러리(SUT)로 추출하는 것이 좋습니다.

// app_core.cpp (SUT 라이브러리)
#include "app_core.h"
int execute_application(int argc, char** argv) {
    // 핵심 로직
    return 0;
}

// main.cpp (부트스트랩 실행 파일)
extern int execute_application(int, char**);
int main(int argc, char** argv) {
    return execute_application(argc, argv);
}
add_library(app_core STATIC app_core.cpp)
add_executable(app_bootstrap main.cpp)
target_link_libraries(app_bootstrap PRIVATE app_core)

GoogleTest와 GMock 통합

GoogleTest(GTest)는 강력한 어서션과 테스트 픽스처를 제공하며, GMock을 통해 의존성을 격리할 수 있습니다.

#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include "app_core.h"

class MockStorage : public IStorage {
public:
    MOCK_METHOD(bool, save_data, (const std::string&), (override));
};

TEST(ApplicationTest, SavesDataSuccessfully) {
    MockStorage mock_storage;
    EXPECT_CALL(mock_storage, save_data(testing::_))
        .Times(1)
        .WillOnce(testing::Return(true));

    AppService service(&mock_storage);
    EXPECT_TRUE(service.process_and_store("test_payload"));
}

CMakeLists.txt에서 GTest를 통합하고 테스트를 자동으로 발견하도록 설정합니다.

enable_testing()
include(GoogleTest)

add_executable(unit_tests test_main.cpp app_core_test.cpp)
target_link_libraries(unit_tests PRIVATE app_core gtest_main gmock)
gtest_discover_tests(unit_tests)

코드 커버리지 측정

테스트의 실효성을 검증하기 위해 LCOV와 GCC/Clang의 --coverage 플래그를 사용하여 코드 커버리지를 측정할 수 있습니다. 커버리지 데이터 파일(.gcda)의 충돌을 방지하기 위해 빌드 전 이전 데이터를 삭제하는 명령어를 추가하는 것이 안전합니다.

if(CMAKE_BUILD_TYPE STREQUAL "Debug")
  target_compile_options(app_core PRIVATE --coverage)
  target_link_options(app_core PUBLIC --coverage)
  
  # 빌드 전 오래된 gcda 파일 정리
  add_custom_command(TARGET app_core PRE_BUILD
    COMMAND ${CMAKE_COMMAND} -E rm -f ${CMAKE_BINARY_DIR}/*.gcda
  )
endif()

add_custom_target(generate_coverage
  COMMAND lcov --capture --directory . --output-file coverage.info
  COMMAND genhtml coverage.info --output-directory coverage_report
  WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
)

태그: cmake CTest GoogleTest GMock LCOV

7월 29일 02:59에 게시됨