Skip to main content

Command Palette

Search for a command to run...

C언어 (6)

Updated
7 min read

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

마무리

전처리기는 컴파일이 시작되기도 전에 소스 코드를 가공하는 도구다. 매크로 상수로 매직 넘버를 제거하고 유지보수를 쉽게 만들 수 있고, 조건부 컴파일로 디버그 코드를 관리하거나 플랫폼별 분기를 처리할 수 있다. 다만 매크로는 텍스트 치환이라는 점을 항상 기억해야 한다. 괄호를 빼먹으면 찾기 어려운 버그가 생기고, 타입 검사도 되지 않는다. 단순한 상수 정의와 헤더 가드에는 매크로가 적합하지만, 복잡한 로직은 함수로 작성하는 것이 안전하다.

More from this blog

C언어 (13)

1. STL의 개념 1-1. 배경 C++로 프로그래밍을 하다 보면 동적 배열, 연결 리스트, 정렬, 검색 같은 자료구조와 알고리즘을 반복적으로 구현하게 된다. 프로젝트마다 매번 새로 만들면 시간도 낭비되고, 버그가 생길 가능성도 높아진다. 이런 문제를 해결하기 위해 자주 사용되는 자료구조와 알고리즘을 미리 만들어서 표준 라이브러리에 포함 시킨 것이 STL이

Apr 1, 202610 min read3

chamdom's tech

16 posts