C++ 헤더 온리 라이브러리 만들기: inline·템플릿·constexpr과 ODR, multiple definition 피하기

이 글의 핵심

헤더 온리 라이브러리는 include 한 줄로 쓸 수 있어 편하지만, 헤더에 함수 정의를 그대로 두면 곧바로 링커 에러가 나고 정의가 모든 번역 단위에 복제되어 빌드가 느려집니다. inline 링키지와 static·익명 네임스페이스의 차이, 명시적 인스턴스화로 컴파일 비용을 줄이는 방법, 헤더만 바꿨는데 크래시가 나는 ODR 위반 문제까지 짚어 헤더 온리로 갈지 판단할 근거를 제공합니다.

들어가며: “헤더에 함수를 정의했더니 링커 에러가 나요”

C++에서 헤더에 평범한 함수를 정의하고 그 헤더를 두 개 이상의 .cpp에서 include하면 링커가 multiple definition 에러를 냅니다. 각 번역 단위(TU)가 같은 이름의 외부 링키지 심볼을 하나씩 만들어 내기 때문입니다. 반면 inline 함수, 템플릿, constexpr 함수는 여러 TU에 정의가 있어도 되도록 규칙이 따로 정해져 있어서, 이것들만으로 헤더 온리 라이브러리를 만들 수 있습니다.

// utils.h — 두 개 이상의 .cpp에서 include하는 경우
void foo() {            // ❌ 링크 시 multiple definition
    std::cout << "foo\n";
}

inline void bar() {     // ✅ 여러 TU에 정의가 있어도 OK
    std::cout << "bar\n";
}

이 글에서는 이 규칙이 왜 성립하는지(ODR과 인라인 링키지), 템플릿 인스턴스화를 어떻게 줄이는지, 컴파일 시간과 바이너리 호환성에서 어떤 비용을 치르는지를 차례로 살펴봅니다.


헤더 온리 라이브러리란?

헤더 온리 라이브러리는 .cpp 파일 없이 헤더 파일만으로 구성된 라이브러리입니다. 사용자는 미리 빌드된 .lib/.a를 링크할 필요 없이 헤더를 include하기만 하면 됩니다.

// 일반 라이브러리
// math.h
int add(int a, int b);

// math.cpp
int add(int a, int b) {
    return a + b;
}

// 헤더 온리 라이브러리
// math.h
inline int add(int a, int b) {
    return a + b;
}
// math.cpp 없음

널리 쓰이는 예로는 Eigen(선형대수), nlohmann/json(JSON 파싱), Boost의 상당수 모듈이 있습니다. fmt는 컴파일된 라이브러리로 쓰는 것이 기본이지만 FMT_HEADER_ONLY를 정의하면 헤더 온리로도 쓸 수 있고, Catch2는 v2까지 단일 헤더로 배포되다가 v3부터 컴파일된 라이브러리 방식으로 바뀌었습니다. 컴파일 시간 부담 때문에 헤더 온리를 포기한 사례라는 점에서 참고할 만합니다.


inline 함수

// utils.h
#ifndef UTILS_H
#define UTILS_H

#include <iostream>
#include <string>

inline void print(const std::string& msg) {
    std::cout << msg << '\n';
}

inline int add(int a, int b) {
    return a + b;
}

#endif
// main.cpp
#include "utils.h"

int main() {
    print("Hello");
    std::cout << add(1, 2) << '\n';
}

여기서 흔히 오해하는 점이 있습니다. 현대 C++에서 inline 키워드의 실질적인 의미는 “호출 지점에 코드를 펼쳐라”가 아니라 “이 정의가 여러 TU에 있어도 된다”입니다. 실제로 인라인 전개를 할지는 컴파일러가 최적화 판단으로 정하며, inline이 없어도 정의가 보이면 전개하고, inline이 있어도 함수가 크면 전개하지 않습니다. 헤더 온리 라이브러리에서 inline이 필요한 이유는 전자의 링키지 규칙 때문입니다.

클래스 본문 안에서 정의한 멤버 함수는 명시하지 않아도 암묵적으로 inline입니다. 그래서 클래스 템플릿이 아닌 일반 클래스도 멤버 함수를 클래스 안에 정의하면 헤더에 그대로 둘 수 있습니다. 반대로 클래스 밖에서 void Foo::bar() { ... } 형태로 헤더에 정의하면 inline을 직접 붙여야 합니다. 이걸 빠뜨려서 링커 에러를 만나는 경우가 가장 흔합니다.


템플릿

템플릿은 사용하는 쪽 TU에서 인스턴스화되어야 하므로 정의가 헤더에 있는 것이 기본입니다. 같은 인스턴스(add<int> 등)가 여러 오브젝트 파일에 생겨도 링커가 하나만 남기므로 multiple definition 에러는 나지 않습니다.

// math.h
#ifndef MATH_H
#define MATH_H

#include <cstddef>
#include <memory>

template <typename T>
T add(T a, T b) {
    return a + b;
}

template <typename T>
class Buffer {
    std::unique_ptr<T[]> data_;
    std::size_t size_;

public:
    explicit Buffer(std::size_t size)
        : data_(std::make_unique<T[]>(size)), size_(size) {}

    T& operator[](std::size_t i) { return data_[i]; }
    std::size_t size() const { return size_; }
};

#endif
#include <iostream>
#include "math.h"

int main() {
    std::cout << add(1, 2) << '\n';      // add<int>
    std::cout << add(1.5, 2.5) << '\n';  // add<double>

    Buffer<int> buf(10);
    buf[0] = 42;
}

원시 포인터와 new[]/delete[]로 버퍼를 직접 관리하면 복사 생성자와 대입 연산자까지 직접 정의해야 합니다. 그렇지 않으면 기본 복사가 포인터만 복사해 두 객체가 같은 메모리를 두 번 해제합니다. 위 예제는 std::unique_ptr<T[]>를 써서 이 문제를 피했고, 결과적으로 Buffer는 이동만 가능하고 복사는 컴파일 에러가 됩니다.


constexpr

constexpr 함수는 C++11부터 암묵적으로 inline이므로 헤더에 정의해도 됩니다. C++17부터는 constexpr 정적 데이터 멤버도 암묵적으로 inline 변수입니다.

// math.h
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

constexpr int power(int base, int exp) {   // 루프는 C++14부터 허용
    int result = 1;
    for (int i = 0; i < exp; ++i) {
        result *= base;
    }
    return result;
}
#include <iostream>
#include "math.h"

int main() {
    constexpr int f5 = factorial(5);  // 컴파일 타임에 120으로 계산
    std::cout << f5 << '\n';

    static_assert(power(2, 10) == 1024);
}

결과를 constexpr 변수에 담거나 static_assert처럼 상수 표현식이 필요한 문맥에서 쓸 때만 컴파일 타임 평가가 보장됩니다. 일반 변수에 대입하면 런타임에 호출될 수도 있습니다.


장단점

장점부터 보면, 사용자는 헤더만 include하면 되므로 빌드 시스템에 라이브러리 타깃을 추가하거나 플랫폼별 바이너리를 맞출 필요가 없습니다. 컴파일러, 표준 라이브러리, 빌드 옵션 조합마다 바이너리를 따로 배포해야 하는 C++ 생태계에서 이 점은 상당히 큽니다. 또한 모든 정의가 호출 지점에서 보이므로 LTO 없이도 인라인 전개와 상수 전파가 잘 됩니다.

단점은 모두 “구현이 모든 TU에 복제된다”는 데서 나옵니다.

// biglib.h 를 200개의 .cpp가 include한다면
#include "biglib.h"
// 전처리·파싱·템플릿 인스턴스화가 200번 반복됩니다.

첫째, 컴파일 시간이 늘어납니다. 헤더를 포함하는 TU 수만큼 파싱과 인스턴스화가 반복되기 때문입니다. 둘째, 구현을 한 줄만 고쳐도 그 헤더를 포함한 모든 파일이 재컴파일됩니다. .cpp에 구현이 있었다면 그 파일 하나만 다시 컴파일하면 됐을 변경입니다. 셋째, 바이너리가 커질 수 있습니다. 링커가 중복된 inline 함수 정의를 하나로 합치더라도, 컴파일러가 여러 호출 지점에서 큰 함수를 전개하면 그만큼 코드가 늘어납니다. 다만 컴파일러는 큰 함수를 무작정 전개하지 않으므로, 이 비용은 대부분 작은 함수가 매우 많이 호출되는 경우에 나타납니다.


인라인 링키지와 ODR

헤더 온리 설계는 결국 하나의 정의 규칙(One Definition Rule, ODR)이 허용하는 예외 안에서 움직이는 일입니다. inline을 붙이는 것으로 끝나는 게 아니라, 어떤 엔티티가 외부 링키지를 갖는지, 여러 번 정의되어도 되는지를 구분해야 합니다.

외부 링키지와 헤더 정의

일반 자유 함수를 헤더에 정의하면 각 TU마다 같은 외부 심볼이 생성되고, 링커는 이를 multiple definition으로 거부합니다. inline으로 표시된 함수는 TU마다 정의가 하나씩 있어도 되지만, 표준은 그 정의들이 같은 토큰 열로 이루어지고 각 이름이 같은 엔티티를 가리킬 것을 요구합니다. 구현 측면에서 컴파일러는 이런 함수를 COMDAT(ELF의 경우 weak/그룹 섹션) 같은 병합 가능한 섹션에 넣고, 링커는 그중 하나만 남깁니다. 링커는 정의들이 실제로 같은지 검사하지 않고 같다고 가정합니다.

C++17부터는 inline 변수도 헤더에 둘 수 있습니다. 예전에는 헤더에 전역 객체를 두려면 extern 선언과 .cpp 정의를 나누거나 함수 안의 static 지역 변수로 우회해야 했지만, 이제 inline std::atomic<int> counter{0};처럼 쓰면 프로그램 전체에서 하나의 객체가 됩니다. 한편 네임스페이스 범위의 const 변수는 기본적으로 내부 링키지라서 TU마다 별도 사본이 생깁니다. 값이 같으면 문제가 드러나지 않지만 주소를 비교하면 서로 다르게 나옵니다.

ODR 위반이 생기는 전형적인 경로

  • inline 함수·변수: 정의가 TU마다 다르면 미정의 동작이며, 컴파일러와 링커 모두 대개 진단하지 못합니다.
  • 클래스 정의: 같은 클래스가 매크로나 #ifdef 분기 때문에 TU마다 다른 멤버 구성을 갖게 되면 ODR 위반입니다.
  • 템플릿: 특수화를 일부 TU에서만 볼 수 있으면, 어떤 TU는 기본 템플릿을, 어떤 TU는 특수화를 쓰게 되어 역시 ODR 위반이 됩니다.

실무에서 가장 흔한 원인은 같은 헤더가 다른 매크로 설정으로 컴파일되는 상황입니다. 예를 들어 MSVC에서 _ITERATOR_DEBUG_LEVEL이 다른 오브젝트를 섞으면 표준 컨테이너의 크기 자체가 달라지고(MSVC는 이 경우 링크 단계에서 불일치 에러를 내 줍니다), libstdc++의 _GLIBCXX_DEBUG도 컨테이너 레이아웃을 바꿉니다. 라이브러리가 #ifdef MYLIB_ENABLE_STATS 같은 옵션으로 클래스 멤버를 추가한다면, 그 매크로가 모든 TU에서 같게 정의되도록 빌드 설정에서 강제해야 합니다.

static과 익명 네임스페이스

함수에 static을 붙이거나 익명 네임스페이스에 넣으면 내부 링키지가 되어 링커 에러가 사라집니다. 하지만 이 방법은 TU마다 독립된 사본을 만들기 때문에, 전개되지 않은 함수 본문이 오브젝트 파일마다 남아 바이너리가 커집니다. 더 나쁜 경우는 그 함수 안에 static 지역 변수가 있을 때입니다. TU마다 서로 다른 변수가 생기므로, “프로그램에 하나뿐인 캐시”라고 생각한 것이 실제로는 TU 수만큼 존재하게 됩니다. 헤더에서 링커 에러를 없애려는 목적이라면 static이 아니라 inline을 쓰는 것이 맞습니다.


템플릿 인스턴스화 제어

암시적 인스턴스화

템플릿을 사용하는 TU마다 컴파일러는 필요한 인스턴스를 생성합니다. 같은 인스턴스가 여러 .o에 생기면 링커가 하나로 합치지만, 각 TU가 이미 그 인스턴스를 컴파일하는 비용을 치른 뒤입니다. 헤더 온리 모델에서 컴파일 시간이 늘어나는 주된 원인이 이 중복 작업입니다.

명시적 인스턴스화와 extern template

C++11의 extern template 선언을 쓰면 특정 인스턴스를 한 TU에서만 생성하도록 할 수 있습니다.

// matrix.h
template <typename T>
class Matrix { /* ... 멤버 정의 전체 ... */ };

// 이 인스턴스는 다른 곳에서 명시적으로 인스턴스화되므로 여기서 만들지 말라는 선언
extern template class Matrix<double>;

// matrix.cpp — 딱 한 곳
#include "matrix.h"
template class Matrix<double>;   // 명시적 인스턴스화 정의

Matrix<double>을 쓰는 다른 TU들은 멤버 함수를 인스턴스화하지 않고 외부 심볼로 참조만 합니다. 단, inline 함수는 여전히 전개를 위해 인스턴스화될 수 있으므로 효과는 비인라인 멤버에서 주로 나타납니다. 또한 template class std::vector<int>;처럼 표준 라이브러리 템플릿을 사용자 정의 타입이 아닌 인자로 명시적 인스턴스화하는 것은 표준이 허용하지 않으므로, 이 기법은 자기 라이브러리의 템플릿에 적용해야 합니다.

이 패턴을 쓰면 .cpp가 하나 생기므로 엄밀히는 헤더 온리가 아니라 하이브리드입니다. 많은 라이브러리가 기본은 헤더 온리로 두고, 사용자가 매크로(예: MYLIB_SEPARATE_COMPILATION)를 정의하면 이런 extern template 선언과 컴파일된 소스를 활성화하는 식으로 두 방식을 모두 지원합니다.

무거운 템플릿 코드를 줄이는 또 다른 방법은 타입에 의존하지 않는 부분을 비템플릿 함수로 빼내는 것입니다. 예를 들어 컨테이너 템플릿의 재할당 로직 중 바이트 단위로 처리할 수 있는 부분을 void* grow_bytes(void*, std::size_t) 같은 비템플릿 함수로 분리하면, 인스턴스마다 반복 생성되던 코드가 하나로 줄어듭니다.


컴파일 시간 영향

헤더 온리는 배포가 쉬운 대신 TU당 작업량이 커집니다. 원인은 헤더의 줄 수보다 전처리, 템플릿 인스턴스화, constexpr 평가가 TU마다 반복된다는 데 있습니다.

  • include 전파: 라이브러리 헤더가 <regex>나 <iostream> 같은 큰 표준 헤더를 끌어오면, 사용자 TU가 그 헤더를 전혀 쓰지 않아도 매번 다시 파싱합니다.
  • 템플릿 재인스턴스화: 같은 템플릿 인스턴스가 수백 개의 TU에서 반복 생성됩니다.
  • 최적화 비용: 정의가 모두 보이므로 옵티마이저가 인라인 전개할 후보가 늘어나고, 그만큼 최적화 단계의 시간과 메모리 사용량이 늘어날 수 있습니다.

완화 방법은 몇 가지가 있습니다. 라이브러리 헤더가 필요한 헤더만 include하도록 정리하고, 포인터나 참조로만 쓰는 타입은 전방 선언으로 대체합니다. 프로젝트 쪽에서는 자주 쓰는 헤더를 PCH(미리 컴파일된 헤더)로 묶거나, 컴파일러와 빌드 시스템이 준비되어 있다면 C++20 모듈로 한 번만 처리하게 할 수 있습니다. 여러 .cpp를 하나로 합쳐 컴파일하는 unity build도 헤더 파싱과 인스턴스화 반복을 줄이지만, 파일 하나를 고쳐도 묶음 전체가 재컴파일되어 증분 빌드와 상충하고, 익명 네임스페이스끼리 이름이 충돌하는 문제가 생길 수 있습니다. 그래서 CI의 전체 빌드에만 켜는 팀도 있습니다.

Clang의 -ftime-trace를 켜면 TU별로 어느 헤더의 파싱과 어느 템플릿의 인스턴스화에 시간이 들었는지 Chrome 트레이싱 형식으로 볼 수 있어, 어디부터 줄일지 판단할 때 유용합니다.


ABI 안정성 관점

ABI(Application Binary Interface)는 컴파일된 코드끼리 맞아야 하는 규칙, 즉 호출 규약, 객체 레이아웃, 이름 맹글링 등을 말합니다. 헤더 온리 라이브러리는 소스 자체가 배포물이라서, 바이너리 호환 문제가 컴파일된 라이브러리와는 다른 형태로 나타납니다.

구현이 헤더에 있을 때의 함의

inline 함수나 템플릿 본문을 바꾸면 그 정의를 포함한 모든 TU를 재컴파일해야 합니다. 컴파일된 라이브러리라면 .cpp의 구현을 바꾸고 라이브러리만 다시 빌드하면 되지만, 헤더 온리에서는 구현이 사용자 코드의 오브젝트 파일 안에 들어가 있으므로 부분 재빌드가 불가능합니다.

더 까다로운 것은 여러 라이브러리가 같은 헤더 온리 라이브러리를 각자 다른 버전으로 포함하는 경우입니다. 라이브러리 A가 json 3.10을, 라이브러리 B가 json 3.11을 내부적으로 include하고 둘을 한 실행 파일에 링크하면, 같은 이름의 inline 함수와 클래스가 두 가지 정의로 존재하게 됩니다. 이는 ODR 위반이며 링커는 아무 경고 없이 한쪽 정의만 남깁니다. 이 문제 때문에 nlohmann/json은 버전별 inline namespace(ABI 태그)를 도입해 버전이 다르면 심볼 이름 자체가 달라지게 했습니다. 자체 라이브러리에도 같은 기법을 쓸 수 있습니다.

namespace mylib {
inline namespace v1_4 {        // 맹글링된 이름에 v1_4가 포함됨
    class Parser { /* ... */ };
}
}
// 사용자는 mylib::Parser로 그대로 사용

”헤더만 바꿨는데 왜 크래시?”

빌드 시스템이 의존성을 제대로 추적하지 못해 일부 오브젝트 파일만 새 헤더로 다시 컴파일되면, 옛 정의와 새 정의가 한 프로그램에 섞입니다. 이때 링커는 inline 함수의 여러 사본 중 임의로 하나를 고르므로, 어떤 TU는 자신이 컴파일될 때 본 것과 다른 구현을 호출하게 됩니다. 클래스에 멤버를 추가한 경우라면 옛 오브젝트는 옛 크기로 객체를 할당하고 새 코드는 새 멤버 위치에 쓰므로 메모리가 손상됩니다. 증상은 헤더를 고친 부분과 무관한 곳에서 나타나는 경우가 많아 원인을 찾기 어렵습니다. 헤더를 수정한 뒤 이상한 크래시가 나면 먼저 클린 빌드부터 해 보는 것이 가장 빠른 확인 방법이고, AddressSanitizer의 ODR 위반 검출(detect_odr_violation)이나 LTO 빌드에서 GCC가 내는 -Wodr 경고도 단서가 됩니다.


배포할 때 쓰는 패턴

CMake INTERFACE 라이브러리

add_library(mylib INTERFACE)
target_include_directories(mylib INTERFACE
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include>
)
target_compile_features(mylib INTERFACE cxx_std_20)

소스가 없는 INTERFACE 타깃으로 선언하면, 사용하는 프로젝트는 target_link_libraries(app PRIVATE mylib) 한 줄로 include 경로와 표준 버전 요구사항을 전달받습니다. 빌드 산출물이 없으므로 설치할 때는 헤더 디렉터리와 CMake 패키지 설정 파일만 내보내면 됩니다.

공개 헤더와 detail 네임스페이스

사용자는 mylib.hpp 하나만 include하고, 내부 구현은 mylib/detail/*.hpp에 둡니다. 헤더 온리에서는 내부 함수도 사용자에게 보일 수밖에 없으므로, mylib::detail 네임스페이스에 넣어 “여기 있는 것은 공개 API가 아니며 버전 간 호환을 보장하지 않는다”는 의도를 이름으로 표시하는 것이 관례입니다.

버전 매크로

#define MYLIB_VERSION_MAJOR 1
#define MYLIB_VERSION_MINOR 4
#define MYLIB_VERSION_PATCH 2
#define MYLIB_VERSION (MYLIB_VERSION_MAJOR * 10000 + MYLIB_VERSION_MINOR * 100 + MYLIB_VERSION_PATCH)

사용자는 #if MYLIB_VERSION >= 10400처럼 기능 유무를 분기할 수 있습니다. 호환을 깨는 변경은 메이저 버전을 올리고, 앞서 본 inline namespace 이름도 함께 바꾸면 버전이 섞였을 때 링크 에러로 드러나게 할 수 있습니다.


예시: 작은 헤더 온리 JSON 값 타입

// json.h
#ifndef JSON_H
#define JSON_H

#include <cstddef>
#include <map>
#include <string>
#include <utility>
#include <variant>
#include <vector>

class Json {
public:
    using Array  = std::vector<Json>;
    using Object = std::map<std::string, Json>;

    Json() : value_(nullptr) {}
    Json(bool v) : value_(v) {}
    Json(int v) : value_(v) {}
    Json(double v) : value_(v) {}
    Json(const char* v) : value_(std::string(v)) {}
    Json(std::string v) : value_(std::move(v)) {}

    template <typename T>
    const T& get() const { return std::get<T>(value_); }

    Json& operator[](const std::string& key) {
        if (!std::holds_alternative<Object>(value_)) {
            value_ = Object{};
        }
        return std::get<Object>(value_)[key];
    }

private:
    std::variant<std::nullptr_t, bool, int, double, std::string, Array, Object> value_;
};

#endif
#include "json.h"

int main() {
    Json obj;
    obj["name"] = "Alice";   // Json(const char*)
    obj["age"] = 30;         // Json(int)
    int age = obj["age"].get<int>();
}

모든 멤버 함수가 클래스 안에 정의되어 있으므로 암묵적으로 inline이고, 템플릿 멤버 get도 헤더에 있어야 하므로 이 파일 하나로 완결됩니다. const char* 생성자를 따로 둔 이유가 있습니다. 이것이 없으면 "Alice"에서 Json으로 가려면 const char* → std::string → Json 두 단계의 사용자 정의 변환이 필요한데, 암시적 변환은 사용자 정의 변환을 한 번만 허용하므로 컴파일 에러가 납니다. 반대로 bool 생성자만 있고 const char* 생성자가 없으면 포인터가 bool로 표준 변환되어 문자열이 true로 저장되는 함정도 있습니다.

Json이 아직 완전한 타입이 아닌 시점에 std::vector<Json>을 쓰는 것은 C++17부터 허용되지만, std::map에 불완전한 값 타입을 넣는 것은 표준이 보장하지 않습니다. 주요 구현에서는 동작하지만, 이식성을 엄격히 따진다면 nlohmann/json처럼 배열과 객체를 포인터로 들고 있는 구조를 고려해야 합니다.


같이 보면 좋은 글

자주 묻는 질문 (FAQ)

Q. 헤더 온리 라이브러리를 CMake에서 다른 프로젝트가 쓰도록 하려면 어떻게 설정하나요?

A. add_library(mylib INTERFACE)로 소스 없는 INTERFACE 타깃을 만들고, target_include_directories(mylib INTERFACE include)와 target_compile_features(mylib INTERFACE cxx_std_17)처럼 사용자에게 전파할 속성만 INTERFACE로 지정합니다. 사용하는 쪽은 target_link_libraries(app PRIVATE mylib) 한 줄로 include 경로와 표준 버전 요구사항을 함께 받습니다. 빌드 산출물이 없으므로 설치·배포할 때는 헤더 디렉터리와 CMake 설정 파일만 내보내면 됩니다.