본문으로 건너뛰기 C++ 헤더 온리 라이브러리 | 'multiple definition' 에러 없이 만들기

C++ 헤더 온리 라이브러리 | 'multiple definition' 에러 없이 만들기

C++ 헤더 온리 라이브러리 | 'multiple definition' 에러 없이 만들기

이 글의 핵심

헤더에 구현을 두되 링커의 multiple definition 없이 배포하는 법, 그리고 인라인 링키지·ODR, 템플릿 인스턴스 제어, 컴파일 비용·ABI, 프로덕션에서 쓰는 패턴까지 확장합니다.

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

"헤더 온리 라이브러리는 어떻게 만드나요?”

C++에서 헤더에 함수를 정의하면 multiple definition 링커 에러가 발생합니다. 하지만 inline, template, constexpr을 사용하면 헤더 온리 라이브러리를 만들 수 있습니다.

foo 함수의 구현 예제입니다.

// ❌ 헤더에 일반 함수 정의
// utils.h
void foo() {  // ❌ multiple definition
    std::cout << "foo\n";
}

// ✅ inline 사용
// utils.h
inline void foo() {  // ✅ OK
    std::cout << "foo\n";
}

이 글에서 다루는 것

  • 헤더 온리 라이브러리란?
  • inline, template, constexpr
  • ODR (One Definition Rule)인라인 링키지
  • 템플릿 인스턴스화 제어, 컴파일 시간, ABI 관점
  • 장단점, 프로덕션 패턴

1. 헤더 온리 라이브러리란?

정의

헤더 온리 라이브러리.cpp 파일 없이 헤더 파일만으로 구성된 라이브러리입니다.

add 함수의 구현 예제입니다.

// 일반 라이브러리
// 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 파싱)
  • Catch2 (테스트 프레임워크)
  • fmt (포맷팅, 헤더 온리 모드 지원)

2. inline 함수

inline 키워드

// utils.h
#ifndef UTILS_H
#define UTILS_H

#include <iostream>

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

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

#endif

사용:

main 함수의 구현 예제입니다.

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

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

장점

  • multiple definition 에러 없음
  • 컴파일러 최적화 가능

3. template

템플릿은 자동으로 헤더 온리

// math.h
#ifndef MATH_H
#define MATH_H

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

template <typename T>
class Vector {
    T* data_;
    size_t size_;
    
public:
    Vector(size_t size) : data_(new T[size]), size_(size) {}
    
    ~Vector() {
        delete[] data_;
    }
    
    T& operator[](size_t i) {
        return data_[i];
    }
};

#endif

사용:

main 함수의 구현 예제입니다.

#include "math.h"

int main() {
    std::cout << add(1, 2) << '\n';
    std::cout << add(1.5, 2.5) << '\n';
    
    Vector<int> vec(10);
    vec[0] = 42;
}

4. constexpr

constexpr 함수

// math.h
#ifndef MATH_H
#define MATH_H

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

constexpr int power(int base, int exp) {
    int result = 1;
    for (int i = 0; i < exp; ++i) {
        result *= base;
    }
    return result;
}

#endif

사용:

main 함수의 구현 예제입니다.

#include "math.h"

int main() {
    constexpr int f5 = factorial(5);  // 컴파일 타임 계산
    std::cout << f5 << '\n';  // 120
    
    constexpr int p = power(2, 10);  // 1024
}

5. 장단점

장점

1. 사용 간편

// 헤더만 include
#include "mylib.h"

// 링크 설정 불필요

2. 컴파일러 최적화

C/C++ 예제 코드입니다.

// inline 함수는 인라인 전개 가능
inline int add(int a, int b) {
    return a + b;
}

// 호출 오버헤드 없음
int x = add(1, 2);  // → int x = 3;

3. 빌드 설정 불필요

  • CMake 설정 간단
  • 라이브러리 빌드 불필요

단점

1. 컴파일 시간 증가

// 헤더를 include하는 모든 파일에서 컴파일
#include "biglib.h"  // 10000줄

// 100개 파일에서 include → 100번 컴파일

2. 헤더 변경 시 전체 재컴파일

C/C++ 예제 코드입니다.

// mylib.h 수정
inline void foo() {
    // 변경
}

// mylib.h를 include하는 모든 파일 재컴파일

3. 바이너리 크기 증가

C/C++ 예제 코드입니다.

// inline 함수가 여러 번 인라인 전개
inline void bigFunction() {
    // 100줄 코드
}

// 10곳에서 호출 → 1000줄 코드

6. 인라인 링키지와 ODR

헤더 온리 설계의 이론적 축은 링커가 허용하는 중복 정의의 예외와, 표준이 요구하는 하나의 정의 규칙(ODR) 사이의 균형입니다. 단순히 inline을 붙였다고 끝이 아니라, 어떤 엔티티가 외부 링키지를 갖는지, 여러 번 정의되어도 되는지를 구분해야 합니다.

외부 링키지와 헤더 정의

일반적인 자유 함수를 헤더에 두면 각 번역 단위(translation unit)마다 동일한 심볼이 생성되고, 링커는 이를 multiple definition으로 거부합니다. inline으로 표시된 함수 정의는 여러 번 나타날 수 있으며, 표준은 이들이 토큰 단위로 동일(token-by-token identical)할 것을 요구합니다. 이는 같은 버전의 헤더를 모든 TU에서 포함한다는 전제 하에서, 링커가 중복을 하나의 구현으로 합치거나(mergeable), 동일하다고 가정하는 모델과 맞닿아 있습니다.

C++17부터는 inline 변수도 헤더에 둘 수 있어, 헤더 온리 라이브러리에서 전역 상수·싱글턴 포인터 같은 패턴을 깔끔하게 표현할 수 있습니다. const만 붙인 전역 객체의 내부 링키지 규칙(구버전 호환)과 혼동하지 말으며, 헤더에 노출되는 정의inline과 ODR 요건을 함께 염두에 두는 것이 안전합니다.

ODR의 두 층

ODR은 프로그램 전체에서 하나의 정의라는 큰 원칙과, 인라인 함수·템플릿·특정 예외에 대한 세부 규칙으로 나뉩니다. 헤더 온리 코드에서는 다음이 특히 중요합니다.

  • 인라인 함수/변수: 여러 TU에 정의가 있어도 모두 동일해야 하며, 그렇지 않면 미정의 동작(UB)이 될 수 있습니다.
  • 클래스 정의: 클래스 본문은 보통 헤더에 한 번만 있지만, ODR 위반은 여러 헤더에서 미묘하게 다른 클래스 정의가 생길 때도 발생합니다(예: 매크로·#ifdef 분기).
  • 템플릿: 특수화·인스턴스화 규칙이 별도로 작동하며, 아래 템플릿 인스턴스화 제어에서 이어집니다.

실무에서는 같은 헤더가 다른 플래그로 두 번 컴파일되는 상황(일부 TU만 NDEBUG, 다른 매크로)이 ODR·ABI 문제를 일으키기 쉽습니다. 헤더 온리 라이브러리는 공개 API가 단일 정의를 유지하도록 빌드 매트릭스를 좁히는 것이 중요합니다.

static과 익명 네임스페이스

함수에 붙은 static(파일 범위) 또는 익명 네임스페이스내부 링키지를 부여해 TU마다 별도의 복사본이 생깁니다. 링커 에러는 나지 않지만, 헤더에 두면 TU마다 코드가 복제되어 바이너리 크기ICache 압력이 커집니다. “헤더 온리 유틸”에서 남용하면 팀이 체감하는 빌드·바이너리 비용으로 돌아옵니다.


7. 템플릿 인스턴스화 제어

템플릿은 정의가 헤더에 있어야 하는 경우가 많지만, “전부 헤더에 두면 인스턴스가 무한히 늘어난다”는 컴파일·링크 비용 문제와 직결됩니다.

암시적 인스턴스화

템플릿 함수나 클래스 멤버를 사용하는 TU마다 컴파일러는 필요한 특정 인스턴스를 생성합니다. 같은 인스턴스가 여러 .o에 생기면 링커는 하나로 합치기(duplicate elimination)를 합니다. 헤더 온리 순수 모델에서는 이 중복 생성이 컴파일 시간의 핵심 부담입니다.

명시적 인스턴스화와 extern template

명시적 인스턴화(explicit instantiation)한 번만 생성하도록 옮길 수 있습니다. 예를 들어 vector<int> 같은 자주 쓰는 인스턴스.cpp 한 곳에서 template class std::vector<int>;처럼 고정하면, 다른 TU에서는 extern template 선언으로 암시적 인스턴스화를 억제할 수 있습니다(컴파일러·표준 버전에 따라 세부 문법·지원 범위 확인).

이 패턴은 엄밀히는 “헤더 + 구현 분리” 하이브리드에 가깝습니다. 순수 헤더 온리를 유지하면서 비용을 줄이려면, 자주 쓰는 타입만 이렇게 분리하는 점진적 최적화가 현실적인 타협입니다.

헤더 온리에서의 실무 팁

  • 무거운 템플릿작은 non-template 래퍼로 감싸 include 범위를 줄입니다.
  • 특수화(specialization)는 한 헤더에 모아 정의가 한 곳에만 있게 하여 ODR 위험을 줄입니다.
  • 가능하면 constexpr/consteval컴파일 타임에 끝낼 부분을 분리하면, 런타임 인스턴스 수가 줄어듭니다.

8. 컴파일 시간 영향

헤더 온리는 배포가 쉽지만, 컴파일 단위당 작업량이 커지는 경향이 있습니다. 원인은 단순히 “줄 수”가 아니라 전처리·템플릿·constexpr 평가가 한 번에 몰리기 때문입니다.

왜 느려지는가

  • Include 전파: 한 헤더가 거대한 표준·서드파티 헤더를 끌어오면, 간접 의존성이 전부 매번 다시 파싱됩니다.
  • 템플릿 재분석: 같은 템플릿 정의가 수백 개의 TU에서 다시 분석·인스턴스화됩니다.
  • 최적화 경계: inline 전개가 많아지면 최적화기가 더 큰 함수 단위를 보게 되어, 컴파일 시간메모리 피크가 함께 오를 수 있습니다.

완화 전략

  • 포함 범위 최소화: 구현 세부가 필요 없는 TU에서는 전방 선언 가능한 타입으로 헤더 의존성을 끊습니다(템플릿이 아닌 경우).
  • PCH(미리 컴파일된 헤더) / C++20 모듈: 공통 베이스를 한 번만 처리하게 하여 반복 파싱을 줄입니다. 모듈 도입은 빌드 시스템 비용이 있으므로, 팀 상황에 맞게 선택합니다.
  • Unity build: 여러 .cpp를 합쳐 템플릿 인스턴스 중복을 줄이는 대신, 증분 빌드와 상충할 수 있어 CI·로컬 정책을 분리하기도 합니다.
  • 라이브러리 경계: 정말 무거운 부분은 정적 라이브러리로 빼고 안정적인 API 헤더만 얇게 유지하는 하이브리드가 종종 더 낫습니다.

9. ABI 안정성 관점

ABI(Application Binary Interface)컴파일된 오브젝트 간 호출 규약·레이아웃·이름 장식 규칙을 말합니다. 헤더 온리 라이브러리는 소스가 곧 배포물이라서, 바이너리 호환 문제가 다른 형태로 나타납니다.

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

  • 인라인·템플릿 본문을 바꾸면 해당 정의를 포함한 모든 TU가 재컴파일됩니다. 정적 .lib에서 같은 심볼을 링크하던 모델과 달리, 부분적 재빌드가 어렵습니다.
  • 서로 다른 컴파일 옵션으로 같은 헤더를 쓰면(예: 최적화 수준, RTTI, 예외), 동일 타입이라도 레이아웃·호출 규약이 어긋날 수 있어 미묘한 런타임 버그로 이어질 수 있습니다.
  • 표준 라이브러리 구현(libstdc++ vs libc++ 등)과 버전이 섞이면, 템플릿 인스턴스 내부 타입의 호환이 깨질 수 있습니다.

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

종종 인라인 함수이전 바이너리새 헤더가 섞이면, 한 TU는 새 구현, 다른 TU는 옛 구현을 링크한 것처럼 동작할 수 있습니다. 순수 헤더 온리에서는 전체 재빌드가 정답에 가깝으며, 동적 라이브러리 경계에서는 버전 매크로·심볼 가시성으로 단일 정의를 강제하는 패턴이 필요합니다.


10. 프로덕션 헤더 온리 패턴

실제 제품 코드에서는 편의만큼이나 경계·버전·재현성이 중요합니다.

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)

소스가 없는 타겟으로 선언하면, 의존 프로젝트가 target_link_libraries 한 줄로 include 경로와 사용 요구사항을 전달받습니다. 헤더 온리에 특히 잘 맞습니다.

단일 헤더(single-include)와 detail 네임스페이스

  • 사용자는 mylib.hpp 하나만 include하며, 내부는 mylib/detail/*.hpp캡슐화합니다.
  • namespace mylib::detail실험적·불안정 API를 두어, semver에서 공개 API만 안정적으로 관리합니다.

버전·호환성 매크로

#define MYLIB_VERSION_MAJOR 1
#define MYLIB_VERSION_MINOR 4
#define MYLIB_VERSION_PATCH 2

조건부 API(#if MYLIB_VERSION >= 10400)를 문서화하며, 깨지는 변경메이저 버전과 함께 마이그레이션 가이드를 제공합니다.

하이브리드: 무거울 때만 .cpp

컴파일 비용이 임계치를 넘으면, 자주 쓰는 인스턴스만 명시적 인스턴스화하거나, non-template 구현.cpp로 옮기고 얇은 헤더만 공개합니다. “순수 헤더 온리”를 유지할지, 빌드 시간·바이너리를 얻을지는 팀의 SLA에 맞게 선택하면 됩니다.


실전 예시

헤더 온리 JSON 라이브러리

// json.h
#ifndef JSON_H
#define JSON_H

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

class Json {
    using Value = std::variant<
        std::nullptr_t,
        bool,
        int,
        double,
        std::string,
        std::vector<Json>,
        std::map<std::string, Json>
    >;
    
    Value value_;
    
public:
    Json() : value_(nullptr) {}
    Json(int v) : value_(v) {}
    Json(const std::string& v) : value_(v) {}
    
    template <typename T>
    T get() const {
        return std::get<T>(value_);
    }
    
    Json& operator[](const std::string& key) {
        if (!std::holds_alternative<std::map<std::string, Json>>(value_)) {
            value_ = std::map<std::string, Json>{};
        }
        auto& map = std::get<std::map<std::string, Json>>(value_);
        return map[key];
    }
};

#endif

사용:

main 함수의 구현 예제입니다.

#include "json.h"

int main() {
    Json obj;
    obj["name"] = "Alice";
    obj["age"] = 30;
}

정리

헤더 온리 라이브러리 만들기

방법사용 시기
inline일반 함수·C++17 inline 변수
template제네릭 코드(인스턴스화 비용 감시)
constexpr컴파일 타임 계산
class 정의항상 가능(ODR·매크로 분기 주의)

핵심 규칙

  1. inline / 템플릿 / constexpr헤더 정의ODR을 동시에 만족시킨다.
  2. 인라인 링키지내부 링키지(static/익명 네임스페이스)의 차이를 알으며, 후자의 코드 복제 비용을 관리한다.
  3. 템플릿 인스턴스가 폭증하면 명시적 인스턴스화·extern template·하이브리드를 검토한다.
  4. 컴파일 시간·바이너리·ABI순수 헤더 온리의 숨은 비용입니다.

체크리스트

  • inline이 필요한 자유 함수·변수에 붙어 있는가?
  • 헤더 가드 또는 #pragma once이중 포함을 막는가?
  • 동일 매크로·플래그로 모든 TU가 같은 ODR 뷰를 보는가?
  • 템플릿·거대 헤더로 컴파일 시간이 한계를 넘지 않는가?
  • 바이너리 크기캐시 친화성이 허용 범위인가?
  • (필요 시) INTERFACE 타겟·단일 include·detail 네임스페이스공개 API를 관리하는가?

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


마치며

헤더 온리 라이브러리사용이 간편하지만, 컴파일 시간·ODR·ABI를 함께 고려해야 합니다.

핵심 원칙:

  1. inline·ODR로 링커와 표준 규칙을 동시에 만족한다.
  2. 템플릿 인스턴스화include 그래프를 통제한다.
  3. 프로덕션 패턴(INTERFACE 타겟, detail, 버전 매크로)으로 경계를 명확히 한다.

inline, template, constexpr와 함께, 위 내부 원리를 염두에 두고 헤더 온리 라이브러리를 설계해 보세요.

다음 단계: 헤더 온리 라이브러리를 이해했다면, C++ 템플릿 가이드에서 더 깊이 배워보세요.


관련 글


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 헤더 온리 라이브러리의 인라인·ODR, 템플릿 인스턴스화, 컴파일 시간·ABI, 프로덕션 패턴까지 실무 관점에서 정리합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


이 글에서 다루는 키워드 (관련 검색어)

C++, header-only, inline, template, ODR, 라이브러리, 링커, ABI, 컴파일 등으로 검색하시면 이 글이 도움이 됩니다.