#ifndef vs #pragma once: C++ 헤더 가드의 동작 원리와 선택 기준

이 글의 핵심

헤더 가드가 왜 필요한지, #ifndef와 #pragma once가 각각 어떻게 동작하는지, 매크로 이름 충돌이나 심볼릭 링크 같은 함정, C++20 모듈과 비교한 위치를 정리합니다.

들어가며

C++ 프로젝트를 진행하다 보면 다음과 같은 컴파일 에러를 만나게 됩니다.

In file included from utils.h:1,
                 from main.cpp:2:
myclass.h:2:7: error: redefinition of 'class MyClass'
    2 | class MyClass {
      |       ^~~~~~~
In file included from main.cpp:1:
myclass.h:2:7: note: previous definition of 'class MyClass'

같은 헤더 파일이 한 .cpp 안에서 두 번 포함되어 생기는 에러입니다. 아래처럼 main.cpp가 myclass.h를 직접 포함하고, 함께 포함한 utils.h도 myclass.h를 포함하면 MyClass 정의가 두 번 나타납니다.

// myclass.h (헤더 가드 없음)
class MyClass {
    int value;
public:
    MyClass(int v) : value(v) {}
};
// utils.h
#include "myclass.h"
void processMyClass(const MyClass& obj);
// main.cpp
#include "myclass.h"  // 첫 번째 포함
#include "utils.h"    // utils.h가 myclass.h를 다시 포함
// 에러: MyClass 중복 정의

헤더 가드(header guard)는 이런 중복 포함을 막는 전처리기 기법입니다. 이 글은 #ifndef 가드와 #pragma once가 각각 어떻게 동작하는지, 둘이 어떤 경계 조건에서 다르게 행동하는지, 가드로 해결되지 않는 순환 포함과 링커 에러, 그리고 C++20 모듈과의 관계를 다룹니다.

헤더 가드가 막는 것과 막지 못하는 것

#include는 파일 내용을 그 자리에 그대로 복사해 붙이는 텍스트 치환일 뿐이라, 전처리기가 같은 파일을 두 번 만나면 같은 클래스 정의가 두 번 등장하는 소스가 만들어집니다. 클래스·열거형·인라인 함수 정의는 한 번역 단위 안에서 한 번만 나와야 하므로(ODR) 여기서 redefinition 에러가 납니다. 헤더 가드는 같은 헤더가 한 번역 단위(translation unit) 안에서 두 번째로 포함될 때 그 내용을 건너뛰게 합니다.

main.cpp가 a.h와 a.h를 포함하는 b.h를 모두 포함할 때 헤더 가드 유무에 따른 결과

그림: 같은 번역 단위에서 a.h가 두 번 들어와도 가드가 있으면 두 번째는 건너뛴다. 번역 단위 사이의 중복 정의는 별개 문제다.

여기서 “한 번역 단위 안에서”라는 범위가 중요합니다. 헤더 가드는 .cpp 파일 하나를 전처리하는 동안에만 유효하고, 다음 .cpp 파일을 컴파일할 때는 모든 매크로가 초기화되어 다시 처음부터 포함됩니다. a.cpp와 b.cpp가 같은 클래스 정의를 각각 하나씩 갖는 것은 ODR이 허용하므로 원래 에러가 아니고, 반대로 헤더에 inline이 아닌 일반 함수나 전역 변수를 정의해서 생기는 링커 에러(multiple definition of ...)는 가드가 있어도 막지 못합니다. “가드를 붙였는데 왜 중복 정의 에러가 나느냐”는 질문의 대부분은 이 차이에서 나오므로, 컴파일 에러인지 링커 에러인지부터 구분하면 원인이 드러납니다. 링커 에러 쪽은 뒤의 “인라인 함수와 템플릿” 절에서 다룹니다.

#ifndef 가드

#ifndef/#define/#endif는 모두 표준 전처리 지시문이므로, 이 조합으로 만든 가드는 표준을 따르는 모든 컴파일러에서 동일하게 동작합니다.

// myclass.h
#ifndef MYCLASS_H           // 1. 매크로가 정의되지 않았다면
#define MYCLASS_H           // 2. 매크로를 정의하고
class MyClass {             // 3. 내용을 포함
    int value;
public:
    MyClass(int v) : value(v) {}
    int getValue() const { return value; }
};
#endif  // MYCLASS_H        // 4. 조건부 블록 종료

첫 번째 포함에서는 MYCLASS_H가 정의되어 있지 않으므로 내용이 들어가면서 매크로가 정의되고, 두 번째 포함에서는 #ifndef가 거짓이 되어 #endif까지 전체가 건너뛰어집니다. 표준만으로 동작하고 가드 이름을 직접 정할 수 있다는 것이 장점이지만, 세 줄의 반복 코드가 필요하고 매크로 이름이 다른 헤더와 겹칠 수 있다는 것이 약점입니다.

가장 흔한 사고는 복사-붙여넣기입니다. 새 헤더를 만들 때 기존 헤더를 복사해 시작하면서 가드 이름을 바꾸지 않으면, 두 헤더가 같은 매크로를 쓰게 되어 나중에 포함된 쪽의 내용이 통째로 사라집니다. 이때 에러 메시지는 가드와 전혀 관계없어 보이는 'Foo' was not declared in this scope나 unknown type name 'Foo'로 나오기 때문에, 헤더를 분명히 포함했는데 타입을 못 찾는다는 증상이 보이면 가드 이름 중복을 먼저 의심해야 합니다. #ifndef FOO_H 다음 줄을 #define FO0_H처럼 오타 내면 가드가 전혀 동작하지 않는데, Clang은 이 상황을 기본으로 켜진 -Wheader-guard 경고로 “FOO_H is used as a header guard here, followed by #define of a different macro”라고 알려 줍니다.

#pragma once

#pragma once는 컴파일러에게 “이 파일은 한 번만 포함하라”고 지시하는 한 줄짜리 방식입니다.

// myclass.h
#pragma once
class MyClass {
    int value;
public:
    MyClass(int v) : value(v) {}
    int getValue() const { return value; }
};

매크로 이름을 짓지 않으므로 이름 충돌이나 복사-붙여넣기 실수가 원천적으로 없습니다. 대신 C++ 표준에는 없는 지시문이라 동작이 각 컴파일러의 구현에 달려 있습니다. GCC, Clang, MSVC, Intel 컴파일러 등 주요 컴파일러는 오래전부터 지원하므로 실무에서 지원 여부가 문제가 되는 일은 드물고, 남은 위험은 일부 오래된 임베디드 툴체인 정도입니다.

지원하지 않는 컴파일러가 위험한 이유는, 표준이 인식하지 못하는 #pragma를 무시하도록 정하고 있기 때문입니다. 에러 없이 컴파일은 진행되고 중복 포함만 막히지 않으므로, 증상이 redefinition 에러로 한참 뒤에야 나타납니다. 또 GCC·Clang은 .cpp 파일(메인 파일)에 #pragma once를 적으면 #pragma once in main file 경고를 내는데, 헤더를 .cpp로 잘못 복사했거나 헤더를 단독으로 컴파일해 볼 때 자주 보게 됩니다.

”같은 헤더”를 판단하는 기준의 차이

두 방식은 무엇을 “같은 헤더”로 보느냐가 다릅니다. #ifndef 가드는 매크로 이름으로 판단하므로, 같은 내용의 헤더가 서로 다른 경로에 복사되어 있어도 가드 이름이 같으면 두 번째는 건너뜁니다. 반대로 #pragma once는 파일 자체의 동일성으로 판단합니다. 컴파일러는 이미 처리한 파일의 식별 정보(경로, inode·장치 번호, 경우에 따라 수정 시각이나 내용)를 기록해 두고 같은 파일로 해석되는 #include를 건너뛰는데, 식별 방식이 컴파일러마다 달라서 심볼릭 링크, 하드 링크, 네트워크 드라이브에서 결과가 달라질 수 있습니다. 그래서 벤더 디렉터리와 설치 디렉터리에 같은 헤더의 복사본이 있으면 #pragma once로는 둘 다 포함되어 중복 정의 에러가 날 수 있습니다. 한 번 포함된 헤더가 다시 전처리되지 않는다는 결과는 같지만 이 경계 조건에서는 서로 반대로 행동한다는 점을 기억해 두면 원인을 빨리 찾을 수 있습니다.

실제로 같은 내용의 헤더를 a/와 b/에 복사해 두고 두 경로를 모두 #include하는 작은 프로젝트로 확인했습니다. #ifndef 가드 버전은 두 컴파일러 모두 에러 없이 통과했습니다. #pragma once 버전은 Apple clang 21에서 redefinition 에러가 났지만 Ubuntu 24.04의 g++ 13.3에서는 통과했습니다. 한쪽을 심볼릭 링크나 하드 링크로 바꾸면 두 컴파일러 모두 같은 파일로 인식해 통과했습니다. g++가 복사본을 통과시킨 것은 크기·수정 시각·내용이 모두 같았기 때문으로 보이며(이 실험에서는 복사 직후라 수정 시각이 같았습니다), 이 판정 기준을 직접 확인하지는 않았습니다. 복사본을 만든 뒤 한쪽을 수정하면 결과가 달라질 수 있으니, 컴파일러마다 다르다는 위 설명대로 복사본이 섞이는 빌드에서는 #ifndef가 안전합니다. (환경: macOS 27.0.1 Apple clang 21.0.0, Docker 컨테이너의 Ubuntu 24.04 g++ 13.3, 2026-10-27.)

속도 차이

흔히 “#pragma once가 더 빠르다”고 소개하지만, GCC·Clang·MSVC는 오래전부터 파일 전체가 #ifndef X ... #endif로 감싸진 패턴을 인식해 기록해 두고, X가 이미 정의되어 있으면 파일을 다시 열지 않습니다(multiple-include optimization). 그래서 주요 컴파일러에서 두 방식의 전처리 속도 차이는 체감되지 않는 수준입니다. 다만 이 최적화는 가드가 파일 전체를 감쌀 때만 동작해서, #ifndef 앞에 주석이 아닌 코드나 다른 지시문이 있거나 #endif 뒤에 무언가가 더 있으면 매번 파일을 다시 읽습니다. 실제 빌드 시간은 가드 방식보다 뒤에서 다룰 불필요한 #include가 훨씬 크게 좌우합니다.

어느 쪽을 쓸까

상황권장 방식이유
주요 컴파일러만 대상으로 하는 애플리케이션#pragma once간결하고 이름 충돌·오타가 없음
여러 환경에 배포하는 오픈소스 라이브러리#ifndef표준만으로 동작
오래된 임베디드 툴체인#ifndef지원 여부를 보장할 수 없음
같은 헤더가 여러 경로(링크, 복사본)로 보이는 빌드#ifndef파일 식별 문제를 피함

둘을 함께 쓰는 코드도 볼 수 있습니다.

// myclass.h
#ifndef MYPROJECT_MYCLASS_H
#define MYPROJECT_MYCLASS_H
#pragma once  // 가드 안쪽에 있어도 파일 단위로 적용됨
class MyClass {
    // ...
};
#endif  // MYPROJECT_MYCLASS_H

이 방식은 #pragma once를 모르는 컴파일러에서도 #ifndef 덕분에 동작하고, 가드 이름 오타가 있어도 #pragma once가 한 번 더 막아 줍니다. 하지만 이점은 크지 않습니다. 두 방식이 경계 조건에서 반대로 행동하기 때문에, 겹쳐 쓰면 “둘 중 하나라도 막으면 막힌다”는 결과가 되어 가드 이름 충돌 같은 문제는 여전히 남고 원인 파악은 오히려 어려워질 수 있습니다. 팀 코드베이스에서는 한 가지로 통일하고, clang-tidy 같은 도구로 그 규칙을 강제하는 편이 관리하기 쉽습니다.

가드 이름 짓기

#ifndef 가드를 쓴다면 이름의 고유성이 가장 중요합니다. UTILS_H처럼 파일 이름만 쓰면 다른 모듈이나 서드파티 라이브러리의 utils.h와 충돌할 수 있으므로, 프로젝트 이름과 경로를 넣는 것이 일반적입니다. Google C++ Style Guide는 <PROJECT>_<PATH>_<FILE>_H_ 형식을 권장합니다.

// 파일: <project>/src/foo/bar/baz.h
#ifndef PROJECT_SRC_FOO_BAR_BAZ_H_
#define PROJECT_SRC_FOO_BAR_BAZ_H_
// ...
#endif  // PROJECT_SRC_FOO_BAR_BAZ_H_

충돌 가능성을 더 줄이려고 MY_CLASS_H_A3F2B1C4처럼 무작위 접미사를 붙이는 팀도 있습니다.

이중 언더스코어(__)를 포함하거나 언더스코어+대문자(_M)로 시작하는 이름은 구현(컴파일러와 표준 라이브러리)용으로 예약되어 있으므로 쓰면 안 됩니다. 예전 코드에서 흔히 보이는 _MYCLASS_H_나 __MYCLASS_H__ 같은 이름은 표준 라이브러리 구현이 쓰는 이름 공간과 겹쳐서, 드물게 실제로 표준 헤더의 내부 매크로와 충돌해 표준 헤더의 일부가 통째로 사라지는 일이 생길 수 있습니다. 경로를 넣은 이름은 파일을 다른 디렉터리로 옮길 때 가드도 바꿔야 하는 부담이 있지만, 갱신을 잊어도 동작에는 영향이 없고 clang-tidy의 llvm-header-guard 같은 검사로 일괄 교정할 수 있습니다.

순환 포함

헤더 가드는 무한 포함을 막아 줄 뿐, 순환 의존을 해결해 주지는 않습니다.

// a.h
#ifndef A_H
#define A_H
#include "b.h"  // B를 포함
class A {
    B* b;  // B 타입 사용
};
#endif
// b.h
#ifndef B_H
#define B_H
#include "a.h"  // A를 포함
class B {
    A* a;  // A 타입 사용
};
#endif
// main.cpp
#include "a.h"
// 전처리 과정:
// 1. a.h 포함 시작, A_H 정의
// 2. b.h 포함 시작, B_H 정의
// 3. b.h 안에서 a.h 포함 시도 → A_H 이미 정의됨 → 건너뜀
// 4. class B에서 A* a; → A가 아직 선언되지 않음
// 에러: 'A' does not name a type

가드가 없다면 a.h와 b.h가 서로를 끝없이 포함해 #include nested depth 200 exceeds maximum 같은 에러가 나고, 가드가 있으면 그 무한 루프는 멈추지만 한쪽 헤더가 다른 쪽보다 먼저 끝까지 처리되지 못해 타입을 찾을 수 없게 됩니다. 더 헷갈리는 점은 어떤 .cpp가 a.h를 먼저 포함하느냐에 따라 에러가 A 쪽에서 나기도 하고 B 쪽에서 나기도 한다는 것입니다. include 순서를 바꿨더니 빌드가 되거나 깨진다면 순환 포함을 의심해 볼 만합니다.

위 a.h·b.h를 그대로 만들어 컴파일해 확인했습니다. main.cpp에서 a.h를 먼저 포함하면 b.h의 A* a;에서, b.h를 먼저 포함하면 a.h의 B* b;에서 에러가 났습니다. g++ 13.3은 'A' does not name a type, Apple clang 21은 unknown type name 'A'라고 해서 메시지 문구는 다릅니다. 가드를 모두 지우면 g++는 #include nested depth 200 exceeds maximum of 200 (use -fmax-include-depth=DEPTH to increase the maximum), clang은 #include nested too deeply를 내고, 이어서 redefinition of 'A' 에러가 따라옵니다. 아래 전방 선언 해법(a.h에서 class B;만 선언)은 두 컴파일러 모두 경고 없이 통과했습니다. .cpp 파일 맨 위에 #pragma once를 적으면 g++는 #pragma once in main file, clang은 같은 문구 뒤에 [-Wpragma-once-outside-header]가 붙은 경고를 냈습니다. (환경: Docker 컨테이너의 Ubuntu 24.04 g++ 13.3, macOS 27.0.1 Apple clang 21.0.0, 2026-10-29.)

해결 1: 전방 선언

위 예제처럼 서로를 포인터로만 가리킨다면 헤더에서는 상대 클래스를 전방 선언만 하고, 실제 정의가 필요한 .cpp에서 헤더를 포함하면 됩니다.

// a.h
#ifndef A_H
#define A_H
class B;  // 전방 선언: "B라는 클래스가 존재한다"고 알림
class A {
    B* b;  // 포인터/참조는 전방 선언만으로 충분
public:
    A();
    ~A();
    void setB(B* newB);
    B* getB() const;
};
#endif
// a.cpp (구현 파일에서 실제 포함)
#include "a.h"
#include "b.h"  // 여기서 B의 전체 정의 포함
A::A() : b(nullptr) {}
A::~A() {}
void A::setB(B* newB) { b = newB; }
B* A::getB() const { return b; }
// b.h, b.cpp도 같은 방식으로 class A;를 전방 선언

전방 선언만으로 쓸 수 있는 것과 전체 정의가 필요한 것은 다음과 같습니다.

class MyClass;
// ✅ 전방 선언으로 충분
void func(MyClass* ptr);        // 포인터
void func(const MyClass& ref);  // 참조
MyClass* createMyClass();       // 포인터 반환
void func2(MyClass obj);        // 선언만이면 값 매개변수도 가능
MyClass make();                 // 선언만이면 값 반환도 가능
class Container {
    MyClass* ptr;               // 포인터 멤버
};
// ❌ 전체 정의 필요
void use(MyClass obj) {}        // 함수 정의 (객체 크기 필요)
class Container2 {
    MyClass obj;                // 값 멤버 (크기 필요)
};
class Derived : public MyClass {};  // 상속

값 매개변수·반환 타입이 불완전해도 되는 것은 선언뿐이고, 그 함수를 정의하거나 호출하는 번역 단위에서는 전체 정의가 있어야 합니다. 멤버 접근, sizeof, new MyClass도 전체 정의가 필요합니다.

전방 선언과 함께 std::unique_ptr<B> 멤버를 쓸 때 자주 만나는 함정이 있습니다. unique_ptr의 소멸자는 B의 전체 정의가 있어야 delete를 할 수 있는데, A의 소멸자를 헤더에서 암시적으로 생성하게 두면 A를 파괴하는 번역 단위에서 불완전한 타입을 삭제하려다 invalid application of 'sizeof' to an incomplete type 같은 에러가 납니다. 해결책은 A의 소멸자를 헤더에서는 선언만 하고, b.h를 포함한 a.cpp에서 A::~A() = default;로 정의하는 것입니다. 앞의 예제에서 소멸자를 .cpp에 둔 것도 같은 습관입니다.

해결 2: 공통 인터페이스로 의존 방향 바꾸기

두 클래스가 서로의 구체적인 타입을 알아야 하는 구조라면, 둘이 함께 의존할 추상 인터페이스를 따로 두어 의존 방향을 한쪽으로 정리합니다.

// character.h
#ifndef CHARACTER_H
#define CHARACTER_H
class Character {
public:
    virtual ~Character() = default;
    virtual void takeDamage(int amount) = 0;
};
#endif
// player.h
#include "character.h"
class Player : public Character {
public:
    void attack(Character& target);  // Enemy를 몰라도 됨
    void takeDamage(int amount) override;
};
// enemy.h
#include "character.h"
class Enemy : public Character {
public:
    void attack(Character& target);  // Player를 몰라도 됨
    void takeDamage(int amount) override;
};

player.h와 enemy.h는 이제 character.h에만 의존하므로 서로를 포함할 이유가 사라집니다.

인라인 함수와 템플릿

헤더에 함수 정의를 둘 수 있는지는 가드와 별개로 ODR이 정합니다. 일반 함수를 헤더에 정의하면 그 헤더를 포함한 .cpp마다 같은 함수의 정의가 하나씩 생기고, 링크할 때 multiple definition of 'add' 에러가 납니다. 가드는 한 번역 단위 안의 중복만 막으므로 이 경우에는 아무 도움이 되지 않습니다.

// utils.h
#ifndef UTILS_H
#define UTILS_H
// ✅ 인라인 함수: 여러 번역 단위에 같은 정의가 있어도 됨
inline int square(int x) {
    return x * x;
}
// ✅ constexpr 함수: 암시적으로 inline
constexpr int cube(int x) {
    return x * x * x;
}
// ✅ 템플릿: 사용하는 곳에서 인스턴스화하려면 정의가 헤더에 있어야 함
template<typename T>
T maxOf(T a, T b) {
    return a > b ? a : b;
}
// ✅ 클래스 안에서 정의한 멤버 함수: 암시적으로 inline
class Counter {
public:
    int getValue() const { return value; }
private:
    int value = 0;
};
// ❌ 일반 함수를 헤더에 정의하면 링커 에러
// int add(int a, int b) { return a + b; }
// ✅ 일반 함수는 선언만 헤더에 두고 정의는 .cpp에
int add(int a, int b);
#endif  // UTILS_H
// utils.cpp
#include "utils.h"
int add(int a, int b) {
    return a + b;
}

inline 함수와 템플릿은 ODR의 예외로, 여러 번역 단위에 정의가 있어도 모두 같은 정의라면 허용되고 링커가 하나만 남깁니다. 전역 변수도 C++17부터 inline int counter = 0;처럼 헤더에 정의할 수 있습니다.

헤더와 빌드 시간

가드 방식보다 빌드 시간에 훨씬 큰 영향을 주는 것은 헤더가 무엇을 포함하느냐입니다. 헤더에 실제로 쓰지 않는 <iostream>, <regex>, <map>이나 대형 서드파티 헤더를 넣어 두면, 그 헤더를 포함하는 모든 번역 단위가 그 내용을 매번 파싱합니다. 헤더에는 그 헤더가 직접 쓰는 것만 포함하고, 포인터나 참조로만 쓰는 타입은 전방 선언으로 바꾸십시오.

// manager.h
class Database;   // 전방 선언
class Network;
class Renderer;
class Manager {
    Database* db;
    Network* net;
    Renderer* rend;
};
// manager.cpp에서만 실제 헤더 포함
#include "manager.h"
#include "database.h"
#include "network.h"
#include "renderer.h"

전방 선언의 진짜 이득은 전처리할 줄 수보다 재컴파일 범위를 줄이는 데 있습니다. manager.h가 database.h를 직접 포함하면 database.h를 한 줄만 고쳐도 manager.h를 포함한 모든 .cpp가 다시 컴파일되지만, 전방 선언으로 바꾸면 manager.cpp만 다시 빌드됩니다. 얼마나 줄어드는지는 헤더 의존 그래프에 따라 크게 다르므로, GCC/Clang의 -H 옵션으로 실제 포함되는 헤더 트리를 찍어 보거나 Clang의 -ftime-trace, MSVC의 /d1reportTime으로 어떤 헤더가 시간을 먹는지 측정한 뒤 손대는 것이 좋습니다.

자주 쓰지만 거의 바뀌지 않는 무거운 헤더(표준 라이브러리, 큰 서드파티 라이브러리)는 미리 컴파일된 헤더(PCH)로 묶을 수 있습니다. CMake에서는 target_precompile_headers(myapp PRIVATE <vector> <string> <memory>)처럼 지정합니다. 단, 자주 바뀌는 프로젝트 헤더를 PCH에 넣으면 그 헤더가 바뀔 때마다 PCH 전체가 다시 만들어지고 PCH를 쓰는 모든 파일이 재컴파일되므로 역효과가 납니다.

C++20 모듈과의 관계

C++20 모듈은 텍스트 포함이 아니라 컴파일된 모듈 인터페이스를 import하는 방식이라, 중복 포함이라는 문제 자체가 없어 헤더 가드가 필요 없습니다.

// myclass.cppm (모듈 인터페이스 파일)
module;                 // 전역 모듈 조각: 기존 헤더는 여기서 #include
#include <string>
#include <vector>
export module myclass;  // 모듈 선언
export class MyClass {  // export로 공개
    std::string name;
    std::vector<int> data;
public:
    MyClass(const std::string& n);
    void addData(int value);
};
// myclass.cpp (모듈 구현 파일)
module;
#include <string>
#include <vector>
module myclass;
MyClass::MyClass(const std::string& n) : name(n) {}
void MyClass::addData(int value) { data.push_back(value); }
// main.cpp
import myclass;  // 헤더 가드 불필요
int main() {
    MyClass obj("test");
}

모듈은 인터페이스를 한 번 컴파일해 두고 가져다 쓰므로 같은 헤더를 번역 단위마다 다시 파싱하지 않고, 매크로가 모듈 밖으로 새지 않아 include 순서에 따라 결과가 달라지는 문제도 없습니다. 표준 라이브러리는 import <string>; 같은 헤더 유닛 문법으로도 가져올 수 있지만 헤더 유닛은 컴파일러와 빌드 시스템 지원이 모듈 본체보다 늦어서, 위처럼 전역 모듈 조각에 #include를 두거나 C++23의 import std;를 지원하는 환경이라면 그것을 쓰는 방식이 더 현실적입니다. 또 모듈은 매크로를 내보내지 않으므로, 설정 매크로(#define MYLIB_ENABLE_X)로 동작이 바뀌는 라이브러리는 모듈로 옮길 때 그 설계부터 바꿔야 합니다.

명명 모듈을 빌드 시스템과 함께 쓰려면 CMake 3.28 이상과 Clang 16, GCC 14, MSVC 17.4(VS 2022) 이상 정도가 현실적인 출발점입니다. 기존 프로젝트는 헤더를 그대로 두고 새 코드부터 모듈로 작성하는 식의 점진적 전환이 가능하지만, 여러 컴파일러와 오래된 표준을 지원해야 하는 라이브러리라면 당분간 헤더와 가드를 유지해야 합니다.

가드 때문에 실제로 나는 오류 세 가지

가드 관련 실수는 오류 메시지에 “가드”라는 말이 나오지 않아서 원인을 찾기 어렵습니다. 작은 파일로 세 경우를 재현했습니다. 환경은 Ubuntu 24.04, g++ 13.3, -std=c++20입니다.

가드 이름이 겹치면 두 번째 헤더가 통째로 사라진다

a.h와 b.h를 각각 만들면서 가드 이름을 둘 다 UTILS_H로 썼습니다. 복사해서 새 헤더를 만들 때 이름을 안 바꾸면 생기는 일입니다.

// a.h                       // b.h
#ifndef UTILS_H              #ifndef UTILS_H
#define UTILS_H              #define UTILS_H
struct A { int x; };         struct B { int y; };
#endif                       #endif

// main.cpp
#include "a.h"
#include "b.h"               // UTILS_H가 이미 정의돼 있어 내용이 전부 건너뛰어진다
int main() { B b{1}; return b.y; }
m1.cpp: In function 'int main()':
m1.cpp:3:14: error: 'B' was not declared in this scope
    3 | int main() { B b{1}; return b.y; }

b.h를 분명히 포함했는데 B가 없다는 오류가 나옵니다. 컴파일러는 가드에 의해 내용이 빠졌다는 사실을 알려 주지 않으므로, “헤더를 포함했는데 선언이 없다”는 증상이면 -E로 전처리 결과를 보거나 가드 이름이 겹치지 않는지 먼저 확인해야 합니다.

#ifndef와 #define의 이름이 다르면 가드가 없는 것과 같다

#ifndef FOO_H
#define FOO_HH            // 오타: 검사하는 이름과 정의하는 이름이 다르다
struct Foo { int v; };
#endif
In file included from m2.cpp:2:
foo.h:3:8: error: redefinition of 'struct Foo'
foo.h:3:8: note: previous definition of 'struct Foo'

FOO_H는 어디에서도 정의되지 않으므로 #ifndef가 항상 참이고, 두 번째 포함에서 내용이 그대로 들어와 재정의 오류가 납니다. 이 오타는 헤더를 한 번만 포함하는 동안에는 드러나지 않다가, 다른 헤더가 이 헤더를 포함하기 시작하는 시점에 갑자기 오류로 나타납니다.

가드는 링크 단계의 multiple definition을 막지 못한다

헤더에 일반 함수 정의를 두고 두 .cpp에서 포함했습니다. 가드는 제대로 붙어 있습니다.

// math.h
#ifndef MATH_H
#define MATH_H
int square(int x) { return x * x; }     // inline이 아닌 일반 함수 정의
#endif
/usr/bin/ld: u2.o: in function `square(int)':
u2.cpp:(.text+0x0): multiple definition of `square(int)'; u1.o:u1.cpp:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

가드는 .cpp 한 개를 컴파일하는 동안만 작동하므로, u1.cpp와 u2.cpp가 각각 square의 정의를 하나씩 오브젝트 파일에 만들고 링커가 충돌을 발견합니다. 함수 앞에 inline을 붙이면 이 오류가 사라지고 실행도 정상이었습니다(square(3) + square(2) = 13). 함수를 .cpp로 옮기고 헤더에는 선언만 두는 방법도 같은 효과가 있습니다.

가드를 붙이지 않는 헤더

일반적인 헤더라면 확장자(.h, .hpp, .hxx)와 내용(템플릿, 인라인 함수)에 관계없이 모두 가드가 필요합니다. 예외는 의도적으로 여러 번 포함하도록 만든 파일입니다. X-매크로 기법에서 쓰는 .def·.inc 파일은 포함할 때마다 다른 매크로 정의로 다른 코드를 생성하려고 일부러 여러 번 포함하므로 가드를 붙이면 안 됩니다. 표준의 <cassert>(<assert.h>)도 NDEBUG 설정을 바꿔 다시 포함할 수 있도록 가드 없이 만들어져 있습니다.

도구로 규칙 강제하기

clang-tidy의 llvm-header-guard 검사는 가드가 없거나 이름이 LLVM 스타일(파일 경로 기반)과 다른 헤더를 찾아 주고, --fix로 가드 이름을 자동 교정할 수도 있어 파일을 대거 이동한 뒤 한 번 돌려 두면 편합니다. 다만 팀의 이름 규칙이 LLVM 스타일과 다르면 경고가 쏟아질 수 있습니다. clang-tidy는 컴파일 옵션을 알아야 하므로 compile_commands.json이 있는 빌드 디렉터리를 -p로 지정해 실행합니다.

clang-tidy -p build --checks='-*,llvm-header-guard' src/foo/bar.h

Clang의 -Wheader-guard는 기본으로 켜져 있어 #ifndef와 #define의 이름이 다른 실수를 잡아 주고, GCC에는 가드 전용 경고가 없습니다. 불필요하거나 빠진 #include는 include-what-you-use(IWYU) 같은 도구로 찾을 수 있습니다.

새 헤더를 만들 때 가드를 자동으로 넣으려면 에디터 기능을 쓰면 됩니다. 예를 들어 VS Code에서는 사용자 스니펫으로 파일 이름 기반 가드를 만들 수 있습니다.

// .vscode/cpp.code-snippets — "guard" 입력 후 Tab
{
    "Header guard": {
        "scope": "c,cpp",
        "prefix": "guard",
        "body": [
            "#ifndef MYPROJECT_${TM_FILENAME_BASE/(.*)/${1:/upcase}/}_H",
            "#define MYPROJECT_${TM_FILENAME_BASE/(.*)/${1:/upcase}/}_H",
            "",
            "$0",
            "",
            "#endif  // MYPROJECT_${TM_FILENAME_BASE/(.*)/${1:/upcase}/}_H"
        ]
    }
}

참고 자료