C++ 전처리 지시자: 매크로 부작용, #if·#ifdef 조건부 컴파일, -E로 결과 확인

이 글의 핵심

#include·#define·#ifdef·#pragma 같은 전처리 지시자가 컴파일 전에 소스를 어떻게 바꾸는지 정리합니다. 매크로의 다중 평가와 괄호 누락 부작용, 인클루드 가드, 플랫폼별 조건부 컴파일처럼 실무에서 자주 쓰는 패턴과 함정을 다룹니다.

전처리 지시자란?

전처리 지시자(preprocessor directive)는 컴파일 전에 처리되는 #으로 시작하는 줄로, 파일 포함, 매크로 정의, 조건부 컴파일 등에 사용됩니다.

전처리기는 C++ 문법을 이해하지 못하고 토큰 단위로 치환만 하는 별도의 단계입니다. 컴파일러가 타입 검사나 문법 분석을 시작하기 이전에 소스 코드를 훑으면서 #으로 시작하는 줄을 발견하면, 그 지시자에 따라 파일 내용을 그대로 삽입하거나, 특정 텍스트를 다른 텍스트로 바꾸거나, 코드 블록 전체를 포함시킬지 말지를 결정합니다. 이 특성 때문에 전처리기는 매우 강력하지만 동시에 타입 안전성이 전혀 없고 디버깅이 어렵다는 근본적인 한계를 갖습니다. 아래 예제는 전처리 지시자 세 가지의 가장 기본적인 형태를 보여줍니다.

#include <iostream>  // 파일 포함
#define MAX 100      // 매크로 정의
#ifdef DEBUG         // 조건부 컴파일
    // 디버그 코드
#endif

이 지시자들은 헤더 파일을 끌어오고, 플랫폼이나 빌드 설정(디버그/릴리스)에 따라 코드를 나누고, 이름에 상수나 코드 조각을 연결하는 데 쓰입니다.

같은 조건부 로직이 여러 파일에 흩어져 있으면 고칠 곳도 그만큼 늘어납니다. 아래 코드의 위쪽처럼 각 파일에 #ifdef DEBUG 블록을 직접 넣으면 로깅 형식을 바꿀 때마다 모든 파일을 찾아 고쳐야 하지만, 아래쪽처럼 헤더 파일 하나에 LOG 매크로를 정의해 두면 그 정의만 바꿔서 전체 코드베이스의 동작을 한 번에 제어할 수 있습니다.

// ❌ 파일마다 조건부 블록을 반복
// file1.cpp
void func() {
    #ifdef DEBUG
        std::cout << "디버그\n";
    #endif
}

// file2.cpp
void func2() {
    #ifdef DEBUG
        std::cout << "디버그\n";
    #endif
}

// ✅ 헤더 하나에서 매크로로 중앙 관리
// config.h
#ifdef DEBUG
    #define LOG(x) std::cout << x << '\n'
#else
    #define LOG(x)
#endif

// file1.cpp, file2.cpp
LOG("디버그");

소스 코드가 실행 파일이 되기까지 거치는 여러 단계 중 전처리는 가장 첫 단계입니다. 아래 다이어그램은 전처리기가 컴파일러보다 먼저 실행되어, 컴파일러는 이미 매크로가 모두 확장되고 #include가 모두 삽입된 “순수한 C++ 코드”만 보게 된다는 것을 보여줍니다. 즉 컴파일 에러 메시지에 전혀 다른 위치의 매크로 정의가 언급되는 경우가 종종 있는 이유도, 컴파일러가 실제로는 우리가 작성한 원본 코드가 아니라 전처리기가 치환을 마친 결과물을 보고 있기 때문입니다.

flowchart LR
    A[소스 코드] --> B[전처리기]
    B --> C[전처리된 코드]
    C --> D[컴파일러]
    D --> E[어셈블러]
    E --> F[오브젝트 파일]
    F --> G[링커]
    G --> H[실행 파일]

전처리기는 “포함 → 매크로 확장 → 조건부 컴파일”처럼 종류별로 여러 번 도는 것이 아니라, 파일을 위에서 아래로 한 번 읽으며 만나는 순서대로 지시자를 실행하고 매크로를 확장합니다. 그래서 #define보다 앞에 있는 줄에는 그 매크로가 적용되지 않고, #include된 파일의 내용도 그 자리에서 같은 방식으로 처리됩니다. #if의 조건이 거짓인 구간 안에 있는 #include나 #define은 실행되지 않습니다.

매크로가 예상과 다르게 확장되어 이상한 컴파일 에러가 발생했다면, 가장 확실한 디버깅 방법은 전처리기가 실제로 만들어낸 결과물을 직접 눈으로 확인하는 것입니다. -E 옵션을 주면 컴파일을 중단하고 전처리 단계까지만 실행한 뒤 그 결과를 출력하는데, 이 파일을 열어보면 모든 #include가 실제 파일 내용으로 치환되고 모든 매크로가 실제 값으로 확장된 최종 코드를 볼 수 있습니다.

# GCC/Clang
g++ -E file.cpp -o file.i

# MSVC: /E는 표준 출력으로, /P는 file.i 파일로 저장
cl /E file.cpp
cl /P file.cpp

주요 지시자

C++ 전처리기가 지원하는 지시자는 크게 여섯 가지 범주로 나눌 수 있습니다. #include는 다른 파일의 내용을 그 자리에 그대로 붙여넣고, #define은 이름에 값이나 코드 조각을 연결해 이후 사용될 때마다 치환되도록 합니다. #undef는 그 정의를 취소해 이후부터는 매크로가 적용되지 않게 하며, #ifdef/#ifndef는 매크로가 정의되어 있는지 여부로 코드 포함 여부를 결정합니다. #if/#elif/#else는 단순한 정의 여부를 넘어 정수 상수 표현식을 평가해 더 복잡한 조건 분기를 만들 수 있고, #pragma는 컴파일러별 특수 지시(헤더 중복 방지, 구조체 정렬 등)를 전달하는 데 쓰입니다. 이 밖에 #error는 조건이 맞지 않을 때 메시지와 함께 컴파일을 중단시키고, #warning은 경고만 출력합니다. #warning은 오래전부터 GCC와 Clang의 확장이었고 C++23에서 표준이 되었습니다.

// 1. #include
#include <iostream>   // 시스템 헤더
#include "myheader.h" // 사용자 헤더

// 2. #define
#define PI 3.14
#define MAX(a,b) ((a)>(b)?(a):(b))

// 3. #undef
#undef MAX

// 4. #ifdef, #ifndef
#ifdef DEBUG
    #define LOG(x) std::cout << x
#else
    #define LOG(x)
#endif

// 5. #if, #elif, #else
#if VERSION >= 2
    // 버전 2 이상
#elif VERSION == 1
    // 버전 1
#else
    // 그 외
#endif

// 6. #pragma
#pragma once
#pragma pack(1)

// 7. #error
#ifndef VERSION
    #error "VERSION is not defined"
#endif

실전 예시

아래 네 가지 예시는 헤더 파일 중복 포함 방지, 플랫폼별 코드 분기, 매크로 함수, 토큰 조작이라는 전처리기의 대표적인 활용 사례를 보여줍니다.

예시 1: 인클루드 가드

같은 헤더 파일이 하나의 번역 단위(.cpp 파일 하나와 그것이 포함하는 모든 헤더)에서 직접 또는 다른 헤더를 거쳐 두 번 이상 #include되면, 그 안의 클래스 정의 등이 중복되어 컴파일 에러가 발생합니다. 서로 다른 .cpp 파일이 같은 헤더를 각각 포함하는 것은 이 문제와 무관합니다. 인클루드 가드는 헤더 파일 맨 위에서 아직 정의되지 않은 고유한 매크로 이름을 검사해, 이미 한 번 포함되었다면 나머지 내용을 건너뛰도록 만드는 관용구입니다. #pragma once는 같은 목적을 훨씬 간결한 한 줄로 달성하는 비표준(그러나 사실상 모든 주요 컴파일러가 지원하는) 대안입니다.

// myheader.h
#ifndef MYHEADER_H
#define MYHEADER_H

class MyClass {
    // ...
};

#endif

// 또는 #pragma once
#pragma once

class MyClass {
    // ...
};

예시 2: 조건부 컴파일

플랫폼이나 빌드 설정에 따라 서로 다른 코드를 포함시켜야 할 때 조건부 컴파일을 사용합니다. 이 예제는 DEBUG_MODE 값에 따라 로깅 매크로의 동작을 바꾸고, 운영체제별로 서로 다른 헤더(windows.h vs unistd.h)를 조건부로 포함시킵니다. 중요한 점은 조건이 거짓인 분기의 코드는 전처리 단계에서 아예 컴파일러에게 전달되지 않는다는 것입니다. 즉 PLATFORM_WINDOWS가 아닌 환경에서는 windows.h가 존재하지 않아도 컴파일 에러가 나지 않습니다.

// config.h
#define DEBUG_MODE 1
#define PLATFORM_WINDOWS 1

// main.cpp
#include <iostream>
#include "config.h"

#if DEBUG_MODE
    #define LOG(x) std::cout << "[DEBUG] " << x << std::endl
#else
    #define LOG(x)
#endif

#ifdef PLATFORM_WINDOWS
    #include <windows.h>
#elif defined(PLATFORM_LINUX)
    #include <unistd.h>
#endif

int main() {
    LOG("프로그램 시작");
}

예시 3: 매크로 함수

매크로는 함수처럼 인자를 받는 형태로도 정의할 수 있습니다. SQUARE(x), MAX(a,b)처럼 정의해두면 함수 호출과 비슷한 문법으로 사용할 수 있지만, 실제로는 함수 호출이 아니라 텍스트 치환이 그대로 일어난다는 점이 중요합니다. 이 예제의 매크로들은 모두 각 인자와 전체 식을 괄호로 감싸고 있는데, 이는 뒤에서 다룰 연산자 우선순위 문제를 막기 위한 것입니다. 다만 괄호로는 인자가 두 번 평가되는 문제까지 막을 수 없다는 점도 뒤에서 함께 다룹니다.

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

int main() {
    int x = SQUARE(5);        // 25
    int max = MAX(10, 20);    // 20
    int min = MIN(10, 20);    // 10
}

예시 4: 문자열화

#과 ##은 전처리기 전용의 특별한 연산자로, 일반 매크로 치환과는 동작 방식이 다릅니다. # 연산자(문자열화 연산자)는 매크로 인자를 그대로 문자열 리터럴로 감싸 주는데, STRINGIFY(Hello)가 "Hello"라는 실제 문자열이 되는 식입니다. ## 연산자(토큰 결합 연산자)는 두 토큰을 이어 붙여 새로운 하나의 토큰을 만드는데, 흔히 코드 생성 매크로에서 변수 이름이나 함수 이름을 동적으로 조합할 때 사용됩니다.

#include <iostream>

#define STRINGIFY(x) #x
#define CONCAT(a,b) a##b

int main() {
    std::cout << STRINGIFY(Hello) << std::endl;  // 문자열 리터럴 "Hello"로 치환되어 Hello 출력
    
    int xy = 10;
    int result = CONCAT(x, y);  // xy
}

조건부 컴파일

크로스 플랫폼 코드를 작성할 때는 컴파일러가 미리 정의해주는 매크로들을 활용해 운영체제나 컴파일러 종류를 감지할 수 있습니다. _WIN32, __linux__, __APPLE__은 각각 해당 플랫폼에서 컴파일될 때만 자동으로 정의되는 매크로이고, __GNUC__와 _MSC_VER은 각각 GCC/Clang 계열과 MSVC를 구분하는 데 쓰입니다. NDEBUG는 컴파일러가 미리 정의하는 매크로가 아니라, 표준이 assert의 동작 기준으로 지정한 이름입니다. CMake의 Release 구성이나 Visual Studio의 Release 구성처럼 빌드 시스템이 릴리스 빌드에서 -DNDEBUG를 넘겨 주는 경우가 많아 디버그/릴리스 분기의 기준으로 널리 쓰이지만, 직접 만든 Makefile이라면 스스로 정의해야 합니다.

#ifdef X는 #if defined(X)와 같습니다. 조건이 하나면 #ifdef가 간결하고, #if defined(DEBUG) && defined(VERBOSE)처럼 여러 조건을 묶거나 #elif에서 정의 여부를 검사할 때는 defined 연산자를 써야 합니다.

// 플랫폼별
#ifdef _WIN32
    // Windows 코드
#elif defined(__linux__)
    // Linux 코드
#elif defined(__APPLE__)
    // macOS 코드
#endif

// 컴파일러별 (Clang도 __GNUC__를 정의함)
#ifdef __GNUC__
    // GCC/Clang 코드
#elif defined(_MSC_VER)
    // MSVC 코드
#endif

// 디버그/릴리스
#ifdef NDEBUG
    // 릴리스 코드
#else
    // 디버그 코드
#endif

자주 발생하는 문제

괄호 없는 매크로가 전처리 후 어떻게 펼쳐지는지와 계산 결과

그림: 실제 실행 결과 — g++ -E로 전처리 결과를 보면 SQUARE(1 + 2)가 1 + 2 * 1 + 2로 펼쳐져 5가 된다. 매크로 인자와 전체를 괄호로 감싸야 9가 나온다. (환경: Ubuntu 24.04 · g++ 13.3)

문제 1: 매크로 부작용

매크로는 단순 텍스트 치환이기 때문에 인자를 괄호로 감싸지 않으면 연산자 우선순위가 완전히 깨질 수 있습니다. #define SQUARE(x) x * x에 SQUARE(1 + 2)를 넘기면, 매크로는 x 자리에 1 + 2라는 텍스트를 그대로 치환하므로 실제로는 1 + 2 * 1 + 2가 되어 곱셈이 덧셈보다 먼저 계산되는 바람에 5라는 잘못된 결과가 나옵니다. 인자 전체와 각 인자 사용처를 모두 괄호로 감싸면 이런 우선순위 문제를 피할 수 있지만, 이런 실수는 매크로가 함수보다 위험한 대표적인 이유이기도 합니다.

// ❌ 부작용
#define SQUARE(x) x * x
int result = SQUARE(1 + 2);  // 1 + 2 * 1 + 2 = 5

// ✅ 괄호 사용
#define SQUARE(x) ((x) * (x))
int result = SQUARE(1 + 2);  // 9

괄호로도 해결되지 않는 부작용이 인자의 중복 평가입니다. 매크로는 인자를 쓰는 횟수만큼 그 텍스트를 복사하므로, 증감 연산자나 함수 호출처럼 부수 효과가 있는 식을 넘기면 그 효과가 두 번 일어납니다.

int i = 3;
int a = SQUARE(i++);   // ((i++) * (i++)): 같은 변수를 두 번 수정 → 정의되지 않은 동작

int j = 5, k = 4;
int m = MAX(j++, k);   // ((j++) > (k) ? (j++) : (k)): j가 두 번 증가, m은 6, j는 7

MAX(f(), g())처럼 넘기면 큰 쪽 함수가 두 번 호출됩니다. 이런 경우는 매크로 대신 std::max나 constexpr 함수를 써야 인자가 정확히 한 번만 평가됩니다.

문제 2: 인클루드 가드 누락

인클루드 가드가 없는 헤더 파일이 한 번역 단위 안에서 두 번 포함되면(예를 들어 a.h와 b.h가 둘 다 myheader.h를 포함하고 main.cpp가 a.h와 b.h를 모두 포함하는 경우), 그 안에 정의된 클래스가 중복 정의되어 컴파일 에러가 발생합니다. 특히 헤더끼리 서로를 참조하는 순환 포함 구조에서는 가드가 없으면 무한 포함 루프에 빠질 수도 있습니다. 새로 만드는 헤더 파일에는 예외 없이 인클루드 가드나 #pragma once를 넣는 것이 습관화되어야 합니다.

// ❌ 가드 없음
// myheader.h
class MyClass {};

// ✅ 가드 추가
#ifndef MYHEADER_H
#define MYHEADER_H
class MyClass {};
#endif

문제 3: 매크로 vs 함수

매크로로 만든 MAX는 타입을 검사하지 않기 때문에 MAX(-1, 1u)처럼 부호 있는 정수와 부호 없는 정수를 섞어도 그대로 치환되고, 일반 산술 변환에 따라 -1이 매우 큰 부호 없는 값으로 바뀌어 엉뚱한 쪽이 선택됩니다. 앞에서 본 중복 평가 문제도 있고, 매크로는 디버거에서 한 줄로 보여 단계별로 추적하기도 어렵습니다. 템플릿 함수로 같은 기능을 구현하면 인자가 한 번만 평가되고, 서로 다른 타입을 섞으면 템플릿 인자 추론이 실패해 컴파일 단계에서 드러납니다.

// ❌ 매크로: 타입 검사 없음, 인자 중복 평가
#define MAX(a,b) ((a)>(b)?(a):(b))

// ✅ 템플릿 함수 (이름 충돌을 피하려면 표준의 std::max를 그대로 쓰는 편이 낫다)
template<typename T>
constexpr const T& max_of(const T& a, const T& b) {
    return a < b ? b : a;
}

Windows에서 <windows.h>를 포함하면 min/max라는 함수형 매크로가 정의되어 std::max(a, b)까지 치환되어 깨지는 문제가 자주 생깁니다. <windows.h>보다 먼저 NOMINMAX를 정의하거나 (std::max)(a, b)처럼 괄호로 감싸면 매크로 확장을 피할 수 있습니다. 함수형 매크로는 이름 바로 뒤에 (가 와야 확장되기 때문입니다.

문제 4: #pragma once vs 가드

#pragma once는 코드가 짧고 오타로 인한 매크로 이름 충돌(같은 MYHEADER_H를 다른 헤더에서도 실수로 재사용하는 경우)이 생길 여지가 없다는 장점이 있지만, 엄밀히는 C++ 표준에 포함된 기능이 아니라 컴파일러 확장입니다. 다만 GCC, Clang, MSVC를 포함한 주요 컴파일러가 모두 지원하기 때문에 널리 쓰입니다. 컴파일러는 파일의 정체를 경로나 파일 시스템 정보로 판단하므로, 같은 헤더가 심볼릭 링크나 복사본처럼 서로 다른 경로로 포함되면 같은 파일로 인식하지 못하는 경우가 있습니다. 이식성을 극도로 중시하는 프로젝트나 오래된 툴체인을 지원해야 하는 코드베이스에서는 전통적인 인클루드 가드를 여전히 선호하거나, 안전하게 둘 다 함께 쓰는 경우도 있습니다.

// #pragma once (간단)
#pragma once
class MyClass {};

// 인클루드 가드 (표준)
#ifndef MYHEADER_H
#define MYHEADER_H
class MyClass {};
#endif

#pragma 지시자

#pragma는 표준 지시자로 처리할 수 없는, 컴파일러별 특수 지시를 전달하는 만능 창구입니다. #pragma pack은 구조체 멤버 사이의 패딩(정렬용 빈 공간)을 제거하거나 조정할 때 쓰이는데, 네트워크 프로토콜이나 파일 포맷처럼 바이트 단위 정렬이 정확히 맞아야 하는 바이너리 구조체를 다룰 때 필수적입니다. #pragma warning은 MSVC에서 특정 경고를 억제할 때(GCC/Clang은 #pragma GCC diagnostic을 씁니다), #pragma message는 빌드 로그에 커스텀 메시지를 남길 때, #pragma omp는 OpenMP 병렬화 지시어를 컴파일러에 전달할 때 사용됩니다. 이런 #pragma들은 컴파일러마다 지원 여부와 문법이 다를 수 있으므로 사용 전 대상 컴파일러의 문서를 확인해야 합니다.

// 1. #pragma once
#pragma once

// 2. #pragma pack
#pragma pack(push, 1)
struct Data {
    char c;
    int i;
};
#pragma pack(pop)

// 3. #pragma warning (MSVC 전용)
#pragma warning(disable: 4996)

// 4. #pragma message
#pragma message("컴파일 메시지")

// 5. #pragma omp (OpenMP)
#pragma omp parallel for
for (int i = 0; i < 100; i++) {
    // 병렬 처리
}

실무 패턴

아래 세 가지 패턴은 크로스 플랫폼 지원, 디버그 로깅, 버전 호환성 관리처럼 실무 C++ 프로젝트에서 전처리기가 실제로 어떤 역할을 하는지 보여줍니다.

패턴 1: 플랫폼 추상화

여러 운영체제를 지원하는 코드베이스에서는 플랫폼별 차이(심볼 내보내기 문법, 경로 구분자, 시스템 헤더 등)를 애플리케이션 코드 전반에 흩뿌리는 대신, platform.h 같은 헤더 하나에 모아두고 나머지 코드는 공통 매크로(EXPORT, PATH_SEP)만 사용하도록 만드는 것이 유지보수에 훨씬 유리합니다. 이렇게 하면 새로운 플랫폼을 지원해야 할 때도 이 헤더 하나만 확장하면 되고, 애플리케이션 로직 코드는 플랫폼 차이를 전혀 신경 쓰지 않아도 됩니다.

// platform.h
#ifdef _WIN32
    #define EXPORT __declspec(dllexport)
    #define PATH_SEP '\\'
    #include <windows.h>
#elif defined(__linux__)
    #define EXPORT __attribute__((visibility("default")))
    #define PATH_SEP '/'
    #include <unistd.h>
#elif defined(__APPLE__)
    #define EXPORT __attribute__((visibility("default")))
    #define PATH_SEP '/'
    #include <TargetConditionals.h>
#endif

// 사용
EXPORT void myFunction() {
    std::string path = "dir" + std::string(1, PATH_SEP) + "file.txt";
}

패턴 2: 디버그 로깅

디버그 빌드에서만 상세한 로그와 단언(assertion)을 출력하고, 릴리스 빌드에서는 그 오버헤드를 완전히 제거하고 싶을 때 매크로가 유용합니다. 이 예제의 LOG와 ASSERT는 DEBUG가 정의되어 있을 때만 실제 코드로 확장되고, 그렇지 않으면 빈 매크로로 치환되어 릴리스 바이너리에는 관련 코드가 아예 존재하지 않게 됩니다. __FILE__과 __LINE__은 컴파일러가 자동으로 채워주는 매크로로, 로그 메시지에 호출한 쪽의 소스 위치를 남길 수 있습니다(C++20부터는 std::source_location으로 함수에서도 같은 정보를 얻을 수 있습니다). ASSERT를 do { ... } while (0)로 감싼 이유는, 단순히 if 블록으로 정의하면 if (x) ASSERT(p); else ...에서 뒤의 else가 매크로 안의 if에 붙어 버리기 때문입니다.

// debug.h
#ifdef DEBUG
    #define LOG(level, msg) \
        std::cout << "[" << level << "] " << __FILE__ << ":" << __LINE__ \
                  << " " << msg << '\n'
    #define ASSERT(cond) \
        do { \
            if (!(cond)) { \
                std::cerr << "Assertion failed: " #cond << '\n'; \
                std::abort(); \
            } \
        } while (0)
#else
    #define LOG(level, msg) do {} while (0)
    #define ASSERT(cond) do {} while (0)
#endif

// 사용
void processData(int* data, size_t size) {
    ASSERT(data != nullptr);
    ASSERT(size > 0);
    
    LOG("INFO", "Processing " << size << " items");
    // ...
}

이 예제를 -DDEBUG를 주고 컴파일해 -E로 확장 결과를 보면 ASSERT(data != nullptr)가 do { if (!(data != nullptr)) { ... } } while (0);로, LOG가 std::cout << "[" << "INFO" << "] " << ... 한 문장으로 펼쳐집니다. nullptr을 넘기면 Assertion failed: data != nullptr을 출력하고 abort()로 종료했고(종료 코드 134), -DDEBUG 없이 빌드하면 아무것도 출력하지 않고 정상 종료했습니다. if (x) ASSERT(x > 0); else ...도 do ... while (0) 덕분에 else가 바깥 if에 붙은 채 컴파일됐습니다. (환경: macOS · Apple clang++ -std=c++17)

패턴 3: 버전 관리

라이브러리를 여러 버전에 걸쳐 유지보수해야 할 때, 새 기능이 특정 버전부터 추가되었다면 전처리 조건으로 그 기능의 사용 가능 여부를 표현할 수 있습니다. VERSION_MAJOR >= 2일 때만 HAS_NEW_FEATURE를 정의해두면, 실제 사용하는 코드(useAPI)는 그 매크로의 정의 여부만 확인해서 신버전 API와 구버전 호환 코드 중 하나를 컴파일 타임에 선택할 수 있습니다. 이 패턴은 하나의 코드베이스로 여러 SDK 버전을 동시에 지원해야 하는 라이브러리 개발에서 특히 자주 쓰입니다.

// version.h
#define VERSION_MAJOR 2
#define VERSION_MINOR 3
#define VERSION_PATCH 1

#if VERSION_MAJOR >= 2
    #define HAS_NEW_FEATURE 1
#endif

// api.cpp
void useAPI() {
    #ifdef HAS_NEW_FEATURE
        // 새 기능 사용
        newFeature();
    #else
        // 구버전 호환
        oldFeature();
    #endif
}

참고 자료