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
| 매크로 | 의미 |
|---|---|
_WIN32 | Windows (32비트와 64비트 모두 정의됨) |
_WIN64 | 64비트 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의 경로를 런타임에 잘라 쓰는 방법도 고려할 만합니다.