CMake를 사용해보자!

CMake를 사용해보자

CMake를 들어보았는가? CMake란 C/C++에서 사용되는 빌드 시스템이다. CMake를 사용하면 C++ 프로젝트를 더 쉽게 관리할 수 있게 된다.

그런데 우선, CMake를 알아보기 전에 make에 대해서 먼저 알아보자.

make란?

C++ 파일을 실행시키기 위해서는 어떻게 해야 하는가? 만약 C++를 처음 배운다면, Visual Studio를 실행시키고, 새로운 프로젝트를 생성해서, ctrl + shift + b 로 빌드하여 실행을 했을 것이다.

그러나 그 과정이 어떻게 이루어지는 지 알고 있는지 궁금해본 적 있는가? 간단히만 설명하자면, C++ 파일을 실행하기 위해서는 컴파일, 빌드, 링크 등의 과정이 필요하다.

컴파일은 CPP 파일을 읽고 기계어로 변환하는 과정이다. 이렇게 변환된 기계어를 하나의 오브젝트 파일로 만드는 것이 빌드 과정이다. 링크 과정은, 실행 중에 필요한 오브젝트 파일들을 연결하는 과정이다.

이 글에서는 CMake를 위한 설명이므로 더 자세히는 다루지 않겠다. 단지 우리가 사용하는 Visual Studio에서는 이 과정을 생각할 필요가 없었던 이유는, 이 과정을 Visual Studio 자체 빌드 시스템을 통해서 자동으로 해주었기 때문이다.

만약에 Visual Studio를 사용하지 않고서 직접 코드를 하나 빌드한다고 해보자.

// main.cpp
#include <iostream>

int main()
{
    std::cout << "Hello World!" << std::endl;
    return 0;
}

위 코드를 빌드하고 실행하기 위해서는 어떻게 해야 할까? gcc를 이용해서 위 코드를 빌드하고 실행해보자.

$ g++ main.cpp -o main
$ ls
main main.cpp
$ ./main
Hello World!

-o main 의 의미는, main.cpp를 컴파일하여 main이라는 실행 파일을 만들라는 이야기이다. ./main을 통해 실행 파일을 직접 실행할 수 있다.

그런데 매번 이렇게 CLI를 통해 빌드하고 실행하는 것은, 코드 베이스가 커진다면 불가능하다. 만약에 파일이 수십개가 된다면, 이를 명령어로 하나하나 관리할 수 있겠는가?

그래서 나온게 make가 등장하였다. make는 이러한 빌드 과정을 자동화해주는 툴이다. CLI 명령어를 하나하나 치는 것보다 훨씬 편리하다.

make에서는 makefile이라는 파일을 통해 빌드 과정을 관리한다. makefile은 make가 어떤 파일을 어떻게 빌드할 것인지에 대한 정보를 담고 있다.

# makefile
main: main.o
    g++ main.o -o main

main.o: main.cpp
    g++ -c main.cpp

위와 같이 makefile을 작성하고, make 명령어를 통해 빌드를 해보자.

$ make
g++ -c main.cpp
g++ main.o -o main
$ ls
main main.cpp main.o makefile
$ ./main
Hello World!

make 명령어를 통해 빌드를 하면, makefile에 적혀있는 대로 빌드가 진행된다. 위의 makefile은 main.cpp를 컴파일하여 main.o라는 오브젝트 파일을 만들고, 이를 링크하여 main이라는 실행 파일을 만든다.

이렇게 makefile을 통해 빌드를 하면, 코드 베이스가 커져도 빌드 과정을 편리하게 관리할 수 있다.

하지만 사실, make조차도 일정 규모 이상의 프로젝트를 다루는데 있어서는 한계가 있다. CLI로 파일 하나하나 관리하는 것보다는 낫지만, 그래도 makefile을 직접 관리하는 것이 여간 번거로운 일이 아니기 때문이다.
그래서 등장한 것이 바로 CMake이다.

CMake란?

CMake는 간단히 말하자면, makefile을 자동으로 생성해주는 툴이라고 보면 된다. 즉 내부적으로 make를 이용하는데, 이 makefile을 직접 관리하는 대신 CMake를 통해서 관리하는 개념이다.

이렇게 말하면 단순히 make의 개선판이라고 할 수 있는데, 실제로는 CMake의 세계 역시 매우 복잡하고 다양하다. 이 글에서는 다만 CMake를 사용하기 위해서 최소한의 기능, 그리고 내가 쓰는 기능만을 다루고자 한다.

우선 CMake를 이용해보자! CMake는 CMakeLists.txt라는 파일로 관리한다. CMakeLists.txt에는 CMake가 makefile을 생성하는데 필요한 모든 정보를 담고 있다.

CMakeLists.txt를 작성해보자.

# CMakeLists.txt
cmake_minimum_required(VERSION 3.24)
project(hello_world)

add_executable(main main.cpp)

위 파일은 최소한의 정보만을 갖고 있는 cmake 파일이다. cmake_minimum_required를 통해 CMake의 최소 버전을 지정하고, project를 통해 프로젝트의 이름 등을 지정한다.

add_executable을 통해 main이라는 실행 파일을 만들고, 이를 main.cpp를 통해 빌드하라는 의미이다.

#은 주석을 의미한다. 그리고 CMake에서 함수 자체는 대소문자를 구분하지 않는다. ADD_EXECUTABLE이나 add_executable이나 모두 동일한 의미를 갖는다. 다만 팀에 맞추어서 통일하는 것이 좋다.

이제 CMake를 통해 빌드를 해보자. 이때 주의해야 할 것은, 보통 cmake를 사용한다면, build라는 디렉토리를 만들어서, 그 안에서 빌드를 진행하는 게 관습이다. 왜냐면 루트 디렉토리에서 곧장 cmake 빌드를 진행하는 순간 온갖 파일들이 뒤섞이면서 난리가 나기 때문이다.

$ mkdir build
$ cd build
$ cmake ..
-- The C compiler identification is AppleClang 12.0.0.12000032
-- The CXX compiler identification is AppleClang 12.0.0.12000032
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Check for working C compiler: /Library/Developer/CommandLineTools/usr/bin/cc - skipped
-- Detecting C compile features
-- Detecting C compile features - done
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /Library/Developer/CommandLineTools/usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: {프로젝트 디렉토리 경로}/build
$ ls
CMakeCache.txt CMakeFiles cmake_install.cmake main Makefile
$ make
Scanning dependencies of target main
[ 50%] Building CXX object CMakeFiles/main.dir/main.cpp.o
[100%] Linking CXX executable main
[100%] Built target main
$ ls
CMakeCache.txt CMakeFiles cmake_install.cmake main main.cpp.o Makefile
$ ./main
Hello World!

빌드 과정에 대한 결과가 저런 식으로 나올 것이다. cmake ..로 CMake 빌드를 진행하면서, makefile을 생성한다. make 를 통해서 최종적으로 빌드를 수행하는 식이다.

내가 쓰는 CMake

위에서는 CMake의 최소한 기능만을 보여주었는데, 사실 CMake는 규모가 큰 프로젝트에서 힘을 발휘한다.

일단, 보통 C++ 프로젝트를 IDE를 많이 사용해서 진행하므로, 여기서는 CLion을 기준으로 설명하겠다. 물론 다른 IDE나 코드 에디터를 사용해도 똑같긴 한데, Visual Studio는 예외이다.

Visual Studio는 사실 CMake를 2017 버전부터 지원하긴 했는데, Visual Studio는 애초에 자기들만의 자체 빌드 시스템이 있으므로 굳이 사용할 필요가 없다. Visual Studio를 제외하고 가장 많이 사용되는 IDE가 CLion이여서 환경은 CLion으로 가정하겠다.

먼저 프로젝트의 구조부터 다시 생각해보자. 물론 라이브러리를 만들 것이면 구조는 달라지는데, 여기서는 C++ 어플리케이션을 기준으로 설명하겠다.

C++ 어플리케이션의 일반적인 프로젝트 구조는 다음과 같다.

{프로젝트 이름}
├── CMakeLists.txt
├── src
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── sub.hpp
│   └── sub.cpp
│   └── ...
├── test
│   ├── CMakeLists.txt
│   └── ...
└── ...

참고로 다시 말하지만 이건 C++ 어플리케이션 기준이다. 어플리케이션은 보통 CPP 파일과 헤더 파일을 같은 곳에 위치하는데, 라이브러리를 만들 때는 관습적으로 include 등의 폴더를 만들어서, 헤더 파일과 CPP 파일을 분리하는 게 일반적이다.

아무튼, 위와 같은 프로젝트가 있을 때, 한 번 CMakeLists.txt 파일을 구성해보자.

먼저 알아두어야 할 것은, CMakeLists.txt 파일은 재귀적으로 구성된다. 최상단의 루트 CMakeLists.txt 파일이 있으면, 그 안에는 하위 디렉토리의 CMakeLists.txt 파일을 include하는 식으로 구성된다(여기서는 srctest 디렉토리). 그리고 이 srctest 폴더 안에 각각 CMakeLists.txt 파일을 구성하는 식이다.

만약에 src 디렉토리 안에 별도의 디렉토리가 있고(예를 들어 ./src/sub 디렉토리), 이 디렉토리 속의 CPP 파일들이 개별 CMake 빌드 정보를 담아야 한다면, 여기에도 CMakeLists.txt파일이 존재한는 식이다(./src/sub/CMakeLists.txt).

루트 CMakeLists.txt 파일을 구성해보자. 프로젝트 이름은 cmake_tutorial로 하겠다.

cmake_minimum_required(VERSION 3.24)

project(
    cmake_tutorial # 프로젝트 이름
    VERSION 1.0.0  # 프로젝트 버전
    DESCRIPTION "CMake Tutorial" # 프로젝트 설명
    LANGUAGES CXX # 사용할 언어
)

set(CMAKE_CXX_STANDARD 20)

enable_testing()

add_subdirectory(src)
add_subdirectory(test)

project에서 프로젝트 이름과 버전, 설명과 언어 등을 정의해주었다. 이외에도 라이센스 정보 등을 추가하는 등, 프로젝트에 관련된 정보를 설정할 수 있다.

그리고 set은 CMake에서 사용할 변수를 설정하는 명령어이다. 여기서는 C++ 표준을 결정하는 CMAKE_CXX_STANDARD를 20으로 설정해주었다. 이렇게 하면 C++ 표준을 C++20로 사용하겠다는 의미이다.

그리고 enable_testing은 CMake에서 테스트를 사용할 수 있도록 해주는 명령어이다. 이 명령어를 사용하면, add_test 명령어를 사용할 수 있다.

마지막으로 중요한 것은, 루트 디렉토리에 있는 CMakeLists.txt 파일에는 서브 디렉토리를 지정해주어야 한다. 여기서는 srctest 디렉토리를 지정해주었다. 이렇게 하면, 루트 디렉토리의 CMakeLists.txt 파일에서 srctest 디렉토리의 CMakeLists.txt 파일을 찾아서 include하게 될 것이다.

이제 src 디렉토리의 CMakeLists.txt 파일을 구성해보자. 여기서는 소스 파일이 들어있는 src 디렉토리의 성격에 맞추어서, 컴파일 관련 옵션을 지정할 수 있다.

참고로 CMake에서도 다양한 컴파일 옵션들을 지정할 수 있다. 컴파일 옵션이란 gcc나 clang 등에서 제공하는, 컴파일과 관련된 옵션들을 말한다. 보통 정적 분석을 위해서 여러 경고나 에러 관련 옵션들을 지정하고, 또 최적화를 위해서 -O3같은 옵션을 주기도 한다.

특히 요새는 정적 분석을 위해서 clang-tidycppcheck같은 도구들을 필수적으로 활용하게 되는데, 이 도구들에서 제공해주지 않는 기능을 각 컴파일러가 옵션으로 제공해주기도 하니, 왠만한 옵션들은 add_compile_option() 함수를 통해서 켜주는게 좋다.

그리고 만약 프로젝트가 외부 라이브러리들에 의존한다고 가정해보자. 이때 외부 패키지 역시 CMake를 통해서 import해서 프로젝트에 포함시켜야 한다.

예를 들어 아래 ./src/CMakeLists.txt를 보자.

add_compile_options(-Werror -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wundef -Wunreachable-code -Wstrict-aliasing -Wnull-dereference -Wdouble-promotion -Wformat=2 -Wcast-qual -Wcast-align)

if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU")
    # Additional flags for GCC
    add_compile_options(-Wuseless-cast)
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
    # Additional flags for Clang
    add_compile_options(-Weverything -Wno-c++98-compat -Wno-c++98-compat-pedantic -Wno-unused-macros -Wno-padded)
endif ()

find_package(fmt REQUIRED)
find_package(Boost REQUIRED)
find_package(unofficial-argon2 CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

file(GLOB_RECURSE SOURCE_FILES CONFIGURE_DEPENDS "*.cpp")
add_executable(${PROJECT_NAME} ${SOURCE_FILES})

target_link_libraries(${PROJECT_NAME} PRIVATE
        fmt::fmt
        Boost::boost
        unofficial::argon2::libargon2
        nlohmann_json::nlohmann_json)

target_include_directories(
        ${PROJECT_NAME}
        PUBLIC
        ${CMAKE_SOURCE_DIR}/src
        PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}
)

실제로 내가 다른 곳에서 쓰고 있는 CMakeLists.txt 파일이다. 중요한 포인트들만 살펴보자.

add_compile_options

add_compile_options(-Werror -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wundef -Wunreachable-code -Wstrict-aliasing -Wnull-dereference -Wdouble-promotion -Wformat=2 -Wcast-qual -Wcast-align)

if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU")
    # Additional flags for GCC
    add_compile_options(-Wuseless-cast)
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
    # Additional flags for Clang
    add_compile_options(-Weverything -Wno-c++98-compat -Wno-c++98-compat-pedantic -Wno-unused-macros -Wno-padded)
endif ()

add_compile_options를 통해서 컴파일 옵션들을 추가해주었다. 여기서는 gcc와 clang을 위한 컴파일 옵션들을 추가해주었다. 공통으로 쓰이는 옵션들이 있고, gcc와 clang에서 서로 다르게 지원하는 옵션들이 있어서, 이를 구분해서 추가해주었다.

참고로 이 말은 한 프로젝트를 gcc와 clang에서 각각 따로 빌드한다는 것을 의미하는데, 실제로도 정적 분석의 효과를 극대화하기 위해서, 두 개 이상의 컴파일러를 사용해서 각각 따로 빌드하기도 한다. 이렇게 하면, gcc에서는 발견하지 못했지만, clang에서는 발견할 수 있는 문제들을 찾아낼 수 있기 때문이다.

find_package

find_package(fmt REQUIRED)
find_package(Boost REQUIRED)
find_package(unofficial-argon2 CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

위 의미는 외부 라이브러리, 즉 패키지를 프로젝트에서 사용한다는 의미이다. fmt, Boost, unofficial-argon2, nlohmann_json 등의 라이브러리를 불러오는 명령어이다.

이렇게 불러오기만 해서는 안 된다. 링크를 해야 한다.

target_link_libraries

target_link_libraries(${PROJECT_NAME} PRIVATE
        fmt::fmt
        Boost::boost
        unofficial::argon2::libargon2
        nlohmann_json::nlohmann_json)

위에서 볼 수 있듯이 CMake에서도 기본 변수를 지정하고 사용할 수 있는데, PROJECT_NAME에 라이브러리들을 링크한다는 의미이다.

add_executable

add_executable 함수를 통해서 실행 파일에 cpp 파일들을 추가하는 과정이 빠졌다. 이는 다음과 같이 추가할 수 있다.

add_executable(${PROJECT_NAME} main.cpp foo.cpp bar.cpp)

참고로 hpp 파일은 추가하지 않아도 된다. #include <foo.hpp>와 같이 cpp 파일에서 컴파일러에게 추가할 헤더파일을 이미 알려주고 있기 때문이다. 이러면 컴파일러가 알아서 헤더 파일을 복사해서 #include가 있는 자리에 그대로 붙여넣는다.

그런데 자세히 보면, 위에 내가 쓰고 있는 형태와는 조금 다르다.

file(GLOB_RECURSE SOURCE_FILES CONFIGURE_DEPENDS "*.cpp")
add_executable(${PROJECT_NAME} ${SOURCE_FILES})

나는 관리해야 하는 CPP 파일이 너무 많아서, 그냥 한 번에 이를 변수 SOURCE_FILES를 선언하고 지정해두었다. 즉 *.cpp(모든 cpp 파일을 의미) 를 통해 그냥 싹다 SOURCE_FILES에 저장해두고, add_executable에서는 SOURCE_FILES만 집어넣는 형태이다.

그리고 GLOB_RECURSE를 지정해두어야, ./src/ 디렉토리 내부에 하위 디렉토리가 있어도 재귀적으로 모두 찾아서 SOURCE_FILES에 추가해놓는다. 잊고서 빼먹으면 하위 디렉토리에 있는 CPP 파일들만 쏙 빼먹을 수 있으니 주의하자.

이렇게 안 그러면 cpp 파일이 너무 많아서, 너무 길어진다. 사실 이 방법은 좀 논란이 있는 방법이기는 하다. 어디에서는 디렉토리의 CPP 파일들을 한 번에 긁는 것이 아니라, 반드시 사용하는 파일들만 직접 하나하나 지정하게끔 규칙을 지정하기도 한다. 이 부분은 계속해서 토론이 이루어지고 있는 분야이긴 해서, 이를 밝힌다.

다만 여기서 저 프로젝트는 일단 src 디렉토리 내의 모든 파일들을 빠짐없이 사용하고 있고, 파일 하나하나를 직접 관리하는 건 다소 귀찮아서 그냥 저렇게 뭉뚱그렸다.

또한 CONFIGURE_DEPENDS를 지정해두면, CMake가 자동으로 파일이 추가되거나 삭제되었을 때, 이를 감지해서 자동으로 CMake를 재실행해준다. 이를 지정하지 않으면, 파일이 추가되거나 삭제되었을 때, CMake를 재실행해줘야 한다. 이는 귀찮은 일이다. 그래서 이를 지정해두면, CMake가 자동으로 감지해서 재실행해준다.

target_include_directories

마지막으로 살펴볼 것은 target_include_directories 부분이다. 이 부분은 굳이 필요한 것은 아닌데, 헤더 파일을 인클루드할 때 경로를 지정해주기 위한 부분이다.

일반적으로 우리가 헤더 파일을 인클루드할 때는 다음과 같이 사용한다.

#include <iostream>
#include <string>
#include <vector>
// ...

그런데 만약에 우리가 직접 만든 헤더 파일을 인클루드할 때는, ""을 사용해서 상대 경로로 인클루드해야 한다.

#include "foo.hpp"
#include "bar.hpp"
// ...

왜 이런 차이가 발생하냐면, CPP의 기본 라이브러리들의 경우 컴파일러가 미리 지정된 경로를 찾아내기 때문이다. 보통 /usr/include에 저 헤더파일들이 저장되어 있고, 컴파일러 역시 이를 기본값으로 저장해두고 있다.

만약에 우리가 #include <foo.hpp>, #include <bar.hpp> 이런 식으로 일관성을 유지하면서 헤더 파일을 인클루드하고 싶다면, 컴파일러에게 /usr/include 말고 이 프로젝트의 src 디렉토리 역시 인클루드 대상에 포함시키라고 알려주면 된다.

target_include_directories(
        ${PROJECT_NAME}
        PUBLIC
        ${CMAKE_SOURCE_DIR}/src
        PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}
)

위와 같이 작성하면, 이제 아래와 같이 작성해도 컴파일러가 알아들을 것이다.

#include <foo.hpp>
#include <bar.hpp>
// ...

 

CMAKE_SOURCE_DIR은 루트 디렉토리를 의미한다(정확히는 루트 CMakeLists.txt 파일이 위치한 디렉토리). {루트디렉토리}/src 파일을 인클루드 패스에 포함시키라는 의미이다.

CMAKE_CURRENT_SOURCE_DIR은 현재 CMakeLists.txt 파일이 위치한 디렉토리를 의미한다.

참고로 라이브러리를 인클루드할 때도, 여기서도 PRIVATEPUBLIC을 구분해서 사용하고 있다. 조금 복잡한 이야기이긴 한데, PUBLIC은 라이브러리를 사용하는 모든 곳에서 인클루드 패스에 포함시키라는 의미이고, PRIVATE는 해당 라이브러리를 사용하는 곳에서만 인클루드 패스에 포함시키라는 의미이다.

만약에 라이브러리를 만들고 있고, 해당 라이브러리에서 반드시 의존하게 되는 외부 패키지나 디렉토리가 있다면 이를 PUBLIC으로 지정하면 되는데, 그게 아니라 내부적으로만 사용하고 마는 것이면 PRIVATE로 지정한다고 한다.

다만 이부분에 대해서는 본인 역시 자세히는 몰라서, 더 자세히 알고 싶다면 찾아보길 바란다.

CMake로 외부 패키지 설치하기

보통 C++의 외부 패키지는 별도의 툴을 통해서 관리한다. 현대의 C++은 주로 conan, vcpkg 등을 많이 사용하고 있는데, 사실 규모가 작다면 굳이 외부 툴을 사용하지 않고 CMake 자체를 이용해서 관리하는 것도 가능하다.

예를 들어서, C++에서 자주 사용되는 테스트 프레임워크인 gtest를 설치해보자.

# ./test/CMakeLists.txt

FetchContent_Declare(
        googletest
        # Specify the commit you depend on and update it regularly.
        URL https://github.com/google/googletest/archive/5376968f6948923e2411081fd9372e71a59d8e77.zip
)
FetchContent_MakeAvailable(googletest)

FetchContent_Declare를 사용하게 되면, 외부에서 해당 패키지를 다운로드받을 수 있다. 이후 FetchContent_MakeAvailable를 사용하면, 해당 패키지를 사용할 수 있게 된다.

다만 CMake를 통해서 외부 패키지 관리가 가능은 하지, 그리 편리하지는 않아서, 사실 conan이나 vcpkg 등 의존성 관리만을 위한 툴을 사용하는 게 조금 더 편리하기는 하다.

마무리

이 글은 사실 CMake에 대한 자세한 설명글보다는, 소개하는 글에 가깝다. 만약에 CMake에 대해 더 자세히 알고 싶고, 이를 CPP 프로젝트에 활용해보고 싶다면, 아래 글을 읽으면 좋다.

CMake 할때 쪼오오금 도움이 되는 문서

참고 자료