Devin.KR

전처리기 깊이 보기 - 매크로 함정과 X-매크로

개발자KR 조회 12

이 장에서 배우는 것

전처리기는 소스 코드가 컴파일러에 전달되기 전에 텍스트 단위로 코드를 변형하는 강력한 도구다. 이 장에서는 매크로를 작성할 때 흔히 겪는 함정과 이를 피하는 패턴을 알아본다.

  • 전처리기의 텍스트 치환 과정과 한계를 이해한다.
  • 괄호 누락과 다중 평가가 만드는 부작용을 방지한다.
  • do-while(0) 패턴을 사용하여 다중 문장 매크로를 안전하게 구성한다.
  • X-매크로 기법으로 데이터와 코드를 한 곳에서 관리하는 방법을 익힌다.
  • 조건부 컴파일을 통해 빌드 설정을 유연하게 제어한다.

문제 상황

도서관 관리 프로그램을 운영하면서 새로운 기능이 계속 추가되고 있다. 책의 상태를 나타내기 위해 초기에는 대출 가능과 대출 중 두 가지 상태만 있었지만, 곧 연체, 파손, 수리 중, 분실 같은 다양한 상태가 필요해졌다. 상태가 늘어날 때마다 열거형(enum)에 새로운 상수를 추가하고, 화면에 상태를 출력하는 함수 안의 문자열 배열에도 같은 순서로 한글 설명을 추가해야 했다.

어느 날 누군가 열거형에는 '수리 중' 상태를 추가했지만 문자열 배열에는 추가하지 않는 실수를 했다. 컴파일러는 두 목록이 일치하는지 알지 못했으므로 경고 없이 빌드되었다. 결국 프로그램은 수리 중인 도서를 출력할 때 배열 범위를 넘어서거나 엉뚱한 상태 문자열을 화면에 표시하는 버그를 일으켰다.

또한, 도서의 상태가 바뀔 때마다 파일에 로그를 남기고 변수를 증가시키기 위해 매크로 함수를 만들었다. 이 매크로를 if 문과 함께 사용했더니, 특정 조건에서만 실행되어야 할 로그 기록이 항상 실행되거나 컴파일 오류가 발생하는 이상한 현상이 나타났다. 전처리기가 코드를 어떻게 바꾸는지 정확히 알지 못하면 이러한 버그의 원인을 찾기 매우 어렵다. 이번 장에서는 전처리기를 안전하게 제어하는 여러 기법을 살펴본다.

매크로 치환의 함정

C 프로그램의 빌드 과정은 전처리(Preprocessing), 컴파일(Compilation), 링크(Linking)로 나뉜다. 이 중 전처리기는 소스 코드에서 # 기호로 시작하는 지시자를 찾아 텍스트 수준에서 코드를 복사하고 붙여넣는다. 컴파일러가 문법과 타입을 검사하기 전에 일어나는 작업이므로, C 언어의 일반적인 문법 규칙을 따르지 않는다.

괄호 누락과 연산자 우선순위

#define으로 정의한 매크로는 마치 함수처럼 생겼지만 텍스트 치환에 불과하다. 따라서 매크로의 인자는 쉼표로 구분된 문자열 그대로 본문에 들어간다.

#define MULTIPLY(a, b) a * b

int result = MULTIPLY(2 + 3, 4);

위 코드에서 전처리기는 a 자리에 2 + 3을, b 자리에 4를 치환한다. 결과적으로 2 + 3 * 4가 되어 20이 아닌 14가 계산된다. 곱셈 연산자가 덧셈 연산자보다 우선순위가 높기 때문이다. 이러한 문제를 막으려면 매크로 인자와 전체 식을 모두 괄호로 묶어야 한다.

#define MULTIPLY(a, b) ((a) * (b))

이제 ((2 + 3) * (4))로 치환되므로 덧셈이 먼저 계산되어 의도한 결과를 얻는다. 매크로 인자로 무엇이 들어올지 알 수 없으므로, 모든 인자를 괄호로 감싸는 것은 매크로 작성의 기본 규칙이다.

두 번 평가되는 인자

괄호를 철저하게 쳤더라도 매크로 인자를 여러 번 사용하는 경우에는 또 다른 문제가 생긴다. 조건에 따라 더 큰 값을 반환하는 매크로를 생각해 보자.

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

int x = 5;
int y = 10;
int highest = MAX(x++, y++);

이 코드는 전처리기를 거치면 다음과 같이 전개된다.

int highest = ((x++) > (y++) ? (x++) : (y++));

비교 연산을 수행할 때 y++이 한 번 실행된다. 비교 결과가 거짓이므로 삼항 연산자의 뒤쪽 식인 (y++)이 반환되면서 또 한 번 실행된다. 결국 y는 10에서 12로 두 번 증가하게 된다. 매크로는 함수가 아니므로 인자로 전달된 식이 치환된 횟수만큼 그대로 실행된다. 상태를 변경하는 증감 연산자나 함수 호출을 매크로 인자로 전달해서는 안 된다.

매크로 인자에 증감 연산자를 넣었을 때 전개식에서 여러 번 실행되는 부작용

안전한 다중 문장 매크로

개발을 하다 보면 상태 변경과 로그 출력을 하나의 매크로로 묶고 싶을 때가 있다. 도서관 예제에서 책 상태를 변경하며 알림을 출력하는 상황이다.

#define LOG_AND_UPDATE(id, state) \
    printf("도서 ID %d 상태 변경\n", id); \
    update_book(id, state)

이 매크로를 조건문과 함께 쓰면 논리 오류가 발생한다.

if (is_admin)
    LOG_AND_UPDATE(book_id, 1);

전개된 코드를 보면 첫 번째 printf 문장만 if 문에 묶이고, 두 번째 update_book 함수는 if 문과 상관없이 항상 실행된다. 들여쓰기는 사람을 위한 표시일 뿐 컴파일러에게는 아무 의미가 없기 때문이다. 이를 방지하기 위해 중괄호로 묶으면 if-else 구조에서 구문 오류를 일으킨다.

#define LOG_AND_UPDATE(id, state) { \
    printf("도서 ID %d 상태 변경\n", id); \
    update_book(id, state); \
}

if (is_admin)
    LOG_AND_UPDATE(book_id, 1);
else
    LOG_AND_UPDATE(book_id, 0);

if 문 뒤에 사용한 매크로 전개 끝에 세미콜론(;)이 붙으면서 }; 형태가 된다. 이 세미콜론이 if 문의 끝을 알리는 단일 문장으로 처리되어, 바로 이어지는 else 문은 짝을 잃어버리고 컴파일 오류를 낸다.

이 딜레마를 해결하는 표준적인 방법은 do { ... } while (0) 패턴이다.

#define LOG_AND_UPDATE(id, state) \
    do { \
        printf("도서 ID %d 상태 변경\n", id); \
        update_book(id, state); \
    } while (0)

do-while 문은 최소 한 번은 실행을 보장하는 반복문이다. 조건이 0이므로 블록 내부의 코드를 단 한 번만 실행하고 빠져나온다. 이 구문은 하나의 문장으로 취급되며, 끝에 세미콜론을 자연스럽게 요구하므로 if-else 사이에서도 문맥을 해치지 않고 안전하게 동작한다.

문자열화와 토큰 결합

전처리기는 매크로 매개변수를 조작할 수 있는 특수한 연산자 두 개를 제공한다.

첫 번째는 문자열화(Stringification) 연산자인 # 이다. 이 기호는 매개변수로 전달된 코드 자체를 큰따옴표로 감싸서 문자열 리터럴로 바꾼다. 변수 이름이나 식을 디버그 출력할 때 매우 유용하다.

#define PRINT_VALUE(var) printf(#var " = %d\n", (var))

int late_fee = 500;
PRINT_VALUE(late_fee);

위 코드는 printf("late_fee" " = %d\n", (late_fee)); 로 치환된다. C 언어 컴파일러는 인접한 두 문자열 리터럴을 자동으로 하나로 합치므로 최종적으로 "late_fee = %d\n"이 된다.

두 번째는 토큰 결합(Token Pasting) 연산자인 ## 이다. 양쪽의 텍스트 토큰을 물리적으로 이어 붙여 새로운 식별자를 만들어낸다.

#define MAKE_BOOK_VAR(idx) int book_id_ ## idx = 0

MAKE_BOOK_VAR(1);
MAKE_BOOK_VAR(2);

이 코드는 int book_id_1 = 0; 과 int book_id_2 = 0; 으로 전개된다. 비슷한 구조의 변수나 함수를 규칙에 따라 대량으로 선언해야 할 때 유용하게 쓰인다.

X-매크로: 데이터와 코드의 동기화

도서 상태처럼 열거형(enum)과 이를 화면에 보여주기 위한 문자열 배열을 동시에 관리해야 하는 상황은 잦다. 상태가 추가되거나 변경될 때마다 두 곳을 모두 수정해야 하며, 순서가 어긋나면 다른 문자열이 출력되는 버그가 생긴다.

X-매크로(X-Macros)는 이런 동기화 문제를 근본적으로 해결하는 설계 기법이다. 상태 목록을 단 한 번만 정의하고, 매크로의 치환 방식을 바꿔가며 열거형과 배열을 각각 생성한다.

먼저 데이터 테이블을 매크로로 정의한다. 인자 X는 나중에 구체적인 동작을 지시할 또 다른 매크로의 이름이다.

#define BOOK_STATUS_TABLE(X) \
    X(STATUS_AVAILABLE, "대출 가능") \
    X(STATUS_BORROWED, "대출 중") \
    X(STATUS_OVERDUE, "연체") \
    X(STATUS_LOST, "분실")

이제 이 목록을 열거형으로 만든다. 두 개의 인자 중 식별자만 남기고 문자열은 무시하는 매크로 AS_ENUM을 정의하고 테이블을 호출한다.

#define AS_ENUM(status, desc) status,
typedef enum {
    BOOK_STATUS_TABLE(AS_ENUM)
} BookStatus;
#undef AS_ENUM

이 코드는 다음과 같이 치환된다.

typedef enum {
    STATUS_AVAILABLE,
    STATUS_BORROWED,
    STATUS_OVERDUE,
    STATUS_LOST,
} BookStatus;

마찬가지로 문자열 배열도 생성한다. 이번에는 식별자를 버리고 문자열만 취한다.

#define AS_STRING(status, desc) desc,
const char* const book_status_strings[] = {
    BOOK_STATUS_TABLE(AS_STRING)
};
#undef AS_STRING

이 기법을 사용하면 상태를 새로 추가할 때 BOOK_STATUS_TABLE만 수정하면 열거형 상수와 문자열 배열이 항상 완벽한 쌍을 이루며 생성된다. 코드 중복이 사라지고 실수가 발생할 여지가 없어진다.

BOOK_STATUS_TABLE이 열거형과 문자열 배열로 각각 다르게 전개되는 과정

조건부 컴파일과 기능 플래그

전처리기의 또 다른 중요한 역할은 조건부 컴파일이다. #if, #ifdef, #ifndef 등의 지시자를 사용하여 특정 코드를 컴파일 결과에 포함할지 뺄지 결정한다. 이는 플랫폼마다 다른 코드를 작성하거나 기능 플래그(Feature Flag)를 도입할 때 쓰인다.

#ifdef ENABLE_DEBUG_LOG
    printf("[디버그] 책 상태가 변경되었습니다.\n");
#endif

이 코드는 ENABLE_DEBUG_LOG 매크로가 정의되어 있을 때만 컴파일러에게 전달된다. 실무에서는 소스 코드 안에 #define을 적기보다 빌드 스크립트나 터미널에서 컴파일러 옵션을 사용해 매크로를 주입한다.

예를 들어 cc -DENABLE_DEBUG_LOG app.c 명령을 실행하면 소스 코드를 건드리지 않고도 디버그 출력을 켤 수 있다. 앞 장에서 자원을 정리할 때 디버깅 코드를 넣고 빼는 과정에도 이 기법이 유용하다. 여러 파일에 걸쳐 중복 포함을 막아주는 #ifndef 인클루드 가드 패턴은 모듈과 헤더 경계를 다룰 때 구체적으로 알아볼 것이다.

완성 코드

library_macro.c

#include <stdio.h>

/* 1. X-매크로 데이터 테이블 정의 */
#define BOOK_STATUS_TABLE(X) \
    X(STATUS_AVAILABLE, "대출 가능") \
    X(STATUS_BORROWED, "대출 중") \
    X(STATUS_OVERDUE, "연체") \
    X(STATUS_LOST, "분실")

/* 2. 열거형 생성 */
#define AS_ENUM(status, desc) status,
typedef enum {
    BOOK_STATUS_TABLE(AS_ENUM)
} BookStatus;
#undef AS_ENUM

/* 3. 문자열 배열 생성 */
#define AS_STRING(status, desc) desc,
const char* const book_status_strings[] = {
    BOOK_STATUS_TABLE(AS_STRING)
};
#undef AS_STRING

/* 배열 크기를 구하는 매크로 */
#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))

/* 4. 안전한 다중 문장 매크로 (do-while 패턴) */
#define UPDATE_STATUS(id_var, new_status) \
    do { \
        printf("[상태 변경] ID: %d, 변경된 상태: %s\n", \
               (id_var), book_status_strings[(new_status)]); \
    } while (0)

void print_all_statuses(void) {
    printf("--- 전체 도서 상태 목록 ---\n");
    for (size_t i = 0; i < ARRAY_SIZE(book_status_strings); i++) {
        printf("%zu: %s\n", i, book_status_strings[i]);
    }
    printf("---------------------------\n");
}

int main(void) {
    print_all_statuses();

    int my_book_id = 402;
    BookStatus current = STATUS_AVAILABLE;

    /* 조건문 안에서도 안전한 매크로 호출 */
    if (current == STATUS_AVAILABLE)
        UPDATE_STATUS(my_book_id, STATUS_BORROWED);
    else
        UPDATE_STATUS(my_book_id, STATUS_OVERDUE);

    current = STATUS_BORROWED;

    /* 조건부 컴파일 플래그 확인 */
#ifdef ENABLE_DEBUG_LOG
    printf("[디버그] 현재 도서 ID %d의 내부 상태 번호는 %d 입니다.\n", 
           my_book_id, current);
#endif

    return 0;
}

줄별 해설

  • #define BOOK_STATUS_TABLE(X): 식별자와 설명 문자열을 짝지어 관리하는 기본 테이블이다. X는 아직 정해지지 않은 또 다른 매크로의 이름이며, 나중에 이 테이블을 호출할 때 X 자리에 구체적인 처리 방식을 전달한다.
  • #define AS_ENUM(status, desc) status,: 열거형을 만들기 위한 전개 규칙이다. 두 번째 인자인 desc 문자열은 버리고 첫 번째 식별자 뒤에 쉼표를 붙인다.
  • #define AS_STRING(status, desc) desc,: 문자열 배열을 만들기 위한 전개 규칙이다. 첫 번째 식별자를 버리고 두 번째 문자열 뒤에 쉼표를 붙인다.
  • #undef: 매크로 이름 공간을 어지럽히지 않도록 사용이 끝난 AS_ENUM과 AS_STRING 규칙을 해제한다.
  • do { ... } while (0): UPDATE_STATUS 안의 두 줄 이상 코드를 논리적인 단일 문장으로 묶는다. main 함수의 if-else 구조 속에서 중괄호 없이 쓰였지만 문법 오류 없이 깔끔하게 동작한다.
  • #ifdef ENABLE_DEBUG_LOG: 컴파일 명령줄에서 -DENABLE_DEBUG_LOG 옵션을 주었을 때만 안쪽의 printf 코드가 빌드에 포함된다.

실행 결과

기본 옵션으로 컴파일하고 실행하면 디버그 로그 없이 결과가 출력된다.

cc -std=c17 -Wall -Wextra library_macro.c -o library_macro
./library_macro
--- 전체 도서 상태 목록 ---
0: 대출 가능
1: 대출 중
2: 연체
3: 분실
---------------------------
[상태 변경] ID: 402, 변경된 상태: 대출 중

컴파일러에 -D 옵션을 주어 매크로를 정의한 상태로 빌드하면 디버그 코드가 포함된다.

cc -std=c17 -Wall -Wextra -DENABLE_DEBUG_LOG library_macro.c -o library_macro_dbg
./library_macro_dbg
--- 전체 도서 상태 목록 ---
0: 대출 가능
1: 대출 중
2: 연체
3: 분실
---------------------------
[상태 변경] ID: 402, 변경된 상태: 대출 중
[디버그] 현재 도서 ID 402의 내부 상태 번호는 1 입니다.

실무에서 자주 틀리는 것

매크로 인자 괄호 누락

수식을 전달받는 매크로에서 괄호를 생략하면 연산자 우선순위가 꼬인다.

/* 틀린 코드 */
#define SQUARE(x) x * x
int res = SQUARE(3 + 2); /* 3 + 2 * 3 + 2 로 치환되어 11이 됨 */

/* 고친 코드 */
#define SQUARE(x) ((x) * (x))
int res = SQUARE(3 + 2); /* ((3 + 2) * (3 + 2)) 로 치환되어 25가 됨 */

다중 평가 부작용

조건식이나 수식 안에서 매크로 인자를 두 번 이상 사용할 때 증감 연산자를 전달하면 두 번 실행된다.

/* 틀린 코드 */
#define MIN(a, b) ((a) < (b) ? (a) : (b))
int i = 5, j = 10;
int lowest = MIN(i++, j); /* i가 5에서 6으로, 그리고 반환 시 또 증가하여 7이 됨 */

/* 고친 코드 */
int lowest = MIN(i, j);
i++; /* 매크로 호출과 변수 상태 변경을 분리하여 사용한다 */

세미콜론 구문 오류

다중 문장 매크로를 중괄호로만 묶으면 if-else 문과 만났을 때 컴파일 에러를 겪는다.

/* 틀린 코드 */
#define RESET_BOOK(id) { id = 0; printf("초기화됨\n"); }
if (error)
    RESET_BOOK(my_book);
else /* 컴파일 오류 발생: 짝이 없는 else */
    recover_book();

/* 고친 코드 */
#define RESET_BOOK(id) do { id = 0; printf("초기화됨\n"); } while (0)
if (error)
    RESET_BOOK(my_book);
else
    recover_book();

한눈에 보기

전처리기 기호와 관용구 요약
문법 / 기호 기능 주의사항 및 패턴
#define 매크로 상수 또는 함수형 매크로 정의 모든 매개변수와 전체 식을 괄호로 감쌀 것
# 인자를 문자열 리터럴로 변환 디버깅 출력 시 식 자체의 이름을 출력할 때 사용
## 두 개의 토큰을 결합하여 새로운 이름 생성 변수나 함수 이름을 동적으로 생성할 때 사용
do { } while(0) 여러 문장을 하나의 안전한 블록으로 묶음 if-else 문맥에서 끝에 세미콜론이 필요한 상황을 만족시킴

조건부 컴파일 지시자 요약
지시자 설명 활용 사례
#ifdef / #ifndef 매크로가 정의되어 있는지(또는 없는지) 검사 헤더 인클루드 가드, 디버그 모드 전환
#if / #elif / #else 상수 표현식을 평가하여 참일 경우 포함 운영체제별 분기, 특정 기능 활성화 여부 결정
#error 조건을 만족하지 않으면 컴파일을 강제 중단시킴 지원하지 않는 컴파일러나 환경에서 빌드 방지

연습 문제

  1. 조건부 컴파일을 이용하여 USE_NEW_UI 매크로가 정의되어 있으면 draw_new_interface() 함수를 호출하고, 그렇지 않으면 draw_legacy_interface() 함수를 호출하는 코드를 작성하라.
  2. 두 정수 변수의 값을 교환하는 SWAP(a, b) 매크로를 작성하라. 교환 과정에서 임시 변수 선언이 필요하므로 반드시 do { ... } while (0) 구문을 사용해야 한다.
  3. X-매크로를 이용해 도서관 회원 등급(MEMBER_REGULAR, MEMBER_VIP, MEMBER_ADMIN)과 그에 대응하는 한글 문자열("일반 회원", "우수 회원", "관리자")을 선언하는 코드를 작성하라.

정답과 해설

1번 정답:

#ifdef USE_NEW_UI
    draw_new_interface();
#else
    draw_legacy_interface();
#endif

해설: #ifdef는 조건부 컴파일을 수행하며 런타임 성능에 아무런 영향을 주지 않는다. 컴파일 시 -DUSE_NEW_UI 옵션을 추가하면 첫 번째 블록이 선택된다.

2번 정답:

#define SWAP(a, b) \
    do { \
        int temp = (a); \
        (a) = (b); \
        (b) = temp; \
    } while (0)

해설: 값을 교환하려면 임시 변수가 필요하며, 매크로 안에서 변수를 선언하기 위해서는 블록 구조가 필수적이다. do-while(0) 패턴을 쓰면 if-else 구문 사이에서도 문제없이 변수를 교환할 수 있다.

3번 정답:

#define MEMBER_LEVEL_TABLE(X) \
    X(MEMBER_REGULAR, "일반 회원") \
    X(MEMBER_VIP, "우수 회원") \
    X(MEMBER_ADMIN, "관리자")

#define AS_ENUM(level, name) level,
typedef enum {
    MEMBER_LEVEL_TABLE(AS_ENUM)
} MemberLevel;
#undef AS_ENUM

#define AS_STRING(level, name) name,
const char* const member_level_strings[] = {
    MEMBER_LEVEL_TABLE(AS_STRING)
};
#undef AS_STRING

해설: 회원 등급 데이터를 MEMBER_LEVEL_TABLE 하나로 통일하고, 전개 방식을 지시하는 AS_ENUM과 AS_STRING 매크로를 교대로 정의하여 열거형과 문자열 배열을 안전하게 동기화했다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.