Make 기반 빌드 시스템 (6): 빌드모드 별 결과물 관리

빌드모드 별 결과물 관리

이전 글에서 작성된 Makefile 5.1의 경우, 디버그와 릴리즈 모드 구분을 하는 것까지는 잘 됐지만, 개발 과정에서 불편한 문제가 하나 있습니다.

예를 들어 먼저 디버깅 모드로 빌드해서 테스트, 문제 없이 잘 동작하는 것을 확인한 다음, 이제 버전 관리 툴에 커밋하기 전에 확인 사살 차원에서 릴리즈 모드로도 잘 동작하는 것을 확인하는 상황을 생각해 볼까요.

이미 디버그 모드로 빌드되어 있는 상황에서는, 아무리 make RELEASE=1 이라고 실행해봐야 모든 선행조건들이 만족된 상태이기 때문에 컴파일이 일어나지 않고, 링킹도 안 일어납니다. 따라서 릴리즈 모드로 컴파일 되게 하려면 먼저 make clean 을 수행해야만 합니다.

마찬가지로, 릴리즈 모드로 컴파일되어 있는 상태에서 특정한 소스 코드의 디버그 메시지를 보고 싶은 경우에도, 해당 소스 파일을 touch 해서 이전에 빌드된 목적 파일보다 더 최신 상태가 되게 하거나 목적 파일을 지워서 컴파일이 일어나게 해야 합니다. 아니면 make clean을 수행하고요. 디버그/릴리즈 모드간 전환이 번거롭고 시간도 또 많이 걸립니다. 특별한 상황이 아니라면 바람직한 상황은 아니죠.

사실 이에 대한 해결책은 단순합니다. 각 모드의 목적 파일들이 각각 다른 디렉토리에 저장되도록 하는 것이죠. 그리고 이렇게 되면, 소스 파일들과 컴파일된 목적 파일들이 같은 디렉토리에 섞여 너저분해 지는 걸 막아 주는 부수적인 효과도 있습니다.

목적 파일들을 별도의 디렉토리에 저장하기

이렇게 하려면 먼저 목적 파일들이 저장될 위치부터 정해야 할 텐데요, 디버그 모드와 릴리즈 모드에 대한 목적 파일들을 저장할 디렉토리로는 각각 Debug 와 Release로 하면 무난하겠죠? 그럼 이 녀석을 OBJS_DIR이라는 변수로 선언합니다.

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

다음에 할 일은, 컴파일한 목적 파일이 OBJS_DIR에 저장되도록 해야 하는데, 음… 이건 제법 손이 많이 가는 일입니다.

먼저 LIB_SRCS 에서 LIB_OBJS 로 바꿀 때 목적 파일의 경로에 $(OBJS_DIR)을 추가합니다. 이제는 익숙(?)한 make의 문자열 치환 기능을 쓰면 이건 어렵지 않습니다.

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

LIB_OBJS은 Debug/MySocket.o Debug/MyMutex.o … 이렇게 지정됩니다.

문제는, Makefile에 이를 처리할 규칙이 없다는 데 있습니다. Makefile에 우리가 작성한 확장자 규칙은, 임의의 이름의 .c 파일을 동일 이름인데 확장자만 .o 인 파일로 바꾸는 경우에만 일치됩니다. 따라서 소스 파일은 현재 디렉토리의 .c 파일이고 목적 파일을 $(OBJS_DIR) 의 .o 파일에 다룰 수 있는 규칙이 필요하단 거죠.

make 패턴 규칙

어떻게 하면 규칙의 디렉토리 불일치를 맞출 수 있을까요? 그쵸, 우리의 make는 이런 경우에 사용할 수 있는 패턴 규칙을 제공합니다. 패턴 규칙(pattern rules)이란 변수의 치환에서 이미 사용한 것과 비슷하게 규칙의 타겟과 선행조건에 % 가 들어가 있는 규칙입니다. 변수 치환에서와 마찬가지로 % 는 ‘비어 있지 않은 임의의 문자열’과 일치합니다. 음… 글로 쓰고 보니 복잡하게 느껴지네요. 다음을 보세요.

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

위의 규칙에서 $(OBJS_DIR)/ 와 .o 그리고 .c는 정확하게 일치해야 하고, % 은 빈 문자열이 아닌 임의의 문자열과 일치할 수 있습니다. 따라서 make는 타겟이 Debug/MySocket.o 이고 선행조건이 MySocket.c 인 경우와 일치시킬 수 있기 때문에, gcc에게 현재 디렉토리의 MySocket.c 를 컴파일해서 그 결과를 Debug/MySocket.o 로 저장하라고 명령할 수 있습니다. 네, 바로 우리가 원하는 바죠.

그래도 여전히 문제가 하나 남아 있는데요, 다름이 아니라, 현재 디렉토리에는 Debug 디렉토리가 없다라는 겁니다. 그래서 이 상태로 그냥 실행시키면 gcc가 ‘치명적 오류: 그런 디렉토리 또는 파일이 없습니다.’라는 험악한 에러 메시지를 뿜으며 작업을 중단합니다. 까칠한 make의 비위를 맞춰 주려면 gcc 호출 전에 Debug 디렉토리가 있는 지를 확인해서 없으면 생성하는 작업이 필요합니다.

MKDIR = mkdir
...
$(OBJS_DIR)/%.o : %.c
    @echo "================================================"
    @echo "= Compiling $@ "
    @echo "================================================"
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    $(CC) $(CFLAGS) (DBG_FLAGS) -c $< -o $@

리눅스 쉘 스크립트에서 디렉토리가 있는 지를 확인하는 테스트 구문인 [ -d dir_path ] 를 실행하고, 없을 때만 mkdir 를 실행하도록 || 연산자로 mkdir 명령어를 연결했습니다. 그리고 매번 컴파일할 때마다 해당 명령어가 출력되지 않도록 하기 위해, make의 @`` 구문으로 감싸 준거죠. 이렇게 하고 나면 Debug 또는 Release 디렉토리가 없을 때는 컴파일 전에 디렉토리를 생성하고, 그리고 나서 컴파일 결과를 해당 디렉토리에 저장하게 됩니다. 이미 있는 경우에는 무시하고 다음 명령으로 넘어 갈 테고요.

이제 빌드 결과물이 하나의 디렉토리에 이쁘게 모인 덕분에, clean의 경우는 다음과 같이 그냥 $(OBJ_DIR) 디렉토리를 그냥 지워주면 끝입니다.

clean: 
    rm -fr $(OBJS_DIR)

depend 처리하기

마지막으로 남은 건 depend군요. 이전의 다른 규칙들과 마찬가지로 $(OBJS_DIR) 확인 후 생성하고 경로만 바꿔주면 될 테지요. 이렇게 말입니다.

DEPEN_FILE = $(OBJ_DIR)/depend_file

depend :
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    $(CC) -MM $(LIB_SRCS) $(TARGET_SRCS) > $(DEPEND_FILE)

불행히도 make depend 했을 때 실제로 실행해 보면, depend_file 생성까지는 잘 되는데, 일부 의존성 관계에 따른 재빌드가 정상적으로 동작하지 않는 경우가 있습니다. 예를 들어 모든 .c 파일들이 공통적으로 의존하는 MyCommon.h 파일이 변경되어도 .c 파일들은 다시 컴파일 되지 않습니다.

왜 그럴까요? 위에서 만들어진 depend_file의 내용을 보면 이유를 알 수 있습니다.

MySocket.o: MySocket.c MySocket.h MyCommon.h MyType.h MyDebug.h
MyMutex.o: MyMutex.c MyMutex.h MyCommon.h MyType.h MyDebug.h
MySemaphore.o: MySemaphore.c MySemaphore.h MyCommon.h MyType.h MyDebug.h
MyThread.o: MyThread.c MyThread.h MyCommon.h MyType.h MyDebug.h
MyProtocol.o: MyProtocol.c MyProtocol.h MyCommon.h MyType.h MyDebug.h
MyClient.o: MyClient.c MyCommon.h MyType.h MyDebug.h MySocket.h MyMutex.h \
  MySemaphore.h MyThread.h MyProtocol.h
TestConnection.o: TestConnection.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
TestDataChange.o: TestDataChange.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
TestDrinking.o: TestDrinking.c MyCommon.h MyType.h MyDebug.h MySocket.h

$(OBJS_DIR), 즉 Debug 나 Release 디렉토리가 전혀 나타나 있지 않기 때문입니다. 당연한 거죠.

돌이켜 보자면, depend_file의 내용은 gcc 의 -MM 옵션을 이용해서 (시스템 헤더 파일을 제외한) 소스 파일의 의존성만 출력한 것입니다. gcc는 이 일을 수행할 때 특별한 언급(?)이 없으면 .o 파일은 .c 파일과 동일한 경로에 있을 거라고 가정하고 의존성을 출력하기 때문에 위와 같은 결과가 나오는 것입니다. 결국 depend_file에 있는 .o 파일들의 경로가 올바르지 않으므로 의존성 처리가 제대로 처리되지 않는다는 게 문제의 핵심입니다.

그렇다면 해결책은 depend_file에 있는 .o 파일들의 경로를 올바르게 지정하는 것이 될 터입니다. 정규표현식에 익숙하신 능력자라면 gcc에 의해 만들어진 depend_file 파일을 한번 가공하는 방법도 생각해 봄직 하지만, 가능하다면 gcc의 옵션을 이용해서 애초에 올바르게 나오도록 해결하는 게 더 낫겠죠?

make -MT 옵션

위에서 “특별한 언급이 없으면” 이란 표현을 썼는데요, gcc의 옵션 중에는 의존성 처리 시의 타겟을 명확하게 지정할 수 있는 -MT 옵션이 있습니다. 이 옵션을 다음과 같이 사용해서 우리가 원하는 결과를 얻을 수 있습니다.

$ gcc -MM -MT Debug/MySocket.o MySocket.c 
Debug/MySocket.o: MySocket.c MySocket.h MyCommon.h MyType.h MyDebug.h

다만, 위의 gcc 구문은 하나의 파일에 대해서 동작합니다. 그래서 shell의 for 문을 사용해서 처리해야 합니다. 다음을 보세요.

depend :
    @`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
    @$(RM) -f $(DEPEND_FILE)
    @for FILE in $(LIB_SRCS:%.c=%) $(TARGET_SRCS:%.c=%); do \
        $(CC) -MM -MT $(OBJS_DIR)/$FILE.o $FILE.c >> $(DEPEND_FILE); \
    done

(4) ~ (6)은 $(LIB_SRCS)와 $(TARGET_SRCS) 하나하나 파일에 대해 루프를 돌면서, 위의 명령을 수행한 결과를 depend_file에 추가합니다. (5)에서 >> 는 shell에서의 리다이렉션 명령어 중 하나인데, 왼쪽의 실행 결과를 오른 쪽 파일에 추가합니다. 그런데 요렇게만 하면 make depend 할 때마다 depend 파일에 계속 추가될 것이기 때문에, 이를 막기 위해 (3)에서 기존의 파일을 먼저 삭제하는 겁니다.

이렇게 수정된 depend 규칙을 실행한 결과는 다음과 같습니다.

Debug/MySocket.o: MySocket.c MySocket.h MyCommon.h MyType.h MyDebug.h
Debug/MyMutex.o: MyMutex.c MyMutex.h MyCommon.h MyType.h MyDebug.h
Debug/MySemaphore.o: MySemaphore.c MySemaphore.h MyCommon.h MyType.h \
  MyDebug.h
Debug/MyThread.o: MyThread.c MyThread.h MyCommon.h MyType.h MyDebug.h
Debug/MyProtocol.o: MyProtocol.c MyProtocol.h MyCommon.h MyType.h \
  MyDebug.h
Debug/MyClient.o: MyClient.c MyCommon.h MyType.h MyDebug.h MySocket.h \
  MyMutex.h MySemaphore.h MyThread.h MyProtocol.h
Debug/TestConnection.o: TestConnection.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
Debug/TestDataChange.o: TestDataChange.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h
Debug/TestDrinking.o: TestDrinking.c MyCommon.h MyType.h MyDebug.h \
  MySocket.h

각 오브젝트 파일들의 경로가 정확하게 표기되어 헤더 파일이 바뀌었을 때 이를 포함하는 모든 소스 파일들이 컴파일됩니다. 좋군요. 짝짝짝.

자, 이제까지의 모든 내용들을 한번에 적용해 볼까요?

예제 6.1
CC = gcc
AR = ar
RANLIB = ranlib
RM = rm
MKDIR = mkdir

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

DEPEND_FILE = $(OBJS_DIR)/depend_file

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

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

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

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

.SUFFIXES : .c .o

all : $(LIB_FULL_NAME) $(TARGET_NAMES)

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

$(OBJS_DIR)/%.o : %.c
	@echo "================================================"
	@echo "= Compiling $@ "
	@echo "================================================"
	@`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
	$(CC) $(CFLAGS) $(DBG_FLAGS) -c $< -o $@

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

depend :
	@`[ -d $(OBJS_DIR) ] || $(MKDIR) $(OBJS_DIR)`
	@$(RM) -f $(DEPEND_FILE)
	@for FILE in $(LIB_SRCS:%.c=%) $(TARGET_SRCS:%.c=%); do \
		$(CC) -MM -MT $(OBJS_DIR)/$$FILE.o $$FILE.c >> $(DEPEND_FILE); \
	done

clean :
	$(RM) -fr $(OBJS_DIR)

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

RELEASE=1 을 주고 make를 실행하면 릴리즈 모드로 빌드된 모든 오브젝트 파일들과 라이브러리, 실행파일 그리고 depend_file 까지 모두 Release 디렉토리에 저장됩니다. RELEASE=1이 주어지지 않으면 디버그 모드의 빌드 결과물들이 Debug 디렉토리에 저장되고요. 깔끔하죠?

여기서 한발짝 더 나가면, 단지 Debug/Release 뿐 아니라 여러 가지 플랫폼 또는 프로파일들에 맞는 빌드 결과물들을 각각 독립적으로 관리할 수도 있을 겁니다. 환경 변수 또는 make 실행 인자로 적절한 설정값을 주고 이에 따라 OBJS_DIR만 잘 정의하면 될 터입니다. (이와 관련해서는 나중에 좀 더 다뤄보도록 하겠습니다.)