Make 기반 빌드 시스템 (5): 디버그 버전과 릴리즈 버전 빌드

디버그 버전과 릴리즈 버전 빌드

소프트웨어 개발자라면 누구나 한번쯤 경험해 보셨겠지만, 소프트웨어 개발하는 과정 중에는 종종 정말이지 아무것도 믿을 수 없는 상태에 빠질 때가 있습니다. 완벽해 보이는 코드(?)를 작성해서 컴파일하고 링킹해서 빌드된 결과물을 실제로 실행했건만, 의도한 대로 동작하지 않습니다. 갖가지 험악한 메시지를 뿜으며 비정상 종료되거나 조용히 아무런 출력도 없이 데드락이 걸리기도 하고, 시스템의 모든 리소스를 무한정 집어 삼키는 바람에 결국 시스템의 전원을 껐다 켜기만 하는 상황이 발생하기도 하죠.

나 자신 혹은 동료의 바보 같은 오타 때문이든, 테스터가 실행 환경 혹은 테스트 데이터를 잘못 설정해서였든, 덕이 부족한 나랏님 탓이든… 좌우지간 뭔가 잘못 동작하면 그 원인을 찾거나 최악의 경우 회피책이라도 찾아야만 하는 상황에 처하게 됩니다. 원인을 찾아야만 스스로를 탓하며 머리카락을 쥐어 뜯거나, 원인 제공자를 구박하며 분풀이(?)라도 할 수 있을 테고, 촉박한 일정, 하드웨어나 라이브러리의 한계, 뭐 이런저런 이유로 근본적인 원인 파악 및 해결이 힘들다면, 최소한 문제가 발생하는 시점이나 상황을 찾아서 이를 피해갈 수 있는 방법을 찾아야겠지요.

원인이나 문제 발생 지점을 찾기 위해 코어 덤프를 떠서 첨단 디버거에서 분석할 수도 있을테고, 혹은 printf() 호출 신공으로 작성된 로그를, 눈이 빠져라 들여다 보며 소프트웨어 개발자의 팔자를 탓할 수도 있겠습니다…만, 뭐가 됐던 디버깅을 하려면 일단 작성한 코드를 디버그 버전으로 컴파일 해야 합니다. 디버깅 심벌을 삽입하고 최적화도 하지 않고, 최종 배포 버전에서는 출력되지 않을, 각종 로그나 디버그 메시지도 출력되도록 하고요.

디버그 모드와 릴리즈 모드 구분

보통 디버그 버전일 경우에는 컴파일 옵션을 다음과 같이 줍니다.

gcc -g -O0 -DDEBUG source_file -o target_file ...

-g 는 컴파일 시 디버깅 심볼을 넣으라는 옵션이고요, -O (대문자 O입니다.) 뒤에 숫자를 붙는 숫자는 최적화 정도입니다. 디버깅을 할 것이기 때문에 최적화를 하지 않도록 숫자 0 을 붙인 거죠. 그리고 -D 는 컴파일 매크로 선언에 붙는 prefix입니다. 보통은 .c 코드 소스 상에서 디버깅용 코드는 #ifdef DEBUG … #endif 사이에 넣어 두죠. 뭐, 사람이나 조직에 따라 _DEBUG 또는 _DEBUG_ 이렇게 쓰기도 할 텐데, 그런 경우에는 -D_DEBUG 또는 -D_DEBUG_ 식으로 맞춰 선언해 주면 되겠죠?

한편 릴리즈로 빌드할 경우에는 흔히 다음과 같이 줍니다.

gcc -O2 -DNDEBUG ...

최적화 레벨 2에 NDEBUG 라는 매크로를 선언하는 거죠. 역시 필요에 따라 최적화 레벨을 달리 줄 수도 있고 특별한 매크로를 쓰지 않거나 다른 매크로를 선언해 줄 수도 있을 겁니다.

어쨌거나 핵심은 gcc 로 컴파일 할 때 넘겨주는 옵션을 디버그냐 릴리즈냐에 따라 다르게 선언해 주는 거죠. 이건 이전 글에서 이미 다뤘던, if … else … endif 를 사용하면 어렵지 않게 모드에 따라 다르게 선언할 수 있습니다. 모드 구분을 위해 RELEASE 라는 이름의 변수를 추가하고, 이 값이 1이라는 값을 가지면 릴리즈용 옵션으로, 그렇지 않으면 DEBUG용 옵션으로 넘어가게 하는 겁니다. 이렇게 말이죠.

ifeq ($(RELEASE), 1)
DBG_FLAGS = -O2 -DNDEBUG
else
DBG_FLAGS = -g -O0 -DDEBUG
endif

gcc $(DBG_FLAGS) source_file -o target_file ...

그럼 남는 문제는 (1) ‘옵션을 gcc에게 어떻게 넘겨 주느냐’ 와, (2) ‘RELEASE 변수를 어떻게 선언하느냐’ 가 되겠네요.

확장자 규칙으로 컴파일 옵션 지정하기

Make 기반 빌드 시스템 (2): Makefile 기초에서 다룬 확장자 규칙(Suffix Rule)은 참 편리한 기능입니다. 내가 작성하지 않아도 make가 알아서 처리 해주니 간단한 빌드 작업을 위한 Makefile을 작성할 때는 특히나 유용합니다. 하지만, 만약 뭔가 다른 나만의 옵션을 쓰고 싶으면, 내장된 확장자 규칙 대신 그에 상응하는 ‘나만의’ 확장자 규칙을 Makefile에 작성하면 됩니다. 다음을 보세요.

ifeq ($(RELEASE), 1)
DBG_FLAGS = -O2 -DNDEBUG
else
DBG_FLAGS = -g -O0 -DDEBUG
endif

.c.o:
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    $(CC) $(CFLAGS) $(DBG_FLAGS) -c $< -o $@

(1) ~ (5)는 디버그/릴리즈 모드와 관련된 DBG_FLAGS 라는 변수를 정의한 것입니다. 그리고 (7) ~ (11)는 내장 확장자 규칙을 대체할 나만의 .c.o 확장자 규칙인데요, (8) ~ (10)은 단순히 컴파일 작업 앞에 좀 이쁘게(?) 꾸며주는 역할만 수행하는 것으로 뭐 동작에는 큰 영향은 없습니다. (그래도 그냥 gcc 컴파일 명령만 나오는 것 보다는 이런 장식이 있는 게 낫지 않나요? 각자 고유의 미적 감각을 동원해서 자신만의 장식을 한번 만들어 보셔도 좋겠습니다.)

실제 일은 (11)의 gcc 한테 컴파일을 시키는 부분입니다. $< 는 확장자 규칙의 전제조건인 .c 소스 파일이 되고, $@는 타겟인 .o 파일을 의미하게 됩니다. 따라서 gcc 는 $(CFLAGS) 옵션과 $(DBG_FLAGS)을 가지고 .c 파일을 컴파일 해서, 그 결과인 목적 파일을 동일한 이름의 .o 확장자로 저장합니다.

참고로 CFLAGS 변수는 make가 내장 규칙에 사용하는 사전 정의된 변수 중 하나입니다. 사실 DBG_FLAGS의 내용을 CFLAGS에 추가해서 굳이 .c.o 규칙을 만들지 않고 내장 확장자 규칙을 사용할 수도 있습니다.

하지만, 프로젝트 규모가 커지고, 가독성을 위해 다양한 변수들로 옵션을 지정해서 사용하는 것이 더 좋다고 판단되면 .c.o 확장자 규칙을 쓰는 게 낫겠죠. 특히나 나만의 컴파일 출력 문구의 장식 쓸 수 있다는 것도 나만의 확장자 규칙 작성의 의미로 충분할 테지요… 뭐, 전 그렇게 생각합니다.

이제까지의 내용을 모두 적용하면 다음과 같습니다.

예제 5.1
DEPEND_FILE = depend_file

CC = gcc
AR = ar
RANLIB = ranlib

ifeq ($(RELEASE), 1)
DBG_FLAGS = -O2 -DNDEBUG
else
DBG_FLAGS = -g -O0 -DDEBUG
endif

LIB_NAME = MyProtocol
LIB_FULL_NAME = lib$(LIB_NAME).a

LIB_SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyProtocol.c
LIB_OBJS = $(LIB_SRCS:%.c=%.o)

LIBS = -lpthread
LIBS += -l$(LIB_NAME)
LIB_DIRS = -L.

TARGET_SRCS = MyClient.c TestConnection.c TestDataChange.c TestDrinking.c
TARGET_OBJS = $(TARGET_SRCS:%.c=%.o)
TARGET_NAMES = $(TARGET_SRCS:%.c=%)

.SUFFIXES : .c .o

all : $(LIB_FULL_NAME) $(TARGET_NAMES)

$(LIB_FULL_NAME) : $(LIB_OBJS)
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@

.c.o:
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    $(CC) $(CFLAGS) $(DBG_FLAGS) -c $< -o $@

.SECONDEXPANSION:
$(TARGET_NAMES): $$@.o
    $(CC) -o $@ $< $(LIB_DIRS) $(LIBS)

depend :
    $(CC) -MM $(LIB_SRCS) $(TARGET_SRCS) > $(DEPEND_FILE)

clean :
    rm -f $(LIB_FULL_NAME) $(LIB_OBJS) $(TARGET_NAMES) $(TARGET_OBJS)

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(strip $(LIB_SRCS) $(TARGET_SRCS)),)
-include $(DEPEND_FILE)
endif
endif
endif

RELEASE 변수 지정

그럼 이제 디버그냐 릴리즈냐를 구분하기 위한 RELEASE 변수를 어떻게 정하느냐만 남았군요. 뭐 다른 변수들처럼 Makefile 앞 부분에 선언해 두고 릴리즈냐 디버그냐에 따라 바꿔서 써 주면 되긴 합니다. 하지만 이렇게 하면 역시 버전 관리 툴로 관리할 때 실수할 가능성이 커집니다. 예를 들어 출시 직전이라는 ‘비상 상황’에 몰려 야근에 지친 개발자들이 다음과 같이 작업할 수 있다는 거죠…

  1. 디버깅을 위해 Makefile에서 RELEASE = 1 부분을 주석 처리하고, 디버깅을 합니다.
  2. 나중에 디버깅을 마치고, Makefile의 주석 푸는 걸 깜빡하고 다른 소스들과 함께 커밋합니다. 이런!!!

네, 나중에 최종 제품용 소프트웨어에 디버그 심볼과 메시지가 모두 포함되어 릴리즈 될테죠. 이런 극단적인 이유가 아니더라도, 개발 과정에서 이런저런 테스트 하다 보면, 릴리즈/디버그 모드를 바꿔 빌드해야 할 경우도 빈번히 발생할텐데, 그 때마다 Makefile을 수정하는 것은 어쨌든 번거로운 일입니다. 그래서 RELEASE 변수를 Makefile에 포함시키는 것 보다는, 빌드할 때마다, 다시 말해 make 실행 시킬 때마다 인자로 받는 게 좋습니다. 참고로 make는 실행될 때의 시스템 환경 변수에 선언된 값들도 Makefile 내에서 선언된 것과 동일한 변수로 받아들일 뿐 아니라, 실행 시 인자로도 변수를 받아 들일 수 있거든요. 즉, 쉘에서 다음과 같이 make를 실행하면 마치 Makefile내에서 RELEASE = 1 이라고 선언한 것과 동일한 효과를 가집니다. (단, 이때에는 = 전후에 공백이 들어가면 안 되고, RELEASE=1 이렇게 딱 붙여서 써야 합니다.)

$ make RELEASE=1 …

이렇게 make 실행 시 인자로 받아들이게 하면, 매번 실행할 때마다 개발자가 어떤 모드로 실행시키는 지를 끊임없이 상기시키기 때문에, 가끔 릴리즈 모드로 빌드 걸어둔 채, ‘아니 왜 디버그 메시지가 출력되지 않지?’ 하는 개발자 ‘멍모드’로 빠지는 불상사를 조금이나마 방지할 수 있습니다.

네? 릴리즈 모드로 빌드할 때마다 매번 RELEASE=1 를 입력하는 번거로움이 더 치명적이라고요… 진심인가요? 좋습니다. 모름지기 개발자라면 그정도의 게으름은 가지고 있는 것도 나쁘지 않죠. (엄청난 노가다를 두려워 않는 사람 보다는 훨씬 낫다고 생각합니다). 하지만 그 정도의 게으름의 소유자라면 당연히 자신의 편집기에 외부 쉘 명령을 실행시키는 명령어들을 단축키로 등록해서 쓰고 있을 테죠? make 실행 옵션을 디버그용, 릴리즈용으로 등록해서 쓰시도록 권장합니다.

[Tip] emacs 빌드 단축키 지정

제가 주로 사용(하려고 노력)하는 emacs의 경우, ~/.emacs 파일에 다음을 추가하면 F7/F9 키로 디버그용 빌드 및 전체 리빌드를, Ctrl + F7/F9 키로 릴리즈용 빌드 및 전체 리빌드를 수행할 수 있습니다. make 인자나 키 지정은 각자의 취향대로 설정할 수 있겠지요.

;; Compile debug command
(defun buildDebug ()
  (interactive)
  (compile "make")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(defun rebuildAllDebug ()
  (interactive)
  (compile "make clean depend all")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(defun buildRelease ()
  (interactive)
  (compile "make RELEASE=1")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(defun rebuildAllRelease ()
  (interactive)
  (compile "make RELEASE=1 clean depend all")
  (other-window 1)
  (end-of-buffer)
  (other-window 1))

(global-set-key [(f7)] 'buildDebug)
(global-set-key [(f9)] 'rebuildAllDebug) 
(global-set-key [(control f7)] 'buildRelease)
(global-set-key [(control f9)] 'rebuildAllRelease)