이번 문서의 목표: 이 파일을 다 읽으면 선행처리기가 컴파일보다 먼저 소스 코드를 어떻게 문자 그대로 치환하는지 설명할 수 있고, 매크로 함수의 함정, 조건부 컴파일, 헤더 중복 포함 방지 패턴을 코드로 재현할 수 있다.
왜 이 주제가 필요한가
06편에서 C 프로그램이 “번역 단위”로 컴파일된다고 배울 때, 실제 컴파일러가 소스 코드를 보기 직전에 한 번 더 처리하는 단계가 있다고 언급했다. 이 단계를 담당하는 것이 선행처리기(preprocessor, 전처리기라고도 부른다)다. #으로 시작하는 줄(선행처리기 지시문, preprocessor directive)은 문법적으로는 C 문법이 아니라, 선행처리기가 컴파일 전에 소스 코드 자체를 텍스트 수준에서 가공하는 명령이다. 이 원리를 정확히 모르면 매크로 함수가 예상과 다르게 동작하는 문제를 이해할 수 없으므로, 이 편에서 “치환은 문자 그대로 일어난다”는 원칙을 중심으로 파고든다.
선행처리기의 동작 원리: 컴파일 전 텍스트 치환
쉽게 말하면: 선행처리기는 코드의 의미를 전혀 모른 채, 지시문이 적힌 자리를 오직 “문자 그대로” 바꿔치기만 하는 단순한 치환기다.
06편에서 다룬 컴파일 과정의 맨 앞 단계가 바로 선행처리다. 선행처리기는 #include, #define 같은 지시문을 찾아 그 자리를 다른 텍스트로 바꾼 뒤, 그 결과물(전처리된 소스, translation unit의 입력)을 진짜 컴파일러에게 넘긴다. 선행처리기는 C 언어의 문법이나 자료형을 전혀 이해하지 못하고, 오직 문자열 치환만 수행한다는 점이 핵심이다.
#include: 헤더 파일 포함
#include 지시문은 지정한 파일의 내용 전체를 그 자리에 그대로 끌어와 붙여넣는다. 04편에서 다룬 stdio.h 같은 헤더 파일(header file, 함수 원형·매크로 상수 등의 선언을 모아둔 파일)을 포함할 때 쓴다.
#include <stdio.h> /* 컴파일러가 표준 설치 경로에서 찾는다 (표준 라이브러리 헤더) */
#include "myheader.h" /* 현재 소스 파일과 같은 폴더에서 먼저 찾는다 (내가 만든 헤더) */꺾쇠괄호(<>)와 큰따옴표("")의 차이는 헤더를 찾는 경로 우선순위다. 꺾쇠괄호는 컴파일러가 표준으로 지정한 시스템 헤더 경로에서 찾고, 큰따옴표는 먼저 현재 소스 파일과 같은 폴더를 찾은 뒤 시스템 경로를 찾는다.
#define: 객체 형 매크로
쉽게 말하면: #define은 코드에 등장하는 특정 텍스트를 다른 텍스트로 바꿔치기하라고 선행처리기에게 시키는 것이다.
가장 단순한 형태는 객체 형 매크로(object-like macro)로, 이름 하나를 다른 텍스트로 치환한다. 관례상 매크로 이름은 대문자로 쓴다.
#include <stdio.h>
#define 최대인원 100
#define 원주율 3.14159
int main(void) {
printf("최대 인원: %d\n", 최대인원);
printf("원주율: %f\n", 원주율);
return 0;
}이 코드는 선행처리 단계에서 실제로 다음처럼 문자 그대로 치환된 뒤 컴파일된다.
int main(void) {
printf("최대 인원: %d\n", 100);
printf("원주율: %f\n", 3.14159);
return 0;
}최대인원이라는 이름 자체는 컴파일 단계에서는 아예 존재하지 않는다 — 컴파일러는 애초에 100이라는 숫자만 본다. const int 최대인원 = 100;처럼 변수로 선언하는 것과 달리, 매크로 상수는 진짜 변수가 아니므로 메모리 공간을 차지하지 않고 자료형 검사도 받지 않는다.
매개변수화 매크로: 매크로 함수와 그 함정
매개변수화 매크로(function-like macro, 매크로 함수)는 함수처럼 괄호 안에 매개변수를 받아 치환 결과에 반영한다.
#include <stdio.h>
#define 제곱(x) x * x
int main(void) {
int a = 5;
printf("%d\n", 제곱(a));
printf("%d\n", 제곱(a + 1));
return 0;
}25
11한 줄씩 치환 추적:
| 원본 코드 | 치환 결과(문자 그대로) | 계산 순서 | 최종 값 |
|---|---|---|---|
제곱(a) | a * a | 5 * 5 | 25 (의도대로 동작) |
제곱(a + 1) | a + 1 * a + 1 | 곱셈이 덧셈보다 우선순위가 높아 a + (1 * a) + 1 | 5 + 5 + 1 = 11 (의도와 다름) |
제곱(a + 1)이 11이 나온 이유는 선행처리기가 x를 a + 1이라는 텍스트 그대로 바꿔 넣었기 때문이다. 매크로는 함수처럼 (a + 1)을 먼저 계산한 값을 넘겨받는 것이 아니라, a + 1이라는 글자들을 그 자리에 그대로 삽입할 뿐이다. 그 결과 x * x는 a + 1 * a + 1이 되고, 연산자 우선순위 규칙에 따라 곱셈이 먼저 계산되어 의도한 (5+1) * (5+1) = 36이 아니라 11이라는 엉뚱한 값이 나온다.
시험 함정: 매크로 함수를 작성할 때는 매개변수 전체와 식 전체를 반드시 괄호로 감싸야 한다. #define 제곱(x) ((x) * (x))처럼 써야 제곱(a + 1)이 ((a + 1) * (a + 1))로 치환되어 올바른 36이 나온다. “매크로는 괄호 없이 써도 함수처럼 안전하게 계산된다”는 설명은 옳지 않은 것 고르기의 단골 오답이다.
매크로와 함수의 차이
| 비교 항목 | 매크로 함수(#define) | 일반 함수 |
|---|---|---|
| 처리 시점 | 컴파일 전, 선행처리 단계에서 텍스트 치환 | 컴파일된 뒤 실행 시점에 호출 |
| 인자 평가 | 인자 자체가 텍스트로 여러 번 삽입될 수 있음 | 인자가 한 번 평가되어 값으로 전달(10편의 값 전달) |
| 자료형 검사 | 없음(선행처리기는 자료형을 모름) | 매개변수 자료형에 따라 검사됨 |
| 실행 속도 | 함수 호출 오버헤드 없음(치환만 일어남) | 함수 호출 오버헤드(스택 프레임 생성 등) 있음 |
| 디버깅 | 치환된 결과가 보이지 않아 오류 추적이 어려움 | 디버거로 호출 과정을 그대로 추적 가능 |
조건부 컴파일: 코드를 선택적으로 포함하기
쉽게 말하면: 조건부 컴파일은 특정 조건이 참일 때만 그 부분의 코드를 아예 존재하는 것처럼, 거짓이면 아예 없는 것처럼 만든다.
조건부 컴파일(conditional compilation)은 특정 조건에 따라 소스 코드의 일부를 컴파일 대상에 포함하거나 제외하는 기능이다. 대표적으로 디버깅 코드를 배포 버전에서만 빼거나, 운영체제·컴파일러 환경에 따라 다른 코드를 쓸 때 활용한다.
#include <stdio.h>
#define 디버그모드 1
int main(void) {
int 결과 = 10 + 20;
#if 디버그모드
printf("디버그: 결과 계산 완료, 값 = %d\n", 결과);
#endif
printf("결과: %d\n", 결과);
return 0;
}디버그: 결과 계산 완료, 값 = 30
결과: 30결과 변수에는 이미 계산된 값 30이 들어 있으므로, #if 디버그모드 안의 printf문이 그 값을 그대로 출력한다. 디버그모드를 0으로 바꾸면 #if와 #endif 사이의 printf 줄 자체가 컴파일 대상에서 통째로 빠지므로, 실행 파일에는 그 코드가 아예 존재하지 않게 된다.
자주 쓰이는 조건부 컴파일 지시문을 정리한다.
| 지시문 | 의미 |
|---|---|
#if 조건식 | 조건식이 참(0이 아님)이면 이후 코드를 포함 |
#ifdef 이름 | 그 이름이 매크로로 정의되어 있으면 포함 |
#ifndef 이름 | 그 이름이 매크로로 정의되어 있지 않으면 포함 |
#else | 앞 조건이 거짓일 때 포함 |
#elif 조건식 | 앞 조건이 거짓이고 이 조건식이 참일 때 포함 |
#endif | 조건부 블록의 끝을 표시(반드시 필요) |
헤더 중복 포함 방지: include guard
같은 헤더 파일이 여러 소스 파일에서 중복으로 #include 되면, 그 안에 있는 구조체·함수 선언이 중복 정의되어 컴파일 오류가 날 수 있다. 이를 막는 관용구가 include guard(포함 방지 패턴)다.
#ifndef MYHEADER_H
#define MYHEADER_H
struct 점 {
int x;
int y;
};
#endif처음 이 헤더가 포함될 때는 MYHEADER_H가 정의되어 있지 않으므로 #ifndef(정의되어 있지 않으면)의 조건이 참이 되어, #define MYHEADER_H로 즉시 그 이름을 정의하고 구조체 선언까지 포함시킨다. 같은 헤더가 다시 포함되면 이번에는 MYHEADER_H가 이미 정의되어 있으므로 #ifndef 조건이 거짓이 되어, #endif까지의 내용 전체가 건너뛰어진다. 그 결과 구조체 선언이 한 번만 유효하게 되어 중복 정의 오류를 막는다.
매크로 함수의 부작용 함정: 인자를 여러 번 평가
매크로 함수에 부작용(side effect, 대입·증가 연산처럼 상태를 변화시키는 효과)이 있는 식을 인자로 넘기면 더 위험한 문제가 생긴다.
#include <stdio.h>
#define 큰값(a, b) ((a) > (b) ? (a) : (b))
int main(void) {
int x = 5;
int y = 10;
int 결과 = 큰값(x++, y);
printf("결과 = %d, x = %d\n", 결과, x);
return 0;
}치환 결과: ((x++) > (y) ? (x++) : (y))
x++가 치환문 안에 두 번 삽입된다. 조건 판단에서 x++가 한 번 평가되어 x가 5에서 6으로 증가하는데, 이때 6은 10보다 작으므로 조건식은 거짓이 되어 실제로는 거짓 분기(b, 즉 y)를 타지만, 이 예시의 요점은 조건이 참이든 거짓이든 x++가 텍스트 삽입 자체는 두 곳에 이루어진다는 점이다. 만약 조건이 참이 되는 경우라면 참 분기의 (a) 자리에서 x++가 또 한 번 평가되어 x가 한 번 더 증가한다. 함수라면 인자가 호출 전 딱 한 번만 평가되지만, 매크로는 텍스트가 그대로 여러 번 복사되므로 부작용이 여러 번 일어날 수 있다.
시험 함정: “매크로 함수는 일반 함수와 완전히 동일하게 동작하므로 서로 바꿔 써도 무방하다”는 설명은 틀렸다. 매크로는 인자를 텍스트로 여러 번 삽입할 수 있어, 증가·감소 연산자가 섞인 인자를 넘기면 일반 함수와 다른 결과가 나올 수 있다.
자주 틀리는 점
- 매크로 상수·매크로 함수 뒤에 세미콜론을 붙이는 습관 때문에, 치환 후 문법 오류가 나는 경우가 있다.
#define 값 10;처럼 정의하면값이 쓰이는 모든 자리에 세미콜론까지 함께 삽입되어 예상치 못한 오류가 난다. - 매크로 함수의 매개변수와 전체 식에 괄호를 빠뜨려 연산자 우선순위 문제로 틀린 결과를 얻는 경우가 가장 흔한 실수다.
#if,#ifdef,#ifndef는 반드시 짝이 되는#endif로 닫아야 하는데, 이를 빠뜨려 컴파일 오류가 나는 경우가 있다.- 선행처리기 지시문은 C 문(statement)이 아니므로 뒤에 세미콜론을 붙이지 않는다는 점(단
#define상수에 실수로 세미콜론을 넣는 경우 제외)을 혼동하는 경우가 있다.
핵심 정리
- 선행처리기는 컴파일 전에
#으로 시작하는 지시문을 처리하며, 자료형이나 문법을 이해하지 못한 채 문자 그대로 텍스트를 치환한다. #include는 지정한 파일 내용 전체를 그 자리에 붙여넣고,#define은 이름을 다른 텍스트로 바꿔치기한다.- 매크로 함수는 매개변수 전체와 식 전체를 괄호로 감싸지 않으면 연산자 우선순위 문제로 의도와 다른 결과가 나올 수 있다.
- 매크로는 인자를 텍스트로 여러 번 삽입할 수 있어, 부작용 있는 식을 인자로 넘기면 일반 함수와 다르게 동작한다.
- 조건부 컴파일(
#if/#ifdef/#ifndef~#endif)로 코드 일부를 선택적으로 포함할 수 있고, include guard 패턴으로 헤더 중복 포함을 방지한다.
마무리 복습
참고 자료
- C 기초 강의 – 자료형·연산자·입출력·제어문·함수 성격의 대학 강의 자료 — 선행처리기 지시문과 매크로 기본 문법 설명에 참고
- 국가평생교육진흥원 독학학위제: https://bdes.nile.or.kr