X-Macro로 중복 없애기: enum↔문자열 매핑, 에러 코드 표, 상태 머신, 커맨드 테이블

들어가며: “enum과 문자열을 두 번 정의하지 말고 싶어요”

구체적인 문제 시나리오

enum과 문자열, 에러 코드와 메시지, 상태와 전이 조건 등을 다룰 때 이런 상황을 자주 겪습니다:

  • enum을 정의하며, 문자열 배열도 따로 만들며, switch로 to_string을 구현했는데, 새 값을 추가할 때 세 곳을 모두 수정해야 한다
  • 에러 코드마다 숫자·문자열·HTTP 상태·재시도 가능 여부가 있는데, 한 곳만 빼먹으면 런타임에 이상 동작한다
  • 상태 머신에서 상태 enum·전이 테이블·초기 상태를 각각 정의했더니, 상태 하나 추가할 때마다 여러 파일을 수정한다
  • CLI 커맨드마다 이름·설명·핸들러 함수가 있는데, 명령어 추가 시 enum·help 텍스트·dispatch 테이블을 동기화해야 한다 이런 “동일한 데이터를 여러 형태로 반복 정의”하는 문제를 해결하는 기법이 X-Macro다. 한 곳에 데이터를 정의하며, 매크로를 재정의해 enum·문자열·switch·테이블 등을 자동 생성한다.

X-Macro 동작 원리 시각화

flowchart TD
    subgraph source["단일 소스 (데이터 정의)"]
        D["X(RED)\nX(GREEN)\nX(BLUE)"]
    end
    subgraph gen1[매크로 X = enum 생성]
        E["enum  RED, GREEN, BLUE"]
    end
    subgraph gen2[매크로 X = 문자열 배열]
        S["#quot;RED#quot;, #quot;GREEN#quot;, #quot;BLUE#quot;"]
    end
    subgraph gen3[매크로 X = switch case]
        W["case RED: return #quot;RED#quot;; ..."]
    end
    D --> E
    D --> S
    D --> W

추가 문제 시나리오

로그 레벨

DEBUG, INFO, WARN, ERROR enum과 "DEBUG", "INFO" 문자열, 그리고 switch로 로그 레벨을 출력하는 함수가 있습니다. 새 레벨 TRACE를 추가하면 enum·문자열 배열·switch 세 곳을 수정해야 합니다.

프로토콜 메시지 타입

MSG_LOGIN, MSG_LOGOUT, MSG_PING 등 메시지 타입 enum과, 각 타입의 크기·직렬화 함수·역직렬화 함수가 있습니다. 새 메시지 추가 시 네 곳 이상을 동기화해야 합니다.

설정 키

config.h에 KEY_HOST, KEY_PORT, KEY_TIMEOUT 등 키 enum이 있으며, config.cpp에 "host", "port", "timeout" 문자열과 파싱 로직이 있습니다. 키 추가 시 enum·문자열·파싱 분기·기본값을 모두 수정해야 합니다.

게임 몬스터 타입

몬스터마다 이름·아이콘·레벨·공격 타입·독 면역 여부가 있습니다. 배열로 관리하면 런타임에 조회할 수 있지만, X-Macro로 소스 코드에 인코딩하면 컴파일 타임에 switch로 전개되어 메모리 사용이 줄어듭니다.

파서 토큰

TOKEN_IF, TOKEN_ELSE, TOKEN_WHILE 등 토큰 enum과 "if", "else", "while" 키워드 문자열, 그리고 키워드→토큰 매핑 테이블이 있습니다. 새 키워드 추가 시 세 곳을 수정해야 합니다.

X-Macro로 목록 하나에서 enum·문자열 테이블·매핑을 동시에 생성하는 원리를 보고, 에러 코드, 상태 머신, 커맨드 테이블 예제로 적용해 봅니다.

개념을 잡는 비유

템플릿 인자 자리는 붕어빵 틀의 칸 수가 정해지듯, 컴파일 시점에 크기·상수가 박혀 있어야 하는 경우가 많습니다. constexpr·컴파일 타임 계산은 그 값을 미리 찍어내어, 배열 크기와 static_assert 같은 곳에 그대로 얹을 수 있게 해 줍니다.


X-Macro 기초

X-Macro란?

X-Macro는 C/C++ 전처리기를 이용해 한 데이터 소스에서 여러 코드를 생성하는 기법입니다. 데이터를 X(...) 형태의 매크로 호출로 정의하며, #define X을 다르게 정의한 뒤 같은 데이터를 #include하면, 같은 데이터가 다른 형태로 전개됩니다. 이름 “X”는 관례적인 이름일 뿐, ITEM, ENTRY, DEF 등 어떤 이름이든 사용할 수 있습니다.

기본 구조

데이터는 별도 파일에 X(ID) 형태로만 나열합니다. X는 사용처에서 정의합니다:

// colors.def
X(RED)
X(GREEN)
X(BLUE)
// main.cpp - enum 생성
enum class Color {
#define X(ID) ID,
#include "colors.def"
#undef X
};
// 전개 결과:
// enum class Color {
//     RED,
//     GREEN,
//     BLUE,
// };
// main.cpp - 문자열 배열 생성
const char* color_names[] = {
#define X(ID) #ID,
#include "colors.def"
#undef X
};
// 전개 결과:
// const char* color_names[] = {
//     "RED",
//     "GREEN",
//     "BLUE",
// };

핵심: #define X(ID) ID,와 #define X(ID) #ID,로 같은 colors.def를 다른 형태로 전개합니다. #ID는 매크로 인자를 문자열 리터럴로 만드는 연산자입니다.

X-Macro 전개 흐름

flowchart LR
    A["colors.def\nX(RED)\nX(GREEN)\nX(BLUE)"] --> B["#define X(ID) ID,"]
    B --> C[RED,\nGREEN,\nBLUE,]
    A --> D["#define X(ID) #ID,"]
    D --> E["#quot;RED#quot;,\n#quot;GREEN#quot;,\n#quot;BLUE#quot;,"]

최소 동작 예제

// colors.def
X(RED)
X(GREEN)
X(BLUE)
// main.cpp
#include <iostream>
enum class Color {
#define X(ID) ID,
#include "colors.def"
#undef X
};
const char* to_string(Color c) {
    switch (c) {
#define X(ID) case Color::ID: return #ID;
#include "colors.def"
#undef X
        default: return "unknown";
    }
}
int main() {
    std::cout << to_string(Color::GREEN) << "\n";  // GREEN
    return 0;
}

설명: colors.def 한 파일만 수정하면 enum과 to_string이 자동으로 동기화됩니다. BLACK을 추가하려면 colors.def에 X(BLACK) 한 줄만 넣으면 됩니다.


enum ↔ 문자열 매핑

양방향 변환 (enum ↔ string)

enum을 문자열로, 문자열을 enum으로 변환하는 완전한 예제입니다.

// log_level.def
X(DEBUG)
X(INFO)
X(WARN)
X(ERROR)
// log_level.h
#pragma once
enum class LogLevel {
#define X(L) L,
#include "log_level.def"
#undef X
};
const char* to_string(LogLevel level);
LogLevel from_string(const char* s);
// log_level.cpp
#include "log_level.h"
#include <cstring>
const char* to_string(LogLevel level) {
    switch (level) {
#define X(L) case LogLevel::L: return #L;
#include "log_level.def"
#undef X
        default: return "UNKNOWN";
    }
}
LogLevel from_string(const char* s) {
#define X(L) if (strcmp(s, #L) == 0) return LogLevel::L;
#include "log_level.def"
#undef X
    // 파싱 실패 시 기본값 또는 예외
    return LogLevel::INFO;
}

동작: to_string(LogLevel::WARN) → "WARN", from_string("ERROR") → LogLevel::ERROR.

enum 개수 자동 계산

// log_level.cpp - 개수 계산
constexpr size_t log_level_count = 0
#define X(L) +1
#include "log_level.def"
#undef X
;
// 전개: 0 +1 +1 +1 +1 = 4

주의: +1 앞에 공백이 있어야 0 +1로 파싱됩니다. 0+1도 동작하지만 가독성을 위해 공백을 넣습니다.

문자열 배열 (인덱스 접근)

// log_level.cpp
const char* const log_level_names[] = {
#define X(L) #L,
#include "log_level.def"
#undef X
};
// 사용: log_level_names[static_cast<size_t>(LogLevel::WARN)] == "WARN"

주의: enum 값이 0부터 연속적이어야 인덱스로 안전하게 접근할 수 있습니다. enum class는 기본적으로 0부터 시작하므로 문제없습니다.

사용자 정의 문자열 (enum과 다른 표시용 문자열)

enum 이름과 출력용 문자열이 다를 때는 인자를 두 개로 확장합니다.

// log_level.def - (enum이름, 표시문자열)
X(DEBUG, "Debug")
X(INFO, "Information")
X(WARN, "Warning")
X(ERROR, "Error")
// log_level.h
enum class LogLevel {
#define X(L, S) L,
#include "log_level.def"
#undef X
};
const char* to_string(LogLevel level);
// log_level.cpp
const char* to_string(LogLevel level) {
    switch (level) {
#define X(L, S) case LogLevel::L: return S;
#include "log_level.def"
#undef X
        default: return "Unknown";
    }
}
// to_string(LogLevel::INFO) → "Information"

에러 코드와 메시지 테이블

에러 코드 + 숫자 + 메시지

에러 코드 enum, 숫자 값, 메시지 문자열을 한 소스에서 생성합니다.

// error_codes.def
// X(enum이름, 숫자값, 메시지)
X(SUCCESS,        0, "Success")
X(INVALID_ARG,   -1, "Invalid argument")
X(NOT_FOUND,     -2, "Resource not found")
X(TIMEOUT,       -3, "Operation timed out")
X(IO_ERROR,      -4, "I/O error")
// error_codes.h
#pragma once
enum class ErrorCode {
#define X(E, N, M) E = N,
#include "error_codes.def"
#undef X
};
const char* error_message(ErrorCode e);
int error_number(ErrorCode e);
// error_codes.cpp
#include "error_codes.h"
const char* error_message(ErrorCode e) {
    switch (e) {
#define X(E, N, M) case ErrorCode::E: return M;
#include "error_codes.def"
#undef X
        default: return "Unknown error";
    }
}
int error_number(ErrorCode e) {
    switch (e) {
#define X(E, N, M) case ErrorCode::E: return N;
#include "error_codes.def"
#undef X
        default: return -999;
    }
}

에러 코드 테이블 (배열로 조회)

런타임에 배열 인덱스로 조회하려면 enum 값을 0부터 연속으로 두는 것이 좋습니다. 숫자 값을 별도로 두려면 위와 같이 switch를 사용합니다.

// error_codes.def - 0부터 연속
X(SUCCESS, "Success")
X(INVALID_ARG, "Invalid argument")
X(NOT_FOUND, "Resource not found")
X(TIMEOUT, "Operation timed out")
// error_codes.cpp
struct ErrorInfo {
    const char* message;
};
const ErrorInfo error_table[] = {
#define X(E, M) { M },
#include "error_codes.def"
#undef X
};
// error_table[static_cast<size_t>(ErrorCode::NOT_FOUND)].message

HTTP 상태 코드 매핑

// http_status.def
X(OK, 200, "OK")
X(CREATED, 201, "Created")
X(BAD_REQUEST, 400, "Bad Request")
X(NOT_FOUND, 404, "Not Found")
X(INTERNAL_ERROR, 500, "Internal Server Error")
// http_status.cpp
int http_status_code(HttpStatus s) {
    switch (s) {
#define X(E, C, M) case HttpStatus::E: return C;
#include "http_status.def"
#undef X
        default: return 500;
    }
}
const char* http_status_message(HttpStatus s) {
    switch (s) {
#define X(E, C, M) case HttpStatus::E: return M;
#include "http_status.def"
#undef X
        default: return "Unknown";
    }
}

상태 정의와 전이 테이블

상태 정의와 전이 테이블

상태 enum, 초기 상태, 전이(transition) 조건을 X-Macro로 관리합니다.

// states.def
X(IDLE)
X(RUNNING)
X(PAUSED)
X(STOPPED)
// state_machine.h
#pragma once
enum class State {
#define X(S) S,
#include "states.def"
#undef X
};
const char* state_name(State s);
State initial_state();  // IDLE
// state_machine.cpp
#include "state_machine.h"
const char* state_name(State s) {
    switch (s) {
#define X(S) case State::S: return #S;
#include "states.def"
#undef X
        default: return "UNKNOWN";
    }
}
State initial_state() {
    return State::IDLE;  // 첫 번째 상태를 초기값으로
}

전이 테이블 (이벤트 → 다음 상태)

이벤트와 현재 상태에 따른 다음 상태를 정의합니다.

// transitions.def
// X(현재상태, 이벤트, 다음상태)
X(IDLE,     START,   RUNNING)
X(RUNNING,  PAUSE,   PAUSED)
X(RUNNING,  STOP,    STOPPED)
X(PAUSED,   RESUME,  RUNNING)
X(PAUSED,   STOP,    STOPPED)
X(STOPPED,  RESET,   IDLE)
// state_machine.cpp
State transition(State current, Event ev) {
    switch (current) {
#define X(FROM, EV, TO) case State::FROM: if (ev == Event::EV) return State::TO;
#include "transitions.def"
#undef X
        default: return current;
    }
}

주의: 위 패턴은 case 안에 if가 들어가므로, 한 상태에서 여러 이벤트를 처리하려면 switch를 중첩하거나, (FROM, EV, TO) 조합별로 분기하는 방식으로 확장해야 합니다. 아래는 더 실용적인 패턴입니다.

전이 테이블 (조합별 switch)

// transitions.def
X(IDLE,     START,   RUNNING)
X(RUNNING,  PAUSE,   PAUSED)
X(RUNNING,  STOP,    STOPPED)
X(PAUSED,   RESUME,  RUNNING)
X(PAUSED,   STOP,    STOPPED)
X(STOPPED,  RESET,   IDLE)
// 이벤트 enum
enum class Event { START, PAUSE, RESUME, STOP, RESET };
State transition(State current, Event ev) {
#define X(FROM, EV, TO) \
    if (current == State::FROM && ev == Event::EV) return State::TO;
#include "transitions.def"
#undef X
    return current;  // 유효하지 않은 전이
}

상태 머신 다이어그램

stateDiagram-v2
    [*] --> IDLE
    IDLE --> RUNNING : START
    RUNNING --> PAUSED : PAUSE
    RUNNING --> STOPPED : STOP
    PAUSED --> RUNNING : RESUME
    PAUSED --> STOPPED : STOP
    STOPPED --> IDLE : RESET

CLI 커맨드 테이블과 디스패치

CLI 커맨드: 이름 + 설명 + 핸들러

커맨드 이름, 설명, 핸들러 함수를 한 소스에서 정의합니다.

// commands.def
// X(이름, 설명, 핸들러함수)
X(help,    "Show help",           cmd_help)
X(version, "Show version",        cmd_version)
X(quit,    "Exit program",        cmd_quit)
X(config,  "Show configuration", cmd_config)
// commands.h
#pragma once
#include <string>
#include <functional>
enum class CommandId {
#define X(N, D, H) N,
#include "commands.def"
#undef X
};
using CommandHandler = std::function<int(int argc, char** argv)>;
const char* command_name(CommandId id);
const char* command_description(CommandId id);
CommandHandler command_handler(CommandId id);
void print_all_commands();  // help 출력
// commands.cpp
#include "commands.h"
#include <iostream>
#include <cstring>
static int cmd_help(int, char**);
static int cmd_version(int, char**);
static int cmd_quit(int, char**);
static int cmd_config(int, char**);
const char* command_name(CommandId id) {
    switch (id) {
#define X(N, D, H) case CommandId::N: return #N;
#include "commands.def"
#undef X
        default: return "unknown";
    }
}
const char* command_description(CommandId id) {
    switch (id) {
#define X(N, D, H) case CommandId::N: return D;
#include "commands.def"
#undef X
        default: return "";
    }
}
CommandHandler command_handler(CommandId id) {
    switch (id) {
#define X(N, D, H) case CommandId::N: return H;
#include "commands.def"
#undef X
        default: return nullptr;
    }
}
void print_all_commands() {
#define X(N, D, H) std::cout << "  " #N " - " D "\n";
#include "commands.def"
#undef X
}
// 핸들러 구현
static int cmd_help(int, char**) {
    std::cout << "Available commands:\n";
    print_all_commands();
    return 0;
}
static int cmd_version(int, char**) {
    std::cout << "version 1.0\n";
    return 0;
}
static int cmd_quit(int, char**) { return -1; }  // -1 = 종료
static int cmd_config(int, char**) { /* ... */ return 0; }

문자열 → CommandId 파싱

CommandId parse_command(const char* s) {
#define X(N, D, H) if (strcmp(s, #N) == 0) return CommandId::N;
#include "commands.def"
#undef X
    return static_cast<CommandId>(-1);  // invalid
}

디스패치 루프 예시

int main(int argc, char** argv) {
    if (argc < 2) {
        cmd_help(0, nullptr);
        return 0;
    }
    CommandId id = parse_command(argv[1]);
    if (static_cast<int>(id) < 0) {
        std::cerr << "Unknown command: " << argv[1] << "\n";
        return 1;
    }
    auto handler = command_handler(id);
    int ret = handler(argc - 2, argv + 2);
    if (ret == -1) return 0;  // quit
    return ret;
}

인자 개수 불일치, #undef 누락, 쉼표 위치 같은 에러

에러 1: 매크로 인자 개수 불일치

원인: colors.def가 X(RED) 한 개 인자인데, #define X(ID, NAME) NAME,처럼 두 개 인자를 기대하면 전처리 후 X(RED)가 RED,로만 전개되어 NAME이 비어 있거나 에러가 납니다.

// ❌ 잘못된 예: colors.def는 X(RED) 한 개 인자
// #define X(ID, NAME) NAME,
// #include "colors.def"
// → X(RED)가 NAME,로 전개되는데 NAME이 없음

해결: 데이터 정의와 매크로 정의의 인자 개수를 일치시킵니다.

// ✅ colors.def를 두 인자로 변경
X(RED, Red)
X(GREEN, Green)
X(BLUE, Blue)
#define X(ID, NAME) ID,
#include "colors.def"
#undef X

에러 2: #undef X 누락

원인: #include "colors.def" 후 #undef X를 하지 않으면, 이후 코드에서 X가 다른 의미로 사용될 때 충돌합니다.

// ❌ 잘못된 예
#define X(ID) ID,
#include "colors.def"
// #undef X 없음
int X = 42;  // X가 매크로로 남아있어 에러

해결: #include 직후 반드시 #undef X를 호출합니다.

#define X(ID) ID,
#include "colors.def"
#undef X

에러 3: 쉼표·세미콜론 위치

원인: enum이나 배열 마지막 요소 뒤에 trailing comma가 있으면 C++에서는 허용되지만, 일부 구식 컴파일러나 C에서는 에러가 날 수 있습니다. 반대로 쉼표를 빼먹으면 구문 에러가 납니다.

// ❌ enum에서 쉼표 누락
#define X(ID) ID
#include "colors.def"
// → RED GREEN BLUE (쉼표 없음, 구문 에러)
// ✅ 올바른 예
#define X(ID) ID,
#include "colors.def"
#undef X
// → RED, GREEN, BLUE,

C++11 이후 enum의 trailing comma는 허용됩니다. C 호환성이 필요하면 X 정의를 조정해 마지막 요소에만 쉼표를 붙이지 않도록 할 수 있지만, 보통은 trailing comma를 사용하는 것이 단순합니다.

에러 4: .def 파일의 매크로 이름 충돌

원인: colors.def에서 X 대신 COLOR를 쓰며, 사용처에서 #define X(ID)를 하면 매칭되지 않습니다. .def 파일의 매크로 이름과 #define의 이름이 같아야 합니다.

// ❌ colors.def
COLOR(RED)
COLOR(GREEN)
// main.cpp
#define X(ID) ID,
#include "colors.def"  // COLOR가 정의되지 않아서 COLOR(RED)가 그대로 남음

해결: .def 파일의 매크로 이름을 X로 통일하거나, 사용 전에 #define X COLOR로 별칭을 만듭니다.

// ✅ 방법 1: .def에서 X 사용
X(RED)
X(GREEN)
// ✅ 방법 2: COLOR를 X로 매핑
#define X(ID) ID,
#define COLOR X
#include "colors.def"
#undef COLOR
#undef X

에러 5: include 가드 없이 .def를 여러 번 include

원인: .def 파일에 #pragma once를 넣으면, 한 번 include된 후 재정의 없이 다시 include할 때 내용이 비어 있습니다. X-Macro는 같은 .def를 다른 X 정의로 여러 번 include하는 패턴이므로, .def에 include 가드를 넣으면 안 됩니다.

// ❌ colors.def
#pragma once
X(RED)
X(GREEN)
// main.cpp
#define X(ID) ID,
#include "colors.def"  // OK
#undef X
#define X(ID) #ID,
#include "colors.def"  // #pragma once 때문에 비어있음!

해결: .def 파일에는 include 가드를 넣지 않습니다. .def는 “데이터”일 뿐이며, 매번 include될 때마다 전개되어야 합니다.

에러 6: LIST_OF_ITEMS(X) 형태에서 매크로 확장 한도 초과

원인: #define LIST X(a)\nX(b)\n...처럼 한 매크로 안에 수백 개의 X(...)를 넣으면, 일부 컴파로는 매크로 확장 깊이/길이 제한에 걸립니다.

// ❌ 수백 개 항목을 한 매크로에
#define LIST_OF_COLORS(X) \
    X(RED) X(GREEN) X(BLUE) ... (500개)

해결: .def 파일을 사용해 #include로 불러오면, 매크로 확장은 한 줄씩 일어나므로 제한에 덜 걸립니다. 항목이 매우 많으면(수천 개) X-Macro 대신 런타임 데이터 구조를 고려합니다.

에러 7: switch에 default 누락

원인: enum에 값을 추가했는데 to_string의 switch에 case를 추가하지 않으면, 새 값이 default로 빠집니다. X-Macro를 쓰면 .def만 수정하면 되므로 이 문제는 자동으로 해결됩니다. 다만 default를 두지 않으면 컴파일러가 “모든 case를 처리했는지” 경고할 수 있습니다.

// ✅ default로 unknown 처리
const char* to_string(Color c) {
    switch (c) {
#define X(ID) case Color::ID: return #ID;
#include "colors.def"
#undef X
        default: return "unknown";
    }
}

에러 8: 문자열화 # 연산자와 인자

원인: #ID는 ID를 문자열 "ID"로 만듭니다. ID가 매크로로 확장된 결과를 문자열화하려면 #만으로는 부족하며, 이중 매크로가 필요할 수 있습니다. 단순히 X(RED)에서 #ID → "RED"는 문제없습니다.

#define X(ID) #ID
X(RED)  // → "RED"

.def 파일 네이밍, enum class, static_assert 개수 검증

.def 파일 네이밍

colors.def, error_codes.def, states.def처럼 데이터 도메인을 나타내는 이름을 사용합니다. .h가 아니라 .def 또는 .inc로 구분해 “헤더가 아니라 데이터/생성용”임을 드러냅니다.

X 대신 도메인별 이름 (선택)

가독성을 위해 #define COLOR(ID) ID,처럼 도메인별 매크로 이름을 쓰며, .def에서도 COLOR(RED)를 사용할 수 있습니다. 단, 한 파일에서 여러 X-Macro를 쓸 때는 X를 재사용하고 #undef로 정리하는 것이 단순합니다.

인자 추가 시 영향 범위 문서화

매크로 인자를 추가하면(예: X(RED) → X(RED, 0xFF0000)) 모든 사용처에서 X 정의를 수정해야 합니다. colors.def 상단에 인자 스키마를 주석으로 남깁니다.

// colors.def
// X(enum_id, hex_color)
X(RED,   0xFF0000)
X(GREEN, 0x00FF00)
X(BLUE,  0x0000FF)

enum class 사용

C++11 이상에서는 enum class를 사용해 타입 안전성을 높입니다. Color::RED처럼 네임스페이스가 분리되어 int와의 암시적 변환도 막을 수 있습니다.

static_assert로 개수 검증

enum 개수와 배열 크기가 일치하는지 static_assert로 검증합니다.

const char* const names[] = {
#define X(ID) #ID,
#include "colors.def"
#undef X
};
enum class Color {
#define X(ID) ID,
#include "colors.def"
#undef X
};
static_assert(sizeof(names) / sizeof(names[0]) ==
    static_cast<size_t>(Color::BLUE) - static_cast<size_t>(Color::RED) + 1,
    "enum and names array size mismatch");

#undef는 블록 단위로

한 블록에서 여러 번 #include "colors.def"를 쓸 때마다 #define X → #include → #undef X를 쌍으로 유지합니다.

#define X(ID) ID,
#include "colors.def"
#undef X
#define X(ID) #ID,
#include "colors.def"
#undef X

프로토콜 메시지 타입, 설정 키, 테스트·플러그인 등록

패턴 1: idempotent include (LIST_OF_X 매크로)

.def를 직접 include하지 않으며, 매크로로 감싸서 “한 번 정의, 여러 번 사용”하게 할 수 있습니다. 이 방식은 매크로 확장 크기 제한에 유의해야 합니다.

// colors.inc
#define LIST_OF_COLORS(X) \
    X(RED) \
    X(GREEN) \
    X(BLUE)
// main.cpp
#define X(ID) ID,
enum class Color { LIST_OF_COLORS(X) };
#undef X
#define X(ID) #ID,
const char* names[] = { LIST_OF_COLORS(X) };
#undef X

패턴 2: 프로토콜 메시지 타입

// messages.def
// X(타입, 크기, 직렬화함수)
X(PING,   4, serialize_ping)
X(PONG,   4, serialize_pong)
X(LOGIN,  64, serialize_login)
X(LOGOUT, 4, serialize_logout)
enum class MsgType {
#define X(T, SZ, F) T,
#include "messages.def"
#undef X
};
size_t message_size(MsgType t) {
    switch (t) {
#define X(T, SZ, F) case MsgType::T: return SZ;
#include "messages.def"
#undef X
        default: return 0;
    }
}

패턴 3: 설정 키-기본값

// config.def
// X(키, 타입, 기본값문자열)
X(host,    string, "localhost")
X(port,    int,    "8080")
X(timeout, int,    "30")
const char* default_value(ConfigKey key) {
    switch (key) {
#define X(K, T, D) case ConfigKey::K: return D;
#include "config.def"
#undef X
        default: return "";
    }
}

패턴 4: 테스트 케이스 등록

// test_cases.def
X(test_add)
X(test_sub)
X(test_mul)
void run_all_tests() {
#define X(T) T();
#include "test_cases.def"
#undef X
}

패턴 5: 플러그인/모듈 등록

// modules.def
X(ModuleA, init_a, shutdown_a)
X(ModuleB, init_b, shutdown_b)
void init_all() {
#define X(M, I, S) I();
#include "modules.def"
#undef X
}
void shutdown_all() {
#define X(M, I, S) S();
#include "modules.def"
#undef X
}

X-Macro 코드 점검 항목

실무에서 X-Macro를 도입할 때 확인할 항목입니다.

  • 데이터 소스가 명확한가? (enum·문자열·숫자 등)
  • 반복 정의가 2곳 이상인가? (DRY 위반)
  • 인자 스키마를 문서화했는가?
  • #undef X를 모든 include 직후에 호출하는가?
  • .def 파일에 include 가드를 넣지 않았는가?
  • 항목 수가 수천 개 이상이 아닌가? (컴파일 시간)
  • enum class를 사용하는가?
  • static_assert로 배열·enum 크기 일치를 검증하는가?
  • 모든 코드 블록에 언어 태그(cpp, mermaid)가 있는가?

X-Macro를 쓸지, 다른 방법을 쓸지

X-Macro가 가장 값을 하는 경우는 “같은 목록에서 enum, 문자열 표, switch, 등록 코드처럼 서로 맞아야 하는 산출물이 셋 이상 나오는” 때입니다. 목록이 수십 개 수준이고 한 바이너리 안에서만 쓰인다면 외부 도구 없이 동기화를 컴파일러에게 맡길 수 있다는 장점이 매크로의 가독성 비용보다 큽니다.

반대로 필요한 것이 enum↔문자열 변환 하나뿐이라면 magic_enum 같은 헤더 전용 라이브러리가 더 간단합니다. 다만 magic_enum은 컴파일러 내장 기능으로 값을 훑기 때문에 기본 검사 범위를 벗어난 enum 값은 이름을 찾지 못한다는 제약이 있어, 비트 플래그나 큰 값을 쓰는 enum이면 문서의 범위 설정부터 확인해야 합니다. 목록을 다른 언어나 서비스와 공유해야 하거나 항목 수가 수백 개를 넘는다면, YAML·JSON 같은 원본에서 헤더를 생성하는 코드 생성기가 유지보수하기 쉽습니다.


같이 보면 좋은 글


X-Macro로 enum·문자열·에러 코드·상태 머신·커맨드 테이블을 한 소스에서 생성해 DRY를 지키고, 인자 변경 시 주의하며 #undef로 매크로를 정리합니다.


자주 묻는 질문 (FAQ)

Q. X-Macro를 쓸 때 #undef X를 빠뜨리면 어떤 문제가 생기나요?

A. X-Macro는 같은 리스트를 enum 정의, 문자열 배열, switch 문처럼 여러 번 전개하면서 매번 X를 다른 의미로 다시 정의하는 방식입니다. 앞에서 정의한 X를 지우지 않고 다시 #define하면 매크로 재정의 경고나 에러가 나고, 경고를 무시하면 이전 정의로 전개되어 엉뚱한 코드가 생성될 수 있습니다. #define X, 리스트 전개, #undef X를 항상 한 묶음으로 쓰고, 리스트 매크로가 X를 인자로 받는 LIST(X) 형태를 쓰면 이런 실수를 줄일 수 있습니다.