14편에서 메모리 오류를 찾는 방법을 다뤘다면, 이번에는 컴파일러가 코드를 읽기 전에 실행되는 "전처리" 단계에 집중합니다. 전처리기는 텍스트를 치환하고 파일을 합치고, 특정 조건에 따라 코드를 껐다 켰다 할 수 있습니다. 이 과정을 이해하면 헤더 파일, 상수 정의, 빌드 환경 구분이 한층 명확해집니다.
이번 글에서 새로 나오는 용어
- 전처리기 (Preprocessor): 컴파일 전에
#으로 시작하는 지시문을 처리하는 도구 - 매크로 (Macro): 특정 텍스트를 다른 텍스트로 치환하는 규칙
- 조건부 컴파일:
#if,#ifdef,#ifndef를 사용해 상황에 따라 코드를 포함하거나 제외하는 기법 - 가드 (include guard): 헤더가 중복 포함되는 것을 막기 위한 패턴
핵심 개념
학습 메모
- 소요 시간: 45~60분
- 준비물: 헤더 파일, 함수 선언, 빌드 흐름 기초 이해
- 학습 목표: 전처리 지시문 종류를 구분하고, 실용적인 매크로와 가드를 작성하기
전처리 단계는 크게 세 가지 역할을 합니다. 가장 먼저 기억할 것은, 전처리기가 "코드를 실행하는 것"이 아니라 컴파일 전에 글자를 바꾸고 파일을 끼워 넣는다는 점입니다.
- 파일 병합:
#include로 헤더를 끼워 넣어 컴파일러가 필요한 선언을 볼 수 있게 합니다. - 상수·함수 치환:
#define으로 반복 코드나 숫자를 이름으로 관리합니다. - 조건 분기: 플랫폼이나 디버그 모드에 따라 특정 코드를 빼거나 넣습니다.
이번 글에서는 아래 내용을 중심으로 다룹니다.
#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는 터미널에서 직접 컴파일하고 다시 실행하는 흐름이 중요하니, 이번 글의 코드를 파일로 만들고 빌드-실행 사이클을 다시 따라가 보세요.
💬 댓글
이 글에 대한 의견을 남겨주세요