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로 파일에 저장해 열어 보는 것이 편리합니다.
왜 중요한가
- 전처리 흐름을 이해하면 헤더 구조와 빌드 에러 메시지를 훨씬 빠르게 해석할 수 있습니다.
- 매크로는 상수 관리뿐 아니라 반복되는 코드 패턴을 줄이는 데도 쓰이며, 괄호 규칙을 지키면 예기치 않은 버그를 줄일 수 있습니다.
- 조건부 컴파일은 플랫폼별 차이나 디버그 옵션을 깔끔하게 제어하는 가장 단순한 방법입니다.
실습
- 따라 하기:
SQUARE,DOUBLE매크로를 직접 입력하고, 괄호를 뺐을 때 어떤 결과가 나오는지 비교합니다. - 확장하기:
LOG매크로에printf포맷 인자를 받을 수 있도록LOG(fmt, ...)형태의 가변 인자 매크로를 만들어 봅니다. - 디버깅:
#include가드를 일부러 빼고 헤더를 두 번 포함시켜 어떤 컴파일 오류가 발생하는지 관찰합니다. - 완료 기준: 전처리기 지시문 이름을 듣고 어떤 역할인지 설명할 수 있고, 간단한 include guard와 상수 매크로를 직접 작성할 수 있으면 됩니다.
마무리
전처리기는 C 코드의 무대 세트를 준비하는 역할을 합니다. 지금까지 배운 메모리, 함수, 파일 구조 위에 전처리기를 얹으면 환경별 설정과 반복되는 패턴을 더 깔끔하게 관리할 수 있습니다. 다음 편에서는 변수의 저장 기간과 스코프를 살펴보며, 어떤 변수가 언제까지 살아남는지 체계적으로 정리하겠습니다.
💬 댓글
이 글에 대한 의견을 남겨주세요