CMake로 여러 타깃을 나눠 빌드하다 보면 “라이브러리는 분명히 링크했는데 헤더를 못 찾는다”는 에러를 자주 마주친다. 원인은 대부분 target_link_libraries의 PUBLIC/PRIVATE/INTERFACE 키워드를 잘못 골랐기 때문이다. 이 세 키워드는 단순히 “링크한다/안 한다”가 아니라, 그 라이브러리가 가진 include 경로·컴파일 정의 같은 사용 요구사항(usage requirement)이 상위 타깃으로 전파되는지를 결정한다. 이 글에서는 실제로 빌드가 성공/실패하는 예제를 통해 target_link_libraries의 동작을 직접 확인하며 정리한다.
기본 문법
가장 많이 쓰는 키워드 서명(keyword signature)은 다음과 같은 형태다.
target_link_libraries(<타깃> <라이브러리1> [<라이브러리2> ...])
<타깃>에는 add_executable이나 add_library로 만든 타깃 이름을, <라이브러리>에는 다른 타깃 이름·라이브러리 파일 경로·시스템 라이브러리 이름 등을 넘긴다. 키워드 없이 라이브러리 이름만 나열하는 구식(plain) 서명도 있는데, 같은 타깃에 두 서명을 섞어 쓸 수는 없다. 실제로 섞으면 다음과 같은 에러가 난다.
CMake Error at CMakeLists.txt:5 (target_link_libraries):
The keyword signature for target_link_libraries has already been used with
the target "app". All uses of target_link_libraries with a target must be
either all-keyword or all-plain.
새 코드를 작성한다면 구식 서명은 쓸 이유가 없다. 항상 PRIVATE/PUBLIC/INTERFACE 중 하나를 명시하는 키워드 서명을 사용해야 한다.
PUBLIC / PRIVATE / INTERFACE — 전파의 차이
세 키워드는 두 가지 축을 조합해서 결정된다. 하나는 “이 타깃 자신을 빌드할 때 이 요구사항이 필요한가”, 다른 하나는 “이 타깃을 링크하는 다른 타깃에도 이 요구사항이 전파돼야 하는가”다.
- PRIVATE: 이 타깃을 빌드하는 데만 쓰인다. 이 타깃을 링크하는 다른 타깃에는 전파되지 않는다.
- PUBLIC: 이 타깃을 빌드하는 데도 쓰이고, 이 타깃을 링크하는 다른 타깃에도 그대로 전파된다.
- INTERFACE: 이 타깃 자신을 빌드하는 데는 쓰이지 않고, 이 타깃을 링크하는 다른 타깃에만 전파된다. 헤더 전용 라이브러리에서 주로 쓴다.
말로만 설명하면 헷갈리니, core → wrapper → app 3단계로 링크되는 프로젝트로 직접 확인해본다. wrapper가 core를 PRIVATE으로 링크한 상태에서, app이 core.h를 직접 include하도록 만들었다.
add_library(core STATIC libcore/core.cpp)
target_include_directories(core PUBLIC libcore/include)
add_library(wrapper STATIC libwrapper/wrapper.cpp)
target_include_directories(wrapper PUBLIC libwrapper/include)
target_link_libraries(wrapper PRIVATE core)
add_executable(app app/main.cpp)
target_link_libraries(app PRIVATE wrapper)
app/main.cpp에서 wrapper.h뿐 아니라 core.h도 include하도록 하고 빌드하면, core와 wrapper는 문제없이 빌드되지만 app에서 다음과 같이 실패한다.
app/main.cpp:3:10: fatal error: core.h: No such file or directory
3 | #include "core.h"
| ^~~~~~~~
compilation terminated.
wrapper가 core를 PRIVATE으로 링크했기 때문에, core의 include 경로(libcore/include)는 wrapper 자신을 빌드할 때만 쓰이고 app에는 전파되지 않은 것이다. wrapper가 core.h를 자신의 구현(.cpp)에서만 쓰고 자신의 공개 헤더(wrapper.h)에서는 노출하지 않는다면, 이건 정확히 의도한 캡슐화다. 반대로 wrapper.h가 core.h의 타입을 그대로 노출하는 구조라면 PRIVATE은 버그다. 이 경우 다음처럼 PUBLIC으로 바꿔야 한다.
target_link_libraries(wrapper PUBLIC core)
이렇게 바꾸면 같은 app/main.cpp가 그대로 빌드되고 실행된다.
$ ./app
43
42
core의 include 경로가 wrapper를 거쳐 app까지 전파됐기 때문에 app이 core.h를 직접 include해도 문제없이 컴파일된 것이다. 이처럼 PUBLIC/PRIVATE 선택은 링크 자체의 성공 여부가 아니라, 그 라이브러리의 헤더가 상위 타깃에서도 필요한지를 기준으로 판단해야 한다.
헤더 전용 라이브러리 연결하기
컴파일할 소스 파일이 없는 헤더 전용(header-only) 라이브러리는 add_library(<이름> INTERFACE)로 선언한다. 이 타깃 자체는 빌드되는 게 없으므로 include 경로도 INTERFACE로 지정한다.
add_library(mathutil INTERFACE)
target_include_directories(mathutil INTERFACE include)
add_executable(app app/main.cpp)
target_link_libraries(app PRIVATE mathutil)
app이 mathutil을 PRIVATE으로 링크했지만, mathutil의 include 경로 자체가 INTERFACE로 선언돼 있으므로 app 빌드 시 정상적으로 전파돼 include/mathutil.h를 찾을 수 있다.
$ ./app
36
시스템 라이브러리와 외부 패키지 연결
시스템에 이미 설치된 라이브러리는 find_package로 찾은 뒤 그 결과로 제공되는 임포트 타깃(Imported Target)을 링크하는 방식을 권장한다. 스레드 라이브러리가 대표적인 예다.
find_package(Threads REQUIRED)
target_link_libraries(app PRIVATE Threads::Threads)
Threads::Threads 같은 임포트 타깃은 플랫폼별로 필요한 링크 플래그(리눅스의 -pthread 등)를 라이브러리 쪽에서 이미 정의해두고 있어, 플랫폼마다 다른 플래그를 직접 챙기지 않아도 된다. 이런 임포트 타깃을 제공하지 않는 라이브러리라면 라이브러리 이름만 그대로 넘겨도 된다. 예를 들어 리눅스의 수학 라이브러리는 다음처럼 연결한다.
target_link_libraries(app PRIVATE m)
주의사항
- PUBLIC을 남발하지 않는다. 헤더에서 실제로 노출되지 않는 구현 세부 의존성까지 습관적으로 PUBLIC으로 걸면, 상위 타깃들이 몰라도 되는 내부 라이브러리에 불필요하게 결합된다. 헤더에서 그 타입/함수를 쓰는지를 기준으로 PUBLIC과 PRIVATE을 구분해야 한다.
- 키워드 서명과 구식 서명을 같은 타깃에 섞지 않는다. 한 번 키워드 서명을 쓴 타깃에는 이후에도 계속 키워드 서명만 써야 하며, 섞으면 구성(configure) 단계에서 바로 에러가 난다.
- include 경로 오류는 링크 키워드부터 의심한다. “헤더는 있는데 못 찾는다”는 에러가 나면, 대상 라이브러리를 링크한
target_link_libraries호출의 키워드가 PRIVATE으로 돼 있어 상위 타깃까지 전파가 안 된 경우가 많다. - INTERFACE 라이브러리는 그 자체로 빌드되지 않는다. 소스 파일을 넘기면 에러가 나므로, 헤더 전용 라이브러리에만 사용해야 한다.
마무리
target_link_libraries를 제대로 쓰는 핵심은 “링크했으니 되겠지”가 아니라, 그 라이브러리의 사용 요구사항이 상위 타깃까지 필요한지를 매번 판단하는 것이다. 헤더에서 노출되는 의존성은 PUBLIC, 구현 파일 안에서만 쓰고 끝나는 의존성은 PRIVATE, 컴파일할 것 없이 요구사항만 전달하면 되는 헤더 전용 라이브러리는 INTERFACE로 선언하면 대부분의 상황을 커버할 수 있다.