지금까지 작성한 C 코드는 컴파일러가 곧바로 기계어로 바꿔주는 것처럼 보이지만, 사실 그 전에 한 단계가 더 숨어 있습니다. 바로 “전처리(preprocessing)” 과정입니다. #include, #define 처럼 #으로 시작하는 줄들은 컴파일러가 아니라 전처리기라는 별도의 프로그램이 먼저 읽고 처리한 뒤, 그 결과물을 컴파일러에게 넘겨줍니다. 오늘은 이 전처리기가 하는 일과, 코드를 더 간결하고 유연하게 만들어주는 매크로에 대해 알아보겠습니다.

전처리기는 무엇을 하는 걸까요

전처리기는 소스 코드를 컴파일하기 전에 텍스트 수준에서 미리 손질해주는 역할을 합니다. #include <stdio.h> 라고 쓰면 전처리기가 stdio.h 파일의 내용을 그 자리에 그대로 붙여 넣고, #define PI 3.14 라고 쓰면 코드에 등장하는 PI라는 단어를 모두 3.14로 바꿔치기합니다. 즉 전처리기는 코드를 “이해”하는 게 아니라, 정해진 규칙에 따라 문자 그대로 치환하고 조립하는 일을 합니다. 이 작업이 끝난 뒤에야 컴파일러가 진짜 코드 분석과 기계어 변환을 시작합니다.

상수 매크로 만들기

가장 많이 쓰이는 매크로는 반복되는 값을 이름으로 대체하는 상수 매크로입니다.

#include <stdio.h>

#define PI 3.14159
#define MAX_STUDENTS 30

int main(void) {
    double radius = 5.0;
    double area = PI * radius * radius;

    printf("반지름 %.1f인 원의 넓이: %.2f\n", radius, area);
    printf("최대 학생 수: %d명\n", MAX_STUDENTS);

    return 0;
}

실행하면 다음과 같은 결과가 나옵니다.

반지름 5.0인 원의 넓이: 78.54
최대 학생 수: 30명

#define PI 3.14159는 변수 선언처럼 보이지만 전혀 다릅니다. 이 줄은 컴파일 전에 코드 안의 PI라는 글자를 모두 3.14159로 바꿔치기하라는 지시일 뿐이며, 메모리 공간을 차지하지도 않고 자료형도 갖지 않습니다. 이렇게 하면 원주율처럼 여러 번 반복해서 쓰는 값을 한 곳에서만 관리할 수 있어서, 나중에 값을 바꿔야 할 때도 #define 줄 하나만 고치면 됩니다.

함수처럼 동작하는 매크로

매크로는 값뿐만 아니라 간단한 계산식도 대신할 수 있습니다.

#include <stdio.h>

#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

int main(void) {
    int num = 7;
    int a = 15, b = 22;

    printf("%d의 제곱: %d\n", num, SQUARE(num));
    printf("%d와 %d 중 큰 수: %d\n", a, b, MAX(a, b));

    return 0;
}

이 코드의 실행 결과는 다음과 같습니다.

7의 제곱: 49
15와 22 중 큰 수: 22

SQUARE(x)는 마치 함수처럼 보이지만 실제로는 ((x) * (x))라는 텍스트로 치환될 뿐입니다. 여기서 괄호를 꼼꼼히 감싸주는 이유가 중요합니다. 만약 #define SQUARE(x) x * x 처럼 괄호 없이 정의했다면, SQUARE(a + b)가 a + b * a + b로 잘못 치환되어 전혀 다른 계산이 되어버립니다. 매크로는 컴파일러가 타입을 검사해주는 일반 함수와 달리 단순 치환이기 때문에, 괄호를 넉넉하게 써서 의도치 않은 연산 순서 변경을 막아주는 습관이 꼭 필요합니다.

조건부 컴파일로 코드 분기하기

전처리기는 특정 조건에 따라 코드의 일부만 컴파일에 포함시킬 수도 있습니다.

#include <stdio.h>

#define DEBUG 1

int main(void) {
    int total = 0;

    for (int i = 1; i <= 5; i++) {
        total += i;

#if DEBUG
        printf("현재까지 합계: %d (i=%d)\n", total, i);
#endif
    }

    printf("최종 합계: %d\n", total);

    return 0;
}

DEBUG가 1로 정의되어 있으므로 실행 결과는 다음과 같습니다.

현재까지 합계: 1 (i=1)
현재까지 합계: 3 (i=2)
현재까지 합계: 6 (i=3)
현재까지 합계: 10 (i=4)
현재까지 합계: 15 (i=5)
최종 합계: 15

만약 코드 맨 위의 #define DEBUG 1을 #define DEBUG 0으로 바꾸면 #if DEBUG와 #endif 사이의 printf는 아예 컴파일되지 않아서, 디버깅용 출력 없이 “최종 합계: 15” 한 줄만 출력됩니다. 이런 방식은 개발 중에는 자세한 로그를 남기다가, 실제 배포할 때는 그 코드를 완전히 빼버리고 싶을 때 유용하게 쓰입니다.

코드 동작 설명 및 예상 실행 결과

  • 첫 번째 예제는 PI와 MAX_STUDENTS라는 상수 매크로를 정의해서, 원의 넓이 계산과 출력에 그대로 활용합니다.
  • 두 번째 예제는 SQUARE와 MAX라는 함수형 매크로를 정의해서, 괄호를 이용해 안전하게 계산식을 치환합니다.
  • 세 번째 예제는 #if와 #endif를 이용해 DEBUG 값에 따라 디버깅용 출력 코드를 포함할지 말지를 컴파일 시점에 결정합니다.
  • 세 예제 모두 실제 실행 전에 전처리기가 먼저 텍스트를 치환하거나 코드를 걸러낸 뒤, 그 결과를 컴파일러가 처리한다는 공통점을 갖고 있습니다.

핵심 요약

  • 전처리기는 컴파일에 앞서 #으로 시작하는 지시문을 처리하며, #include는 파일 내용을 삽입하고 #define은 텍스트를 치환합니다.
  • 상수 매크로(#define NAME value)는 반복되는 값을 한 곳에서 관리할 수 있게 해주며, 변수와 달리 메모리를 차지하지 않습니다.
  • 함수형 매크로(#define NAME(x) ...)를 만들 때는 매개변수와 전체 식을 괄호로 감싸서 연산 순서 오류를 막아야 합니다.
  • #if, #endif 같은 조건부 컴파일 지시문을 사용하면 특정 조건에서만 코드를 컴파일에 포함시킬 수 있습니다.
  • 매크로는 컴파일러의 타입 검사를 받지 않는 단순 치환이므로, 지나치게 복잡한 매크로보다는 필요한 곳에 신중하게 사용하는 것이 좋습니다.

다음 편에서는 지금까지 배운 내용을 바탕으로 연결리스트, 스택, 큐 같은 “자료구조 응용”을 직접 구현해보겠습니다.