모두의 코드
씹어먹는 C++ - <19 - 2. C++ 프로젝트를 위한 CMake 사용법>

작성일 : 2021-02-24 이 글은 90768 번 읽혔습니다.

이번 강좌에서는

  • CMake 사용법

  • 실행 파일 및 라이브러리 만들기

  • FetchContent 로 외부 라이브러리 불러오기

에 대해서 다루어 보겠습니다.

안녕하세요. 지난번 글에서 make 빌드 시스템을 위한 Makefile 을 작성하는 방법에 대해서 다루어 보았습니다. Makefile 은 간단한 프로젝트를 관리하기 좋지만, 프로젝트 크기가 커질 경우, 아니면 해당 프로젝트를 여러 플랫폼에서 배포하기 위해서는 이것 저것 불편한 점이 많습니다. 이러한 문제를 해결하기 위해서 자주 사용하는 프로그램이 CMake 입니다.

참고 사항

그냥 대부분의 C++ 프로젝트에서 사용할 수 있는 만능 CMake 프로젝트 템플릿을 보시려면 여기 를 참고하시면 됩니다.

CMake 란?

CMake빌드 파일을 생성해주는 프로그램 입니다. 다시 말해 CMake 를 통해서 프로젝트를 빌드를 하는 것이 아니라, CMake 를 통해서 빌드 파일을 생성하면 빌드 프로그램을 통해서 프로젝트를 빌드 하는 것입니다. 예를 들어서 make 를 사용한다면 CMake 를 통해서 Makefile 을 생성할 것이고, 요즘 핫한 Ninja 를 사용한다면 CMake 를 통해서 .ninja 빌드 파일을 만들어줄 것입니다.

아무튼, 이 부분에 대해서 많은 사람들이 혼동하고 계시는데, 다시 한 번 강조하자면 CMake 는 빌드 프로그램 아니라 빌드 파일을 생성하는 프로그램 입니다.

CMake 는 이제 대부분의 C/C++ 계열 프로젝트에서 사용되고 있습니다. 사실 요즘에는 CMake 를 안쓰는 곳을 찾아보기 힘들어졌죠. 물론 CMake 역시 make 처럼 초반에 러닝 커브가 조금 있고, CMake 역사가 꽤 긴 만큼, 올바른 사용 방식이 (Best practice) 버전에 따라 많이 바뀌어서 아직도 구시대적인 CMake 문법을 사용하는 경우가 많습니다. 마치 C++ 98 을 아직도 사용하고 있는 느낌으로 말이죠.

이 글에서는 CMake 문법과 함께 최신 CMake 에서 권장하는 작성 방식에 대해서 다루어 보고자 합니다.

CMake 개요

CMake 를 사용하는 모든 프로젝트에는 반드시 프로젝트 최상위 디렉토리에 CMakeLists.txt 파일이 있어야 합니다. 해당 파일에는 CMake 가 빌드 파일을 생성하는데 필요한 정보들이 들어 있습니다. 따라서 보통의 컴파일 과정은 다음과 같이 진행됩니다.

CMake 를 사용한 프로젝트 빌드 과정

물론 꼭 Makefile 을 만들 필요는 없고 원하는 빌드 프로그램을 선택할 수 있습니다. 이에 관해서는 아래에서 다루겠습니다.

최상위 CMakeLists.txt 에는 반드시 아래 두 개의 내용이 들어가야 합니다.

# CMake 프로그램의 최소 버전
cmake_minimum_required(VERSION 3.11)

# 프로젝트 정보
project(
  ModooCode
  VERSION 0.1
  DESCRIPTION "예제 프로젝트"
  LANGUAGES CXX)

먼저 make 처럼 CMake 에서도 주석을 달기 위해서는 # 를 사용하면 됩니다.

cmake_minimum_required(VERSION 3.11)

CMakeLists.txt 최상단에는 해당 프로젝트에서 사용할 CMake 의 최소 버전을 명시해줍니다. 시스템 마다 설치된 CMake 의 버전이 다를 텐데, 저의 경우 3.19 버전이 설치되어 있습니다. 앞서 이야기 했듯이 CMake 는 버전이 바뀜에 따라 차이가 꽤 커서 (특히 2. 대 버전일 경우) 옛날 버전의 CMake 를 사용할 경우 지원하지 않는 기능이 있을 수 도 있습니다. 최소한 이 글에서 다룰 내용들은 최소 3.0 을 사용 하므로 minimum_required 를 3.0 이상으로 씁시다.

# 프로젝트 정보
project(
  ModooCode
  VERSION 0.1
  DESCRIPTION "예제 프로젝트"
  LANGUAGES CXX)

그 다음으로 프로젝트의 정보를 간단히 명시할 수 있습니다. 사실 꼭 필요한 것은 프로젝트 이름이고 (위에서 ModooCode 부분), 나머지 정보는 없어도 됩니다.

참고로 LANGUAGES 부분의 경우 C 프로젝트면 C 를, C++ 프로젝트면 CXX 를 명시하면 되고, 만일 명시하지 않았을 경우 디폴트로 CCXX 가 설정됩니다. 그 외에도 CUDA, OBJC, OBJCXX, Fortran 등등이 가능합니다. 자세한 내용은 여기 참조하세요.

실행 파일 만들기

그렇다면 가장 간단한 실행 파일을 하나 빌드해봅시다.

#include <iostream>

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

위 내용을 main.cc 에 저장하고

# CMake 프로그램의 최소 버전
cmake_minimum_required(VERSION 3.11)

# 프로젝트 정보
project(
  ModooCode
  VERSION 0.1
  DESCRIPTION "예제 프로젝트"
  LANGUAGES CXX)

add_executable (program main.cc)

위 내용으로 CMakeLists.txt 를 생성합니다. 이 때 두 파일은 같은 경로에 있어야 합니다.

이제 빌드 파일을 생성해봅시다. CMake 에서 권장하는 방법은 빌드 파일은 작업하는 디렉토리와 다른 디렉토리에서 만드는 것입니다. 따라서 build 라는 폴더를 하나 만듭시다.

$ tree
.
├── build
├── CMakeLists.txt
└── main.cc

현재 프로젝트 폴더의 형태는 위와 같습니다.

그 다음에 build 안으로 들어가서 cmake .. 을 실행 합니다.

$ cmake ..
-- The CXX compiler identification is GNU 9.3.0
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Check for working CXX compiler: /usr/bin/c++ - skipped
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: /build

위와 같이 CMake 에서 여러가지 설정들을 체크한 뒤에 빌드 파일을 생성한 것을 볼 수 있습니다.

CMake 를 실행 할 때에는 최상위 CMakeLists.txt 가 위치한 폴더의 경로를 입력해야 하는데, 바로 부모 디렉토리에 있으므로 .. 을 쓰는 것입니다. build 안에 여러가지 폴더와 파일들이 생성된 것을 볼 수 있습니다. 제 경우 아래와 같습니다.

$ tree
.
├── build
│   ├── CMakeCache.txt
│   ├── CMakeFiles
│   │   ├── 3.19.1
│   │   │   ├── CMakeCXXCompiler.cmake
│   │   │   ├── CMakeDetermineCompilerABI_CXX.bin
│   │   │   ├── CMakeSystem.cmake
│   │   │   └── CompilerIdCXX
│   │   │       ├── a.out
│   │   │       ├── CMakeCXXCompilerId.cpp
│   │   │       └── tmp
│   │   ├── cmake.check_cache
│   │   ├── CMakeDirectoryInformation.cmake
│   │   ├── CMakeOutput.log
│   │   ├── CMakeTmp
│   │   ├── Makefile2
│   │   ├── Makefile.cmake
│   │   ├── program.dir
│   │   │   ├── build.make
│   │   │   ├── cmake_clean.cmake
│   │   │   ├── DependInfo.cmake
│   │   │   ├── depend.make
│   │   │   ├── flags.make
│   │   │   ├── link.txt
│   │   │   └── progress.make
│   │   ├── progress.marks
│   │   └── TargetDirectories.txt
│   ├── cmake_install.cmake
│   └── Makefile
├── CMakeLists.txt
└── main.cc

위 처럼 여러가지 파일들이 생성된 것을 볼 수 있습니다. 특히 Makefile 도 만들어졌네요!

주의 사항

반드시 빌드 파일 용 디렉토리를 만든 다음에 해당 디렉토리에서 CMake 를 실행합시다. CMake 는 실행시 여러가지 파일들 (캐시 용도로) 을 생성하는데 이 때문에 프로젝트 디렉토리가 난장판이 될 수 있습니다. 특히 이미 있는 파일을 덮어 씌우기야 한다면 더욱 끔찍..

그럼 이제 build 디렉토리로 들어가서 make 를 실행해봅시다.

$ make
Scanning dependencies of target program
[ 50%] Building CXX object CMakeFiles/program.dir/main.cc.o
[100%] Linking CXX executable program
[100%] Built target program

$ ls
CMakeCache.txt  CMakeFiles  cmake_install.cmake  Makefile  program

그러면 실제로 위 처럼 program 이라는 이름의 실행 파일이 생성된 것을 볼 수 있습니다. 해당 파일을 실행해보면

실행 결과

Hello, CMake

예상대로 잘 동작합니다.

생성할 실행 파일을 추가하는 명령 add_executable

CMake 에서 실행 파일을 생성하기 위해서는 위와 같이 add_executable 을 사용하면 됩니다.

add_executable (<실행 파일 이름> <소스1> <소스2> ... <소스들>)

맨 처음에 생성하고자 하는 실행 파일 이름을 적은 뒤에, 그 뒤로 해당 실행 파일을 만드는데 필요한 소스들을 쭈르륵 나열하면 됩니다.

예를 들어서 실행 파일을 생성하는데 main.cc 말고도 foo.cc 도 필요하다고 해봅시다.

각각의 파일들이 다음과 같이 구성되어 있습니다.

  • foo.h

int foo();
  • foo.cc

#include "foo.h"

int foo() { return 3; }
  • main.cc

#include <iostream>

#include "foo.h"

int main() {
  std::cout << "Foo : " << foo() << std::endl;
  return 0;
}

그렇다면 컴파일에 필요한 것이 소스 파일들인 main.ccfoo.cc 이므로, add_executable 을 다음과 같이 수정해주면 됩니다.

add_executable (program main.cc foo.cc)

그럼 이제 build 디렉토리에 들어가서 make 를 실행해봅시다.

$ make
Scanning dependencies of target program
[ 50%] Linking CXX executable program
/usr/bin/ld: CMakeFiles/program.dir/main.cc.o: in function `main':
main.cc:(.text+0x24): undefined reference to `foo()'
collect2: error: ld returned 1 exit status
make[2]: *** [CMakeFiles/program.dir/build.make:103: program] Error 1
make[1]: *** [CMakeFiles/Makefile2:95: CMakeFiles/program.dir/all] Error 2
make: *** [Makefile:103: all] Error 2

만일 그냥 실행하였다면 위와 같이 오류가 발생합니다. 왜냐하면 아직 CMakeLists.txt 를 바꾸었지만, 아직 Makefile 를 바꾼 것은 아니기 때문이죠. 따라서 cmake .. 을 통해서 Makefile 을 다시 생성해야 합니다.

아무튼 CMake 를 다시 실행해서 만든 새로운 Makefile 에서 컴파일을 해보면

실행 결과

Foo : 3

잘 나옴을 알 수 있습니다.

컴파일 옵션 지정하기

만약에 컴파일 옵션을 지정하고 싶다면 어떨까요? 간단합니다. target_compile_options 명령을 사용하면 됩니다.

target_compile_options(<실행 파일 이름> PUBLIC <컴파일 옵션1> <컴파일 옵션2> ...)

예를 들어서

target_compile_options(program PUBLIC -Wall -Werror)

위 경우 program 을 빌드할 때 컴파일 옵션으로 -Wall (모든 경고 표시) 과 -Werror (경고는 컴파일 오류로 간주) 을 준다는 의미 입니다. 중간에 PUBLIC 은 뒤에서 설명할 텐데, 실행 파일을 빌드할 때에는 그닥 중요하지 않습니다. (PUBLIC 말고도 INTERFACE 와 PRIVATE 도 있는데 사실 어느 옵션을 사용하도 큰 상관은 없습니다. 적어도 실행 파일을 만드는 단계에서는요.)

컴파일 옵션이 잘 돌아가는지 보려면 아래와 같이 main.cc 를 수정해보세요.

#include <iostream>

#include "foo.h"

int main() {
  int i;
  std::cout << "Foo : " << foo() << std::endl;
  return 0;
}

그럼 빌드 시에 아래와 같이 unused variable 오류가 발생합니다.

컴파일 오류

/home/jaebum/teach/cmake/main.cc: In function ‘int main()’:
/home/jaebum/teach/cmake/main.cc:6:7: error: unused variable ‘i’ [-Werror=unused-variable]
    6 |   int i;
      |       ^
cc1plus: all warnings being treated as errors

즉, 컴파일 옵션이 제대로 들어간 것을 볼 수 있죠.

CMake 에서의 기본 개념 Target 과 Property

앞서 target_compile_options 명령을 통해서 program 을 빌드할 때 컴파일 옵션을 줄 수 있었습니다. 그렇다면 CMake 에서 말하는 타겟(target) 이란 무엇일까요? 쉽게 말해서 타겟이란 여러분의 프로그램을 구성하는 요소들 이라 보시면 됩니다. 예를 들어서 위 처럼 실행 파일이 될 수 도 있고, 라이브러리 파일이 될 수 도 있습니다.

CMake 의 모든 명령들은 이 타겟들을 기준으로 돌아갑니다. 그리고 각 타겟에는 속성(Property) 을 정의할 수 있는데, 위와 같이 컴파일 옵션을 주는 것도 program 이라는 타겟에 컴파일 옵션 속성을 설정하는 것이라 볼 수 있죠. 그 외에도, 어떠한 라이브러리를 링크하는 것인지, include 하는 파일은 어디서 보는지 등등의 여러가지 속성들을 정의해줄 수 있습니다.

아무튼 모든 CMake 명령은 그냥 타겟을 정의하고 (add_executable 와 같이), 해당 타겟들의 속성을 지정하는 명령들 (target_compile_options 처럼) 로 이루어진 것이라 보시면 됩니다.

include 경로 지정하기

CMake 에서는 컴파일 시에 헤더 파일들을 찾을 경로의 위치를 지정할 수 있습니다. 보통 컴파일러는 #include <> 의 형태로 include 되는 헤더 파일들은 시스템 경로에서 찾고, #include "" 의 형태로 include 된 헤더 파일의 경우는 따로 지정하지 않는 이상 현재 코드의 위치를 기준으로 찾습니다.

하지만 경우에 따라서 (특히 나중에 라이브러리 만들 시에 더욱) 헤더 파일들을 다른 곳에 위치시키는 경우가 있는데 컴파일러가 해당 파일들을 찾기 위해서는 컴파일 시에 따로 경로를 지정해줘야 합니다.

예를 들어서 foo.hincludes 라는 폴더 안에 있다고 해봅시다. 즉 현재 프로젝트 구조는

$ tree
├── CMakeLists.txt
├── foo.cc
├── includes
│   └── foo.h
└── main.cc

와 같이 생겼죠. 만일 그냥 컴파일 한다면

컴파일 오류

/home/jaebum/teach/cmake/main.cc:3:10: fatal error: foo.h: No such file or directory
    3 | #include "foo.h"
      |          ^~~~~~~
compilation terminated.

위 처럼 foo.h 를 찾을 수 없다는 오류가 발생합니다.

따라서 CMakeLists.txtincludes 디렉토리를 헤더 파일 경로 탐색 시 확인해라 라고 알려줘야 합니다.

target_include_directories(program PUBLIC ${CMAKE_SOURCE_DIR}/includes)

사용 방법은 아래와 같습니다.

target_include_directories(<실행 파일 이름> PUBLIC <경로 1> <경로 2> ...)

위 경우 ${CMAKE_SOURCE_DIR}/includes 를 헤더 파일 탐색 경로에 추가하고 있습니다. 한 가지 중요한 점은 CMake 에서 디렉토리의 경로를 지정할 때 왠만하면 절대 경로를 쓰지 않는 것이 좋습니다. 왜냐하면 CMake 의 가장 큰 장점으로 여러 플랫폼에서 사용할 수 있다 인데, 절대 경로로 박아 놓으면 다른 시스템에서 사용할 수 없기 때문이죠.

${CMAKE_SOURCE_DIR} 은 CMake 에서 기본으로 제공하는 변수로, 최상위 CMakeLists.txt,cmake .. 할 때 읽어들이는 CMakeLists.txt 의 경로를 의미합니다. 다시 말해 프로젝트의 경로라고 볼 수 있죠. 따라서 ${CMAKE_SOURCE_DIR}/includes 는 현재 프로젝트 경로 안에 includes 디렉토리라 보시면 됩니다.

아무튼 이렇게 헤더 파일 탐색 경로를 추가하고 나면

실행 결과

Foo : 3

위와 같이 컴파일이 잘 됨을 확인할 수 있습니다.

라이브러리 만들기

보통 어느 정도 규모가 큰 C++ 프로젝트의 경우 아래와 같은 구조를 가집니다.

일반적인 C++ 프로젝트의 구성

보통 라이브러리 라고 하면 특정 역할을 수행하는 코드를 모아놓은 것이라 보면 됩니다. 예를 들어서 C++ 에 파일 시스템 관련 작업을 하는 것을 모아놓은 <filesystem> 라이브러리가 있는 것 처럼 말이죠.

우리가 C++ 로 개발을 하게 되면 프로그램의 여러가지 요소들을 그냥 하나의 거대한 라이브러리로 만드는 것 보다 각각의 요소들로 쪼개서 만드는 경우가 많습니다. 그 이유는 여러가지가 있는데

  1. 라이브러리로 쪼개지 않고 하나의 거대한 실행 파일로 관리하게 되면, 코드가 바뀔 때 마다 전체를 다시 컴파일 해야 한다. 반면의 라이브러리의 경우 바뀌지 않은 부분은 컴파일 하지 않아도 되서 (링킹만 하면 됨) 개발 속도가 빠르다.

  2. 라이브러리의 각 요소들을 전체를 한꺼번에 묶어 놓았을 때 보다 테스팅 하기 용이하다.

등등이 있습니다.

위 그림에서 나온 프로젝트의 경우 실행 파일을 만들기 위해 두 개의 라이브러리들이 사용됩니다. 그리고 각각의 라이브러리는 또 다른 외부 라이브러리들을 참조하고 있음을 알 수 있죠.

만약에 이와 같은 구성을 그냥 make 로 작업하였으면 꽤나 골치 아팠을 것입니다. 일단 위 프로젝트를 성공적으로 빌드 하기 위해서는

  1. 외부 라이브러리들이 잘 설치되어 있는지 확인해야 하고

  2. 라이브러리 1 과 라이브러리 2 가 각각에 맞는 외부 라이브러리들을 참조할 수 할 수 있도록 설정해야 하고

  3. 실행파일을 만들 때 라이브러리 1 과 2 를 사용하도록 해야 합니다.

하지만 CMake 에서는 위와 같은 작업들을 아주 간단하게 할 수 있습니다.

예를 들어서 아주 간단한 라이브러리를 만들어보겠습니다. 먼저 우리가 사용할 라이브러리는 프로젝트의 lib 폴더 안에 만들도록 하겠습니다.

  • shape.cc

#include "shape.h"

Rectangle::Rectangle(int width, int height) : width_(width), height_(height) {}

int Rectangle::GetSize() const {
  // 직사각형의 넓이를 리턴한다.
  return width_ * height_;
}

그리고 헤더 파일은 includes 폴더 안에 정의하겠습니다. C++ 라이브러리의 경우 헤더와 소스를 따로 분리하는데, 그 이유는 라이브러리를 사용할 경우 라이브러리의 구현 부분을 참조할 필요는 없지만 헤더는 꼭 참조해야 하기 때문입니다. 따라서 구현 부분을 lib 안에, 헤더 파일은 includes 에 따로 뺍니다.

아무튼 includes 안에는 아래와 같이 shapes.h 헤더 파일을 작성합니다.

  • shape.h

class Rectangle {
 public:
  Rectangle(int width, int height);

  int GetSize() const;

 private:
  int width_, height_;
};

그렇다면 이제 해당 라이브러리를 어떻게 빌드 해야 할지 알려주는 CMakeLists.txt 를 작성해야 합니다. 우리의 shape 라이브러리는 /lib 안에 구현되어 있으니까 해당 위치에 CMakeLists.txt 를 새로 만들어줍시다.

# /lib/CMakeLists.txt

# 정적 라이브러리 shape 를 만든다.
add_library(shape STATIC shape.cc)

# 해당 라이브러리 컴파일 시 사용할 헤더파일 경로
target_include_directories(shape PUBLIC ${CMAKE_SOURCE_DIR}/includes)

# 해당 라이브러리를 컴파일 할 옵션
target_compile_options(shape PRIVATE -Wall -Werror)

먼저 add_library 명령을 통해서 만들어낼 라이브러리 파일을 추가합시다. add_library 의 사용법은 간단합니다.

add_library (<라이브러리 이름> [STATIC | SHARED | MODULE ] <소스 1> <소스 2> ...)

중간에 어떠한 형태의 라이브러리를 만들지 설정할 수 있는데 STATIC 으로 명시하면 정적 라이브러리를, SHARED 로 설정하면 동적으로 링크되는 동적 라이브러리를, MODULE 로 명시하면, 동적으로 링크되지는 않지만, dlopen 과 같은 함수로 런타임 시에 불러올 수 있는 라이브러리를 생성합니다.

정적 라이브러리와 동적 라이브러리의 차이는 여기 에 정리해놓았으니 참조하시면 됩니다. 간단히 말하자면 정적 라이브러리는 프로그램 실행 파일에 라이브러리 코드가 전부 들어 있는 것이도, 동적 라이브러리는 프로그램 실행 파일에 라이브러리가 포함되어 있는 것이 아니라 메모리에 라이브러리가 따로 올라가는데 이를 참조하는 형태 입니다. 보통 정적으로 링크하면 실행 파일의 크기가 커지는 대신 동적 라이브러리를 사용할 때 보다 더 빠릅니다.

우리의 경우 간단한 라이브러리이므로 STATIC 으로 설정해서 정적 라이브러리를 만들도록 하겠습니다.

# 해당 라이브러리 컴파일 시 사용할 헤더파일 경로
target_include_directories(shape PUBLIC ${CMAKE_SOURCE_DIR}/includes)

# 해당 라이브러리를 컴파일 할 옵션
target_compile_options(shape PRIVATE -Wall -Werror)

나머지 부분은 아까와 같습니다. 여기서 한 가지 중요한 점은 PUBLICPRIVATE 의 차이 입니다.

기본적으로 CMake 에서 만약에 A 라이브러리가 B 라이브러리를 사용한다면 A 는 B 의 컴파일 옵션들과 헤더 파일 탐색 디렉토리 목록을 물려받게 됩니다. 정확히 말하면 PUBLIC 으로 설정된 것은 물려 받고, PRIVATE 으로 설정된 것은 물려받지 않습니다. (아래 부분에서 좀 더 정확히 다루겠습니다만 일단 이정도로 알고 계셔도 됩니다.)

따라서

target_include_directories(shape PUBLIC ${CMAKE_SOURCE_DIR}/includes)

위 문장의 의미는 다음과 같습니다.

  1. shape 를 컴파일 할 때 헤더 파일 검색 경로에 ${CMAKE_SOURCE_DIR}/includes 를 추가해주세요. 그리고

  2. shape 를 참조 하는 타겟의 헤더 파일 검색 경로에 ${CMAKE_SOURCE_DIR}/includes 를 추가해주세요

가 됩니다. 따라서 예를 들어서 아까 위에서 보았던 programshape 를 사용한다면, program 을 컴파일 할 때 파일 검색 경로에 ${CMAKE_SOURCE_DIR}/includes 가 자동으로 추가됩니다.

반면에 라이브러리를 컴파일 하는 옵션은

target_compile_options(shape PRIVATE -Wall -Werror)

PRIVATE 으로 설정되어 있습니다. 그 이유는 shape 를 빌드할 때에는 -Wall-Werror 옵션을 사용하고 싶지만, shape 를 사용하는 애들에게까지 이 옵션을 강제하고는 싶지 않기 때문이죠.

자 그렇다면 이 shape 를 한 번 program 에 링크해서 사용해봅시다.

shape 사용하는 간단한 프로젝트

참고를 위해 그림으로 나타내보자면 현재 전체적인 구성은 위와 같습니다.

프로젝트 레벨 CMakeLists.txt 를 다음과 같이 수정합니다.

# CMake 프로그램의 최소 버전
cmake_minimum_required(VERSION 3.11)

# 프로젝트 정보
project(
  ModooCode
  VERSION 0.1
  DESCRIPTION "예제 프로젝트"
  LANGUAGES CXX)

# 확인할 디렉토리 추가
add_subdirectory(lib)

add_executable (program main.cc)

# program 에 shape 를 링크
target_link_libraries(program shape)

먼저 add_subdirectory 명령을 통해서 CMake 가 추가로 확인해야 할 디렉토리의 경로를 지정해줍니다. 그러면 CMake 실행 시에, 해당 디렉토리로 들어가서 그 안에 있는 CMakeLists.txt 도 실행할 것입니다.

# program 에 shape 를 링크
target_link_libraries(program PUBLIC shape)

그리고 위와 같이 program 을 빌드 할 때 shape 라이브러리를 링크 시켜 줍니다. 참고로 실행 파일은 PUBLIC 이냐 PRIVATE 이냐의 여부가 크게 중요하지는 않습니다. 왜냐하면 실행 파일을 다른 타겟이 참조할 수 는 없기 때문이죠.

그래서 그냥

target_link_libraries(program shape)

로 써도 됩니다.

아무튼 전체적인 프로젝트의 구조는 다음과 같습니다.

$ tree
├── CMakeLists.txt
├── includes
│   └── shape.h
├── lib
│   ├── CMakeLists.txt
│   └── shape.cc
└── main.cc

그리고 한 번 실행해보면

실행 결과

Get size : 30

잘 나옴을 알 수 있습니다. 실제로 build 디렉토리를 확인해보면 libshape.a 파일이 생긴 것을 볼 수 있습니다. 참고로 CMake 는 라이브러리를 만들게 되면 앞에 lib 을 붙인 라이브러리 파일을 생성합니다.

주의 사항

옛날 버전의 CMake 에서는 앞에 *target_ 이 빠진 include_directories, link_directories 와 같은 명령들이 사용되었는데 이는 최신의 CMake 에서는 사용이 권장되지 않는 명령들 입니다. 현재의 CMake 패러다임은 타겟들을 기준으로 돌아가기 때문에 꼭 target_* 형태의 명령을 사용하도록 합시다.

다른 라이브러리를 사용하는 라이브러리

예를 들어서 우리의 Shape 라이브러리에서 thread 라이브러리를 사용한다고 해봅시다.

#include <iostream>
#include <thread>

#include "shape.h"

Rectangle::Rectangle(int width, int height) : width_(width), height_(height) {}

int Rectangle::GetSize() const {
  std::thread t([this]() { std::cout << "Calulate .." << std::endl; });
  t.join();

  // 직사각형의 넓이를 리턴한다.
  return width_ * height_;
}

리눅스의 경우 보통 thread 라이브러리를 사용하려면 pthread 라이브러리를 링크시켜줘야 합니다. 따라서 아래와 같이 shapeCMakeLists.txt 를 수정해줘야 합니다.

add_library(shape STATIC shape.cc)
target_include_directories(shape PUBLIC ${CMAKE_SOURCE_DIR}/includes)
target_compile_options(shape PRIVATE -Wall -Werror)

# pthread 라이브러리를 링크
target_link_libraries(shape PRIVATE pthread)

위와 같이 target_link_libraries 를 통해서 shapepthread 라이브러리를 추가해준 것을 알 수 있습니다. target_link_libraries 로 의존 라이브러리(Dependency 라 하죠 보통) 를 추가할 때 추가하는 방식이 세 가지가 있는데, 앞서 이야기한 PUBLIC, PRIVATE 말고도 INTERFACE 방식이 또 있습니다. 어떠한 방식을 사용하는지는 다음과 같은 가이드라인을 따르면 좋습니다.

만일 어떤 라이브러리 A 를 참조한다고 할 때

  • A 를 헤더 파일과 구현 내부에서 모두 사용한다면 : PUBLIC

  • A 를 내부 구현에서만 사용하고 헤더 파일에서는 사용하지 않는다면 : PRIVATE

  • A 를 헤더 파일에서만 사용하고 내부 구현에서는 사용하지 않는다면 : INTERFACE

로 명시하면 되는 것입니다.

위 경우 <thread> 를 내부 구현 (shape.cc) 에서만 사용하고 헤더 파일 (shape.h) 에서는 사용하고 있지 않습니다. 따라서 이 경우 pthreadPRIVATE 으로 링크해주는 것이 맞습니다. 이를 통해서 shape 를 사용하는 다른 라이브러리가 불필요하게 pthread 를 링크해주는 일을 막을 수 가 있죠.

파일들 한꺼번에 추가하기

CMake 에서 타겟을 빌드하는데 필요한 소스 파일들을 명시 하기 위해서는

add_library(shape STATIC shape.cc color.cc circle.cc)

와 같이 해당 라이브러리를 빌드하는데 필요한 파일들을 명시해야 한다고 하였습니다. 하지만 이 방법은 파일들이 새로 추가할 때 마다 위 add_library 를 수정해줘야 한다는 귀찮음이 있습니다. 다행이도 CMake 에서는 저와 같이 귀찮은 사람들을 위해서 이 디렉토리에 있는 파일들을 모두 이 라이브러리를 빌드하는데 사용해줘! 라고 명령할 수 있는 방법을 제공합니다.

file(GLOB_RECURSE SRC_FILES CONFIGURE_DEPENDS
  ${CMAKE_CURRENT_SOURCE_DIR}/*.cc
)

add_library(shape STATIC ${SRC_FILES})

file 명령은 CMake 에서 파일들을 관련해서 다룰 때 사용하는 명령인데, GLOB_RECURSE 옵션은, 인자로 주어진 디렉토리와 해당 디렉토리 안에 있는 모든 하위 디렉토리 까지 재귀적으로 살펴본다는 의미 입니다.

참고 사항

CMake 에서 모든 변수들은 ${변수 이름} 과 같은 식으로 참조합니다. make 와 비슷한데, make 는 소괄호 () 로 감싸주는 반면 CMake 는 중괄호 {} 로 감싸줍니다.

이 때 주어진 디렉토리는 ${CMAKE_CURRENT_SOURCE_DIR}/*.cc 인데, ${CMAKE_CURRENT_SOURCE_DIR} 는 CMake 에서 기본으로 제공하는 변수로 현재 CMakeLists.txt 가 위치한 디렉토리, 즉 현재 디렉토리를 의미 합니다.

따라서 위 명령은 현재 디렉토리 안에 있는 모든 .cc 로 끝나는 파일들 (하위 디렉토리 포함) 을 나타내는데, 해당 파일들을 모두 모아서 SRC_FILES 라는 변수를 구성하라는 의미 입니다. 만일 하위 디렉토리를 포함하고 싶지 않다면 GLOB_RECURSE 대신에 GLOB 을 주면 됩니다.

그리고 마지막으로 CONFIGURE_DEPENDS 옵션을 준다면 만약에 GLOB 으로 불러오는 파일 목록이 이전과 다를 경우 (예를 들어서 파일을 추가하거나 지웠을 때) CMake 를 다시 실행해서 빌드 파일을 재생성 하라는 의미가 됩니다. 따라서 만약에 디렉토리 안에 파일이 추가 되더라도, cmake .. 을 다시 실행할 필요 없이 그냥 make 만 실행해도 CMake 가 다시 실행되면서 빌드 파일을 재작성 합니다. 아주 편리하죠!

주의 사항

사실 CMake 에서는 위 명령으로 파일들을 읽어들이는 것을 권장하지는 않습니다. 왜냐하면 파일이 추가되더라도 CMake 가 생성한 빌드 파일 안에 명시된 파일들이 바뀌는 것이 아니기 때문에 어차피 CMake 를 통해서 빌드 파일을 다시 생성해야 하기 때문이죠. 물론 CONFIGURE_DEPENDS 옵션을 주면 되기는 하지만, 모든 빌드 시스템에서 안정적으로 작동하지는 않는다고 합니다.

Note: We do not recommend using GLOB to collect a list of source files from your source tree. If no CMakeLists.txt file changes when a source is added or removed then the generated build system cannot know when to ask CMake to regenerate. The CONFIGURE_DEPENDS flag may not work reliably on all generators, or if a new generator is added in the future that cannot support it, projects using it will be stuck. Even if CONFIGURE_DEPENDS works reliably, there is still a cost to perform the check on every rebuild (링크).

하지만 적어도 제 경우 Make 와 Ninja 빌드 시스템을 사용했을 때에는 문제 없이 작동하였습니다. 또한 매번 CMakeLists.txt 에 파일을 추가하는데 오는 편리함이 무척이나 크기 때문에 엄청 중요한 프로젝트 아니면 굳이 다 명시해야 되나 생각을 합니다.

아무튼 그러면 SRC_FILES 변수 안에 파일들의 목록이 쭈르륵 들어가 있으므로

add_library(shape STATIC ${SRC_FILES})

를 하게 되면 shape 를 빌드하는데 필요한 파일들을 모두 지정할 수 있습니다.

원하는 라이브러리를 설치하는 FetchContent

요즘에 나오는 왠만한 언어들은 외부 라이브러리들을 쉽게 불러오고 설치하는 프로그램이 기본으로 제공됩니다. 예를 들어서 Python 에는 PIP 가 있고, Rust 에는 Cargo 가 있는 것 처럼 말이죠. 하지만 C++ 에는 언어 상 제공하는 기능이 없기 때문에 외부 라이브러리를 불러오거나 설치하는 것이 굉장히 불편합니다.

하지만 CMake 에서 제공하는 FetchContent 를 사용하면 왠만한 외부 라이브러리들을 쉽게 불러오고 설치할 수 있습니다. 예를 들어서 fmt 라이브러리를 사용하고 싶다고 해봅시다.

주의 사항

FetchContent 를 사용하기 위해서는 적어도 3.11 이상 버전의 CMake 를 사용해야 합니다. 그 이전의 CMake 를 사용하고 계신다면 ExternalProject 모듈을 사용하셔도 됩니다. 하지만 FetchContent 는 CMake 를 실행하는 시점에서 외부 파일들을 불러오는 반면 ExternalProject 는 빌드 타임에 불러온다는 점에서 차이가 있습니다.

include(FetchContent)
FetchContent_Declare(
  Fmt
  GIT_REPOSITORY "https://github.com/fmtlib/fmt"
  GIT_TAG "7.1.3"
  )
FetchContent_MakeAvailable(Fmt)

위는 간단히 fmt 라이브러리를 CMake 를 통해 불러오고 설치하는 명령입니다. 상당히 간단하죠. 가장 먼저

include(FetchContent)

include 를 통해서 FetchContent 모듈을 불러와야 합니다. 그 다음에;

FetchContent_Declare(
  Fmt
  GIT_REPOSITORY "https://github.com/fmtlib/fmt"
  GIT_TAG "7.1.3"
  )

위와 같이 어디에서 데이터를 불러올지 명시해야죠. 위 경우 깃허브에서 특정 릴리즈를 가져왔습니다. (7.1.3 버전)

마지막으로

FetchContent_MakeAvailable(Fmt)

위와 같이 fmt 를 사용할 수 있도록 설정하면 됩니다. 아주 간단하죠! 참고로 FetchContent 로 불러온 라이브러리는 프로젝트 전체에서 사용 가능합니다.

예를 들어서 shape 라이브러리에서 fmt 를 사용하고 싶다면

target_link_libraries(shape PRIVATE pthread fmt)

과 같이 fmt 라이브러리를 추가하기만 하면 되죠.

#include <fmt/core.h>

#include <iostream>
#include <thread>

#include "shape.h"

Rectangle::Rectangle(int width, int height) : width_(width), height_(height) {}

int Rectangle::GetSize() const {
  std::thread t([this]() { std::cout << "Calulate .." << std::endl; });
  t.join();

  fmt::print("width : {} \n", width_ * height_);

  // 직사각형의 넓이를 리턴한다.
  return width_ * height_;
}

void Rectangle::SetColor(Color color) { color_ = color; }

성공적으로 컴파일 하였다면

실행 결과

Get size : Calulate ..
width : 30 
30

위와 같이 fmt::print 을 성공적으로 사용할 수 있습니다.

만능 CMake 프로젝트

여태까지 다룬 내용들을 바탕으로 대부분의 C++ 프로젝트에서 간단히 사용할 수 있는 만능 CMake 프로젝트 템플릿을 준비하였습니다. 여기 에서 보시면 됩니다.

Make 이외의 빌드 시스템 사용하기

CMake 에서 따로 설정하지 않았다면 디폴트로 make 가 선택되지만 경우에 따라선 다른 빌드 프로그램을 선택해야 합니다. 예를 들어서 요새 많이 사용되는 Ninja 를 사용하고 싶을 수 있고, 아니면 비주얼 스튜디오를 사용할 경우 비주얼 스튜디오용 빌드 파일을 생성해야 합니다.

이 경우 아래와 같은 CMake 명령을 사용하면 됩니다.

$ cmake .. -DCMAKE_GENERATOR=Ninja 

CMAKE_GENERATOR 인자로 원하는 빌드 시스템을 전달하면 되는데, 전체 목록은 여기 에서 보실 수 있습니다. 대표적으로

  • "Unix Makefiles" (디폴트)

  • "Ninja"

  • "Visual Studio 16 2019"

등등을 사용할 수 있습니다.

한 가지 중요한 점은 이미 빌드 시스템을 설정하였다면 바꿀 수 없다는 것입니다. 새 디렉토리를 만들어서 CMake 명령을 다시 실행하거나, 기존 디렉토리 안의 파일들을 모두 지워야 합니다.

정리

이 글에서는 간단히 CMake 의 기초적인 개념들과 함께 C++ 프로젝트에 CMake 를 도입하는데 필요한 것들을 살펴보았습니다. 사실 CMake 는 꽤 방대합니다. 예를 들어서 여타 다른 언어들 처럼 변수를 정의할 수 있고, if, else, for 문 모두 사용 가능합니다. 하지만 이 글에서는 CMake 의 전반적인 기능들을 살펴보는 것이 목적이였기에 이에 관해서는 따로 적지는 않고 링크 만 남겨놓겠습니다.

만일 CMake 를 좀 더 전문적으로 사용하고 싶다면 (예를 들어서 라이브러리를 배포하고 싶다던지), 아래의 글과 영상들을 보시기 바랍니다.

  • Daniel Pfeifer "Effective CMake". CMake 를 올바르게 사용하고 싶다면 꼭 봐야할 동영상 입니다.

  • CMake 공식 문서. 사실 이해가 잘 되게 정리가 잘 되어 있는 편은 아니라 생각합니다. 하지만 공식 문서인 만큼 참고용으로 좋은 것 같습니다.

  • CMake 할때 쪼오오금 도움이 되는 문서. CMake 의 기초적인 사용법을 넘어서 좀 더 깊이 있는 내용을 보고 싶다면 이 글을 읽는 것을 추천합니다. 한국어로 된 자료 중에 가장 괜찮은 것 같습니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C++ - <19 - 2. C++ 프로젝트를 위한 CMake 사용법>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 24 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..