Menu

C의 조건부 컴파일: #ifdef, #ifndef, #if, 그리고 -D

#ifdef, #ifndef, #if, #elif, #else로 빌드마다 다른 코드를 컴파일하는 법 - 디버그 스위치, 플랫폼 분기, -D로 명령줄에서 매크로 정의하기, 그리고 #if 0으로 코드를 비활성화하기.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

조건부 컴파일은 하나의 소스 파일을 여러 개의 서로 다른 프로그램으로 만들어 줍니다. 전처리기가 컴파일러 실행 전에 어떤 텍스트 블록이 살아남을지 결정하므로, 성립하지 않는 분기 안의 코드는 실행 시점에 그냥 건너뛰는 게 아니라 아예 삭제됩니다. 파싱조차 되지 않으니 실행 파일에 아무런 기여도 하지 않으며, 심지어 지금 컴파일러가 거부할 만한 문법을 담고 있어도 괜찮습니다.

평범한 if와 다른 점이 바로 이것입니다. 둘 다 경로를 고르지만, 하나는 프로그램이 존재하기 전에 고릅니다.

#ifdef와 #ifndef

가장 단순한 질문은 매크로가 정의되어 있는지 여부입니다.

#define DEBUG 줄을 지우면 모든 디버그 출력이 빌드에서 사라집니다. 비활성화되는 게 아니라 없어집니다. 있는 그대로 한 번 실행해 보고, 그 줄을 지운 뒤 다시 실행해 보세요.

#ifndef는 그 반대입니다. 매크로가 정의되어 있지 않을 때만 블록을 남깁니다. 가장 흔한 용도는 헤더 파일에서 다루는 인클루드 가드이고, 호출하는 쪽이 덮어쓸 수 있는 기본값을 제공하는 방법이기도 합니다.

모든 #ifdef#ifndef#endif로 닫아야 합니다. 긴 파일에서는 닫는 쪽에 주석을 달아 두면 - #endif /* DEBUG */ - 나중에 시간을 크게 아낄 수 있습니다.

#if, #elif, #else

#if는 정수 상수식을 받아서 그 값이 0이 아닐 때 블록을 남깁니다. 덕분에 버전이나 레벨 비교가 가능해집니다.

LOG_LEVEL을 0이나 3으로 바꾸고 다시 실행해서 프로그램 자체가 모양을 바꾸는 걸 확인해 보세요.

이 식은 전처리기가 평가합니다. 따라서 정수 상수, 산술·비교 연산자, 그리고 그런 값으로 확장되는 매크로만 쓸 수 있습니다. sizeof, enum 값, 변수처럼 컴파일러가 필요한 것은 전혀 보지 못합니다. #if 안에서 정의되지 않은 매크로는 오류가 아니라 0으로 평가되는데, 편리하기도 하고 가끔은 놀랍기도 합니다.

#if FEATURE_X        /* FEATURE_X는 어디에도 정의되지 않음 -> 0 -> 블록 삭제 */

defined(NAME)#ifdef가 던지는 질문을 #if 안에서 쓸 수 있는 형태로 바꿔 주므로, 여러 조건을 조합할 수 있습니다.

#if defined(DEBUG) && !defined(NDEBUG)
    /* 디버그 빌드이면서 assert가 켜져 있음 */
#endif

#if defined(LINUX) || defined(BSD)
    /* 둘 중 어느 유닉스 계열 시스템이든 */
#endif

#if defined(X)#ifdef X는 같은 뜻입니다. &&, ||, !가 필요할 때 앞의 형태를 쓰세요.

명령줄에서 매크로 정의하기

이 스위치들의 진짜 힘은 소스를 전혀 고치지 않아도 된다는 데 있습니다. gcc -D는 컴파일 전체에 대해 매크로를 정의합니다.

gcc -DDEBUG program.c -o program        # DEBUG가 1로 정의됨
gcc -DLOG_LEVEL=3 program.c -o program  # 특정 값 지정
gcc -DDEBUG -DBUFFER_SIZE=512 a.c b.c -o app

-DNAME만 쓰면 #define NAME 1과 같습니다. -U NAME은 정의를 해제하는데, 헤더가 정의해 둔 것을 끄고 싶을 때 요긴합니다.

그래서 보통의 구성은 이렇습니다. 소스에는 #ifndef로 감싼 기본값을 두고, 빌드 명령이 구성을 선택합니다.

/* config.h */
#ifndef LOG_LEVEL
#define LOG_LEVEL 1        /* 기본은 조용하게 */
#endif

#ifndef MAX_CONNECTIONS
#define MAX_CONNECTIONS 64
#endif
# 개발 빌드
gcc -DDEBUG -DLOG_LEVEL=3 -Wall -Wextra -g src/*.c -o app-dev

# 릴리즈 빌드
gcc -DNDEBUG -O2 src/*.c -o app

NDEBUG는 표준으로 정해져 있습니다. 이걸 정의하면 프로그램의 모든 assert()가 비활성화되는데, <assert.h> 자체가 조건부 컴파일로 작성되어 있기 때문입니다. 지금까지의 패턴을 축소해 놓은 예라고 할 수 있습니다. 빌드가 무엇을 정의했느냐에 따라 서로 다른 것으로 컴파일되는 헤더 말이죠.

플랫폼 분기

컴파일러는 대상 시스템을 식별하는 매크로를 미리 정의해 두므로, 하나의 소스 파일로 각 시스템에서 알맞은 API를 호출할 수 있습니다.

#if defined(_WIN32)
    #include <windows.h>
    #define CLEAR_SCREEN "cls"
#elif defined(__APPLE__)
    #include <unistd.h>
    #define CLEAR_SCREEN "clear"
#elif defined(__linux__)
    #include <unistd.h>
    #define CLEAR_SCREEN "clear"
#else
    #error "Unsupported platform"
#endif

흔히 쓰이는 것들: _WIN32(32비트와 64비트 윈도우 모두에서 정의됨), _WIN64, __linux__, __APPLE__, __unix__, __ANDROID__. 컴파일러 식별용으로는 __GNUC__, __clang__, _MSC_VER가 있고, 아키텍처용으로는 __x86_64__, __aarch64__가 있습니다.

무엇 위에서 빌드되었는지 알려 주는 실행 가능한 버전입니다.

#error는 그 자체로 알아 둘 만합니다. 여러분이 적은 메시지와 함께 컴파일을 중단시킵니다. 플랫폼 분기의 끝을 #else / #error "Unsupported platform"로 마무리하면, 조용히 잘못 빌드되던 상황이 빌드 로그 맨 위의 명확한 실패로 바뀝니다.

컴파일러가 미리 정의하는 매크로를 전부 보려면 이렇게 하세요.

gcc -dM -E - < /dev/null

이 출력이 "이 머신에서 무엇을 검사할 수 있는가"에 대한 가장 확실한 답입니다.

#if 0으로 코드 비활성화하기

/* ... */로 블록을 주석 처리하는 방식은 그 블록 안에 주석이 하나라도 있으면 바로 실패합니다. C 주석은 중첩되지 않아서, 안쪽의 첫 */가 바깥 주석을 끝내 버리고 그 뒤는 전부 떠돌이 코드가 되기 때문입니다. #if 0에는 그런 문제가 없습니다.

#if 0
    /* 이 영역 전체가 주석까지 통째로 제거됩니다. */
    legacy_init();
    int n = old_calculation(42);  /* 이 주석도 문제없습니다 */
    report(n);
#endif

#if 1로 바꾸면 코드가 돌아옵니다. 편집기는 여전히 C로 강조해 주고, #if 0을 다른 #if 안에 중첩할 수도 있습니다.

이건 디버깅 도구이지 보관 시스템이 아닙니다. #if 0 안에 놓인 코드는 컴파일되지 않으므로 조용히 썩습니다. 누군가 스위치를 되돌릴 때쯤이면 이미 빌드되지 않을 겁니다. 문제를 좁혀 나가는 동안 쓰고, 다 끝나면 블록을 지운 뒤 기억은 버전 관리에 맡기세요.

조건부 컴파일이 잘못되는 지점

세 가지 실패 유형이 대부분의 고통을 설명합니다.

컴파일되지 않는 코드는 검사되지도 않습니다. 비활성 #ifdef 분기 안의 오타는 누군가 그 구성으로 빌드하기 전까지 보이지 않습니다. 몇 달 뒤일 수도 있고, 여러분에게 없는 플랫폼의 CI에서일 수도 있습니다. 프로젝트에 의미 있는 플랫폼 분기가 있다면 전부 정기적으로 빌드하세요.

#ifdef를 제어 흐름과 뒤섞으면 순식간에 읽기 힘들어집니다. 이런 코드는 머릿속에서 따라가기가 어렵습니다.

if (ready) {
#ifdef FAST_PATH
    fast_send(buf);
} else {
#endif
    slow_send(buf);
}

한 분기에서 연 중괄호를 다른 분기에서 닫는 것은 문법적으로 합법이지만 끔찍합니다. 함수 전체를 감싸는 조건문을 쓰고, 그중 하나를 고르는 쪽이 낫습니다.

#ifdef FAST_PATH
static void send_data(const char *buf) { fast_send(buf); }
#else
static void send_data(const char *buf) { slow_send(buf); }
#endif

실행 시점의 if가 더 나을 때가 많습니다. 두 분기가 어디서든 컴파일된다면, 평범한 if (debug_enabled)가 두 경로 모두를 타입 검사 대상이자 테스트 가능한 상태로 유지하고 다시 빌드하지 않아도 전환할 수 있게 해 줍니다. 전처리기는 오직 전처리기만 할 수 있는 일, 즉 다른 플랫폼에서 정말로 컴파일될 수 없는 코드에 아껴 쓰세요.

이 모든 것을 한데 묶은 패턴 - 기본값을 담은 헤더, 빌드마다 주는 -D, 인클루드 가드로 감싼 플랫폼 분기 - 은 헤더 파일을, #define 자체는 매크로를 참고하세요.

자주 묻는 질문

C에서 조건부 컴파일이란 무엇인가요?

컴파일러가 실행되기 전에 전처리기 지시자로 소스 코드 블록을 남기거나 버리는 것입니다. 성립하지 않는 #ifdef 안의 코드는 텍스트에서 아예 삭제됩니다. 파싱되지도, 컴파일되지도, 실행 파일에 나타나지도 않습니다. 하나의 소스 파일로 여러 플랫폼이나 빌드 유형을 지원하는 방법입니다.

#ifdef와 #if의 차이는 무엇인가요?

#ifdef NAME은 값과 무관하게 그 매크로가 존재하는지만 묻습니다. #if 식은 정수 상수식을 평가하므로 값을 비교할 수 있습니다. #if VERSION >= 3처럼요. 있고 없고만 따지는 플래그에는 #ifdef를, 값이 중요할 때는 #if를 쓰세요. #if defined(NAME)은 둘을 합친 형태이며 &&||로 이어 붙일 수 있습니다.

gcc에서 명령줄로 매크로를 정의하려면 어떻게 하나요?

-D를 씁니다. gcc -DDEBUG program.c는 파일 맨 앞에 #define DEBUG 1이 있는 것처럼 DEBUG를 정의하고, -DMAX=50은 특정 값을 지정합니다. 빌드 시스템이 소스를 고치지 않고 기능을 켜는 방법이며, -U NAME은 정의를 해제합니다.

코드를 주석 처리하는 대신 #if 0을 쓰는 이유는 무엇인가요?

/* ... */ 주석은 중첩되지 않기 때문입니다. 영역 안에 있는 첫 */가 주석을 일찍 끝내 버리고, 나머지는 깨진 코드가 됩니다. #if 0 ... #endif는 주석이 몇 개 들어 있든 얼마든지 많은 코드를 제거하고, 문법 강조도 그대로 유지되며, #if 1로 바꾸기만 하면 간단히 되돌릴 수 있습니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기