전처리기 조건부 컴파일 — if 0으로 코드 끄기와 빌드 옵션 분기
NOTE
코드를 지우지 않고 통째로 꺼두거나, 빌드 환경에 따라 다른 코드를 컴파일에 포함시키는 전처리기 조건부 컴파일(
#if/#ifdef) 패턴을 정리한다.
0. 왜 필요한가
리팩터링 중이거나 원인을 못 찾은 버그를 조사할 때, “이 블록을 잠깐 꺼보고 싶다”는 상황이 자주 생긴다. 지워버리면 되돌리기 번거롭고, /* */로 통째로 주석 처리하려니 그 안에 이미 다른 주석이 섞여 있으면 문제가 생긴다.
/* 이 함수를 통째로 주석 처리하고 싶은데... */
int calc(int x) {
int y = x * 2; /* 2배 */
return y;
}/* 2배 */라는 주석이 안에 있는 상태에서 함수 전체를 /* ... */로 감싸면, 첫 번째 */(2배 주석의 끝)에서 주석이 이미 끝나버려 그 뒤 코드가 그대로 컴파일된다. C의 블록 주석은 중첩(nesting)을 지원하지 않기 때문이다.
1. #if 0 ... #endif — 전처리 단계에서 코드를 통째로 들어낸다
#if 0
int calc(int x) {
int y = x * 2; /* 2배 — 이 안에 주석이 있어도 안전하다 */
return y;
}
#endif#if 0은 “이 조건은 항상 거짓”이라는 뜻이라, #endif까지의 내용은 컴파일러가 보기도 전에 전처리기가 통째로 잘라낸다. 그 안에 블록 주석이 몇 개가 들어있든, 심지어 문법이 깨진 코드가 들어있든 상관없다 — 전처리기 입장에선 그냥 텍스트 덩어리를 건너뛰는 것뿐이라 중첩 주석 문제 자체가 발생하지 않는다.
2. #if 1 / #if 0 스위치로 신·구 구현을 나란히 남겨두기
리팩터링 중 기존 로직을 바로 지우지 않고, 새 구현과 나란히 둔 채 스위치 하나로 바꿔가며 테스트하는 것도 흔한 실무 패턴이다.
int get_value(int key)
{
#if 1 /* 새 구현 — 정렬된 배열에서 이진 탐색 */
return binary_search(g_table, g_table_len, key);
#else /* 이전 구현 — 순차 탐색. 새 구현 검증 끝나면 통째로 삭제 예정 */
int i;
for (i = 0; i < g_table_len; i++) {
if (g_table[i].key == key) {
return g_table[i].value;
}
}
return -1;
#endif
}#if 1을 #if 0으로 한 글자만 바꾸면 즉시 이전 구현으로 돌아갈 수 있어서, 새 구현이 의심스러울 때 되돌리는 비용이 거의 0에 가깝다. 다만 이건 임시 안전장치이지 정착시킬 스타일은 아니다 — 검증이 끝나면 #else 이하는 통째로 지우는 게 맞다(자세한 내용: 5번 항목).
2-1. 실무 통신/임베디드 레거시의 특수 패턴: #if 1 변경 이력 박제(Feature Tagging)
통신망(5GC, NRF 등)이나 폐쇄망 장비의 C 코드베이스에서는 #else도 없이 #if 1 블록만 덩그러니 놓여 있는 코드가 흔히 발견된다:
#if 1 // ADD_PROF_SET_ID : R110 YM 2021.10.07
st_sbi_string_list_t st_nf_set_id_list;
#endif무조건 컴파일되는 코드인데 왜 이렇게 해두었을까?
- 변경 범위(Scope)의 시각적 박제: 단순 주석 한 줄만 달면 어디서부터 어디까지가 이번 기능 추가분인지 모호하지만,
#if 1~#endif로 감싸두면 블록의 시작과 끝이 한눈에 드러난다. - 긴급 롤백(Quick Feature Toggle): 상용망 배포 후 장애 발생 시, 코드를 복잡하게 지울 필요 없이
#if 1을#if 0으로 단 한 글자만 바꾸어 즉시 해당 기능을 비활성화할 수 있다. - 폐쇄망(에어갭) 환경의 한계 극복: 운영 서버 콘솔(vi)에서 긴급 패치나 트러블슈팅을 할 때, git blame/log를 쓸 수 없는 환경이 많다. 코드 자체에 **
[기능명] : [릴리즈] [작업자] [작업일자]**를 남겨둠으로써 소스만으로 작업 맥락을 즉시 파악한다. - 런타임 비용 0: 전처리기 단계에서 조건 평가 후 매크로 블록만 남기므로, 컴파일된 바이너리 크기나 실행 성능에는 어떠한 오버헤드도 없다.
레거시 부채화 주의
이 패턴은 긴급 대응과 추적에는 유리하지만, 기능이 안정화된 수년 뒤에도 코드를 정리(Refactoring)하지 않으면 불필요한
#if 1과 죽은#if 0블록이 코드베이스 전체에 누적되어 가독성을 크게 떨어뜨리는 기술 부채가 된다.
3. #ifdef / #if defined(...) — 빌드 환경에 따라 다른 코드 포함하기
#if 0이 “항상 끔”이라면, #ifdef/#if defined(...)는 빌드 시점에 정의된 매크로에 따라 켜고 끄는 조건부 컴파일이다. 디버그 전용 로그나 플랫폼별 코드 분기에 흔히 쓰인다.
#ifdef DEBUG_LOG
fprintf(stderr, "[DEBUG] value=%d\n", value);
#endif
#if defined(PLATFORM_LINUX)
epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);
#elif defined(PLATFORM_BSD)
kevent(kq, NULL, 0, events, MAX_EVENTS, &ts);
#else
#error "지원하지 않는 플랫폼입니다"
#endifDEBUG_LOG나 PLATFORM_LINUX 같은 매크로는 소스 안에서 #define으로 직접 정의할 수도 있지만, 실무에서는 대개 컴파일 명령의 -D 옵션(gcc -DDEBUG_LOG ...)이나 빌드 설정 파일(Makefile의 CFLAGS)에서 주입한다 — 소스 코드를 건드리지 않고도 빌드 타깃마다 다른 옵션으로 같은 코드를 컴파일할 수 있다는 게 핵심 이점이다.
4. #error로 잘못된 조합을 컴파일 타임에 막기
3번 예제의 #error처럼, 지원하지 않는 매크로 조합이 들어오면 컴파일 자체를 그 자리에서 실패시키고 메시지를 남기는 것도 조건부 컴파일의 일부다. 런타임에 가서야 “이 플랫폼은 지원 안 함”을 알게 되는 것보다, 빌드 단계에서 바로 걸러지는 게 훨씬 안전하다.
5. 자주 하는 실수
#if 0으로 꺼둔 코드는 컴파일 검증을 받지 않는다는 걸 잊기 — 전처리기가 통째로 잘라내므로, 그 안의 코드가 참조하던 함수나 구조체 필드가 나중에 이름이 바뀌거나 삭제돼도 컴파일 에러가 나지 않는다. 한참 뒤에#if 1로 다시 켜는 순간 무더기로 깨진 코드를 마주하게 된다.#if 0블록을 “나중에 정리하겠다”며 방치하기 — 임시로 끈 코드가 코드베이스에 계속 쌓이면, 파일을 열 때마다 “이게 왜 꺼져 있지, 지워도 되나”를 매번 판단해야 하는 부담이 생긴다. 검증이 끝났으면 지우고, 버전 관리 시스템(git 히스토리)이 필요하면 그쪽에서 되찾으면 된다 — 소스 안에 죽은 코드로 남겨둘 이유가 없다.#endif짝을 안 맞추기 —#if/#ifdef가 열렸는데 대응하는#endif가 빠지면, 그 뒤 파일 전체(또는 include된 다음 파일들)가 의도와 다르게 통째로 켜지거나 꺼진 채로 컴파일된다. 중첩이 많아지면#endif옆에// DEBUG_LOG처럼 어떤 조건의 끝인지 주석을 달아두는 게 실무 관례다.- 매크로 이름 오타를 컴파일러가 못 잡아주는 것 —
#ifdef DEBUG_LOG라고 썼는데 실제 정의는DEBUG_LOGS처럼 철자가 다르면, 경고나 에러 없이 그냥 “정의 안 됨”으로 조용히 처리돼 의도한 코드가 컴파일에서 빠진다. 매크로 이름은 헤더 한 곳에 모아 정의해서 오타 여지를 줄이는 게 좋다.