C++ 매크로 고급 기법: X-Macro, 문자열화(#)·토큰 붙이기(##), 가변 인자 매크로

이 글의 핵심

C++ 전처리기는 컴파일이 시작되기도 전에 소스 코드를 텍스트 수준에서 치환하는 단계로, #define 매크로·조건부 컴파일·문자열화(#)·토큰 붙이기(##) 같은 기능을 제공합니다. 강력하지만 타입 체크가 전혀 없고 단순 텍스트 치환이라는 근본적인 한계 때문에, 대부분의 경우 constexpr이나 템플릿 같은 현대 C++ 대안으로 대체하는 것이 안전합니다. 이 글은 매크로의 고급 기법과 그 위험성을 함께 정리합니다.

기본 매크로

전처리기는 컴파일러가 문법을 이해하기도 전에, 소스 코드에서 매크로 이름을 정의된 텍스트로 그대로 바꿔치기합니다.

// 객체형 매크로
#define PI 3.14159
#define MAX_SIZE 100

// 함수형 매크로
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main() {
    cout << PI << endl;
    cout << SQUARE(5) << endl;  // 25
    cout << MAX(10, 20) << endl;  // 20
}

SQUARE(x)가 ((x) * (x))처럼 인자와 전체 식을 모두 괄호로 감싸는 이유는 뒤에서 다룰 “괄호 필수” 함정 때문입니다. 매크로는 타입을 전혀 모르는 순수 텍스트 치환이라서, int든 double든 SQUARE에 넣으면 그대로 치환되어 컴파일됩니다 — 이게 편리해 보이지만 타입 오류를 컴파일 타임에 잡아주지 못한다는 뜻이기도 합니다.

조건부 컴파일

#ifdef DEBUG
    #define LOG(msg) cout << "[DEBUG] " << msg << endl
#else
    #define LOG(msg)
#endif

int main() {
    LOG("프로그램 시작");  // DEBUG 정의 시만 출력
}

// 컴파일
// g++ -DDEBUG program.cpp

DEBUG가 정의되지 않은 빌드에서는 LOG(msg)가 빈 문자열로 치환되어, LOG("프로그램 시작");이라는 코드 자체가 컴파일 대상에서 완전히 사라집니다. 런타임에 if(debug) 검사를 하는 것과 근본적으로 다른데, 조건부 컴파일은 검사 비용조차 릴리스 빌드에 전혀 남기지 않는다는 점이 핵심입니다.

그런데 이 장점이 그대로 함정이 되기도 합니다. DEBUG가 꺼진 빌드에서는 LOG(...) 안의 식이 컴파일조차 되지 않으므로, 그 안에서 존재하지 않는 변수를 참조하거나 오타를 내도 릴리스 빌드는 멀쩡히 통과합니다. 몇 달 뒤 누군가 디버그 빌드를 켜는 순간 한꺼번에 컴파일 에러가 쏟아지는 식입니다. 반대로 LOG(computeSummary())처럼 인자에 부작용이 있는 함수 호출을 넣으면 디버그 빌드에서만 그 함수가 실행되어, 디버그와 릴리스의 동작이 달라지는 버그가 됩니다. 로그 인자에는 부작용이 없는 식만 넣는다는 규칙을 팀 차원에서 정해 두는 것이 좋습니다. 이 문제가 부담스럽다면 if constexpr (kDebug) { ... }처럼 조건을 constexpr bool 상수로 두는 방식도 있습니다. 이렇게 하면 꺼진 쪽 코드도 문법·이름 검사는 받으면서(템플릿 밖이라면), 최적화 후에는 똑같이 사라집니다.

문자열화 (#)

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

int main() {
    cout << STRINGIFY(hello) << endl;  // "hello"
    cout << TOSTRING(__LINE__) << endl;  // "5"
}

STRINGIFY와 TOSTRING을 굳이 두 단계로 나눈 이유가 이 패턴의 핵심입니다. STRINGIFY(__LINE__)를 직접 호출하면 __LINE__이라는 매크로 이름 자체가 문자열화되어 "__LINE__"이 나오지만, TOSTRING(__LINE__)처럼 한 단계 감싸면 __LINE__이 먼저 실제 줄 번호로 치환된 뒤에 STRINGIFY로 전달되어 "5"처럼 실제 값이 문자열화됩니다. 이 “한 단계 감싸기”는 매크로 인자를 먼저 확장한 뒤 문자열화하고 싶을 때 항상 필요한 관용구입니다.

토큰 붙이기 (##)

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

int main() {
    int xy = 10;
    cout << CONCAT(x, y) << endl;  // xy → 10
}

##는 두 토큰을 문자 그대로 이어 붙여 새로운 식별자를 만듭니다. 이 기법은 뒤에서 다룰 X-Macro 패턴이나, variable1, variable2처럼 번호가 붙은 변수/함수 이름을 매크로로 자동 생성할 때 자주 쓰입니다.

실전 예시

예시 1: 디버그 매크로

#ifdef DEBUG
    #define DEBUG_PRINT(fmt, ...) \
        fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)
#else
    #define DEBUG_PRINT(fmt, ...)
#endif

int main() {
    int x = 10;
    DEBUG_PRINT("x = %d", x);
    // [main.cpp:12] x = 10
}

##__VA_ARGS__의 ##는 앞서 배운 토큰 붙이기와 조금 다른 특수 용법입니다 — 가변 인자가 하나도 없을 때(DEBUG_PRINT("시작")처럼) 포맷 문자열 뒤에 남는 여분의 쉼표를 GCC/Clang 확장 기능으로 제거해주는 역할을 합니다. 표준 C++20부터는 __VA_OPT__(,)라는 이식성 있는 대안이 생겼으므로, 여러 컴파일러를 지원해야 한다면 그쪽을 검토할 가치가 있습니다. 즉 fprintf(stderr, "[%s:%d] " fmt "\n", __FILE__, __LINE__ __VA_OPT__(,) __VA_ARGS__)처럼 쓰면 인자가 있을 때만 쉼표가 들어갑니다. MSVC의 기존(비표준 호환) 전처리기는 여분의 쉼표를 자체적으로 지우는 방식이 달라서, MSVC에서 이런 매크로가 이상하게 펼쳐진다면 /Zc:preprocessor 옵션으로 표준 호환 전처리기를 켜 보는 것이 첫 번째 확인 사항입니다.

fmt를 문자열 리터럴 연결("[%s:%d] " fmt "\n")에 쓰고 있다는 점도 눈여겨볼 부분입니다. 인접한 문자열 리터럴은 컴파일러가 하나로 합쳐 주므로, 이 매크로는 fmt 자리에 리터럴만 받을 수 있습니다. DEBUG_PRINT(userMessage)처럼 변수를 넘기면 expected ')' before 'userMessage' 같은 알아보기 힘든 에러가 나는데, 원인은 리터럴 연결이 불가능하기 때문입니다. 이 제약이 오히려 사용자 입력을 포맷 문자열로 넘기는 보안 실수를 막아 주는 부수 효과도 있습니다.

예시 2: X-Macro 패턴

// 에러 코드 정의
#define ERROR_CODES \
    X(SUCCESS, 0, "성공") \
    X(NOT_FOUND, 1, "찾을 수 없음") \
    X(PERMISSION_DENIED, 2, "권한 없음") \
    X(TIMEOUT, 3, "시간 초과")

// enum 생성
enum ErrorCode {
    #define X(name, code, desc) name = code,
    ERROR_CODES
    #undef X
};

// 문자열 변환
const char* errorToString(ErrorCode code) {
    switch (code) {
        #define X(name, code, desc) case name: return desc;
        ERROR_CODES
        #undef X
        default: return "알 수 없는 에러";
    }
}

int main() {
    cout << errorToString(NOT_FOUND) << endl;  // "찾을 수 없음"
}

X-Macro는 매크로 기법 중에서도 실무적 가치가 가장 명확한 패턴입니다. 에러 코드 목록을 ERROR_CODES 한 곳에만 정의해두면, enum 정의와 문자열 변환 함수가 항상 같은 목록에서 자동으로 생성되어 서로 어긋날 수가 없습니다. 새 에러 코드를 추가할 때 X(...) 한 줄만 추가하면 enum과 switch가 동시에 갱신되는 것이 이 패턴이 존재하는 이유이며, 이런 “여러 곳에 동기화해야 하는 목록”이 코드베이스에 있다면 X-Macro가 그 동기화 문제를 구조적으로 없애줍니다.

대가도 있습니다. IDE의 “정의로 이동”은 NOT_FOUND를 누르면 ERROR_CODES 목록 한 줄로 가거나 아예 찾지 못하는 경우가 많고, 컴파일 에러는 매크로가 펼쳐진 위치 기준으로 보고되어 목록의 어느 줄이 문제인지 알기 어렵습니다. 또 목록 각 줄 끝의 \ 뒤에 공백이 하나라도 있으면 줄 이어 붙이기가 끊겨서 warning: backslash and newline separated by space 경고와 함께 이상한 에러가 납니다. 목록이 수십 개를 넘고 여러 언어(예: C++ enum과 Python 상수)가 같은 목록을 공유해야 한다면, X-Macro보다 목록을 JSON이나 YAML로 두고 빌드 단계에서 코드를 생성하는 쪽이 유지보수가 쉬운 경우가 많습니다. 한 번역 단위 안에서 끝나는 작은 목록이라면 X-Macro가 가장 가볍습니다.

예시 3: 플랫폼별 코드

#ifdef _WIN32
    #include <windows.h>
    #define SLEEP(ms) Sleep(ms)
#else
    #include <unistd.h>
    #define SLEEP(ms) usleep((ms) * 1000)
#endif

int main() {
    cout << "대기 중..." << endl;
    SLEEP(1000);  // 1초
    cout << "완료" << endl;
}

윈도우의 Sleep()은 밀리초 단위, POSIX의 usleep()은 마이크로초 단위를 받는다는 근본적인 API 차이를 SLEEP(ms) 매크로 하나로 감춰서, 호출하는 쪽 코드는 플랫폼과 무관하게 항상 밀리초로만 생각하면 됩니다. 이런 플랫폼 차이 흡수는 매크로가 여전히 실무에서 자주 쓰이는 몇 안 되는 영역 중 하나입니다.

예시 4: 버전 관리

#define VERSION_MAJOR 1
#define VERSION_MINOR 2
#define VERSION_PATCH 3

#define MAKE_VERSION(major, minor, patch) \
    ((major) * 10000 + (minor) * 100 + (patch))

#define VERSION MAKE_VERSION(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH)

int main() {
    cout << "버전: " << VERSION_MAJOR << "."
         << VERSION_MINOR << "." << VERSION_PATCH << endl;

    #if VERSION >= 10200
        cout << "새 기능 사용 가능" << endl;
    #endif
}

#if VERSION >= 10200처럼 매크로 값을 조건부 컴파일에 직접 쓸 수 있다는 것이 이 패턴의 핵심입니다. 런타임 if문과 달리 이 비교는 컴파일 타임에 완전히 결정되므로, 특정 버전 이상에서만 필요한 코드를 아예 컴파일 대상에서 제외할 수 있어 라이브러리의 하위 호환성 코드를 관리할 때 자주 쓰이는 관용구입니다.

가변 인자 매크로

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

int main() {
    LOG("시작");
    LOG("값: %d", 42);
    LOG("값: %d, %s", 42, "test");
}

...와 __VA_ARGS__는 인자 개수가 정해지지 않은 매크로를 만들 수 있게 해줍니다. printf 스타일 포맷 함수를 감싸는 로깅 매크로가 거의 예외 없이 이 패턴을 쓰는데, __FILE__/__LINE__을 자동으로 앞에 붙여주면서도 호출하는 쪽은 원래 printf를 쓰듯 자유롭게 인자를 넘길 수 있기 때문입니다.

매크로 주의사항

괄호 필수

// ❌ 위험
#define SQUARE(x) x * x

int result = SQUARE(2 + 3);  // 2 + 3 * 2 + 3 = 11

// ✅ 괄호 사용
#define SQUARE(x) ((x) * (x))

int result = SQUARE(2 + 3);  // (2 + 3) * (2 + 3) = 25

이 버그가 무서운 이유는 SQUARE(5)처럼 단순 값을 넣으면 정상 동작해서 테스트를 통과하고, SQUARE(2 + 3)처럼 식을 넣는 순간에만 깨진다는 것입니다. 매크로가 단순 텍스트 치환이라는 사실을 잊으면, x * x가 2 + 3 * 2 + 3으로 그대로 치환되어 연산자 우선순위 규칙에 따라 곱셈이 먼저 계산되는 것을 코드만 봐서는 알아채기 어렵습니다.

부작용

// ❌ 부작용
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int x = 5;
int result = MAX(x++, 10);  // x++가 두 번 실행될 수 있음!

// ✅ 함수 사용
template<typename T>
T max(T a, T b) {
    return a > b ? a : b;
}

괄호를 아무리 완벽하게 쳐도 이 버그는 해결되지 않습니다. a가 매크로 본문에 두 번((a) > (b)의 비교와 (a)의 반환) 등장하기 때문에, x++처럼 부작용이 있는 식을 넣으면 그 부작용이 두 번 일어납니다. 이는 매크로의 근본적인 한계이며, 함수는 인자를 한 번만 평가하므로 이 문제 자체가 발생하지 않습니다.

타입 안전성

// ❌ 타입 체크 없음
#define ADD(a, b) ((a) + (b))

ADD("hello", 10);  // 에러 없이 컴파일됨: 포인터 산술("hello" + 10) → 문자열 끝을 넘는 포인터

// ✅ 템플릿 사용
template<typename T>
T add(T a, T b) {
    return a + b;
}

이 예제가 무서운 이유는 에러가 나지 않는다는 데 있습니다. ADD("hello", 10)은 (("hello") + (10))로 치환되고, 문자열 리터럴은 const char*로 변환되므로 이 식은 “문자열 시작에서 10바이트 뒤를 가리키는 포인터”라는 합법적인(하지만 배열 범위를 넘어 정의되지 않은) 포인터 산술이 됩니다. 결과를 int에 대입하려 할 때에야 에러가 나고, auto로 받거나 cout에 넘기면 쓰레기 메모리를 출력합니다. 템플릿 add("hello", 10)은 T를 const char*와 int 중 하나로 정할 수 없어 deduced conflicting types for parameter 'T'라는 명확한 에러가 호출 위치에서 납니다. 매크로는 이처럼 치환된 이후의 코드를 기준으로만 검사되기 때문에, 에러가 나더라도 원래 어떤 매크로 호출이 문제였는지 추적하기 번거롭고, 최악의 경우 에러 없이 틀린 코드가 됩니다.

유용한 매크로

컴파일 시간 정보

cout << "파일: " << __FILE__ << endl;
cout << "라인: " << __LINE__ << endl;
cout << "함수: " << __func__ << endl;
cout << "날짜: " << __DATE__ << endl;
cout << "시간: " << __TIME__ << endl;

이 미리 정의된 매크로들은 컴파일러가 자동으로 채워주는 값으로, 로깅이나 어설션 메시지에 “어디서 발생했는지” 컨텍스트를 자동으로 붙일 때 유용합니다. 참고로 __func__는 매크로가 아니라 함수 안에 암묵적으로 선언되는 지역 변수라서 #if나 문자열 리터럴 연결에는 쓸 수 없습니다.

__DATE__와 __TIME__은 조심해서 써야 합니다. 빌드할 때마다 값이 달라지므로 같은 소스에서 항상 같은 바이너리를 만드는 재현 가능한 빌드가 깨지고, 빌드 캐시(ccache 등)도 매번 적중에 실패합니다. GCC·Clang은 -Wdate-time 경고로 이런 사용을 알려 주며, 빌드 시각이 필요하다면 빌드 시스템에서 -DBUILD_TIMESTAMP=...처럼 한 곳에서만 주입하는 편이 낫습니다.

C++20부터는 std::source_location::current()로 파일명·줄 번호·함수명을 매크로 없이 얻을 수 있습니다. 로깅 함수의 기본 인자로 std::source_location loc = std::source_location::current()를 두면 호출한 쪽의 위치가 채워지므로, __FILE__/__LINE__을 넘기기 위해 매크로로 감싸던 로깅 코드를 일반 함수로 바꿀 수 있습니다. 다만 앞의 DEBUG_PRINT처럼 릴리스 빌드에서 호출 자체와 인자 평가까지 없애야 한다면 여전히 매크로가 필요합니다.

static_assert 매크로

#define STATIC_ASSERT(cond, msg) \
    static_assert(cond, msg)

STATIC_ASSERT(sizeof(int) == 4, "int는 4바이트여야 함");

디버그 매크로

#define ASSERT(cond) \
    do { \
        if (!(cond)) { \
            fprintf(stderr, "Assertion failed: %s, %s:%d\n", \
                    #cond, __FILE__, __LINE__); \
            abort(); \
        } \
    } while(0)

int main() {
    int x = 10;
    ASSERT(x > 0);
    ASSERT(x < 5);  // 실패
}

#cond는 앞서 배운 문자열화 연산자를 이용해, 어떤 조건식이 실패했는지 그 텍스트 그대로를 에러 메시지에 출력합니다. do { ... } while(0)으로 감싼 이유는 바로 다음 절에서 다루는 세미콜론 문제 때문입니다.

자주 발생하는 문제

문제 1: 세미콜론 문제

// ❌ 세미콜론 문제
#define LOG(msg) cout << msg << endl;

if (condition)
    LOG("test");
else
    cout << "else" << endl;  // 에러!

// ✅ do-while 사용
#define LOG(msg) \
    do { \
        cout << msg << endl; \
    } while(0)

매크로 정의 안에 세미콜론을 포함시키면, LOG("test");로 호출했을 때 세미콜론이 두 개가 되어 if문의 else와 문법적으로 연결이 끊깁니다. 두 번째 세미콜론이 빈 문장으로 if를 끝내 버리므로, GCC는 error: 'else' without a previous 'if'를 냅니다. 매크로 본문을 { ... } 블록으로만 감싸도 }; 뒤의 else에서 같은 에러가 나기 때문에 do-while(0)이 쓰입니다. do { ... } while(0)으로 감싸면 매크로 호출부에 반드시 세미콜론이 하나 필요한 하나의 완결된 문장처럼 동작해서, if-else를 포함한 어떤 문맥에서도 안전하게 쓸 수 있습니다.

문제 2: 매크로 재정의

// ❌ 재정의 경고
#define MAX 100
#define MAX 200  // 경고

// ✅ undef 후 재정의
#undef MAX
#define MAX 200

여러 헤더 파일이 같은 이름의 매크로를 각자 정의하려고 하면 이 문제가 발생하기 쉽습니다. 서드파티 라이브러리 두 개가 우연히 같은 매크로 이름(MAX, MIN 같은 흔한 이름일수록 위험)을 정의하는 경우가 실무에서 드물지 않게 발생합니다.

문제 3: 네임스페이스 오염

// ❌ 전역 네임스페이스 오염
#define SIZE 100

// ✅ 접두사 사용
#define MYLIB_SIZE 100

// ✅ constexpr 사용 (더 좋음)
constexpr int SIZE = 100;

매크로는 namespace의 영향을 전혀 받지 않는다는 점이 이 문제의 근본 원인입니다. namespace mylib { constexpr int SIZE = 100; }처럼 감싸도 #define SIZE는 여전히 전역으로 적용되어, 같은 이름의 변수나 다른 매크로와 충돌합니다. 이것이 현대 C++에서 상수를 정의할 때 매크로보다 constexpr을 권장하는 이유 중 하나입니다 — constexpr 변수는 네임스페이스 규칙을 그대로 따릅니다.

매크로 vs 대안

// 매크로
#define PI 3.14159
#define SQUARE(x) ((x) * (x))

// constexpr (권장)
constexpr double PI = 3.14159;

constexpr int square(int x) {
    return x * x;
}

// inline 함수 (권장)
inline int square(int x) {
    return x * x;
}

constexpr과 inline 함수는 매크로의 가장 흔한 용도(상수, 간단한 계산)를 타입 안전하게 대체하면서, 이 글에서 다룬 괄호·부작용·타입 안전성 문제를 애초에 발생시키지 않습니다. 매크로가 여전히 필요한 영역은 조건부 컴파일, __FILE__/__LINE__ 같은 전처리기 전용 정보, 그리고 X-Macro처럼 코드 생성 자체가 목적인 경우로 좁혀지고 있습니다.

조건부 컴파일 패턴

// 플랫폼
#ifdef _WIN32
    // Windows
#elif __linux__
    // Linux
#elif __APPLE__
    // macOS
#endif

// 컴파일러
#ifdef __GNUC__
    // GCC
#elif _MSC_VER
    // MSVC
#endif

// C++ 버전
#if __cplusplus >= 202002L
    // C++20
#elif __cplusplus >= 201703L
    // C++17
#endif

이 세 그룹(플랫폼/컴파일러/언어 버전)의 매크로는 서로 다른 벤더가 정의하지만 표준화된 값이라, 크로스 플랫폼 라이브러리 코드에서 사실상 표준 관용구로 자리잡았습니다. 여기에는 잘 알려진 함정이 두 가지 있습니다. 첫째, Clang도 GCC 호환성을 위해 __GNUC__를 정의하므로 위 순서로는 Clang이 GCC 분기로 들어갑니다. Clang 전용 처리가 필요하면 #if defined(__clang__)을 가장 먼저 검사해야 합니다(Windows의 clang-cl은 _MSC_VER까지 정의합니다). 둘째, __cplusplus는 표준이 정의하는 매크로지만 MSVC는 하위 호환 때문에 기본값으로 항상 199711L을 보고합니다. /Zc:__cplusplus 옵션을 켜야 실제 표준 버전이 들어가고, 옵션 없이 쓰려면 MSVC 전용인 _MSVC_LANG을 함께 확인해야 합니다. 특정 기능의 지원 여부가 궁금한 것이라면 언어 버전보다 C++20의 <version> 헤더가 제공하는 __cpp_concepts, __cpp_lib_format 같은 기능 테스트 매크로를 검사하는 쪽이 더 정확합니다. 컴파일러가 C++20 모드여도 특정 라이브러리 기능은 아직 구현되지 않았을 수 있기 때문입니다.

FAQ

Q1: 매크로는 언제 사용하나요?

A:

  • 조건부 컴파일
  • 디버그 로깅
  • 플랫폼별 코드

Q2: 매크로 대신 무엇을 사용하나요?

A:

  • constexpr (상수)
  • inline 함수 (함수)
  • 템플릿 (제네릭)

Q3: 매크로의 단점은?

A:

  • 타입 체크 없음
  • 디버깅 어려움
  • 네임스페이스 오염
  • 부작용 가능

Q4: X-Macro는 언제 사용하나요?

A: enum과 문자열 변환, 테이블 생성 등 반복적인 코드 생성.

Q5: 매크로 디버깅은?

A: g++ -E file.cpp(MSVC는 /P)로 전처리가 끝난 코드를 출력해 보는 것이 가장 확실합니다. 헤더까지 전부 펼쳐져 수만 줄이 나오므로 -P 옵션으로 줄 번호 표시를 빼고 해당 함수 부분만 검색해 보면 됩니다. 매크로가 기대와 다르게 펼쳐지는 이유는 거의 항상 이 출력에서 바로 보입니다. 짧은 코드는 Compiler Explorer에서 “Preprocessor output” 창을 열어 확인할 수도 있습니다.


같이 보면 좋은 글