1. CMake의 include 경로: target_include_directories
처음에 fatal error: ...: No such file or directory 같은 오류를 만나면 컴퓨터에게 억울함을 느끼기 쉽습니다. “파일이 분명 프로젝트에 있는데!”라고요. 하지만 컴파일러는 점쟁이가 아닙니다. 컴파일러는 디렉토리 집합(include paths)에서 헤더를 찾고, 필요한 폴더가 이 목록에 없다면 파일은 “보이지 않는” 것으로 간주됩니다. 설령 파일이 디스크에서 바로 옆에 있어도 마찬가지입니다.
두 가지를 구분하는 것이 중요합니다. C++의 #include 지시자는 “컴파일 시 이 헤더의 텍스트가 필요하다”고 말합니다. 그런데 정확히 어디서 그 텍스트를 찾을지는 컴파일러 설정으로 결정됩니다. 커맨드라인에서 수동으로 지정할 때는 보통 -I... 플래그입니다. CMake에서는 타겟 접근법에서는 직접 -I를 쓰지 않고, target_include_directories로 include 경로를 기술합니다.
비유하자면 컴파일러는 배달원이고, 당신은 배달원에게 “집 calc.hpp에서 소포를 가져와”라는 쪽지를 준 상황입니다. 배달원이 어느 동네를 검색해야 하는지 알려주지 않으면, 그는 자신의 기본 경로만 돌다가 당연히 찾지 못할 것입니다. 전체 컴퓨터를 “혹시나” 돌아다닐 의무는 없습니다 — 그렇지 않다면 모든 프로젝트 빌드가 엄청 오래 걸렸을 겁니다.
target_include_directories가 하는 일과 왜 이것이 타겟 속성인지
CMake에서 include 경로는 프로젝트 전체에 대해 한꺼번에 설정되는 것이 아니라 각 타겟별로 지정됩니다: 실행 파일이나 라이브러리 단위입니다. 이것이 바로 타겟 접근법입니다: 각 타겟은 자체 속성을 가지며, include 경로도 그런 중요한 속성 중 하나입니다. 왜냐하면 컴파일에 직접적인 영향을 미치기 때문입니다.
명령의 최소 형태는 다음과 같습니다:
target_include_directories(my_target PRIVATE include)
의미: my_target을 컴파일할 때 컴파일러는 include/ 폴더도 검색하게 됩니다 (CMakeLists.txt를 기준으로 한 상대 경로). 그러면 C++ 코드에서 #include "math.hpp" (또는 #include "calc/add.hpp" — 폴더 구조에 따라)처럼 쓸 수 있고 컴파일러가 파일을 찾습니다.
실용적인 점: target_include_directories는 보통 add_executable / add_library 뒤에 둡니다. 타겟을 생성하기 전에 설정할 대상이 없기 때문입니다.
add_executable(app src/main.cpp)
target_include_directories(app PRIVATE include)
이것은 단순한 스타일 문제가 아니라 상식입니다: 먼저 엔티티를 만들고 그 다음에 속성을 지정합니다.
학습 예제: 라이브러리 calc와 애플리케이션 app
추상적으로만 설명하지 않기 위해 예제를 계속 진행합니다. 간단한 프로젝트 구조를 만들어 봅시다:
MiniProject/
CMakeLists.txt
include/
calc/
sum.hpp
src/
sum.cpp
main.cpp
헤더는 함수를 선언하고, .cpp가 구현하며, main.cpp가 이를 사용합니다. 애플리케이션은 두 수의 합을 출력할 것입니다(심하게 복잡하진 않지만 빌드 규율을 다루기에는 충분합니다).
파일 include/calc/sum.hpp:
#pragma once
int sum(int a, int b);
파일 src/sum.cpp:
#include "calc/sum.hpp"
int sum(int a, int b) {
return a + b;
}
파일 src/main.cpp:
#include <iostream>
#include "calc/sum.hpp"
int main() {
std::cout << sum(2, 3) << '\n'; // 5
}
그럼 컴파일러는 어디서 include/ 폴더가 존재하고 그 안에서 calc/sum.hpp를 찾아야 하는지 어떻게 알까요? 답은 target_include_directories입니다.
애플리케이션의 include 경로: PRIVATE 시나리오
메커니즘을 체감하려고 간단한 경우부터 시작해 봅시다. 당장 모든 것을 하나의 타겟 app으로 빌드(별도 라이브러리 없이)한다고 가정하고, main.cpp가 include/의 헤더를 보도록 하고 싶습니다.
cmake_minimum_required(VERSION 3.20)
project(MiniProject LANGUAGES CXX)
add_executable(app
src/main.cpp
src/sum.cpp
)
target_compile_features(app PRIVATE cxx_std_23)
target_include_directories(app PRIVATE include)
여기서 PRIVATE는 이렇게 읽습니다: “이 include 경로는 오직 이 타겟 app의 빌드에만 필요하다”.
이것이 합리적인 이유는 app이 최종 실행 파일이기 때문입니다. 보통 실행 파일은 다른 누군가가 라이브러리처럼 사용하는 대상이 아닙니다. 따라서 include 경로를 다른 데로 “전달”할 필요가 거의 없습니다.
또한 src/sum.cpp 역시 #include "calc/sum.hpp"를 사용합니다. 이는 정상입니다: include 경로는 해당 타겟에 포함된 모든 .cpp 파일에 적용됩니다.
3. include 경로의 가시성: PRIVATE / PUBLIC / INTERFACE
왜 PUBLIC과 INTERFACE가 필요한가
지금까지는 PRIVATE가 “유일한 올바른 선택”처럼 보였을 수 있습니다. 그러나 이는 우리가 아직 핵심 단계를 밟지 않았기 때문입니다: 라이브러리와 애플리케이션의 분리입니다.
라이브러리 calc가 생기면 일반적으로 두 가지를 원합니다.
첫째: 라이브러리 자체를 빌드할 때 컴파일러가 라이브러리의 헤더를 찾아야 합니다.
둘째: 다른 애플리케이션(우리의 app)이 라이브러리를 사용할 때도 라이브러리의 헤더를 찾아야 합니다 — 그렇지 않으면 소비자는 #include "calc/sum.hpp"를 쓸 수 없습니다.
여기서 핵심 단어는 가시성입니다.
PUBLIC / PRIVATE / INTERFACE의 의미와 소비자로의 “전달” 방식
다음과 같이 작성하면:
target_include_directories(calc PUBLIC include)
CMake에 두 가지를 동시에 말하는 것입니다:
1) include 경로는 라이브러리 자신(calc)이 컴파일되는 데 필요하다(자체적인 요구).
2) include 경로는 이 라이브러리를 링크하는 모든 대상이 필요하다 — 즉 공개 헤더를 사용하기 위해 필요하다(소비자를 위한 요구).
바로 이 때문에 PUBLIC을 “자신과 타인을 위한 것”이라고 설명합니다.
기억하기 쉽게 요약표를 머릿속에 두세요:
| 키워드 | 타겟 자체 빌드에 필요한가? | 타겟 소비자에게 필요한가? |
|---|---|---|
|
예 | 아니오 |
|
예 | 예 |
|
아니오 | 예 |
처음에는 INTERFACE가 “타겟 자신에게 필요하지 않다”는 점이 이상하게 느껴질 수 있습니다. 그러나 헤더 전용 라이브러리(header-only, .cpp가 없는 경우)나 설정만 전달하는 인터페이스 타겟에는 아주 자연스러운 개념입니다.
라이브러리 calc를 올바르게 빌드하기: PUBLIC include
이제 CMake를 다시 작성하여 calc를 별도의 라이브러리로 만들고, app을 별도 실행 파일으로 만든 뒤, app이 calc에 링크되게 합시다(전이적 include 경로 시연을 위해 링크 의존성이 필요합니다).
cmake_minimum_required(VERSION 3.20)
project(MiniProject LANGUAGES CXX)
add_library(calc
src/sum.cpp
)
target_compile_features(calc PRIVATE cxx_std_23)
target_include_directories(calc PUBLIC include)
add_executable(app
src/main.cpp
)
target_compile_features(app PRIVATE cxx_std_23)
target_link_libraries(app PRIVATE calc)
여기서 아름다운 아이디어는 이렇습니다: app은 명시적으로 target_include_directories(app ...)를 써서 calc의 헤더를 지정할 필요가 없습니다. calc가 PUBLIC으로 선언했기 때문에 include 경로를 전이적으로 받습니다.
이것이 타겟 접근법을 좋아하는 주요 이유 중 하나입니다: 의존성이 정직해집니다. 라이브러리를 추가하면 해당 라이브러리를 사용하기 위해 실제로 필요한 것들만(공개적으로 선언된 것들만) 자동으로 제공됩니다.
PRIVATE include 경로: 소비자가 볼 필요가 없는 구현 헤더
프로젝트를 좀 더 현실적으로 만들어 봅시다. 라이브러리에는 보통 두 종류의 헤더가 있습니다:
- 공개 헤더 — 라이브러리 사용자가 사용하도록 허용하는 것들. 보통 include/에 위치합니다.
- 내부 구현 헤더 — 라이브러리 내부에서만 필요한 것들로, 소비자가 직접 접근하지 않는 것이 바람직합니다.
다음과 같은 구조를 만들어 봅시다:
include/
calc/
sum.hpp (공개)
src/
detail/
sum_impl.hpp (내부)
sum.cpp
파일 src/detail/sum_impl.hpp:
#pragma once
inline int sum_impl(int a, int b) {
return a + b;
}
파일 src/sum.cpp:
#include "calc/sum.hpp"
#include "detail/sum_impl.hpp"
int sum(int a, int b) {
return sum_impl(a, b);
}
이제 라이브러리 calc는 include/와 src/ 양쪽의 헤더를 포함할 수 있어야 합니다 (왜냐하면 detail/은 src/ 내부에 있으니까요). 하지만 소비자 app은 detail/sum_impl.hpp에 대해 알 필요가 전혀 없습니다.
이것을 CMake에서는 다음과 같이 표현합니다:
target_include_directories(calc
PUBLIC include
PRIVATE src
)
읽는 방법: “include/ 폴더는 공개 인터페이스의 일부이고, src/ 폴더는 내부 주방이다 — 밖으로 꺼내지 않는다.”
이제 누군가가 app에서 #include "detail/sum_impl.hpp"를 시도하면, 대부분 컴파일이 실패할 것입니다. 그리고 그것은 의도한 바입니다: 내부 세부 사항에 울타리를 친 셈이니까요.
INTERFACE include 경로: 헤더 전용 라이브러리
INTERFACE는 .cpp가 없는 라이브러리를 만날 때 이해가 됩니다. 예를 들어, 기능 몇 개가 헤더에만 있고 배포는 헤더만으로 하고 싶을 때입니다.
작은 예제를 만들어 보겠습니다: textutils라는 라이브러리로, include/textutils/trim.hpp에 있고 .cpp는 없습니다.
include/
textutils/
trim.hpp
파일 include/textutils/trim.hpp:
#pragma once
#include <string>
inline std::string trim_one_space(std::string s) {
if (!s.empty() && s.front() == ' ') s.erase(s.begin());
if (!s.empty() && s.back() == ' ') s.pop_back();
return s;
}
이런 라이브러리는 CMake에서 인터페이스 타겟으로 기술할 수 있습니다:
add_library(textutils INTERFACE)
target_include_directories(textutils INTERFACE include)
여기서 INTERFACE는 “이 타겟 자체는 컴파일하지 않지만, 소비자에게 include 경로와 다른 요구사항을 전달한다”는 의미입니다.
그럼 app은 이렇게 연결할 수 있습니다:
target_link_libraries(app PRIVATE textutils)
약간 낯설게 보일 수 있지만: 우리는 헤더 전용 라이브러리를 마치 “링크”하는 것처럼 보입니다. CMake에서 target_link_libraries는 전통적인 링크 외에도 include 경로, compile-definitions 등 사용 요구사항(usage requirements)을 연결하는 역할을 자주 합니다.
스키마: include 경로가 타겟 간에 어떻게 “전달”되는가
모든 것을 마법처럼 외워두기보다는 요구사항 전달로 생각하면 도움이 됩니다.
flowchart LR
A[calc target] -- PUBLIC include --> B[app target]
A -- PRIVATE src --> A
C[textutils INTERFACE] -- INTERFACE include --> B
스키마의 의미: PUBLIC과 INTERFACE는 소비자에게 “나간다(outgoing)”는 뜻이고, PRIVATE는 내부에 남습니다.
PRIVATE / PUBLIC / INTERFACE 선택을 위한 실용 규칙
처음 CMakeLists.txt를 작성할 때 모든 것을 PUBLIC으로 설정하고 싶은 유혹이 큽니다. “이렇게 하면 확실히 동작할 거야”라는 생각이죠. 물론 동작은 하겠지만, 곧 프로젝트가 문 없는 주방처럼 될 수 있습니다: 냄새가 나고 소음이 거실까지 퍼지며, 손님이 국자를 만지작거립니다.
초보자에게 권하는 실용 전략은 다음과 같습니다.
- 실행 파일(add_executable)의 include 경로는 거의 항상 PRIVATE입니다. 실행 파일은 보통 라이브러리처럼 사용되지 않기 때문입니다.
- 라이브러리(add_library)의 공개 헤더(include/)는 보통 PUBLIC으로 지정합니다. 그렇지 않으면 소비자가 라이브러리를 사용할 수 없습니다. 반면 내부 구현 폴더(src/, src/detail/)는 보통 PRIVATE로 지정하여 소비자가 내부에 의존하지 않도록 합니다.
- 헤더 전용 라이브러리(add_library(name INTERFACE))의 include 경로는 INTERFACE로 설정합니다.
또 한 가지 중요한 심리적 포인트: PUBLIC은 약속입니다. 폴더를 PUBLIC으로 표시하면 “사용자가 그 폴더에 있는 헤더에 의존해도 된다”라고 말하는 셈이며, 일단 약속하면 유지해야 합니다.
4. target_include_directories를 사용할 때 흔한 실수
오류 #1: “헤더를 찾을 수 없음” 문제를 해결하려고 C++ 코드에 또 다른 #include를 추가한다.
컴파일러가 calc/sum.hpp을 못 찾았다고 하면, 문제는 보통 “헤더를 덜 포함했다”가 아니라 include 검색 경로가 올바르게 설정되지 않았기 때문입니다. 수정은 CMakeLists.txt의 target_include_directories에서 이루어져야 합니다. 아무렇게나 include를 늘려서 문제를 숨기지 마세요.
오류 #2: 그냥 PUBLIC으로 해서 “확실히 동작하게” 만든다 — 그리고 무엇을 공개하는지 모른다.
예를 들어 target_include_directories(calc PUBLIC src)라고 하면 소비자에게 “내부 헤더를 src에서 포함해도 된다”라고 말하는 것입니다. 나중에 src/detail를 리팩터링하거나 내부 구조를 바꾸면 다른 사람의 코드(또는 당신의 app)가 깨질 수 있습니다. 내부는 PRIVATE로 두는 편이 낫습니다.
오류 #3: CMake가 자동으로 include/를 찾아줄 것이라고 기대한다.
CMake는 당신이 의도한 바를 추측하지 않습니다. include/가 있다고 해서 마법처럼 등록되는 것은 아닙니다: 당신이 target_include_directories로 추가하기 전까지 컴파일러는 그 폴더를 모를 수 있습니다. 몇몇 IDE는 헤더를 하이라이트하거나 자동완성을 제공하지만, 이는 빌드 설정이 올바르게 되어 있다는 보장이 아닙니다.
오류 #4: include 경로와 소스 파일 목록을 혼동한다.
include 디렉토리를 추가하는 것은 컴파일러가 헤더를 찾도록 하는 것입니다. 이것은 .cpp 파일을 빌드에 추가하는 것과는 다릅니다. 그래서 때로는 “선언은 보이지만 링킹에서 실패(undefined reference)”하는 경우가 생깁니다. 헤더는 컴파일과 관련되고, .cpp나 라이브러리는 링킹과 관련된 문제입니다.
오류 #5: 공개 헤더와 구현 헤더를 한데 섞어 놓는다.
모든 것을 include/에 막 넣으면 소비자는 내부 디테일까지 보게 됩니다. 반대로 공개 헤더를 src/에 넣으면 src/를 PUBLIC으로 선언해야 하고 상황은 더 안 좋아집니다. 인터페이스(include/)와 구현(src/)을 분리하는 것은 번거롭게 보일 수 있지만 C++에서는 매우 투자 대비 효과가 큰 습관입니다.
GO TO FULL VERSION