[C언어 50강] 42강. 전처리기: #define 매크로, 조건부 컴파일, 매크로 주의점

[C언어 50강] 42강. 전처리기: #define 매크로, 조건부 컴파일, 매크로 주의점

C를 처음 배울 때는 전처리기를 단순히 "컴파일 전에 텍스트를 치환해주는 도구" 정도로 이해하고 넘어가곤 합니다. 그런데 실무에서는 전처리기가 코드 품질과 유지보수성에 큰 영향을 줍니다. 잘 쓰면 반복을 줄이고 플랫폼 차이를 깔끔하게 흡수하지만, 잘못 쓰면 디버깅이 어려운 코드를 양산합니다. 이번 강의에서는 #define, 조건부 컴파일, 그리고 매크로 사용 시 꼭 지켜야 할 안전 규칙을 개념 중심으로 정리해보겠습니다.


핵심 개념

  • 전처리기(preprocessor)는 C 컴파일러의 앞단에서 동작하며, 컴파일 이전에 소스 코드를 변환한다.
  • 매크로는 함수가 아니라 텍스트 치환 규칙이므로 타입 검사, 평가 순서, 부작용을 스스로 관리해야 한다.
  • 조건부 컴파일(#if, #ifdef, #ifndef)은 환경별 코드 분기를 만들 수 있지만, 남용하면 코드 파편화가 심해진다.

개념 먼저 이해하기

전처리기를 이해할 때 가장 중요한 문장은 이것입니다. 전처리기는 C 문법을 이해하지 않는다. 전처리기는 파싱된 AST를 다루는 도구가 아니라, 토큰 단위 치환과 포함 처리(#include)를 수행하는 단계입니다. 즉, #define으로 만든 매크로는 함수처럼 보일 수 있어도 실제로는 함수 호출이 아닙니다. 그래서 디버거에서 "함수 진입"처럼 추적되지 않고, 컴파일 에러 위치도 기대와 다르게 나올 수 있습니다. 초보자가 매크로를 어려워하는 이유가 바로 이 지점입니다.

예를 들어 #define SQUARE(x) x * x처럼 작성하면 SQUARE(a + b)a + b * a + b로 치환됩니다. 사람은 (a+b)*(a+b)를 기대하지만 전처리기는 그런 의도를 알지 못합니다. 이 문제는 단순 실수가 아니라 전처리기의 본질에서 오는 결과입니다. 따라서 매크로를 작성할 때는 "이 매크로가 어떤 텍스트로 확장되는가"를 항상 눈으로 확인하는 습관이 필요합니다. 괄호 규칙 ((x) * (x))가 중요한 이유도 이 때문입니다.

또 하나의 핵심은 평가 횟수입니다. 함수는 인자를 한 번 평가해 전달하지만, 매크로는 치환 결과 안에서 인자가 여러 번 등장하면 그만큼 다시 평가됩니다. 예를 들어 #define MAX(a,b) ((a) > (b) ? (a) : (b))에서 MAX(i++, j++)를 호출하면 i++, j++가 여러 번 실행될 수 있습니다. 이는 논리 버그와 성능 문제를 동시에 만들 수 있습니다. 그래서 부작용이 있는 표현식(증감, 함수 호출, 대입)을 매크로 인자로 넣는 습관은 강하게 피해야 합니다.

조건부 컴파일은 "한 코드베이스를 여러 환경에서 빌드"할 때 강력한 도구입니다. 운영체제 분기, 디버그 로그 on/off, 특정 기능 플래그 제어가 대표적입니다. 예를 들어 #ifdef _WIN32#ifdef __APPLE__로 플랫폼별 코드를 나눌 수 있습니다. 그러나 여기서 중요한 실무 원칙은 분기를 최소화하고 경계를 모듈로 숨기는 것입니다. 프로젝트 전체에 조건부 컴파일이 흩어지면 테스트 케이스가 폭증하고, 어떤 빌드에서 어떤 코드가 실제 컴파일되는지 파악하기 어려워집니다. 플랫폼 분기가 필요하다면 플랫폼 추상화 레이어(예: platform.h/.c)에 모아두는 것이 훨씬 안전합니다.

헤더 가드(include guard)도 전처리기의 대표적 활용입니다. 같은 헤더가 여러 번 포함될 때 중복 선언/정의 오류를 막아줍니다. #ifndef MY_HEADER_H 패턴은 단순해 보이지만 대규모 프로젝트에서 컴파일 안정성의 기반이 됩니다. 최근에는 #pragma once도 많이 사용하지만, 팀 규칙에 따라 호환성 정책을 정하면 됩니다.

마지막으로, 전처리기의 목적을 분명히 해야 합니다. 상수는 가능하면 constenum을 우선, 함수형 동작은 가능하면 static inline을 우선, 전처리기는 정말 필요한 텍스트 치환/빌드 분기/컴파일 타임 옵션에만 사용합니다. 이 우선순위만 지켜도 코드 가독성과 디버깅 난이도가 크게 좋아집니다. 요약하면, 전처리기는 강력하지만 "제어 가능한 범위" 안에서 써야 이점이 살아납니다.

기본 사용

예제 1) 안전한 매크로 작성 규칙

#include <stdio.h>

#define SQUARE_BAD(x) x * x
#define SQUARE_OK(x) ((x) * (x))

int main(void) {
    int a = 3;
    int b = 4;

    int r1 = SQUARE_BAD(a + b);  // 예상과 다른 결과 가능
    int r2 = SQUARE_OK(a + b);   // 의도한 결과

    printf("SQUARE_BAD(a+b) = %d\n", r1);
    printf("SQUARE_OK(a+b)  = %d\n", r2);
    return 0;
}

설명:

  • 매크로 본문과 인자 모두 괄호로 감싸는 것이 기본 규칙입니다.
  • 매크로는 텍스트 치환이므로 연산자 우선순위 문제를 스스로 막아야 합니다.
  • 이 예제는 "매크로를 함수처럼 착각하면 왜 위험한지"를 가장 직접적으로 보여줍니다.

예제 2) 조건부 컴파일로 디버그 로그 제어하기

#include <stdio.h>

#ifdef DEBUG
#define LOG(fmt, ...) fprintf(stderr, "[DEBUG] " fmt "\n", ##__VA_ARGS__)
#else
#define LOG(fmt, ...) ((void)0)
#endif

int divide(int a, int b, int *out) {
    LOG("divide called: a=%d, b=%d", a, b);

    if (out == NULL) {
        LOG("out is NULL");
        return -1;
    }
    if (b == 0) {
        LOG("division by zero");
        return -2;
    }

    *out = a / b;
    LOG("result=%d", *out);
    return 0;
}

int main(void) {
    int v = 0;
    if (divide(10, 2, &v) == 0) {
        printf("%d\n", v);
    }
    return 0;
}

설명:

  • -DDEBUG 옵션으로 빌드하면 로그가 활성화되고, 아니면 컴파일 단계에서 제거됩니다.
  • 런타임 분기보다 오버헤드를 줄일 수 있지만, 로그가 실제로 없는 빌드도 반드시 테스트해야 합니다.
  • 가변 인자 매크로는 디버깅에 유용하지만, 포맷 문자열과 인자 타입 일치 검증은 더 엄격히 관리해야 합니다.

예제 3) 매크로보다 static inline이 나은 경우

#include <stdio.h>

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

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

int main(void) {
    int i = 1;
    int j = 2;

    int m1 = MAX_MACRO(i++, j++);   // 부작용 위험
    printf("m1=%d, i=%d, j=%d\n", m1, i, j);

    i = 1;
    j = 2;
    int m2 = max_inline_int(i++, j++); // 인자 1회 평가
    printf("m2=%d, i=%d, j=%d\n", m2, i, j);

    return 0;
}

설명:

  • 함수형 동작은 매크로보다 static inline이 안전한 경우가 많습니다.
  • inline 함수는 타입 검사를 받으며, 인자 평가 횟수도 함수 호출 규칙을 따릅니다.
  • "매크로가 빠르다"는 막연한 믿음보다, 현대 컴파일러 최적화와 코드 안정성을 함께 고려해야 합니다.

자주 하는 실수

실수 1) 매크로 인자/본문 괄호를 생략함

  • 원인: 간단해 보여서 #define ADD(a,b) a+b처럼 작성함.
  • 해결: 인자와 전체 식을 모두 괄호로 감싸는 규칙을 팀 컨벤션으로 강제한다.

실수 2) 부작용 있는 인자를 함수형 매크로에 전달함

  • 원인: MAX(i++, j++)처럼 증감/함수 호출을 그대로 넣음.
  • 해결: 매크로 인자는 순수 표현식으로 제한하고, 필요하면 static inline 함수로 대체한다.

실수 3) 조건부 컴파일 분기가 프로젝트 전역에 흩어짐

  • 원인: 급한 수정 때마다 #ifdef를 여기저기 추가함.
  • 해결: 플랫폼/빌드 분기는 전용 모듈로 모으고, 인터페이스는 단일 헤더로 노출한다.

실수 4) 헤더 가드 이름 충돌

  • 원인: #ifndef HEADER_H 같은 일반 이름을 여러 파일에서 재사용함.
  • 해결: 프로젝트 접두어 포함(DEVLAB_NET_SOCKET_H)처럼 고유한 가드명을 사용한다.

실무 패턴

  • 우선순위 원칙: 상수(const, enum) → inline 함수 → 매크로 순으로 선택한다.
  • 매크로 네이밍 분리: 전처리 매크로는 대문자, 내부 구현 함수는 소문자/스네이크로 구분해 가독성을 높인다.
  • 빌드 플래그 표준화: DEBUG, FEATURE_X, PLATFORM_...처럼 플래그 이름 체계를 문서화한다.
  • 전처리 결과 점검: 문제 발생 시 clang -E 또는 gcc -E로 실제 확장 코드를 확인해 원인을 찾는다.
  • 조건부 분기 테스트: 최소한 DEBUG on/off, 주요 플랫폼 조합을 CI에서 각각 빌드해 죽은 분기를 줄인다.

오늘의 결론

한 줄 요약: 전처리기는 강력한 도구지만, "함수처럼 쓰지 말고 빌드 도구처럼" 다뤄야 안전하다.

연습문제

  1. #define CLAMP(x, lo, hi) 매크로를 작성하되, 괄호 규칙을 철저히 적용해 연산자 우선순위 문제를 방지하라.
  2. DEBUG 플래그에 따라 로그를 켜고 끄는 매크로를 만들고, -DDEBUG 유무에 따른 실행 결과를 비교하라.
  3. MAX 기능을 매크로 버전과 static inline 버전으로 각각 구현한 뒤, 부작용 인자(i++)를 넣었을 때 동작 차이를 설명하라.

이전 강의 정답

41강 연습문제(에러 처리) 핵심 정답 요약:

  • load_config(const char *path, Config *out)는 입력 검증 실패(APP_ERR_INVALID_ARG), 파일 열기 실패(APP_ERR_IO), 파싱 실패(APP_ERR_PARSE)를 분리해 반환해야 하며, 호출부는 오류 코드별로 사용자 메시지를 다르게 출력해야 한다.
  • 두 파일 비교 함수는 중간 실패가 나도 fclose 누락이 없도록 goto cleanup 패턴으로 정리 지점을 단일화해야 한다. 특히 파일 하나만 연 상태에서 실패한 경우도 안전하게 처리해야 한다.
  • errno/perror/strerror 비교 시, 장애 재현성은 "함수명 + 파라미터 + errno 문자열"이 함께 남는 로그가 가장 높다. 단일 perror("error")는 맥락이 부족해 실무에서 한계가 있다.

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 이상 또는 GCC 13+
  • 컴파일 옵션: -std=c11 -Wall -Wextra -Wpedantic -O0 -g
  • 실행 환경: macOS (arm64), 터미널 zsh
  • 재현 체크:
    • clang -E sample.c | less로 매크로 확장 결과 직접 확인
    • -DDEBUG 유무로 로그 출력 차이 확인
    • 매크로 인자에 i++ 사용 시 부작용 발생 여부 비교