실수를 잡아주는 XMacro 패턴

매크로를 꽤나 복잡하게 쓰는 방식이라 처음 봤을 땐 별로 좋아하지 않았다. 2중으로 전개되는 매크로는 실제로 어떤 코드가 나오는지 따라가기가 힘들다.

그런데 이 방식을 사용해서 코드 구조를 크게 갈아엎고 나니 생각이 조금 바뀌었다. 관련된 작업을 한곳으로 모을 수 있고, 항목을 추가하면서 빠뜨리는 부분도 줄어들어서 현재는 긍정적으로 보고있다.

물론 실제 로직을 읽기 어렵다는 단점은 여전하다.

아래 영상으로 컴파일 타임 매크로 튜플? 대충 이런 느낌으로 알고 있었는데, 이 방식에 XMacro라는 이름이 있다는 걸 알게됐다.

enum을 string으로 바꿀 때 magic_enum 같은 라이브러리를 쓸 수도 있지만, 목록을 직접 관리할 수 있다면 XMacro로도 만들 수 있다. 템플릿이든 매크로든 생성되는 코드가 많아지면 컴파일 비용은 봐야 한다. 여기서는 두 방식의 속도를 비교한 건 아니다.

항목 하나 추가할 때 챙길 것들

어떤 시스템에 타입을 추가한다고 생각해보자. enum에 값을 하나 넣고, 문자열 이름을 등록하고, 처리 함수를 연결하는 식으로 챙겨야 할 곳이 다섯 군데쯤 있다.

작업 자체는 어렵지 않다. 근데 한 군데를 빼먹기 쉽다. 기존 코드는 그대로 컴파일되고, 새 항목을 실제로 사용하는 시점에야 문제가 드러날 수도 있다.

이걸 목록 한 줄에서 같이 관리하면 어떨까?

필요한 정보는 매크로 인자로 받게 하고, 그 목록에서 enum과 테이블, 처리 코드를 각각 생성한다. 누락될 작업을 아예 자동으로 만들거나, 필요한 인자가 없으면 전처리 단계에서 진단이 나도록 구조를 잡는 거다. 여기에 static_assert나 템플릿 검사를 같이 붙일 수도 있다.

개인적으로는 항목을 추가할 때 작업할 범위가 한곳에 모인다는 것이 가장 큰 장점이라고 생각한다. 다만 목록에 넣은 값 자체가 잘못됐거나 생성된 함수의 동작이 틀린 것까지 자동으로 잡아주는 건 아니다. 어떤 실수를 컴파일 단계에서 잡을지는 생성하는 코드와 검사에 달려있다.

같은 목록을 여러 번 읽기

XMacro는 항목 목록을 한 번 적어두고, 그 목록을 사용할 때마다 매크로의 의미를 바꿔서 전개하는 방식이다.

아래는 C11 기준 예제다. C++에서도 패턴은 같고, 정적 검사는 static_assert를 쓰면 된다.

1
2
3
4
/* colors.def — 헤더 가드 없음, 오직 호출 목록만 */
ITEM(Red,   "red",   1)
ITEM(Green, "green", 2)
ITEM(Blue,  "blue",  3)

colors.def 자체에는 enum인지 배열인지에 대한 코드가 없다. ITEM을 어떻게 정의하고 읽느냐에 따라 결과가 달라진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/* color.h */
#pragma once

#include <stddef.h>

/* enum 생성 */
typedef enum Color {
#define ITEM(name, str, val) name = val,
#include "colors.def"
#undef ITEM
} Color;

/* enum 값과 별개로 항목 수 계산 */
enum {
	Color_Count = 0
#define ITEM(name, str, val) + 1
#include "colors.def"
#undef ITEM
};

typedef struct ColorName {
	Color value;
	const char* name;
} ColorName;

/* 이름 테이블 생성 */
static const ColorName kColorName[] = {
#define ITEM(name, str, val) { name, str },
#include "colors.def"
#undef ITEM
};

/* 값으로 찾아서 문자열 반환 */
static inline const char* color_to_string(Color c) {
	for (size_t i = 0; i < sizeof(kColorName) / sizeof(kColorName[0]); ++i) {
		if (kColorName[i].value == c) {
			return kColorName[i].name;
		}
	}
	return "unknown";
}

_Static_assert(sizeof(kColorName) / sizeof(kColorName[0]) == Color_Count,
	"Color table size mismatch");

처음에는 ITEM(Red, "red", 1)이 Red = 1,로 바뀐다. 두 번째에는 + 1이 되고, 세 번째에는 { Red, "red" },가 된다. 같은 줄을 세 번 읽은 건데 만들어지는 코드는 전부 다르다.

Color_Count는 항목 수를 직접 센 값이다. enum 마지막에 이름 하나를 붙이면 이전 값에 1을 더한 값이 되니 항목 수와 같다는 보장이 없다. 위의 색상 값은 1부터 시작하고, 에러 코드처럼 값이 띄엄띄엄 있을 수도 있다.

그래서 이 예제에서는 enum 값을 배열 인덱스로 쓰지 않고 값과 이름을 함께 저장했다. 조회는 선형 검색이라 O(n)이다. XMacro로 코드를 만들었다고 조회 비용까지 없어지는 건 아니다.

색상 한 줄을 추가하면 enum과 테이블, 항목 수가 같이 늘어난다. 위의 정적 검사는 테이블 길이와 항목 수만 비교하는 것이고, 이름 문자열의 내용이나 값의 중복까지 검사하는 건 아니다.

switch와 함수도 만들 수 있다

목록을 읽는 위치가 배열 초기화일 필요는 없다. switch 안에서 읽으면 항목별 case를 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
/* color_print.c — switch 케이스를 목록으로부터 생성 */
#include <stdio.h>
#include "color.h"

void print_color(Color c) {
	switch (c) {
#define ITEM(name, str, val) case name: printf("%s\n", str); break;
#include "colors.def"
#undef ITEM
	default: printf("unknown\n"); break;
	}
}

이 경우 같은 색상 값을 중복으로 넣으면 case가 겹쳐서 컴파일 에러가 난다. 이런 식으로 생성된 코드의 문법을 이용해 잡을 수 있는 실수도 있다.

항목마다 함수가 필요하면 선언과 정의를 같은 목록에서 각각 만들 수도 있다.

1
2
3
4
5
6
7
8
9
10
/* color_visit.c — 각 항목에 공통 동작 적용 */
#include "color.h"

#define ITEM(name, str, val) void visit_##name(void);
#include "colors.def"
#undef ITEM

#define ITEM(name, str, val) void visit_##name(void) { /* 로깅/메트릭 등 */ }
#include "colors.def"
#undef ITEM

visit_##name은 visit_Red, visit_Green처럼 식별자를 붙여 만든다. ##는 토큰을 이어 붙이는 연산자다. 위의 빈 함수 본문은 생성되는 모양만 보여주기 위한 것이고, 실제로는 항목마다 반복할 공통 동작을 넣는 자리다.

필요한 동작까지 목록으로 표현할 수 있는지는 따로 봐야 한다. 항목마다 전혀 다른 로직을 억지로 매크로 안에 넣으면 원래 코드보다 읽기 어려워질 수도 있다.

필요한 정보를 한 줄에서 받기

색상 이름과 값 대신 에러 코드, 이름, 심각도를 묶어보자. 목록의 필드만 바뀌고 방식은 같다.

1
2
3
4
5
/* errors.def */
/* code, name, severity */
ITEM(1001, NetworkError,   2)
ITEM(1002, TimeoutError,   1)
ITEM(2001, PermissionError,3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* error.h */
#pragma once

#include <stddef.h>

typedef enum Severity { Sev_Info=0, Sev_Warn=1, Sev_Error=2, Sev_Fatal=3 } Severity;

typedef enum ErrorCode {
#define ITEM(code, name, sev) EC_##name = (code),
#include "errors.def"
#undef ITEM
} ErrorCode;

typedef struct ErrorMeta { int code; const char* name; Severity sev; } ErrorMeta;

static const ErrorMeta kErrorTable[] = {
#define ITEM(code, name, sev) { (code), #name, (Severity)(sev) },
#include "errors.def"
#undef ITEM
};

/* 이름/심각도 조회 */
static inline const ErrorMeta* get_error_meta(ErrorCode ec) {
	for (size_t i = 0; i < sizeof(kErrorTable)/sizeof(kErrorTable[0]); ++i) {
		if (kErrorTable[i].code == (int)ec) {
			return &kErrorTable[i];
		}
	}
	return NULL;
}

EC_##name은 EC_NetworkError라는 enum 이름을 만들고, #name은 "NetworkError"라는 문자열을 만든다. #의 문자열화를 쓰니 문자열 이름을 따로 다시 적을 필요가 없다.

에러를 추가할 때는 코드와 이름, 심각도를 한 줄에서 받는다. 심각도 인자를 빼먹으면 매크로 호출이 맞지 않아 진단이 나지만, 인자에 엉뚱한 숫자를 넣은 것까지 이 예제가 막아주지는 않는다. 값의 범위까지 보장하고 싶다면 그 조건의 정적 검사를 추가해야 한다.

꼭 .def 파일이어야 하는 것도 아니다. 목록 자체를 매크로로 묶을 수도 있다.

1
2
3
4
5
6
// ERROR_LIST.h

#define ERROR_LIST \
	ITEM(1001, NetworkError,   2) \
	ITEM(1002, TimeoutError,   1) \
	ITEM(2001, PermissionError,3)

이때는 목록 헤더를 읽어둔 뒤, ITEM을 정의한 자리에서 ERROR_LIST를 전개하면 된다.

1
2
3
4
5
6
7
#include "ERROR_LIST.h"

typedef enum ErrorCode {
#define ITEM(code, name, sev) EC_##name = (code),
ERROR_LIST
#undef ITEM
} ErrorCode;

매크로 범위와 전개 결과

.def 파일은 같은 내용을 여러 번 읽어야 하니 헤더 가드를 넣지 않는다. 반대로 ERROR_LIST처럼 목록을 매크로로 정의하는 헤더는 한 번 읽어도 이후에 여러 번 전개할 수 있으니 헤더 가드를 둘 수 있다.

ITEM은 한 번 사용할 때마다 정의하고, 끝나면 #undef로 정리한다. C 코드의 중괄호로 감쌌다고 매크로의 범위가 제한되는 건 아니다. 같은 이름을 다른 헤더에서도 쓰고 있다면 충돌할 수 있으니 큰 코드베이스에서는 용도를 알 수 있는 이름을 붙이는 편이 낫다.

쉼표와 세미콜론도 실제 생성되는 문법에 맞춰 넣어야 한다. enum을 만들 때의 ITEM과 함수 정의를 만들 때의 ITEM은 끝에 붙는 문자가 다르다.

전개되는 모양이 잘 안 보이면 전처리 결과를 직접 보는 게 가장 확실하다. MSVC는 /P 옵션, GCC나 Clang은 -E로 확인할 수 있다. 이 글의 예제도 실제 컴파일러가 보는 건 평범한 enum, 배열, switch, 함수들이다.

에러 코드나 명령어, 설정 항목처럼 같은 목록에서 여러 코드를 반복해서 만들어야 할 때는 꽤 쓸 만하다. 다만 전개 결과를 보지 않고는 로직을 이해하기 어려울 정도로 복잡해지면 템플릿이나 별도 코드 생성 방식이 더 나을 수도 있다. 작업할 곳을 줄이려고 넣었는데 코드 읽는 일이 더 늘어나면 다시 생각해볼 부분이다.

Posted 2026-02-06