C언어 (6)
1. 전처리
1-1. 전처리란?
C 소스 코드가 실행 파일이 되기까지의 과정을 다시 떠올려보자.
소스파일(.c) → [전처리] → 전처리 파일(.i) → [컴파일] → 목적파일(.o) → [링크] → 실행파일
전처리(Preprocessing) 는 컴파일이 시작되기 전에 소스 코드를 먼저 가공하는 단계다. #으로 시작하는 지시문들을 처리하는 것이 이 단계에서 일어나는 일이다. #include로 헤더 파일의 내용을 붙여넣고, #define으로 정의된 매크로를 치환하고, #ifdef 같은 조건부 컴파일 지시자를 평가한다.
전처리가 끝나면 #으로 시작하는 지시문은 전부 사라지고, 순수한 C 코드만 남는다. 이 결과물이 전처리 파일(.i)이고, 이것이 컴파일러에 전달된다.
1-2. 전처리의 규칙
전처리 지시문에는 몇 가지 규칙이 있다.
전처리 지시문은 항상 #으로 시작한다. # 앞에 공백은 있어도 되지만, 일반적으로 줄 맨 앞에 쓴다.
전처리 지시문은 한 줄에 하나씩 작성한다. 세미콜론으로 끝나지 않는다. C 코드의 문장은 ;으로 끝나지만, 전처리 지시문은 줄바꿈이 곧 끝이다. 한 줄에 다 쓰기 어려울 때는 줄 끝에 \를 붙여서 다음 줄로 이어쓸 수 있다.
#define LONG_MACRO(x, y) \
((x) > (y) ? (x) : (y))
전처리 지시문은 코드 어디에든 올 수 있지만, 관례적으로 파일 상단에 모아두는 것이 일반적이다.
1-3. 전처리기 지시자의 종류
자주 사용하는 전처리기 지시자를 정리하면 이렇다.
#include 는 다른 파일의 내용을 현재 위치에 삽입한다. <stdio.h>처럼 꺾쇠로 감싸면 시스템 헤더 경로에서 찾고, "myheader.h"처럼 따옴표로 감싸면 현재 디렉토리에서 먼저 찾는다.
#define 은 매크로를 정의한다. 단순 상수 치환부터 함수처럼 동작하는 매크로까지 만들 수 있다.
#undef 는 이미 정의된 매크로를 해제한다.
#ifdef, #ifndef, #if, #elif, #else, #endif 는 조건부 컴파일을 위한 지시자들이다.
#pragma 는 컴파일러에게 특별한 지시를 내린다. 컴파일러마다 지원하는 내용이 다르다.
2. 매크로
2-1. 매크로 상수
#define으로 이름에 값을 대응시키는 것이 매크로 상수다. 전처리 단계에서 해당 이름이 등장하는 곳을 전부 지정한 값으로 텍스트 치환 한다.
#define PI 3.14159
#define MAX_SIZE 100
#define MESSAGE "Hello, World!"
int main(void) {
double area = PI * 5 * 5;
int arr[MAX_SIZE];
printf("%s\n", MESSAGE);
return 0;
}
전처리 후에는 이렇게 바뀐다.
int main(void) {
double area = 3.14159 * 5 * 5;
int arr[100];
printf("%s\n", "Hello, World!");
return 0;
}
PI, MAX_SIZE, MESSAGE라는 이름이 전부 대응하는 값으로 치환된 것이다. 매크로 이름은 관례적으로 대문자 로 쓴다. 일반 변수와 구분하기 위해서다.
2-2. 매크로 상수를 사용하면 좋은 점
매직 넘버를 제거한다. 코드 곳곳에 100이라는 숫자가 흩어져 있으면, 그것이 배열 크기인지 점수 기준인지 알 수 없다. MAX_SIZE라는 이름을 붙이면 의도가 명확해진다.
유지보수가 쉬워진다. 배열 크기를 200으로 바꿔야 할 때, #define MAX_SIZE 100을 #define MAX_SIZE 200으로 한 줄만 수정하면 된다. 100이 등장하는 모든 곳을 찾아 바꿀 필요가 없다.
배열 크기 지정에 사용할 수 있다. C에서 const int는 진정한 컴파일 타임 상수가 아니라서 배열 크기로 사용하지 못하는 경우가 있다. 하지만 #define 매크로는 전처리 단계에서 숫자로 치환되므로 배열 크기로 사용할 수 있다.
#define SIZE 10
int arr[SIZE]; // OK
const int size = 10;
// int arr2[size]; // C89에서는 에러
2-3. 매크로의 특징
매크로는 텍스트 치환 이라는 점이 핵심이다. 컴파일러가 처리하는 것이 아니라 전처리기가 단순히 텍스트를 바꿔치기하는 것이다. 이 때문에 주의해야 할 점들이 있다.
매크로 함수 를 만들 수도 있다.
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
int result = SQUARE(5); // ((5) * (5)) → 25
int bigger = MAX(10, 20); // ((10) > (20) ? (10) : (20)) → 20
매개변수를 괄호로 감싸는 것이 매우 중요하다. 괄호가 없으면 연산자 우선순위 때문에 의도하지 않은 결과가 나올 수 있다.
#define BAD_SQUARE(x) x * x
int result = BAD_SQUARE(3 + 2);
// 치환 결과: 3 + 2 * 3 + 2
// 의도: 25, 실제: 11
BAD_SQUARE(3 + 2) 는 3 + 2 * 3 + 2로 치환된다. 곱셈이 먼저 계산되어 3 + 6 + 2 = 11이 된다. ((x) * (x))로 썼다면 ((3 + 2) * (3 + 2)) = 25로 의도한 대로 동작한다.
매크로는 타입 검사를 하지 않는다. 함수는 매개변수와 반환값의 타입을 컴파일러가 검사하지만, 매크로는 텍스트 치환이므로 어떤 타입이든 들어갈 수 있다. 이것이 장점이 될 수도 있지만(제네릭하게 사용 가능), 타입 관련 버그를 잡기 어렵게 만들 수도 있다.
매크로는 디버깅이 어렵다. 전처리 단계에서 이미 치환되어 사라지기 때문에, 디버거에서 매크로 이름으로 중단점을 잡거나 값을 확인할 수 없다.
매크로 함수의 장점은 함수 호출 오버헤드가 없다 는 것이다. 일반 함수는 호출 시 스택 프레임 생성, 인자 복사, 점프 등의 비용이 발생하지만, 매크로는 코드가 직접 삽입되므로 이런 비용이 없다. 아주 작고 자주 호출되는 로직에는 매크로가 유리할 수 있다. 다만 현대 컴파일러는 작은 함수를 자동으로 인라인 처리하므로, 성능 차이는 대부분의 경우 미미하다.
3. 조건부 컴파일
조건부 컴파일은 특정 조건에 따라 코드의 일부를 컴파일에 포함시키거나 제외하는 기능 이다. if-else와 비슷하지만, 런타임이 아니라 전처리 단계 에서 평가된다. 조건에 맞지 않는 코드는 아예 컴파일러에 전달되지 않는다.
3-1. 지시자의 형식
#ifdef / #ifndef
#ifdef 는 매크로가 정의되어 있으면 해당 코드를 포함한다. #ifndef 는 반대로 정의되어 있지 않으면 포함한다.
#define DEBUG
#ifdef DEBUG
printf("디버그 모드입니다.\n");
printf("x = %d\n", x);
#endif
DEBUG가 정의되어 있으므로 printf문이 컴파일에 포함된다. #define DEBUG를 지우면 이 코드는 아예 컴파일되지 않는다. 디버깅용 출력을 넣었다 뺐다 할 때 유용하다. 일일이 주석 처리할 필요 없이 #define 한 줄만 제어하면 된다.
#if / #elif / #else / #endif
더 세밀한 조건 분기가 필요하면 #if를 사용한다.
#define VERSION 3
#if VERSION == 1
printf("버전 1\n");
#elif VERSION == 2
printf("버전 2\n");
#elif VERSION >= 3
printf("버전 3 이상\n");
#else
printf("알 수 없는 버전\n");
#endif
#if의 조건에는 정수 상수 표현식만 올 수 있다. 변수나 함수 호출은 사용할 수 없다. 전처리 단계에서는 아직 변수가 존재하지 않기 때문이다.
defined() 연산자를 #if와 함께 쓰면 #ifdef와 같은 효과를 낸다. 여러 조건을 논리 연산자로 조합할 때 유용하다.
#if defined(WINDOWS) && defined(DEBUG)
printf("Windows 디버그 모드\n");
#elif defined(LINUX)
printf("Linux 환경\n");
#endif
헤더 가드 (Include Guard)
조건부 컴파일의 가장 대표적인 활용 사례가 헤더 가드 다. 같은 헤더 파일이 여러 번 #include되면 구조체나 함수 선언이 중복되어 컴파일 에러가 발생한다. 헤더 가드는 이 문제를 방지한다.
// student.h
#ifndef STUDENT_H
#define STUDENT_H
typedef struct {
char name[20];
int age;
} Student;
void printStudent(const Student *s);
#endif
처음 #include "student.h"가 실행되면 STUDENT_H가 정의되어 있지 않으므로 #ifndef 조건이 참이 되어 내용이 포함된다. 동시에 #define STUDENT_H로 매크로가 정의된다. 두 번째 #include "student.h"가 실행되면 이미 STUDENT_H가 정의되어 있으므로 #ifndef 조건이 거짓이 되어 전체 내용이 건너뛰어진다.
헤더 파일을 작성할 때는 항상 헤더 가드를 넣는 것이 기본이다. 일부 컴파일러는 #pragma once라는 비표준 지시자로 같은 효과를 제공한다.
// student.h (pragma once 방식)
#pragma once
typedef struct {
char name[20];
int age;
} Student;
#pragma once 는 간결하지만 표준이 아니므로, 모든 컴파일러에서 동작을 보장하지는 않는다. 이식성이 중요한 코드에서는 #ifndef 방식을 사용하는 것이 안전하다.
플랫폼별 분기
조건부 컴파일은 하나의 소스 코드로 여러 운영체제를 지원할 때도 사용된다.
#ifdef _WIN32
#include <windows.h>
#define CLEAR "cls"
#elif defined(__linux__)
#include <unistd.h>
#define CLEAR "clear"
#elif defined(__APPLE__)
#include <unistd.h>
#define CLEAR "clear"
#endif
system(CLEAR); // 플랫폼에 맞는 화면 지우기 명령
Windows에서는 _WIN32가 자동으로 정의되고, Linux에서는 __linux__가, macOS에서는 __APPLE__이 정의된다. 같은 코드를 컴파일하더라도 플랫폼에 따라 다른 코드가 포함되는 것이다.
Visual Studio에서 scanf_s를 쓰고 macOS에서 scanf를 써야 하는 상황도 이 방식으로 해결할 수 있다.
#ifdef _MSC_VER
scanf_s("%d", &x);
#else
scanf("%d", &x);
#endif
마무리
전처리기는 컴파일이 시작되기도 전에 소스 코드를 가공하는 도구다. 매크로 상수로 매직 넘버를 제거하고 유지보수를 쉽게 만들 수 있고, 조건부 컴파일로 디버그 코드를 관리하거나 플랫폼별 분기를 처리할 수 있다. 다만 매크로는 텍스트 치환이라는 점을 항상 기억해야 한다. 괄호를 빼먹으면 찾기 어려운 버그가 생기고, 타입 검사도 되지 않는다. 단순한 상수 정의와 헤더 가드에는 매크로가 적합하지만, 복잡한 로직은 함수로 작성하는 것이 안전하다.