C++ 전처리기 제대로 쓰기: #define과 #ifdef 실전 패턴

[C++ 실전 가이드 #5-1] C++ 전처리기

#include 한 줄은 헤더 파일 내용을 통째로 그 자리에 붙여 넣고, #define은 이름을 다른 텍스트로 바꾸며, #ifdef는 플랫폼이나 빌드 설정에 따라 코드를 넣거나 뺍니다. 이 모든 일은 컴파일러가 C++ 문법을 분석하기 전에 일어납니다. 그래서 전처리기에서 생긴 문제는 컴파일러 에러가 엉뚱한 줄을 가리키거나, 에러 없이 동작만 이상해지는 형태로 나타나는 경우가 많습니다.

이 글은 #define, 조건부 컴파일, include guard, __FILE__/__LINE__, # 문자열화와 ## 토큰 붙이기를 실제로 만나는 에러와 함께 설명합니다.

컴파일 전에 텍스트를 바꾸는 전처리기

전처리기는 C++ 문법을 이해하지 않는 텍스트 변환 단계입니다. #으로 시작하는 지시문(directive)을 처리하고, 정의된 매크로 이름을 만날 때마다 치환합니다.

flowchart LR
  A[.cpp 소스] --> B[전처리기]
  B --> C[.i 전처리 결과]
  C --> D[컴파일러]
  D --> E[.o 오브젝트]
지시문역할
#include헤더 파일 내용 삽입
#define / #undef매크로 정의와 해제
#ifdef / #ifndef / #if조건부 컴파일
#pragma컴파일러별 확장 지시
#error조건이 맞지 않으면 컴파일 중단

전처리만 수행해 결과를 확인하려면 -E 옵션을 씁니다. 매크로 문제를 조사할 때 가장 먼저 해 볼 일입니다.

g++ -E main.cpp -o main.i     # 전처리 결과
g++ -E -dM main.cpp           # 최종적으로 정의된 매크로 목록

전처리기를 알아야 풀리는 상황들

”redefinition of ‘class X’” 에러

common.h에 struct Config { ... };가 있고, a.h와 b.h가 둘 다 common.h를 포함하며, main.cpp가 a.h와 b.h를 모두 포함한다고 해 봅시다. 전처리 후 main.cpp 하나에 struct Config 정의가 두 번 나타나므로 컴파일러가 redefinition 에러를 냅니다.

중요한 점은 이것이 한 번역 단위(전처리된 .cpp 하나) 안의 문제라는 것입니다. main.cpp와 utils.cpp가 각각 common.h를 한 번씩 포함하는 것은 정상이고, 클래스 정의가 여러 .cpp에 똑같이 들어가는 것은 C++의 ODR(One Definition Rule)이 허용합니다. include guard는 한 번역 단위 안에서 같은 헤더가 두 번 펼쳐지는 것을 막는 장치입니다.

반면 헤더에 inline이 아닌 함수 정의나 전역 변수 정의(int counter = 0;)를 두면, 이 헤더를 포함한 .cpp마다 같은 심볼이 생겨 링크 단계에서 multiple definition 에러가 납니다. 이 경우는 include guard로 해결되지 않고, 함수에 inline을 붙이거나(C++17부터는 변수도 inline 가능) 정의를 .cpp 하나로 옮겨야 합니다.

Windows와 Linux에서 다른 API 사용

같은 소스를 Windows와 Linux에서 빌드할 때 파일·스레드·소켓 API가 다르면 #ifdef _WIN32로 분기합니다. 컴파일러가 대상 플랫폼에 맞춰 _WIN32, __linux__, __APPLE__ 같은 매크로를 미리 정의해 주므로 빌드 옵션을 따로 줄 필요가 없습니다. 다만 표준 라이브러리에 이미 플랫폼 독립 API가 있다면(std::thread, std::filesystem, std::this_thread::sleep_for) 분기 자체를 없애는 것이 낫습니다.

디버그 빌드에서만 로그 출력

if (debug) printf(...)는 런타임 분기라 릴리스 바이너리에도 로그 코드와 문자열이 남습니다. #ifdef DEBUG로 감싸면 릴리스 빌드에서는 해당 코드가 아예 컴파일되지 않습니다. 단, 로그 인자에 부작용이 있는 식(LOG(next_id()))을 넣으면 빌드 설정에 따라 프로그램 동작이 달라지므로 피해야 합니다.

Windows 헤더의 min/max 매크로

<windows.h>는 min과 max를 함수형 매크로로 정의합니다. 그래서 std::max(a, b)를 쓰면 max(a, b) 부분이 매크로로 치환되어 std:: 뒤에 식이 오는 이상한 코드가 되고, 컴파일 에러가 납니다. <windows.h>를 포함하기 전에 #define NOMINMAX를 정의하거나(빌드 옵션 -DNOMINMAX로 주는 편이 확실합니다), (std::max)(a, b)처럼 함수 이름을 괄호로 감싸 함수형 매크로 확장을 막습니다. 함수형 매크로는 이름 바로 뒤에 (가 와야 확장되기 때문입니다.

C++ 표준 버전별 기능 분기

__cplusplus는 C++17에서 201703L, C++20에서 202002L입니다. 주의할 점은 MSVC가 /Zc:__cplusplus 옵션 없이는 이 값을 항상 199711L로 보고한다는 것입니다. MSVC를 지원해야 한다면 _MSVC_LANG을 함께 보거나, 헤더 존재 여부를 직접 검사하는 __has_include(C++17)나 기능별 매크로(__cpp_lib_optional 등)를 쓰는 편이 정확합니다.

#if __cplusplus >= 201703L || (defined(_MSVC_LANG) && _MSVC_LANG >= 201703L)
    #include <optional>
    using std::optional;
#else
    #include "optional_polyfill.hpp"
#endif

매크로 인자에 쉼표가 포함된 경우

전처리기는 소괄호 깊이는 추적하므로 LOG(f(a, b))의 쉼표는 문제가 없습니다. 하지만 꺾쇠괄호(<>)와 중괄호는 모르기 때문에 DECLARE(std::map<int, int>, m)은 std::map<int, int>, m 세 인자로 쪼개집니다. 해결책은 using IntMap = std::map<int, int>;로 별칭을 만들어 넘기는 것입니다. 식 위치에서 쓰는 인자라면 LOG((std::pair<int, int>{1, 2}))처럼 소괄호로 한 번 더 감싸는 방법도 있지만, 괄호로 감싼 타입은 선언에 쓸 수 없으므로 타입 인자에는 별칭이 안전합니다.


#define 상수 매크로와 -D 옵션

객체형 매크로

#define PI 3.14159265359
#define APP_NAME "MyApp"
#define MAX_BUFFER 4096

int main() {
    double area = PI * 10 * 10;  // 3.14159265359 * 10 * 10로 치환
}

전처리기는 문법을 모르므로 PI라는 토큰이 나오는 모든 곳을 바꿉니다. 문자열 리터럴 안의 "PI"나 PI_VALUE 같은 다른 식별자의 일부는 별개의 토큰이므로 바뀌지 않습니다. C++에서 상수는 constexpr double pi = 3.14159265359;가 타입과 스코프를 가지므로 더 낫고, 매크로 상수는 #if에서 비교해야 하는 값처럼 전처리기가 알아야 하는 경우에 씁니다.

컴파일 시 매크로 정의 (-D 옵션)

g++ -DDEBUG -DMAX_SIZE=100 main.cpp -o main

-DDEBUG는 #define DEBUG 1, -DMAX_SIZE=100은 #define MAX_SIZE 100과 같습니다. 문자열 값을 넣으려면 셸에서 따옴표를 이스케이프해야 합니다. -DVERSION=1.2.3은 #define VERSION 1.2.3이 되어 std::cout << VERSION이 문법 에러를 내고, -DVERSION=\"1.2.3\"이라고 써야 #define VERSION "1.2.3"이 됩니다.

여러 줄 매크로

줄 끝의 \는 다음 줄과 이어 붙이라는 뜻입니다. \ 뒤에 공백이 하나라도 있으면 이어지지 않아 엉뚱한 에러가 나는데, 눈에 보이지 않아 찾기 어렵습니다. GCC는 이 경우 “backslash and newline separated by space” 경고를 냅니다.

#define LOG_START() \
    do { \
        std::cout << "=== Start " << __func__ << " ===\n"; \
    } while (0)

가변 인자 매크로

C++11부터 ...와 __VA_ARGS__로 가변 인자 매크로를 만들 수 있습니다.

#define LOG(fmt, ...) \
    std::printf("[%s:%d] " fmt "\n", __FILE__, __LINE__, __VA_ARGS__)

LOG("value=%d, name=%s", 42, "test");  // [main.cpp:10] value=42, name=test

LOG("start")처럼 가변 인자 없이 호출하면 printf(..., __LINE__, )처럼 끝에 쉼표가 남아 컴파일 에러가 납니다. GCC·Clang·MSVC는 ##__VA_ARGS__ 확장으로 빈 경우 앞의 쉼표를 지워 주고, C++20은 표준 방법인 __VA_OPT__를 도입했습니다.

// GCC/Clang 확장
#define LOG2(fmt, ...) std::printf(fmt "\n", ##__VA_ARGS__)

// C++20 표준
#define LOG3(fmt, ...) std::printf(fmt "\n" __VA_OPT__(,) __VA_ARGS__)

LOG3("no args");     // std::printf("no args" "\n")
LOG3("x=%d", 1);     // std::printf("x=%d" "\n", 1)

매크로 확장 순서

객체형 매크로는 치환 결과를 다시 훑어(rescan) 그 안의 매크로를 계속 확장합니다.

#define A 1
#define B A
#define C B
// C → B → A → 1

함수형 매크로는 순서가 조금 다릅니다. 인자가 먼저 완전히 확장된 뒤 본문에 대입되고, 그 결과를 다시 훑습니다. 예외는 본문에서 #이나 ##의 피연산자로 쓰인 인자로, 이 인자는 확장되지 않은 원래 토큰 그대로 쓰입니다. 뒤의 문자열화 절에서 2단계 매크로가 필요한 이유가 이 규칙입니다. 또 매크로는 자기 자신의 확장 결과 안에서 다시 확장되지 않으므로 #define foo foo + 1 같은 정의가 무한 루프에 빠지지 않습니다.


#ifdef·#if로 조건부 컴파일

#ifndef NDEBUG
    // NDEBUG가 정의되지 않은 빌드에서만 포함
    validate_invariants();
#endif

#if __cplusplus >= 202002L
    #define HAS_CPP20 1
#else
    #define HAS_CPP20 0
#endif

#ifdef X는 #if defined(X)와 같습니다. 조건을 조합할 때는 #if defined(DEBUG) && !defined(QUIET)처럼 #if를 씁니다. #if 안에서 정의되지 않은 식별자는 오류가 아니라 0으로 평가되므로, 매크로 이름을 잘못 쓰면 조용히 거짓이 됩니다. GCC·Clang의 -Wundef를 켜면 이런 경우 경고를 받을 수 있습니다.

표준 assert는 NDEBUG가 정의되어 있으면 아무 일도 하지 않습니다. CMake는 Release 빌드 기본 플래그에 -DNDEBUG를 이미 넣으므로, 디버그 전용 코드를 NDEBUG 기준으로 감싸면 빌드 시스템 설정과 자연스럽게 맞습니다.

플랫폼 분기

#if defined(_WIN32)
    #define PLATFORM_API __declspec(dllexport)
#elif defined(__GNUC__)
    #define PLATFORM_API __attribute__((visibility("default")))
#else
    #define PLATFORM_API
#endif
매크로의미
_WIN32Windows (32비트와 64비트 모두 정의됨)
_WIN6464비트 Windows
__linux__Linux (Android 포함)
__APPLE__macOS, iOS 등 Apple 플랫폼
__ANDROID__Android

_WIN32는 64비트 Windows에서도 정의되므로 Windows 여부만 판단할 때는 _WIN32 하나로 충분합니다. Android는 __linux__도 정의하므로, Android를 따로 처리하려면 __ANDROID__를 먼저 검사해야 합니다.

최소 표준 강제

#if __cplusplus < 201703L && !(defined(_MSVC_LANG) && _MSVC_LANG >= 201703L)
#error "C++17 or later is required"
#endif

#error는 조건이 맞지 않을 때 원인을 명확한 메시지로 알려 주므로, 오래된 표준으로 빌드했을 때 쏟아지는 수백 줄의 템플릿 에러보다 훨씬 친절합니다.


#ifndef 가드와 #pragma once

// config.h
#ifndef PKGLOG_CONFIG_H
#define PKGLOG_CONFIG_H

struct Config {
    int timeout;
};

#endif  // PKGLOG_CONFIG_H

처음 포함될 때는 PKGLOG_CONFIG_H가 정의되지 않았으므로 블록이 처리되면서 그 이름이 정의되고, 같은 번역 단위에서 다시 포함되면 블록 전체를 건너뜁니다. 가드 이름은 프로젝트 접두어와 파일 경로를 반영해 고유하게 짓습니다. 헤더를 복사해 새 헤더를 만들면서 가드 이름을 그대로 두면, 나중에 포함된 헤더가 통째로 무시되어 “선언을 찾을 수 없다”는 에러가 나는데 원인을 찾기가 의외로 어렵습니다.

// config.h
#pragma once

#pragma once는 표준은 아니지만 GCC·Clang·MSVC가 모두 지원하고, 가드 이름 충돌이 없으며 한 줄이면 됩니다. 다만 같은 파일이 심볼릭 링크나 서로 다른 경로로 포함되면 같은 파일로 인식하지 못하는 경우가 있습니다. 대부분의 프로젝트에서는 #pragma once로 충분하고, 그런 빌드 환경을 지원해야 하면 전통적인 가드를 씁니다.


함수형 매크로와 do-while(0)

괄호 규칙

// ❌ #define SQUARE(x) x * x
int y = SQUARE(2 + 3);  // 2 + 3 * 2 + 3 = 11 (의도: 25)

// ✅
#define SQUARE(x) ((x) * (x))
int z = SQUARE(2 + 3);  // ((2 + 3) * (2 + 3)) = 25

매개변수 각각을 괄호로 감싸야 인자 속 연산자가 본문 연산자와 섞이지 않고, 본문 전체를 괄호로 감싸야 100 / SQUARE(x) 같은 호출 위치의 연산자와 섞이지 않습니다.

다중 평가

#define MAX(a, b) ((a) > (b) ? (a) : (b))

int i = 1, j = 2;
int m = MAX(i++, j++);  // ((i++) > (j++) ? (i++) : (j++))
// i == 2, j == 4, m == 3

괄호를 아무리 잘 쳐도 매개변수가 본문에 두 번 나오면 인자도 두 번 평가됩니다. 위 예에서 j는 비교에서 한 번, 결과로 선택되면서 또 한 번 증가합니다. 인자가 함수 호출이면 그 함수가 두 번 호출됩니다. C++에서는 std::max나 템플릿 함수를 쓰면 이 문제가 원천적으로 없습니다.

여러 문장 매크로

#define LOG_AND_RETURN(msg) \
    do { \
        std::cerr << (msg) << "\n"; \
        return -1; \
    } while (0)

if (error)
    LOG_AND_RETURN("Failed");
else
    proceed();

do { ... } while (0)은 여러 문장을 문장 하나로 묶으면서 끝에 세미콜론을 요구하므로, 위처럼 if/else 사이에 써도 일반 함수 호출과 똑같이 동작합니다. 중괄호만 쓰면 { ... };의 세미콜론이 별도의 빈 문장이 되어 else가 짝을 잃습니다.

구분함수형 매크로inline 함수·템플릿
인자 평가본문에 나온 횟수만큼정확히 한 번
타입 검사없음있음
디버깅심볼이 없어 단계 실행이 어려움일반 함수처럼 가능
스코프정의 이후 모든 곳에 적용네임스페이스·클래스 스코프를 따름

#define SAFE_DELETE(p) do { delete (p); (p) = nullptr; } while (0) 같은 매크로도 여전히 보이지만, 현대 C++에서는 std::unique_ptr가 같은 문제를 더 확실하게 해결합니다.


FILE·__LINE__으로 위치 정보 남기기

__FILE__과 __LINE__은 전처리기가 현재 파일 이름과 줄 번호로 치환하는 매크로입니다.

#include <iostream>
int main() {
    std::cout << "File: " << __FILE__ << ", Line: " << __LINE__ << "\n";
}
File: main.cpp, Line: 3

__FILE__은 컴파일러에 넘긴 경로 그대로 나오므로, 빌드 시스템이 절대 경로를 넘기면 바이너리에 빌드 머신의 경로가 박힙니다. GCC·Clang은 -fmacro-prefix-map=/path/to/src=.로 이 경로를 바꿀 수 있습니다.

커스텀 assert

#include <cstdlib>
#include <iostream>

#define MY_ASSERT(cond) \
    do { \
        if (!(cond)) { \
            std::cerr << "Assertion failed: " #cond \
                      << " at " << __FILE__ << ":" << __LINE__ << "\n"; \
            std::abort(); \
        } \
    } while (0)

MY_ASSERT(ptr != nullptr);

이 기능이 함수가 아니라 매크로여야 하는 이유는 두 가지입니다. #cond로 조건식 자체를 문자열로 남길 수 있고, __FILE__과 __LINE__이 매크로를 호출한 위치에서 확장됩니다. 같은 코드를 함수로 만들면 줄 번호가 항상 함수 정의 안을 가리킵니다. C++20에서는 std::source_location::current()를 기본 인자로 받는 함수로 호출 위치를 얻을 수 있으므로, 조건식 문자열이 필요 없다면 매크로 없이도 같은 일을 할 수 있습니다.

__func__은 현재 함수 이름을 담은 문자열이지만 매크로가 아닙니다. 함수마다 암묵적으로 선언되는 static const char[] 변수라서 #ifdef __func__로 검사할 수 없고 문자열 리터럴 연결("in " __func__)에도 쓸 수 없습니다. __FUNCTION__(GCC·MSVC)과 __PRETTY_FUNCTION__(GCC·Clang)은 비표준 확장입니다.

예외 메시지에 위치 정보 포함

#define THROW_IF(cond, msg) \
    do { \
        if (cond) throw std::runtime_error( \
            std::string(__FILE__) + ":" + std::to_string(__LINE__) + ": " + (msg)); \
    } while (0)

# 문자열화와 ## 토큰 붙이기

문자열화 (#)

#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)

int main() {
    std::cout << STRINGIFY(hello) << "\n";    // hello
    std::cout << STRINGIFY(__LINE__) << "\n"; // __LINE__
    std::cout << TOSTRING(__LINE__) << "\n";  // 7
}

#의 피연산자가 된 인자는 확장되지 않으므로 STRINGIFY(__LINE__)은 "__LINE__"이 됩니다. TOSTRING(__LINE__)에서는 TOSTRING의 본문에 x가 # 없이 쓰였으므로 인자가 먼저 7로 확장된 뒤 STRINGIFY(7)이 되어 "7"이 나옵니다. 매크로의 값을 문자열로 만들고 싶을 때는 항상 이렇게 한 단계를 거쳐야 합니다.

토큰 붙이기 (##)

#define CONCAT(a, b) a##b

int CONCAT(value, 1) = 42;  // int value1 = 42;

##은 양쪽 토큰을 하나로 붙이며, 결과는 유효한 전처리 토큰이어야 합니다. CONCAT(var, 123) → var123이나 CONCAT(1, 2) → 12는 유효하지만, CONCAT(+, /) → +/처럼 하나의 토큰이 될 수 없는 결과는 정의되지 않은 동작이고 GCC는 “pasting does not give a valid preprocessing token” 에러를 냅니다. ##의 피연산자도 확장되지 않으므로, 다른 매크로의 값과 붙이려면 문자열화처럼 2단계 매크로가 필요합니다.

확장 규칙이 결과를 바꾸는 예

#define ERR_INVALID_ARG 1
#define ERROR_NAME(e) #e
#define ERROR_MSG(e) "Error: " #e " (code=" ERROR_NAME(e) ")"

ERROR_MSG(ERR_INVALID_ARG)
// → "Error: " "ERR_INVALID_ARG" " (code=" "1" ")"
// → "Error: ERR_INVALID_ARG (code=1)"

ERROR_MSG 본문에서 #e로 쓰인 자리는 확장 전 토큰(ERR_INVALID_ARG)이 문자열이 되고, ERROR_NAME(e)로 넘긴 자리는 인자가 먼저 1로 확장된 뒤 ERROR_NAME(1)이 되어 "1"이 됩니다. 같은 인자가 한 매크로 안에서 두 가지로 쓰이는 이 성질을 이용하면 이름과 값을 함께 출력할 수 있습니다.

X-매크로로 enum과 문자열 동기화

// colors.def
X(Red)
X(Green)
X(Blue)

// colors.h
enum class Color {
#define X(name) name,
#include "colors.def"
#undef X
};

inline const char* to_string(Color c) {
    switch (c) {
#define X(name) case Color::name: return #name;
#include "colors.def"
#undef X
    }
    return "unknown";
}

목록을 colors.def 한 곳에만 두므로 항목을 추가할 때 enum과 문자열 변환이 어긋날 수 없습니다.


CMake로 버전·빌드 정보 주입

execute_process(
    COMMAND git describe --tags --always
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    OUTPUT_VARIABLE GIT_VERSION
    OUTPUT_STRIP_TRAILING_WHITESPACE
)
target_compile_definitions(app PRIVATE APP_VERSION="${GIT_VERSION}")
#include <iostream>
#ifndef APP_VERSION
#define APP_VERSION "unknown"
#endif

int main() {
    std::cout << "Version: " << APP_VERSION << "\n";
}

target_compile_definitions는 따옴표를 컴파일러 명령줄에 맞게 이스케이프해 주므로 add_definitions에 -D를 직접 쓰는 것보다 안전합니다. 다만 execute_process는 CMake 구성(configure) 단계에서 한 번만 실행되므로, 커밋을 새로 해도 CMake를 다시 구성하기 전에는 버전 문자열이 바뀌지 않습니다. 빌드할 때마다 갱신하려면 add_custom_command로 version.h를 생성하는 방식을 씁니다.

__DATE__, __TIME__도 빌드 시각을 넣는 데 쓸 수 있지만, 같은 소스로 빌드해도 바이너리가 매번 달라져 재현 가능한 빌드를 깨뜨립니다. GCC는 SOURCE_DATE_EPOCH 환경 변수가 있으면 그 값을 쓰므로, 빌드 시각이 필요하다면 이 변수를 CI에서 고정하는 편이 낫습니다.

컴파일러별 확장을 감싸는 매크로

#if defined(__GNUC__)
    #define UNLIKELY(x) __builtin_expect(!!(x), 0)
#else
    #define UNLIKELY(x) (x)
#endif

if (UNLIKELY(ptr == nullptr)) {
    handle_error();
}

이런 매크로는 컴파일러 확장의 차이를 한 곳에 가두는 용도입니다. 표준에 대응하는 기능이 생긴 경우에는 표준을 쓰는 것이 낫습니다. 분기 힌트는 C++20의 [[likely]]/[[unlikely]], 사용 중단 경고는 C++14의 [[deprecated("use new_api")]]가 같은 일을 합니다.


자주 만나는 전처리 에러

에러·증상원인해결
redefinition of 'struct X'한 번역 단위에 같은 헤더가 두 번 포함됨include guard 또는 #pragma once
multiple definition of 'f' (링크)헤더에 비 inline 함수·변수 정의inline 추가 또는 정의를 .cpp로 이동
계산 결과가 이상함매크로 괄호 누락, 인자 다중 평가괄호 추가, inline 함수로 대체
'#' is not followed by a macro parameter함수형 매크로 본문의 # 뒤가 매개변수가 아님# 뒤에 매개변수 이름을 씀
pasting ... does not give a valid preprocessing token## 결과가 토큰 하나가 아님결과가 식별자·숫자가 되도록 설계
-D로 넣은 문자열이 에러따옴표 누락-DVAR=\"value\" 또는 CMake target_compile_definitions
std::max에서 에러 (Windows)<windows.h>의 max 매크로NOMINMAX 정의 또는 (std::max)(a, b)
매크로 인자 개수 에러템플릿 인자의 쉼표타입 별칭 사용
else without previous if중괄호만 쓴 다중 문장 매크로do { ... } while (0)
여러 줄 매크로가 중간에 끊김\ 뒤 공백줄 끝 공백 제거

코드 덩어리를 잠시 비활성화할 때는 #if 0 … #endif가 가장 안전합니다. 안쪽에 #ifdef/#endif가 있어도 전처리기가 짝을 맞춰 건너뛰고, 블록 주석(/* */)과 달리 안쪽에 이미 있는 주석과 충돌하지 않습니다.

다음 글

전처리기를 이해했다면 컴파일 과정의 다음 단계인 컴파일·어셈블·링킹을 보거나, constexpr로 컴파일 타임 상수를 매크로 없이 다루는 방법을 이어서 보면 좋습니다.


자주 묻는 질문 (FAQ)

Q. #pragma once와 include guard 중 뭘 써야 하나요?

A. 주요 컴파일러가 모두 지원하므로 대부분의 프로젝트에서는 #pragma once로 충분합니다. 같은 헤더가 심볼릭 링크나 여러 경로로 포함될 수 있는 빌드 환경이거나, 표준만으로 동작해야 하는 코드라면 #ifndef/#define/#endif를 씁니다.

Q. 매크로와 constexpr/템플릿의 차이는?

A. 매크로는 전처리 단계에서 텍스트로 치환되고, constexpr·템플릿은 컴파일 단계에서 타입 검사를 받으며 처리됩니다. 타입 안전성·디버깅·네임스페이스 면에서 constexpr·템플릿이 유리하므로, 매크로는 조건부 컴파일·__FILE__/__LINE__·문자열화처럼 전처리기만 할 수 있는 일에 씁니다.

Q. __FILE__이 전체 경로로 나오는데 상대 경로로 바꿀 수 있나요?

A. GCC(8 이상)와 Clang(10 이상)은 -fmacro-prefix-map=old=new로 경로를 바꿀 수 있습니다. CMake에서는 add_compile_options(-fmacro-prefix-map=${CMAKE_SOURCE_DIR}/=)처럼 소스 루트를 지울 수 있고, MSVC는 /d1trimfile: 같은 비공식 옵션만 있으므로 C++20 std::source_location의 경로를 런타임에 잘라 쓰는 방법도 고려할 만합니다.


같이 보면 좋은 글