C 언어 시리즈 #08 — 전처리기 8단계·매크로·_Pragma·번역 단위 경계

이 글의 핵심

소스가 컴파일러에 도달하기 전에 전처리기가 수행하는 단계를 순서대로 밟고, 매크로가 왜 디버깅을 어렵게 만드는지, include 가드·_Pragma·표준 pragma가 무엇을 보장하는지 설명합니다.

시리즈 안내

#08 | 📋 전체 목차 | 이전: #07 구조체 · 다음: #09 메모리 관리


들어가며

C 전처리기는 C 문법을 이해하지 못하는 텍스트 치환기입니다. 컴파일러가 타입을 검사하기 전에 동작하므로, 매크로에서 생긴 문제는 컴파일러 진단이 엉뚱한 위치를 가리키거나 아예 진단 없이 런타임 동작만 바꿔 놓는 경우가 많습니다. MAX(a++, b)가 a를 두 번 증가시키는 문제가 대표적인데, 소스만 보면 함수 호출처럼 보여서 gcc -E로 전처리 결과를 열어 보기 전까지는 원인이 잘 보이지 않습니다.

그래서 이 글의 입장은 분명합니다. include guard, 플랫폼 분기, __FILE__/__LINE__을 붙이는 로그 래퍼처럼 전처리기만 할 수 있는 일에는 매크로를 쓰고, 계산이나 비즈니스 로직은 static inline 함수와 enum으로 옮기는 편이 낫습니다. 아래에서는 전처리기가 언제, 무엇을 하는지부터 정리한 뒤 각 지시문과 함정을 차례로 다룹니다.


번역 단계: 전처리기는 어디서 일하는가

C 표준(C11 5.1.1.2, C23도 같은 구조)은 소스가 실행 파일이 되는 과정을 8개의 논리적 번역 단계로 설명합니다. 실제 컴파일러는 단계를 합쳐서 처리하지만, 결과는 이 순서대로 처리한 것과 같아야 합니다. 문제가 어느 단계에서 생겼는지 가늠할 때 유용한 모델입니다.

  1. 물리적 소스 파일의 문자를 소스 문자 집합으로 매핑합니다. C17까지는 이 단계에서 트라이그래프(??= 등)도 치환했지만 C23에서 트라이그래프가 제거되었습니다.
  2. 백슬래시로 끝나는 줄을 다음 줄과 이어 붙입니다. 여러 줄 매크로가 가능한 이유입니다.
  3. 소스를 전처리 토큰과 공백으로 나눕니다. 주석은 공백 하나로 바뀝니다.
  4. 전처리 지시문을 실행하고 매크로를 확장하며 _Pragma 연산자를 처리합니다. #include로 가져온 파일은 1~4단계를 재귀적으로 거칩니다. 이 단계가 끝나면 지시문은 모두 사라집니다.
  5. 문자 상수와 문자열 리터럴 안의 문자와 이스케이프 시퀀스를 실행 문자 집합으로 변환합니다.
  6. 인접한 문자열 리터럴을 이어 붙입니다("a" "b" → "ab").
  7. 전처리 토큰을 일반 토큰으로 바꾸고, 문법·의미 분석을 거쳐 번역 단위 하나를 번역합니다.
  8. 외부 객체·함수 참조를 해결합니다. 보통 링커가 맡는 단계입니다.

흔히 “전처리”라고 부르는 것은 1~4단계(넓게는 6단계까지)입니다. 4단계에서 매크로 확장과 조건부 컴파일이 함께 일어나므로, #if 조건식 안의 매크로도 확장된 뒤에 평가됩니다. 그리고 문자열 연결(6단계)은 매크로 확장(4단계)보다 뒤에 일어나므로, 매크로가 만들어 낸 문자열 리터럴도 옆의 리터럴과 이어 붙습니다. 뒤에 나오는 "[ERR] " fmt "\n" 같은 로그 매크로가 이 성질을 이용합니다.

번역 단위의 경계

각 .c 파일은 포함된 헤더를 모두 펼친 뒤 하나의 번역 단위(translation unit)로 컴파일되어 오브젝트 파일이 되고, 링커가 오브젝트 파일들을 합칩니다. 여기서 두 가지 결과가 나옵니다.

첫째, #include는 다른 언어의 import와 다릅니다. 전처리 시점에 파일 내용을 그 자리에 붙여 넣을 뿐이므로, 같은 헤더가 한 번역 단위에 두 번 들어오면 같은 정의가 두 번 나타납니다. include guard가 필요한 이유입니다.

둘째, 매크로 정의는 번역 단위마다 달라질 수 있습니다. a.c는 -DFEATURE_X로, b.c는 그 플래그 없이 컴파일되면 같은 헤더 안의 구조체 정의가 두 번역 단위에서 다른 레이아웃을 가질 수 있습니다. 컴파일러는 번역 단위를 하나씩만 보므로 이를 잡지 못하고, 링크도 성공한 뒤 런타임에 메모리가 어긋납니다. 기능 플래그는 빌드 시스템에서 프로젝트 전체에 같은 값으로 주는 것이 안전합니다.


#include: 헤더 포함과 탐색 경로

""와 <>

표준은 두 형식의 탐색 위치를 구현에 맡기지만, 주요 컴파일러의 동작은 비슷합니다.

  • #include "foo.h"는 먼저 현재 소스 파일이 있는 디렉터리에서 찾고, 없으면 -I 경로와 시스템 경로를 차례로 찾습니다. 프로젝트 내부 헤더에 씁니다.
  • #include <foo.h>는 현재 디렉터리를 건너뛰고 -I(또는 -isystem) 경로와 시스템 경로에서 찾습니다. 표준 라이브러리와 외부 라이브러리 헤더에 씁니다.

프로젝트 헤더 이름이 시스템 헤더와 겹치면(config.h, types.h 등) -I 순서에 따라 엉뚱한 파일이 포함될 수 있습니다. 프로젝트 이름을 디렉터리로 두고 #include "mylib/config.h"처럼 쓰면 이런 충돌을 피할 수 있습니다.

포함 순서가 문제가 되는 경우

헤더가 다른 헤더를 포함하고, 그 헤더가 매크로를 정의하므로 포함 순서가 결과를 바꿀 수 있습니다. Windows에서 유명한 예는 windows.h와 winsock2.h입니다. windows.h는 WIN32_LEAN_AND_MEAN이 정의되지 않으면 구버전 winsock.h를 포함하므로, 그 뒤에 winsock2.h를 포함하면 구조체와 함수 재정의 오류가 납니다. winsock2.h를 먼저 포함하거나 WIN32_LEAN_AND_MEAN을 정의해서 해결합니다. 이런 규칙은 공통 platform.h 한 곳에 모아 두면 팀원마다 순서를 외울 필요가 없습니다.

또 헤더 하나를 바꾸면 그 헤더를 직간접적으로 포함하는 모든 .c가 다시 컴파일됩니다. 헤더에는 필요한 선언만 두고, 구조체 포인터만 쓰는 곳에서는 struct foo; 같은 전방 선언으로 포함을 줄이면 빌드 시간이 줄어듭니다.


#define: 객체형 매크로와 함수형 매크로

객체형 매크로

#define PI 3.14159265358979323846
#define APP_VERSION_MAJOR 1
#define APP_VERSION_STR "1.0.0"

매크로 끝에 세미콜론을 붙이는 실수가 흔합니다. #define PI 3.14;라고 쓰면 double area = PI * r * r;이 double area = 3.14; * r * r;로 펼쳐져, 컴파일러는 * r * r; 위치에서 문법 오류를 보고합니다. 오류 위치와 원인이 떨어져 있어서 처음 보면 당황하기 쉽습니다.

함수형 매크로

#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define SQUARE(x) ((x) * (x))

함수형 매크로는 인자를 값으로 계산해서 넘기지 않고, 인자 토큰을 본문의 매개변수 자리에 그대로 붙여 넣습니다. 그래서 본문에 매개변수가 두 번 나오면 인자 식도 두 번 평가됩니다. 이 차이에서 다음 절의 함정이 나옵니다.

가변 인자 매크로

#define LOG_ERR(fmt, ...) \
    fprintf(stderr, "[ERR] " fmt "\n", __VA_ARGS__)

C99부터 ...와 __VA_ARGS__로 가변 인자 매크로를 만들 수 있습니다. 문제는 LOG_ERR("disk full")처럼 추가 인자 없이 호출할 때입니다. C17까지는 가변 인자를 하나 이상 넘겨야 했고, 넘기지 않으면 fprintf(stderr, "[ERR] " "disk full" "\n", )처럼 끝에 쉼표가 남아 컴파일 오류가 납니다. GCC·Clang은 , ##__VA_ARGS__ 확장으로 빈 경우 쉼표를 지워 주고, C23은 표준 방법으로 __VA_OPT__를 도입했습니다.

/* C23 (또는 이를 지원하는 GCC 8+, Clang 12+) */
#define LOG_ERR(fmt, ...) \
    fprintf(stderr, "[ERR] " fmt "\n" __VA_OPT__(,) __VA_ARGS__)

이름 규칙

매크로는 스코프를 무시하고 그 뒤의 모든 같은 이름을 치환합니다. MYLIB_BUFFER_MAX처럼 프로젝트 접두어를 붙인 대문자 이름을 쓰면 다른 코드의 식별자와 충돌할 가능성이 줄고, 읽는 사람도 매크로라는 것을 바로 알 수 있습니다. 반대로 max처럼 함수 같은 이름의 매크로는 인자가 여러 번 평가된다는 사실을 숨깁니다.


매크로의 함정: 다중 평가, 괄호, 문장 매크로

다중 평가

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

int x = 1, y = 2;
int m = MAX(x++, y++);
/* 펼치면: ((x++) > (y++) ? (x++) : (y++)) */

이 코드는 정의되지 않은 동작은 아닙니다. 조건 연산자는 첫 번째 피연산자 평가 뒤에 시퀀스 포인트가 있고, 두 번째와 세 번째 피연산자 중 하나만 평가합니다. 순서대로 따라가면 1 > 2가 거짓이므로 x는 2, y는 3이 되고, 이어서 (y++)가 평가되어 m은 3, y는 4가 됩니다. 결과가 정의되어 있어도 y가 두 번 증가한다는 사실은 호출 코드만 봐서는 알 수 없습니다. 인자가 함수 호출이라면 그 함수가 두 번 호출되어 부작용이나 비용이 두 배가 됩니다.

괄호 규칙

#define MUL(x, y) x * y
int z = MUL(1 + 2, 3);  /* 1 + 2 * 3 = 7 */

#define MUL2(x, y) ((x) * (y))
int w = MUL2(1 + 2, 3); /* (1 + 2) * 3 = 9 */

매개변수 하나하나를 괄호로 감싸야 인자 안의 연산자가 본문의 연산자와 섞이지 않고, 본문 전체를 괄호로 감싸야 10 / MUL2(a, b) 같은 호출 위치의 연산자와 섞이지 않습니다.

여러 문장을 담는 매크로

#define REPORT(msg) if (err) printf("%s\n", msg)

if (cond)
    REPORT("x");
else
    cleanup();

이 코드는 else가 바깥 if (cond)가 아니라 매크로 안의 if (err)에 붙어서, 작성자의 의도와 다르게 동작합니다. 여러 문장이나 if를 담는 매크로는 do { ... } while (0)으로 감싸면 호출부에서 세미콜론으로 끝나는 문장 하나처럼 동작합니다. 다만 다중 평가 문제는 그대로 남으므로, 값을 계산하는 매크로라면 함수로 바꾸는 것이 근본적인 해결입니다.


조건부 컴파일

#if defined(_WIN32)
/* Windows */
#elif defined(__linux__)
/* Linux */
#else
/* 기타 */
#endif

#ifdef X는 #if defined(X)와 같습니다. 조건을 여러 개 조합해야 한다면 #if defined(A) && !defined(B)처럼 #if를 쓰는 편이 읽기 쉽습니다.

#if가 다룰 수 있는 식

#if의 조건은 정수 상수 식만 허용합니다. 문자열 비교나 부동소수점은 쓸 수 없고, sizeof나 캐스트, 열거형 상수도 전처리기는 모릅니다. 또한 매크로로 정의되지 않은 식별자는 조용히 0으로 평가됩니다. 그래서 #if FEATURE_LEVEL >= 2에서 FEATURE_LEVEL 철자를 틀리면 오류 없이 거짓이 됩니다. GCC·Clang의 -Wundef를 켜면 이런 경우를 경고로 잡을 수 있습니다.

명령줄 매크로도 주의가 필요합니다. -DFOO는 FOO를 1로 정의하지만, -DFOO=는 빈 값으로 정의하므로 #if FOO가 #if만 남아 오류가 납니다. 빌드 스크립트에서 -D 값을 변수로 만들 때 자주 생기는 문제입니다.

표준 버전 확인

#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L
/* C11 이상: _Static_assert, _Generic 사용 가능 */
#endif

__STDC_VERSION__은 C99에서 199901L, C11에서 201112L, C17에서 201710L, C23에서 202311L입니다.

C++과 함께 쓰는 헤더

C로 작성한 라이브러리를 C++에서 호출하려면, C++ 컴파일러가 이름 맹글링을 하지 않도록 선언을 extern "C"로 감싸야 합니다. C 컴파일러는 extern "C"를 모르므로 __cplusplus로 감쌉니다.

#ifdef __cplusplus
extern "C" {
#endif
/* C 선언 */
#ifdef __cplusplus
}
#endif

#undef

#undef NAME은 NAME의 매크로 정의를 제거합니다. 정의되어 있지 않은 이름에 써도 오류가 아닙니다. 가장 흔한 실전 용도는 다른 헤더가 정의한 매크로와의 충돌을 푸는 것입니다. 예를 들어 windows.h는 min과 max를 매크로로 정의하므로 같은 이름의 함수나 변수를 쓰면 컴파일 오류가 납니다. 이때는 #undef보다 windows.h를 포함하기 전에 NOMINMAX를 정의하는 문서화된 방법이 낫습니다. 아래의 X-매크로처럼 임시 매크로를 정의해서 쓰고 바로 지우는 용도로도 씁니다.

#define TEMP 1
/* ... TEMP 사용 ... */
#undef TEMP

#pragma와 _Pragma

#pragma는 컴파일러에 구현 정의된 지시를 전달합니다. 컴파일러는 모르는 pragma를 무시할 수 있으므로, 철자가 틀린 pragma도 아무 경고 없이 무시될 수 있습니다.

표준이 정한 pragma는 STDC로 시작하는 세 가지(FP_CONTRACT, FENV_ACCESS, CX_LIMITED_RANGE)로, 모두 부동소수점 동작에 관한 것입니다. 다만 GCC는 FENV_ACCESS를 지원하지 않는 등 컴파일러마다 지원 범위가 달라서, 실무에서는 -ffp-contract 같은 컴파일러 옵션으로 빌드 전체에 일관되게 거는 경우가 많습니다.

컴파일러 고유 pragma로는 경고 제어, 구조체 패킹(#pragma pack), 섹션 배치 등이 있습니다. #pragma pack은 구조체 레이아웃을 바꾸므로, 헤더에서 켜고 끄는 범위가 어긋나면 번역 단위마다 같은 구조체의 크기가 달라지는 ABI 문제가 생깁니다. 항상 push/pop으로 범위를 닫는 것이 안전합니다.

_Pragma가 필요한 이유

#pragma는 지시문이므로 매크로 본문 안에 쓸 수 없습니다. C99는 문자열 리터럴을 인자로 받는 _Pragma 연산자를 도입해 매크로로 pragma를 만들 수 있게 했습니다.

#define DO_PRAGMA(x) _Pragma(#x)

#define DIAG_PUSH          DO_PRAGMA(GCC diagnostic push)
#define DIAG_IGNORE_UNUSED DO_PRAGMA(GCC diagnostic ignored "-Wunused-variable")
#define DIAG_POP           DO_PRAGMA(GCC diagnostic pop)

#x가 인자를 문자열로 만들어 _Pragma("GCC diagnostic ignored \"-Wunused-variable\"")가 됩니다. GCC와 Clang은 GCC diagnostic pragma를 모두 인식하므로, 이런 래퍼를 공통 헤더 한 곳에 두면 컴파일러별 분기를 그 안에 가둘 수 있습니다.


미리 정의된 매크로

  • __FILE__, __LINE__: 현재 파일 이름 문자열과 줄 번호입니다. #line 지시문으로 바꿀 수 있습니다.
  • __DATE__, __TIME__: 전처리 시점의 날짜와 시각 문자열입니다. 빌드할 때마다 바이너리가 달라지므로 재현 가능한 빌드를 깨뜨립니다. GCC는 SOURCE_DATE_EPOCH 환경 변수가 있으면 그 값을 쓰고, -Wdate-time으로 사용 위치를 경고할 수 있습니다.
  • __STDC__, __STDC_VERSION__: 표준 준수 여부와 버전입니다.
  • __func__: 현재 함수 이름입니다. C99 표준이지만 매크로가 아니라 함수마다 암묵적으로 선언되는 static const char[] 변수이므로, #ifdef __func__로 검사할 수 없고 문자열 리터럴 연결에도 쓸 수 없습니다.
  • __GNUC__, __clang__, _MSC_VER: 컴파일러가 정의하는 매크로입니다. Clang도 __GNUC__를 정의하므로, GCC만 골라내려면 defined(__GNUC__) && !defined(__clang__)처럼 검사해야 합니다.

호출 위치를 기록하는 어설션

#include <stdio.h>
#include <stdlib.h>

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

이 어설션이 함수가 아니라 매크로여야 하는 이유는 두 가지입니다. #cond로 조건식 자체를 문자열로 남길 수 있고, __FILE__과 __LINE__이 매크로를 호출한 위치에서 확장되기 때문입니다. 같은 코드를 함수로 만들면 __LINE__은 항상 함수 정의 안의 줄 번호를 가리킵니다. 로그 래퍼를 여러 겹으로 감쌀 때도 같은 원리라서, 위치 정보는 가장 바깥 매크로에서 받아 안쪽 함수로 인자로 넘겨야 실제 호출 위치가 찍힙니다.


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

#define STR(x) #x
const char* s1 = STR(hello);  /* "hello" */

#define PASTE(a, b) a##b
int PASTE(var, 123) = 0;      /* int var123 = 0; */

#은 매크로 인자를 문자열 리터럴로 만들고, ##은 양쪽 토큰을 이어 하나의 토큰으로 만듭니다. ##의 결과가 유효한 전처리 토큰이 아니면(예: +와 /를 붙여 +/) 정의되지 않은 동작입니다.

가장 많이 헷갈리는 규칙은, #이나 ##의 피연산자가 된 인자는 먼저 확장되지 않는다는 점입니다.

#define VERSION 3
#define STR(x) #x
#define XSTR(x) STR(x)

STR(VERSION)   /* "VERSION" — 인자가 확장되기 전에 문자열화됨 */
XSTR(VERSION)  /* "3" — XSTR에서 VERSION이 먼저 3으로 확장된 뒤 STR로 전달됨 */

매크로 값을 문자열로 만들려면 이처럼 한 단계 간접 매크로를 거쳐야 합니다. ##도 같은 규칙이라, 다른 매크로의 값과 이어 붙이려면 CAT(a, b) → CAT_(a, b) a##b 식의 두 단계 정의가 필요합니다.


X-매크로: 목록 하나로 여러 코드 생성

X-매크로는 항목 목록을 한 곳에 두고, X의 정의를 바꿔 가며 열거형·문자열 테이블·switch 문 등을 생성하는 기법입니다.

/* colors.def */
X(RED,   "red")
X(GREEN, "green")
X(BLUE,  "blue")

/* colors.h */
#define X(a, s) a,
enum Color {
  #include "colors.def"
  COLOR_COUNT
};
#undef X

#define X(a, s) s,
static const char* const COLOR_NAME[] = {
  #include "colors.def"
};
#undef X

항목을 추가할 때 colors.def 한 곳만 고치면 열거형과 이름 테이블이 함께 바뀌므로, 둘의 순서가 어긋나 엉뚱한 이름이 출력되는 버그가 원천적으로 사라집니다. 오류 코드, 프로토콜 명령어, 상태 머신 상태처럼 같은 목록을 여러 형태로 써야 할 때 유용합니다. 대신 IDE의 정의 이동이나 이름 바꾸기가 제대로 동작하지 않고, 목록이 커지고 생성 형태가 복잡해지면 빌드 단계에서 스크립트로 코드를 생성하는 편이 읽기 쉬워집니다.


include guard와 #pragma once

#ifndef MYLIB_FOO_H
#define MYLIB_FOO_H
/* ... */
#endif /* MYLIB_FOO_H */

전통적인 include guard는 표준만으로 동작합니다. 가드 이름이 다른 헤더와 겹치면 두 번째 헤더가 통째로 무시되므로, 프로젝트 접두어와 경로를 반영한 이름을 씁니다. 헤더를 복사해 새 파일을 만들면서 가드 이름을 바꾸지 않는 실수가 흔한데, 이 경우 증상은 “선언을 찾을 수 없다”는 오류로 나타나서 원인을 찾기 어렵습니다.

#pragma once

#pragma once는 더 짧고 가드 이름 충돌이 없습니다. 반면 표준이 아니고, 같은 파일이 하드 링크·심볼릭 링크·네트워크 경로 등으로 서로 다른 경로를 통해 포함되면 컴파일러가 같은 파일로 인식하지 못할 수 있습니다. 둘 중 하나를 팀 규칙으로 정해 일관되게 쓰는 것이 중요합니다. GCC와 Clang은 include guard 패턴도 인식해 같은 파일을 다시 열지 않으므로, 빌드 속도 차이는 크지 않습니다.

헤더에 함수 정의를 둘 때

C에서 함수 정의를 헤더에 두는 방법은 크게 두 가지이며, 동작이 다릅니다.

  • static inline: 헤더를 포함한 번역 단위마다 내부 링키지 사본이 생깁니다. 링크 오류는 없지만, 인라인되지 않으면 번역 단위마다 함수 사본이 생겨 코드 크기가 늘어나고 함수 주소도 번역 단위마다 다릅니다.
  • inline(C99 의미): 헤더의 inline 정의는 외부 정의를 만들지 않습니다. 컴파일러가 인라인하지 않기로 하면(예: -O0) 외부 심볼을 찾게 되는데, 어느 .c 파일에도 extern inline 선언이 없으면 undefined reference 링크 오류가 납니다. 정확히 한 .c 파일에 extern inline int imax(int, int);를 두어 외부 정의를 만들어야 합니다.

C 표준은 외부 링키지를 가진 식별자가 프로그램 전체에서 정의를 최대 하나만 가져야 한다고 규정합니다(C11 6.9p5). 헤더에 static이 아닌 전역 변수 정의(int counter = 0;)를 두면 이 규칙을 어겨 multiple definition 링크 오류가 납니다. 헤더에는 extern int counter; 선언만 두고 정의는 .c 하나에 둡니다.


gcc -E로 전처리 결과 확인하기

매크로가 의심될 때 가장 확실한 방법은 전처리 결과를 직접 보는 것입니다.

gcc -E -std=c11 -I./include -DFEATURE_X src/main.c -o main.i
  • 실제 빌드와 같은 -I, -D, -std 옵션을 줘야 같은 결과가 나옵니다. 빌드 시스템이 만든 compile_commands.json에서 해당 파일의 명령을 복사해 -c를 -E로 바꾸는 것이 가장 정확합니다.
  • 출력에 섞인 # 12 "include/foo.h" 같은 줄 마커는 이어지는 코드가 어느 파일 몇 번째 줄에서 왔는지 알려 줍니다. 마커가 방해되면 -P로 끌 수 있습니다.
  • gcc -dM -E src/main.c는 확장 결과 대신 최종적으로 정의된 모든 매크로를 출력합니다. 어떤 매크로가 어떤 값으로 정의되어 있는지 확인할 때 유용합니다.
  • -H는 포함되는 헤더를 깊이별로 표준 오류에 출력해 include 체인을 보여 줍니다.
  • -save-temps는 정상 빌드를 하면서 .i(전처리 결과)와 .s(어셈블리) 파일을 남깁니다.
  • MSVC에서는 /E가 표준 출력으로, /P가 .i 파일로 전처리 결과를 냅니다.

문제가 큰 프로젝트에서만 재현된다면, 같은 #include 줄과 문제의 코드 몇 줄만 담은 작은 .c 파일을 만들어 같은 옵션으로 전처리해 보는 것이 빠릅니다.


매크로 대신 쓸 수 있는 것

상수는 enum이나 static const 변수로 대체할 수 있습니다. enum 상수는 C에서 정수 상수 식이므로 배열 크기와 case 라벨에도 쓸 수 있고, 디버거에서 이름으로 보입니다. static const 변수는 타입을 지정할 수 있지만 C에서는 상수 식이 아니어서 case 라벨에 쓸 수 없습니다(C23의 constexpr은 이 제약을 풉니다).

계산하는 매크로는 static inline 함수로 바꿉니다.

static inline int imax(int a, int b) { return a > b ? a : b; }

인자가 한 번만 평가되고, 타입 검사를 받으며, 최적화를 켜면 매크로와 같은 코드가 나옵니다. 여러 타입을 지원해야 한다면 C11의 _Generic으로 타입별 함수를 고를 수 있습니다.

static inline long   lmax(long a, long b)     { return a > b ? a : b; }
static inline double dmax(double a, double b) { return a > b ? a : b; }

#define MAX(a, b) _Generic((a) + (b), \
    int:    imax,                      \
    long:   lmax,                      \
    double: dmax)((a), (b))

_Generic은 선택 식을 평가하지 않으므로 (a) + (b)는 타입 결정에만 쓰이고, 실제 인자는 선택된 함수에 한 번씩만 전달됩니다. (a) + (b)의 타입은 일반 산술 변환을 거친 결과라서, int와 double을 섞으면 dmax가 선택됩니다. 목록에 없는 타입(예: unsigned)이 오면 컴파일 오류가 나므로 필요한 타입을 추가하거나 default:를 둡니다.

매크로가 계속 필요한 곳은 include guard, 플랫폼·기능 분기, #과 __FILE__/__LINE__이 필요한 어설션·로그, X-매크로 같은 반복 생성입니다.


빌드 설정 헤더 예시

/* config.h */
#ifndef CONFIG_H
#define CONFIG_H

#ifndef ENABLE_LOG
#define ENABLE_LOG 0
#endif

#if defined(_WIN32)
#  define PLATFORM_WIN 1
#else
#  define PLATFORM_WIN 0
#endif

#if ENABLE_LOG
#  include <stdio.h>
#  define LOG_INFO(...) \
    do { printf("[I] "); printf(__VA_ARGS__); putchar('\n'); } while (0)
#else
#  define LOG_INFO(...) ((void)0)
#endif

#endif /* CONFIG_H */

_WIN32는 32비트와 64비트 Windows 모두에서 정의되므로 _WIN64를 따로 검사할 필요가 없습니다. LOG_INFO는 형식 문자열까지 ...로 받아서 추가 인자 없이 호출해도 C23 이전 표준에서 문제가 없게 했습니다. 로그를 끈 빌드에서는 ((void)0)으로 바뀌므로 인자 식도 평가되지 않습니다. 인자에 부작용이 있는 식을 넣으면 빌드 설정에 따라 동작이 달라지므로 피해야 합니다.

플랫폼 분기는 매크로보다 함수로 감싸는 편이 타입 검사를 받을 수 있습니다.

#if PLATFORM_WIN
#  include <windows.h>
static inline void sleep_ms(unsigned ms) { Sleep(ms); }
#else
#  include <time.h>
static inline void sleep_ms(unsigned ms) {
    struct timespec ts = { ms / 1000, (long)(ms % 1000) * 1000000L };
    nanosleep(&ts, NULL); /* 시그널로 중단되면 남은 시간이 버려짐: 필요하면 루프로 재시도 */
}
#endif

usleep은 POSIX.1-2008에서 제거되었으므로 새 코드에서는 nanosleep을 씁니다. glibc에서 nanosleep 선언을 보려면 -std=c11처럼 엄격한 표준 모드일 때 _POSIX_C_SOURCE를 199309L 이상으로 정의해야 합니다.


전처리에서 비롯된 오류 추적

증상별로 의심할 곳은 다음과 같습니다.

증상먼저 의심할 곳
redefinition, conflicting typesinclude guard 누락이나 가드 이름 충돌, 같은 타입을 서로 다른 헤더가 정의
엉뚱한 위치의 expected ... 문법 오류매크로 끝의 세미콜론, 괄호가 빠진 매크로 본문
"X" redefined 경고-D로 준 값과 헤더의 #define이 충돌
값이 예상과 다름함수형 매크로의 다중 평가, 괄호 누락
#if가 기대와 다르게 동작매크로 철자 오류(정의되지 않은 이름은 0), -DFOO=처럼 빈 값
multiple definition 링크 오류헤더에 둔 비 static 변수·함수 정의
-O0에서만 undefined reference헤더의 C99 inline 함수에 대응하는 extern inline 정의 누락
어설션·로그의 파일/줄이 엉뚱함위치 정보를 안쪽 함수에서 __LINE__으로 읽음

어느 경우든 출발점은 같습니다. 실제 빌드와 같은 옵션으로 gcc -E 결과를 뽑고, 문제의 줄이 전처리 후에 어떻게 보이는지 확인합니다. 원인을 찾은 뒤에는 그 매크로를 함수나 enum으로 바꿀 수 있는지부터 검토하면 같은 문제가 다시 생기지 않습니다.

다음 글: #09 동적 메모리·단편화·도구


자주 묻는 질문 (FAQ)

Q. 여러 문장으로 된 매크로를 do { … } while (0)으로 감싸는 이유는 무엇인가요?

A. if (err) printf(…)처럼 문장을 그대로 펼치는 매크로는 호출하는 쪽의 if/else 안에 들어갔을 때 else가 매크로 안의 if에 붙는 dangling-else 문제나 세미콜론으로 인한 구문 오류를 일으킵니다. do { … } while (0)으로 감싸면 매크로 전체가 세미콜론으로 끝나는 하나의 문장처럼 동작해 이런 함정을 막을 수 있습니다. 다만 인자를 여러 번 평가하는 문제는 여전히 남으므로, 가능하면 static inline 함수로 바꾸는 것이 더 근본적인 해결입니다.

Q. __DATE__/__TIME__는 릴리스에 넣어도 되나요?

A. 넣을 수는 있지만 같은 소스에서 빌드해도 바이너리가 매번 달라져 재현 가능한 빌드와 바이너리 비교가 어려워집니다. 빌드 식별이 목적이라면 빌드 시스템이 커밋 해시나 SOURCE_DATE_EPOCH 기반 값을 -D로 넣어 주는 편이 추적에 유리합니다.


같이 보면 좋은 글