[C 시리즈 15편] 전처리기와 매크로로 코드 준비하기

English version

14편에서 메모리 오류를 찾는 방법을 다뤘다면, 이번에는 컴파일러가 코드를 읽기 전에 실행되는 "전처리" 단계에 집중합니다. 전처리기는 텍스트를 치환하고 파일을 합치고, 특정 조건에 따라 코드를 껐다 켰다 할 수 있습니다. 이 과정을 이해하면 헤더 파일, 상수 정의, 빌드 환경 구분이 한층 명확해집니다.

이번 글에서 새로 나오는 용어

  1. 전처리기 (Preprocessor): 컴파일 전에 #으로 시작하는 지시문을 처리하는 도구
  2. 매크로 (Macro): 특정 텍스트를 다른 텍스트로 치환하는 규칙
  3. 조건부 컴파일: #if, #ifdef, #ifndef를 사용해 상황에 따라 코드를 포함하거나 제외하는 기법
  4. 가드 (include guard): 헤더가 중복 포함되는 것을 막기 위한 패턴

핵심 개념

학습 메모

  • 소요 시간: 45~60분
  • 준비물: 헤더 파일, 함수 선언, 빌드 흐름 기초 이해
  • 학습 목표: 전처리 지시문 종류를 구분하고, 실용적인 매크로와 가드를 작성하기

전처리 단계는 크게 세 가지 역할을 합니다. 가장 먼저 기억할 것은, 전처리기가 "코드를 실행하는 것"이 아니라 컴파일 전에 글자를 바꾸고 파일을 끼워 넣는다는 점입니다.

  1. 파일 병합: #include로 헤더를 끼워 넣어 컴파일러가 필요한 선언을 볼 수 있게 합니다.
  2. 상수·함수 치환: #define으로 반복 코드나 숫자를 이름으로 관리합니다.
  3. 조건 분기: 플랫폼이나 디버그 모드에 따라 특정 코드를 빼거나 넣습니다.

이번 글에서는 아래 내용을 중심으로 다룹니다.

  • #include와 include guard 구조
  • 단순 상수 매크로와 괄호를 이용한 안전한 연산 매크로
  • 매크로 함수와 인자 평가 시 주의할 점
  • 조건부 컴파일로 디버그 로그 제어하기
  • gcc -E로 전처리 결과 확인하기

코드로 따라하기

#include와 가드 패턴

헤더가 두 번 이상 포함되면 동일한 선언이 중복되어 컴파일 오류가 납니다. 이를 막기 위해 include guard를 사용합니다.

// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b);

#endif

#ifndef는 상수가 정의되지 않았을 때만 아래 코드를 포함합니다. #define으로 한번 정의하면 다시 포함될 때 #ifndef 조건이 거짓이 되어 내용이 무시됩니다. 즉, 같은 헤더가 여러 번 들어와도 중복 선언 문제가 생기지 않도록 막는 잠금장치라고 보면 됩니다. 참고로 #pragma once라는 더 짧은 방식도 널리 쓰이지만, include guard가 더 전통적이고 표준적인 패턴입니다.

상수 매크로로 숫자 관리하기

#include <stdio.h>

#define MAX_USERS 100
#define PI 3.1415926535

int main(void) {
    printf("최대 사용자 수: %d\n", MAX_USERS);
    printf("PI = %f\n", PI);
    return 0;
}

#define 이름 값 형태이며, 전처리기는 등장할 때마다 텍스트를 그대로 치환합니다. 상수를 관리할 때는 이름 전체를 대문자로 쓰고, 단어 사이에 밑줄을 넣는 관례가 널리 쓰입니다. 다만 단순 상수라면 상황에 따라 const 변수나 enum을 쓰는 편이 더 안전하고 읽기 쉬울 때도 많습니다.

계산 매크로와 괄호 규칙

매크로는 함수처럼 보이지만 실제로는 텍스트 치환이므로 괄호가 중요합니다.

#include <stdio.h>

#define SQUARE(x) ((x) * (x))

int main(void) {
    printf("SQUARE(3) = %d\n", SQUARE(3));
    printf("SQUARE(1 + 2) = %d\n", SQUARE(1 + 2));
    return 0;
}

SQUARE(1 + 2)((1 + 2) * (1 + 2))로 치환되어 기대한 대로 9가 나옵니다. 괄호를 빼면 1 + 2 * 1 + 2처럼 엉뚱한 순서로 계산될 수 있으니, 매크로 몸체와 각 인자를 ( )로 감싸는 습관을 유지합니다. 즉, 함수처럼 보여도 실제로는 계산 전에 코드 조각이 그대로 바뀐다는 점이 핵심입니다.

부작용이 있는 인자 조심하기

#include <stdio.h>

#define DOUBLE(x) ((x) + (x))

int main(void) {
    int i = 2;
    printf("%d\n", DOUBLE(++i));
    printf("i = %d\n", i);
    return 0;
}

DOUBLE(++i)(++i) + (++i)로 치환되어 i가 두 번 증가합니다. 함수라면 한 번만 평가되겠지만, 매크로는 인자가 붙을 때마다 그대로 복사된다는 점을 기억합니다. 부작용(증가, 감소, 함수 호출 등)이 있는 표현식은 매크로 인자로 넘기지 않는 것이 안전합니다.

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

#include <stdio.h>

#define DEBUG 1

#if DEBUG
    #define LOG(msg) printf("[DEBUG] %s:%d %s\n", __FILE__, __LINE__, msg)
#else
    #define LOG(msg) ((void)0)
#endif

int main(void) {
    LOG("연산 시작");
    // ... 실제 코드 ...
    LOG("연산 종료");
    return 0;
}

DEBUG 값을 0으로 바꾸거나, 컴파일할 때 -DDEBUG=0을 지정하면 LOG 매크로가 빈 문장으로 치환됩니다. 플랫폼별로 다른 구현을 선택할 때도 같은 방식으로 #if defined(_WIN32) 같은 조건을 사용합니다. 여기서 #if DEBUG는 값이 0인지 아닌지를 보고, #ifdef DEBUG는 이름이 정의되어 있는지만 보는 방식이라는 차이도 함께 알아 두면 좋습니다.

전처리 결과 확인하기

실제로 어떤 코드가 컴파일되는지 궁금하다면 -E 옵션으로 전처리 결과만 출력할 수 있습니다.

clang -E main.c

이 명령은 #include#define이 모두 펼쳐진 중간 코드를 보여 주므로, 매크로가 어떻게 치환되는지 확인할 수 있습니다. 출력이 길어지므로 > main.pp.c로 파일에 저장해 열어 보는 것이 편리합니다.

왜 중요한가

  • 전처리 흐름을 이해하면 헤더 구조와 빌드 에러 메시지를 훨씬 빠르게 해석할 수 있습니다.
  • 매크로는 상수 관리뿐 아니라 반복되는 코드 패턴을 줄이는 데도 쓰이며, 괄호 규칙을 지키면 예기치 않은 버그를 줄일 수 있습니다.
  • 조건부 컴파일은 플랫폼별 차이나 디버그 옵션을 깔끔하게 제어하는 가장 단순한 방법입니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Universal starter입니다. C는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.

Live Practice

C Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Universal starterCterminal
  1. starter를 fork한 뒤 hello.c 같은 실습 파일을 만든다
  2. 본문 코드를 붙여 넣고 clang 또는 gcc가 있으면 직접 컴파일한다
  3. 코드를 고친 뒤 다시 빌드하고 실행 결과를 비교한다

C는 브라우저 미리보기보다 터미널 빌드 흐름이 핵심입니다. Universal starter의 컴파일러 구성이 환경에 따라 다를 수 있으니, 먼저 clang이나 gcc 사용 가능 여부부터 확인하세요.

실습

  • 따라 하기: SQUARE, DOUBLE 매크로를 직접 입력하고, 괄호를 뺐을 때 어떤 결과가 나오는지 비교합니다.
  • 확장하기: LOG 매크로에 printf 포맷 인자를 받을 수 있도록 LOG(fmt, ...) 형태의 가변 인자 매크로를 만들어 봅니다.
  • 디버깅: #include 가드를 일부러 빼고 헤더를 두 번 포함시켜 어떤 컴파일 오류가 발생하는지 관찰합니다.
  • 완료 기준: 전처리기 지시문 이름을 듣고 어떤 역할인지 설명할 수 있고, 간단한 include guard와 상수 매크로를 직접 작성할 수 있으면 됩니다.

마무리

전처리기는 C 코드의 무대 세트를 준비하는 역할을 합니다. 지금까지 배운 메모리, 함수, 파일 구조 위에 전처리기를 얹으면 환경별 설정과 반복되는 패턴을 더 깔끔하게 관리할 수 있습니다. 다음 편에서는 변수의 저장 기간과 스코프를 살펴보며, 어떤 변수가 언제까지 살아남는지 체계적으로 정리하겠습니다.

💬 댓글

이 글에 대한 의견을 남겨주세요