Make 기반 빌드 시스템 (7): 빌드 디렉토리 구성

빌드 디렉토리 구성

이제까지 작성한 예제 6.1을 가지고, 하나의 라이브러리와 이를 사용하는 여러 실행파일들을 한번에 빌드할 수 있습니다. 라이브러리에 새로운 소스 파일을 추가하거나, 새로운 실행파일의 추가하려면, Makefile의 한 줄만 고치면 됩니다. depend만 적절한 시점에 한번씩 실행시켜 주면, 수정된 파일들에 영향받는 파일들만 정확하게 컴파일해서 라이브러리와 이를 사용하는 실행파일들을 다시 빌드해 줍니다.

그런데 라이브러리에 포함되는 소스 파일들이 많아지면 어떻게 될까요? 너무 많은 소스파일들이 하나의 라이브러리에 포함되어 있으면 당연히 라이브러리를 빌드하는데 시간도 오래 걸리고 크기도 커집니다. 아주 바쁠 때, 일부 헤더 파일이 수정되어 depend를 해야 하거나, 뭔가 찜찜해서 clean이라도 해야 할라치면 한숨부터 나올 때가 있습니다. 라이브러리 내의 일부 기능만 쓰고 싶은 경우에, 이건 배보다 배꼽이 더 크다는 느낌이 자꾸 들기도 하고요.

일반적으로 소스 파일들이 많아지면 레이어별 또는 기능별과 같이 몇 가지 기준으로 적절한 규모의 라이브러리들로 분리해서 관리하곤 합니다. 테스트 및 유지 보수 관리 측면에서, 또 여러 명이 협업하는 측면에서 그게 좀더 효율적이겠지요.

이전에 작성한 MyProtocol을 예를 들어 볼까요? 사실 thread, mutex, semaphore 같은 기능들은, 잘 만들어 두면 재활용할 수 있는 여지가 큽니다. 따라서 적당한 문서화 및 단위 테스트만 잘 정리해 놓으면 다른 프로젝트에도 쉽게 재사용할 수 있을 겁니다. 마찬가지로, 파일 I/O, stack, queue, 등의 각종 자료 구조나 이런 저런 알고리듬 등도 성능과 안정성 면에서 검증되어 있다면, MyProtocol의 사양이 확장되어 이런 기능들이 필요할 때 바로 가지고 와서 빠르게 변경 요구에 대응할 수 있을 테고요.

적당한 규모의 라이브러리로의 분리와 재활용… 아 말만 들어도 기분이 좋아지는군요. 하지만 각 라이브러리들의 Makefile들에는 똑같은 부분들이 상당 부분 존재할 겁니다. 계속 진행하다 보면 나중에 새로운 빌드 옵션을 일괄적으로 적용하려 할 때 반복적인 노가다를 뛰어야 합니다. 그러다 자칫 집중력이 떨어져 한 군데에서 빼먹으면 빌드 에러가 납니다. 운이 더 나쁘면, 빌드 에러 없이 통과 되어 런타임에 오묘한 오동작으로 나타날 수도 있겠죠.

모름지기 제대로 된 소프트웨어 개발자라면 사소한 코드 중복도 막기 위해 갖가지 노력을 합니다. 반복되는 값들이 있으면 적절한 상수 변수로 선언하고, 반복되는 코드 블럭은 리팩토링해서 공용 함수로, 공용 객체로 뽑아 내겠죠. (거기, 갑자기 눈길을 피하시는 분들은 뭐죠?)

Makefile도 마찬가지입니다. 공통으로 반복적인 부분들은 공용 파일로 만든 다음 include해서 반복을 피할 수 있습니다. (include 하는 방법은 이전 글 중, Make 기반 빌드 시스템 (3): 소스 목록 관리를 조금 편리하게 에서 다루었습니다.) 역시 개발자에겐 말보단 예제가 빠르겠죠? 다음과 같이 총 3개의 라이브러리로 나눈다고 가정해 보겠습니다.

  • MyThread: thread, mutex, semaphore 등과 같이 여러 개의 thread들을 관리하는데 필요한 기능들을 제공
  • MyNetwork: socket 및 기타 네트워크와 관련된 일련의 기능을 제공
  • MyProtocol: 현재 개발 중인 프로토콜과 관련된 기능을 제공

이때 해당 라이브러리들을 별도의 디렉토리로 각각 분리해서, 각각의 라이브러리 및 실행 프로그램들을 작성한다고 했을 때, Makefile들은 어떻게 되어야 할까요? 제일 처음 할 일은 당연히 라이브러리들의 Makefile들에 공통적으로 들어 있는 부분들을 별도의 파일로 분리해 내는 것 아닐까요.

Makefile들의 공통 부분 분리

다음은 예제 6.1 내용에서 thread 관련된 부분 만을 라이브러리로 만들고, 각각 관련된 테스트 프로그램들을 만든다고 했을 때, 달라지는 부분을 표시한 것입니다.

CC = gcc
AR = ar
...

DEPEND_FILE = $(OBJS_DIR)/depend_file

LIB_NAME = MyThread
LIB_FULL_NAME = $(OBJ_DIR)/lib$(LIB_NAME).a

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

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

TARGET_SRCS = TestMutex.c TestSemaphore.c TestThread.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)
...

네, 보시다시피 달려져야 할 부분은 단 3줄에 불과합니다. MyNetwork이나 MyProtocol의 경우도 마찬가지일 테고요. 따라서 이 3줄을 제외한 나머지 부분을 별도의 파일들로 분리하면 될 것 같습니다. 공용 파일들의 이름은 각각 IncludePre.mk 와 IncludePost.mk 이라고 해 볼까요?

# Makefile of MyThread

include IncludePre.mk

LIB_NAME = MyThread
LIB_SRCS = MyMutex.c MySemaphore.c MyThread.c
TARGET_SRCS = TestMutex.c TestSemaphore.c TestThread.c

include IncludePost.mk
# IncludePre.mk

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_DIRS = -L.
# IncludePost.mk

LIB_FULL_NAME = $(OBJ_DIR)/lib$(LIB_NAME).a
LIB_OBJS = $(LIB_SRCS:%.c=$(OBJS_DIR)/%.o)

LIBS += -l$(LIB_NAME)
LIBS += -lpthread 

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 ($(LIB_SRCS) $(TARGET_SRCS),)
-include $(DEPEND_FILE)
endif
endif
endif

일단 이렇게 분리되고 나면, Network이나 MyProtocol의 Makefile들도 다음처럼 간단해 질 수 있습니다.

# Makefile of MyNetwork

include IncludePre.mk

LIB_NAME = MyNetwork
LIB_SRCS = Socket.c
TARGET_SRCS = TestSocket.c

include IncludePost.mk
# Makefile of MyProtocol

include MakefilePreinc

LIB_NAME = MyProtocol
LIB_SRCS = MyProtocol.c
TARGET_SRCS = Client.c sTestConnection.c TestDataChange.c TestDrinking.c

LIBS += -lMyNetwork -lMyThread

include IncludePost.mk

공통적인 부분은 IncludePre.mk 와 IncludePostmk 2개의 공용 파일로 분리되어 각 Makefile들에서 공유되기 때문에, 개별 Makefile은 극도로 간단해지고 해당 라이브러리 및 관련 실행 프로그램들과 직접적으로 관련된 내용들만 있게 됩니다. 따라서 특정 라이브러리에 별도의 조건이나 선언하기가 용이합니다. 게다가 모든 라이브러리들에 공통적으로 해당하는 변경 사항이 생기면, 공용 파일들에 추가해 주면 일괄적으로 적용할 수 있게 됩니다. 훌륭하죠?

여러 개의 소프트웨어들을 빌드할 때, 개별 변경과 전체 일괄 변경을 함께 처리할 수 있는 소위 한방 빌드 시스템의 윤곽이 비로소 잡히는 순간입니다. 와우. 하지만 이걸 실제 빌드 시스템으로 사용하기 위해서는 손을 좀 봐 줘야 합니다.

첫째, 먼저 각 라이브러리들의 소스 파일들과 Makefile들, 그리고 Makefile에서 포함할 공용 include 파일들을 어디다 위치 시켜야 할 것 인가를 결정해야 합니다. 두 번째로는 라이브러리 간의 의존성 문제도 처리해야 합니다. 다른 라이브러리에 의존하는 라이브러리의 경우, 라이브러리 빌드하는 건 그냥 되지만, 이 라이브러리를 사용하는 실행 프로그램을 링킹할 때에는 의존하는 다른 라이브러리도 링킹해야 하거든요. 이를 위한 라이브러리 빌드 결과물의 저장 위치나 빌드 순서도 고려의 대상이 됩니다.

소스 디렉토리 구성

이런 것들을 해결한 다음에는 궁극적으로 모든 소프트웨어들을 한번에 빌드할 수 있는 방법도 필요하겠지만, 일단 두 가지 이슈들을 하나씩 풀어나가 보죠. 제일 먼저 소스 파일 및 Makefile 파일, 그리고 공용 include 파일들을 어떻게 위치시키는 것부터 살펴 보겠습니다.

이제까지는 하나의 디렉토리에서 단일 라이브러리와 복수 개의 실행 프로그램들을 빌드했습니다.

.
├── Makefile
├── MyClient.c
├── MyCommon.h
├── MyDebug.h
├── MyMutex.c
├── MyMutex.h
├── MyProtocol.c
├── MyProtocol.h
├── MySemaphore.c
├── MySemaphore.h
├── MySocket.c
├── MySocket.h
├── MyThread.c
├── MyThread.h
├── MyType.h
├── TestConnection.c
├── TestDataChange.c
└── TestDrinking.c

이랬던 것을, 이제 3개의 라이브러리(및 실행 프로그램)들을 위한 디렉토리들에 나누어 저장해야 합니다. 일단 공통인 것은 현재 디렉토리에 그대로 두고, 각 라이브러리에 속하는 것들만 옮겨 보죠

.
├── IncludePre.mk
├── IncludePost.mk
├── MyCommon.h
├── MyDebug.h
├── MyType.h
├── Network
│   ├── Makefile
│   ├── MySocket.c
│   ├── MySocket.h
│   └── TestSocket.c
├── Protocol
│   ├── Makefile
│   ├── MyClient.c
│   ├── MyProtocol.c
│   ├── MyProtocol.h
│   ├── TestConnection.c
│   ├── TestDataChange.c
│   └── TestDrinking.c
└── Thread
      ├── Makefile
      ├── MyMutex.c
      ├── MyMutex.h
      ├── MySemaphore.c
      ├── MySemaphore.h
      ├── MyThread.c
      └── MyThread.h

이렇게 나눌 경우, 소스 파일과 Makefile들에 include되는 헤더 파일과 공용 include 파일들의 경로를 수정해야 합니다. 제일 간단하게는 개별 파일의 디렉토리 위치에 대한 상대 경로로 지정할 수 있겠네요. 다음과 같이 말입니다.

# Makefile of MyNetwork

include ../IncludePre.mk
...
include ../IncludePost.mk
/* MyClient.c */

#include "../MyCommon.h"
#include "../Network/MySocket.h"
#include "../Thread/MyMutex.h"
#include "../Thread/MySemaphore.h"
#include "../MyThread/MyThread.h"
...

그런데 이런 식의 상대 경로를 사용하게 되면, 나중에 디렉토리 이름이 바뀌거나 프로젝트 규모가 커진다든지 하는 이유로 디렉토리를 재조직해서 경로를 이동해야 할 경우, 관련된 모든 Makefile 및 소스 파일들의 경로를 수정해야 하는 문제가 생깁니다. 단순한 Find & Replace 작업이긴 합니다만 파일 수가 많아지면 그 노가다 작업도 솔직히 제법 신경 쓰이죠.

Makefile들은, 기준이 되는 루트 디렉토리를 정해서 시스템의 환경 변수로 설정한 다음, 이에 대한 상대 경로로 공용 include 파일들을 지정하는 게 가장 간단하고 확실한 방법입니다. 다음을 보세요.

$ export PROJ_ROOT=~/MyProject
# Makefile of MyNetwork

include $(PROJ_ROOT)/IncludePre.mk
...
include $(PROJ_ROOT)/IncludePost.mk

Makefile내에서는 시스템의 환경 변수를 Makefile의 일반 변수처럼 사용할 수 있습니다. 그래서 쉘 스크립트에서 PROJ_ROOT 를 export 해 두면, Makefile에서는 $(PROJ_ROOT)로 해당 경로를 지정할 수 있게 되는 거죠. 이렇게 되면 모든 Makefile들에서 공용 include 파일들의 경로가 동일해 지므로 설혹 라이브러리 디렉토리의 경로가 바뀌어도 Makefile은 수정되지 않아도 됩니다. 빌드 할 때마다 PROJ_ROOT 환경 변수를 지정해 주는 게 성가시긴 하지만, 이건 쉘 profile에서 처리하거나 간단한 쉘 스크립트 하나를 만들어서 쓰면 쉽게 해결됩니다.

그러면 소스 파일들에 include되는 헤더 파일들은 어떻게 해야 할까요? 제일 손쉬운 방법은 헤더 파일들을 하나의 디렉토리로 모으고, 공용 include 파일에서 make가 헤더 파일을 찾는 경로에 해당 디렉토리를 포함시키는 겁니다. 헤더 파일을 모으는 디렉토리 이름을 Include로 하고, 나머지 구현 파일들과 Makefile들은 Source 디렉토리를 만들어서 거기 저장하는 것으로 해 볼까요?

.
├── Include
│  ├── MyCommon.h
│  ├── MyDebug.h
│  ├── ...
├── IncludePost.mk
├── IncludePre.mk
└── Source
    ├── Network
    │   ├── Makefile
    │   ├── MySocket.c
    │   ├── ...
# IncludePre.mk

...
INC_DIRS = -I$(PROJ_ROOT)/Include
...
# IncludePost.mk

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

개발자 또는 개발 조직의 취향에 따라, 헤더 파일들을 별도의 디렉토리에 모으는 방법은 거부감을 주기도 합니다. 사실 소스 파일을 생성할 때 같은 이름의 헤더 파일과 구현 파일을 동일한 디렉토리에 저장하는 게 자연스럽습니다. 그리고 헤더 파일과 구현 파일을 오가면서 봐야 할 때도 그렇고요. 어떤 경우에는 프로젝트에서 사용하는 코드 생성 도구가 같은 디렉토리에 쌍으로 생성하기도 하죠.

이런 저런 이유로 헤더-구현 파일 쌍을 같은 디렉토리에 두고 싶다면, IncludePre.mk 파일에 ROOT_SRC_DIR 변수를 두고, 소스 파일에서는 거기에서부터의 경로를 사용하면 됩니다.

# IncludePre.mk

...
ROOT_SRC_DIR = $(PROJ_ROOT)/Source
...
INC_DIRS += -I$(ROOT_SRC_DIR)
...
# MyClient.c

#include "MyCommon.h"
#include "Network/MySocket.h"
...

개발하노라면 언젠가 라이브러리의 구조를 수정하거나 이에 준하는 일(이런 일은 꼭 생기는 법이죠. 머피의 법칙이었던가요?)이 생기면 많은 소스 코드들의 include 부분에서의 경로를 수정하는 노가다를 뛰어야 합니다. 다만 이 경우는 잘못되어도 컴파일러가 걸러주기 때문에 그냥 노가다일 뿐 심각한 문제는 아닐 수 있습니다.

이에 비해, 헤더 파일과 구현 파일이 다른 디렉토리에 위치하는 건, 소스 파일들의 경로 수정에 제약이 없고, 바이너리의 라이브러리와 헤더 파일을 외부에 제공해야 하는 일이 있을 때도 무척 편리합니다. 하지만 모든 개발자가 개발 일상에서 불편하다고 느낄 수 있기 때문에 이게 더 심각할 수도 있을 테지요.

2가지 방법 모두 각자의 장단점이 확실하므로, 취향에 따라 선택하시면 될 듯 싶습니다. 이젠 라이브러리 사이의 의존성에 대해 생각해 볼까요?

라이브러리 사이의 의존성 처리

앞서 MyNetwork, MyProtocol, MyThread 이렇게 세 개의 라이브러리들로 구분했는데요, thread, mutex, semaphore를 멋지게 wrapping한 MyThread 라이브러리를 테스트하는 실행 프로그램들은 당연히 MyThread 라이브러리를 링킹하고 있겠죠?

헌데 MyProtocol는 MyNetwork의 네트워크 기능과 더불어 멀티 쓰레딩 기반으로 구현하느라 MyThread의 기능도 사용합니다. 그럼 MyProtocol 라이브러리들을 사용하는 실행 프로그램들은 어떻게 빌드되어야 할까요? 네, 당연히 사용하는 라이브러리들 모두를 링킹해야 합니다. 즉, MyClient 프로그램을 링킹할 때에는 MyThread, MyNetwork, MyProtocol 세 개의 라이브러리들이 모두 필요합니다.

이걸 Makefile에서는 어떻게 처리하는 게 좋을까요? 고민할 것 뭐 있나요? 사용할 라이브러리들을 직접 다 기입하면 되죠 뭐. 이렇게 말입니다.

# Makefile of MyProtocol

...
LIB_DIRS += -L$(PROJ_ROOT)/Source/Network/$(OBJS_DIR)
LIB_DIRS += -L$(PROJ_ROOT)/Source/Thread/$(OBJS_DIR)

이제 게으른 저의 작업 스타일에 어느 정도 익숙해 진 분이라면 위와 같이 뭔가를 반복해서 쓰는 것, 별로 안 내켜할 거란 것 짐작하시려나요? 예를 들어 지금은 Thread 디렉토리가 Source 바로 밑에 있는데, 나중에 이런 저런 공용 라이브러리를 추가, 관리하면서 Common 이라는 디렉토리를 만들고 그 밑으로 Thread를 옮기게 되면 Thread를 사용하는 Makefile들에서 해당 경로를 수정해 줘야 합니다.

제가 어떻게 하자고 할 지 감이 오시죠? 네, 헤더 파일들을 한 디렉토리로 모으는 것과 비슷합니다. 라이브러리들도 하나의 디렉토리에 모아 두고, 해당 경로를 공용 include 파일에 추가하는 거죠. 이 때, Include 처럼 Library 디렉토리를 PROJ_ROOT에 바로 추가해도 되지만, 추후에 라이브러리가 아닌 다른 결과물도 생길 것을 염두에 두고 Out 이라는 디렉토리를 하나 만들어서, 그 밑에 다음과 같이 추가하는 것도 나쁘지 않습니다.

.
├── Include
│ ├── ...
├── IncludePost.mk
├── IncludePre.mk
├── Out
│ ├── Library
└── Source
    │   ├── ...

일단 이렇게 하기로 결정되었으면, 공용 include 파일에 Out 과 Library 디렉토리들을 각각 나타내는 변수들을 추가하고, 빌드된 라이브러리 파일이 저장되는 경로에 이를 적용합니다.

# IncludePre.mk

...
ROOT_OUT_DIR = $(PROJ_ROOT)/Out
ROOT_LIB_DIR = $(ROOT_OUT_DIR)/Library
...
# IncludePost.mk

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

이렇게 하면, 모든 라이브러리들이 $(ROOT_LIB_DIR)/$(OBJS_DIR) 이라는 하나의 디렉토리에 저장될 것이기 때문에, 사용하는 각 라이브러리가 위치하는 디렉토리들을 Makefile에서 지정해 줄 필요가 없어집니다. 이건 좋죠? 대신 모든 라이브러리가 저장될 디렉토리가 처음 빌드할 때는 존재하지 않을 것이기 때문에, 라이브러리를 만드는 규칙에서 디렉토리 존재 여부를 체크해서 없으면 만들도록 해 주어야 합니다.

# IncludePost.mk

...
$(LIB_FULL_NAME) : $(LIB_OBJS)
    @`[ -d $(ROOT_LIB_DIR)/$(OBJS_DIR) ] || $(MKDIR) -p $(ROOT_LIB_DIR)/$(OBJS_DIR)`
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@
...

$(MKDIR) 뒤에 붙은 -p 는 주어진 경로의 중간 디렉토리들을 한번에 생성하게 하는 mkdir의 옵션입니다. 이를 사용해서 Out, Library, 그리고 Debug (or Release) 디렉토리들을 한번에 만드는 거죠.

여기까지 하고 나면, Protocol 디렉토리의 Makefile에서 LIB_DIRS 변수값을 설정할 필요가 없고, 단지 사용하는 라이브러리들을 LIBS 디렉토리에 설정하기만 하면 됩니다. 그 결과는 다음과 같은데요, 애초에 의도했던대로 각 라이브러리들의 Makefile들이 중복이 (거의) 없어지면서 아주 단순해 졌습니다.

# Makefile of MyNetwork

include $(PROJ_ROOT)/IncludePre.mk

LIB_NAME = MyNetwork
LIB_SRCS = MySocket.c
TARGET_SRCS = TestSocket.c

include $(PROJ_ROOT)/IncludePost.mk
# Makefile of MyProtocol

include $(PROJ_ROOT)/IncludePre.mk

LIB_NAME = MyProtocol
LIB_SRCS = MyProtocol.c 
TARGET_SRCS = MyClient.c TestConnection.c TestDataChange.c TestDrinking.c

LIBS += -lMyNetwork -lMyThread

include $(PROJ_ROOT)/IncludePost.mk
# Makefile of MyThread

include $(PROJ_ROOT)/IncludePre.mk

LIB_NAME = MyThread
LIB_SRCS = MyMutex.c MySemaphore.c MyThread.c
TARGET_SRCS = TestMutex.c TestSemaphore.c TestThread.c 

include $(PROJ_ROOT)/IncludePost.mk

아참, 라이브러리 저장 위치가 바뀌었으니까 make clean 할 때, 새 경로에 있는 라이브러리 파일도 같이 지워지도록 해 줘야겠죠?

# IncludePost.mk

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

라이브러리 링킹 순서

글을 마무리하기 전에, 마지막으로 하나만 더 알아 볼까 합니다. TARGET_NAMES 변수에 지정된 실행 파일들을 LIBS 변수에서 지정된 라이브러리들과 링킹할 때, 좀 주의해야 할 점이 있는데요, 딴 게 아니라 LIBS에 지정되는 순서입니다.

즉, libA.a 라이브러리가 libB.a 라이브러리에 있는 심볼을 사용하는 경우, 링킹할 때는 반드시 -lA 이 -lB 앞에 있어야 합니다. 반대로 되어 있으면 링킹하다가 A가 사용한 B 심볼을 찾을 수 없다는 깜짝 에러 메시지가 나타납니다. (사실 이건 처음 당해보면 도대체 뭐가 잘못 된 건지 짐작하기도 힘듭니다. 네, 제 경험담이죠…)

이걸 왜 얘기하냐 하면, 이제까지 작성한 Makefile은 LIB_NAME(예: Protocol)만 정하고, 이 라이브러리에서 사용하는 LIBS(예: -lNetwork -lThread)를 선언하고 나서, 이후 IncludePost.mk를 include합니다. 문제는 마지막에 포함되는 IncludePost.mk에서 LIBS에다 -l$(LIB_NAME)을 추가한다는 거죠. 각 Makefile에 동일하게 쓰는 부분은 최대한 공용 include 파일로 옮기려다 보니 생긴 참사(?)인데요, 의존 순서에 맞지 않기 때문에 그대로 두면 링킹 에러가 발생합니다.

음… 모든 Makefile에서 LIBS 변수는 제일 처음에 -l$(LIB_NAME)로 설정되도록 해도 되지만, 그보다는 IncludePost.mk를 약간만 손 봐주는게 나을 듯 싶습니다.

# IncludePost.mk

...
ALL_LIBS = -l$(LIB_NAME) $(LIBS)
...
$(TARGET_NAMES): $$@.o
    @echo "==================================================="
    @echo "= Linking $@ "
    @echo "==================================================="
    $(CC) -o $@ $< $(LIB_DIRS) $(ALL_LIBS)

보시다시피, ALL_LIBS 변수 하나만 추가하는 걸로 잘 해결 되었습니다. 다른 개발자들한테 “Makefile에 LIBS 에는 항상 $(LIB_NAME) 이 제일 먼저 들어가야해…”라로 백번 말하는 것 보다 훨씬 효과가 좋을 겁니다. 제가 장담하죠.

그런데 여기까지는 좋은데요, 만약 라이브러리 사이의 의존 관계가 단방향이 아니라 순환적일 때는 어떻게 될까요? 다시말해, A 가 B 에 의존적이고, B 가 다시 A 에 의존적인 경우가 되겠죠. 사실 이건 별로 좋지 않은 구조입니다. 라이브러리들의 의존 관계는 단방향인 게 맞습니다.

예를 들어 어떤 프레임워크 라이브러리가 있고, 이를 사용해서 구체적인 응용 프로그램 라이브러리를 만드는 경우를 생각해 볼까요? 프레임워크와 응용 프로그램 사이에 순환 관계가 성립한다는 얘기는, 응용 프로그램 독립적이어야 할 프레임워크가 어떤 구체적인 응용 프로그램에 의존적이라 걸 의미합니다. 이렇게 되면 그 프레임워크를 사용하는 다른 응용 프로그램들도 특정 응용 프로그램에 의존적이 되므로 프레임워크로서의 가치가 없어지죠.

일반적으로 프레임워크에서 추후 응용 프로그램에서 결정되어야만 하는 부분에 대해서는, 함수 포인터나 가상 함수들을 선언해 두고 프레임워크 내에서 이들을 사용해서 나머지 기능들을 구현하는 게 보통입니다. 이후 프레임워크를 사용하는 응용 프로그램들은 프레임워크가 요구하는 함수 포인터를 설정해 주거나 가상 함수를 오버로딩 하는 식의 작업을 처리해 줘야 하고요

그런데 응용 프로그램을 만들다가 프레임워크에서 미처 고려되지 못한 기능을 급하게 구현하거나 혹은 프레임워크의 설계를 충분히 이해하지 못한 상태에서 아무 생각없이 프레임워크 쪽에 응용 프로그램 쪽 기능을 가져다 쓰는 순간, 프레임워크와 응용 프로그램 사이에 순환 의존성이 발생하게 됩니다.

따라서 라이브러리들 사이의 순환 의존성이 발생한다면, 단방향 의존 관계로 되게끔 수정하는 게 가장 좋습니다. 하지만 당장 급하게 빌드 해야만 하는 상황에 부딪히면, 일단은 어떻게든 빌드 되도록 해야 하는데, 몇 가지 방법이 있습니다.

첫 번째 방법은 C/C++의 전방선언과 비슷한 방법입니다. 즉 A와 B가 서로 의존하는 경우, 하나를 두번 선언하는 겁니다. 무슨 얘기냐 하면, -lA -lB -lA 이런 식으로 하라는 거죠. 이렇게 하면 보통은 컴파일러가 알아서 잘 처리해 줍니다. 하지만 때로는 이렇게 해도 안 되는 경우가 발생하기도 합니다. 이 경우는 A 와 B 가 서로 지나치게 친한 경우인데요, 이럴 때에는 아예 2개의 라이브러리들를 하나로 합치는 것도 고려해 봄 직 합니다.

하지만 앞에서 예로 든 프레임워크와 응용 프로그램의 경우처럼 하나로 합치는 건 말이 안 되는 경우도 있겠죠? 나중에 바로 잡더라도 일단 급한 불은 꺼야 하는데 첫 번째 방법으로도 안 된다면 링커(ld)의 -( -) 옵션을 사용해서 처리해야 합니다. 참고로 ld 의 -( -) 옵션은 -(-) 사이에 나오는 아카이브들을 그룹핑해서, 새로운 정의되지 않은 참조들이 생성되지(나타나지) 않을 때까지 검색합니다.

자 그럼 이걸 어떻게 써야 할까요? gcc에는 링커에게 특정한 옵션을 넘겨 줄 수 있는 옵션이 있습니다. -Wl,option 이 바로 그건데요, -Wl, 다음에 입력되는 option 을 그대로 링커에게 넘겨줍니다. 다음을 보세요.

# IncludePost.mk
...
$(TARGET_NAMES): $$@.o
    @echo "==================================================="
    @echo "= Linking $@ "
    @echo "==================================================="
    $(CC) -o $@ $lt; $(LIB_DIRS) -Wl,-\( $(ALL_LIBS) -Wl,-\)
...

우리가 해 줘야 하는 건 $(LIBS) 를 -(  -) 로 그룹핑하는 걸 링커에게 넘기는 거죠. 그런데 ( )는 Makefile 내에서 의미가 있는 문자기 때문에 백슬래시(\)로 에스케이프 해 줘야 gcc 가 링커에게 -( -) 을 잘 전달할 수 있습니다. 빙고.

아 처음부터 이걸로 해 줬으면 좋았잖아요… 투덜거리실 분도 있으려나요? 사실 거기엔 다 이유가 있습니다. 세상에 공짜 점심은 없다니까요.

일반적으로 링커는 명령행에서 지정된 아카이브들을 입력받은 순서대로 단한번만 검색합니다. 그래서 순환 참조가 생기면 심볼을 못 찾는 겁니다. 이에 비해 -( -) 그룹핑을 하게 되면 새로운 정의되지 않은 참조들이 없어질 때까지 반복적으로 심볼을 찾기 때문에 그만큼 비효율적(당연하겠죠?)입니다. 따라서 이 옵션을 사용하는 것에는 상당한 성능 비용을 치르게 되고요, 두개 또는 그 이상의 아카이브들 간에 피할 수 없는 순환 참조가 있을 때만 사용하는 는 것이 좋습니다.

저도 압니다. 소프트웨어가 복잡하게 구성되고, 여러 플랫폼과 다양한 기능들을 선택적으로 빌드하려다 보면, 어쩔 수 없이 순환 참조가 생기기도 합니다. 그래서 정말 다급할 때에는 선택적으로 그룹핑 옵션을 쓸 수 있도록 하는 것도 의미가 있습니다. 사실 어려운 건 아니거든요.

# IncludePost.mk
...
$(TARGET_NAMES): $$@.o
    @echo "==================================================="
    @echo "= Linking $@ "
    @echo "==================================================="
ifeq ($(LIBS_CYCLING_DEPEND),1)
    $(CC) -o $@ $< $(LIB_DIRS) -Wl,-\( $(ALL_LIBS) -Wl,-\)
else
    $(CC) -o $@ $< $(LIB_DIRS) $(ALL_LIBS)
endif

LIBS_CYCLING_DEPEND 변수 값이 1이면 그룹핑하고, 그렇지 않으면 한번만 찾도록 하는 거죠. 따라서 도저히 피할 수 없는 순환 참조 문제가 발생하면, Makefile에서 LIBS_CYCLING_DEPEND 변수값을 1로 선언하거나 make 실행할 때 LIBS_CYCLING_DEPEND=1 을 인자로 주면 됩니다.

후유, 다 되었네요.

총정리

자, 이제까지 얘기한 것들을 하나로 모아 볼까요? 먼저, 2개의 공용 include 파일들인 IncludePre.mk와 IncludePost.mk는 각각 다음과 같습니다.

# MakefilePre.mk

CC = gcc
AR = ar
RANLIB = ranlib
RM = rm
MKDIR = mkdir

ROOT_SRC_DIR = $(PROJ_ROOT)/Source
ROOT_OUT_DIR = $(PROJ_ROOT)/Out
ROOT_LIB_DIR = $(ROOT_OUT_DIR)/Library

INC_DIRS = -I$(PROJ_ROOT)/Include

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_DIRS = -L$(ROOT_LIB_DIR)/$(OBJS_DIR)
# MakefilePost.mk

LIB_FULL_NAME = $(ROOT_LIB_DIR)/$(OBJS_DIR)/lib$(LIB_NAME).a
LIB_OBJS = $(LIB_SRCS:%.c=$(OBJS_DIR)/%.o)

ALL_LIBS = -l$(LIB_NAME) $(LIBS)
ALL_LIBS += -lpthread

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)
    @`[ -d $(ROOT_LIB_DIR)/$(OBJS_DIR) ] || $(MKDIR) -p $(ROOT_LIB_DIR)/$(OBJS_DIR)`
    $(AR) rcv $@ $(LIB_OBJS)
    $(RANLIB) $@

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

.SECONDEXPANSION:
$(TARGET_NAMES): $$@.o
    @echo "==================================================="
    @echo "= Linking $@ "
    @echo "==================================================="
ifeq ($(LIBS_CYCLING_DEPEND),1)
    $(CC) -o $@ $< $(LIB_DIRS) -Wl,-\( $(ALL_LIBS) -Wl,-\)
else
    $(CC) -o $@ $< $(LIB_DIRS) $(ALL_LIBS)
endif

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 \
            $(CFLAGS) $(DBG_FLAGS) $(INC_DIRS) >> $(DEPEND_FILE); \
    done

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

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

그리고 각 모듈의 Makefile은 이 공용 파일들을 사용해서 다음과 같이 작성합니다.

include $(PROJ_ROOT)/IncludePre.mk

# 라이브러리 이름입니다. 추후 빌드된 라이브러리 이름은 lib$(LIB_NAME).a가 됩니다.
LIB_NAME = # Example

# 라이브러리 소스 파일들의 목록입니다.
LIB_SRCS = # LibSource1.c LibSource2.c ...

# 라이브러리를 사용하는 실행 파일들의 소스 파일 목록입니다.
TARGET_SRCS = # TargetBin1.c TargetBin2.c ...

# 실행 파일들이 링킹해야 하는 라이브러리입니다.
LIBS += # -lLibrary1 -lLibrary2

include $(PROJ_ROOT)/IncludePost.mk

이왕이면 이를 새로운 모듈 작성 시 참조할 수 있도록 template로 만들어서, 공용 include 파일과 동일한 위치에 MakefileTemplate.mk 라고 저장해 두면 좋겠네요. 자, 이제 디렉토리 구조는 다음과 같습니다.

$(PROJ_ROOT)
├── Include
│   ├── Header1.h
│   ├── ...
├── IncludePost.mk
├── IncludePost.mk
├── MakefileTemplate.mk
└── Source
    ├── Module1
    │   ├── Makefile
    │   ├── Source1-1.c
    │   └── ...
    ├── ...

그리고 새로운 모듈을 추가하려면,

  • Source 디렉토리 밑에 적당한 서브 디렉토리를 생성합니다.
  • $PROJ_ROOT/MakefileTemplate.mk 파일을, 생성한 디렉토리에 Makefile 로 복사한 다음, LIB_NAME 변수 값을 지정해 줍니다.
  • 라이브러리의 소스 파일들 중 구현 파일들은 서브 디렉토리에 저장하고, Makefile의 LIB_SRCS 변수에 추가합니다.
  • 라이브러리를 사용하는 (main 함수를 가지고 있는) 실행 프로그램들의 소스 파일들도 서브 디렉토리에 저장하고, Makefile의 TARGET_SRCS 변수에 추가합니다.
  • 해당 라이브러리가 의존하는 다른 라이브러리들을 Makefile의 LIBS 변수에 추가합니다. 만약 라이브러리 간의 순환 참조가 발생한 경우에는 LIBS_CYCLING_DEPEND이 1이 되도록 Makefile에 변수로 추가하거나 make 실행 시 인자로 넣어줍니다.
  • 라이브러리의 소스 파일들 중 헤더 파일들은 Include 또는 Source 디렉토리 중, 취향에 따라 편한 위치에 저장합니다.

빌드하고자 하는 디렉토리에서 make를 실행하면, 라이브러리 파일은 $(PROJ_ROOT)/Out/Library 디렉토리 아래, Debug 또는 Release 디렉토리에 저장됩니다. 나머지 .o 파일들과 실행 프로그램들은 해당 라이브러리 디렉토리 밑의 Debug 또는 Release 디렉토리에 저장됩니다.

길어지긴 했지만 이번 글의 핵심은, 프로젝트 규모가 커질 때 좀더 쉽게 유지 보수할 수 있고, 재활용이 용이하도록 적절한 수준의 라이브러리들로 나누어 구성하는게 핵심입니다. 그리고 이 과정에서 Makefile들의 공통 부분을 별도의 파일들로 분리한 다음 각 Makefile들이 이를 include 하도록 하면, 개별 Makefile은 최대한 단순하게 하면서도 전체 모듈들에 일괄적으로 적용해야 하는 변경 사항들도 쉽게 처리할 수 있게 되죠.

아직까지도 이번 글에서 다룬 내용만 가지고는 실제 사용하기에는 좀 많이 부족합니다. 하지만 중요한 것은, 빌드 디렉토리가 일단 이렇게 한번 구성된 이후에는 전체 모듈들의 빌드 과정을 손쉽게 개선시킬 수 있다는 건데요, 앞으로 그런 것들을 하나씩 살펴 보도록 하겠습니다.