Make 기반 빌드 시스템 (4): 라이브러리와 실행 파일의 분리

라이브러리와 실행 파일의 분리

이전 글에서 새로운 기능 추가를 가뿐하게 완료했습니다. 더불어 추후에 소스 파일들이 추가되거나 할 일이 있어도 쉽게 대응할 수 있는 것도 가능해 졌고요…

그런데 이제 이를 가지고 테스트를 하는데 대체로 잘 동작하는데 가끔씩 뭔가 오동작을 합니다. 여러분의 ‘완벽한’ 코드가 문제는 아닐 테고, 보나마나 저쪽 서버 사이드에서 뭔가 사양대로 구현되지 않았거나 예외 처리가 잘 안 되었을 가능성이 99.999999%일 테죠? 하지만 갑-을 관계의, 을 입장에서 그렇게 쉽게 얘기할 순 없지 않겠습니까?

어쩌나 고민 끝에 사양의 각 항목 별로 단순한 기능을 테스트해서 원인 규명을 하기로 결정합니다. 처음엔 테스트하는 부분 별로 조건부 컴파일 플래그를 만들어서 할까 했지만, 그러자니 매번 다른 테스트를 할 때마다 컴파일을 다시 해야 하는 게 귀찮습니다. 모든 기능을 다 넣은 다음 configuration 파일의 입력이나 환경 변수 등을 참조해서 테스트 기능이 동작하게 하는 방안은, 귀차니스트 입장에선 조건부 컴파일 플래그 안과 별 차이도 없는데다, 만약 릴리즈할 때 깜빡하면 테스트용 코드가 실제 프로그램에 들어갈 수 있다는 게 마음에 걸립니다. 그보다는 테스트 코드들을 별도의 프로그램들로 만들어 두면 두고두고 유용할 것 같습니다. 좋습니다. 별도 프로그램들로 만들기로 하죠.

TestConnection, TestDataChange, TestDrinking (응?), … 여러 개의 실행 파일들을 구현하는 건 그냥 하면 되는데, 현재의 Makefile에서는 MyClient 만 만들어지도록 되어 있다는 게 걸림돌이군요. 그리고 아마도 이 테스트 프로그램들은 MyClient와 소스의 많은 부분을 공유하겠지요. 어떻게 하는게 좋을까요. 그냥 각 테스트 프로그램마다 MyClient의 모든 소스 파일과 Makefile이 있는 디렉토리를 통째로 복사한 다음, MyClient.c 대신 TestConnection.c로 바꾸면 되는 것 아니냐고요? 이거 왜 이러세요, 코드 중복보다 더 무서운게 소스 파일의 중복이란 것쯤은 다 아시잖아요…

그 보다는 MyClient로부터 각 테스트에 공통으로 사용되는 부분을 분리해서 하나의 라이브러리로 만들면 어떨까요? 일단 라이브러리로 분리만 되어 있으면, 테스트 별 필요한 코드들만 별도의 소스 파일로 만들어서 컴파일하고, 만들어진 라이브러리와 링킹해서 각 테스트 프로그램들을 만들 수 있겠네요. 사실 그렇게 공용으로 쓰려고 만드는 게 라이브러리죠.

자, 이런 전차로 이제 TARGET 이라는 하나의 실행파일만 빌드하던 것을 한 개의 라이브러리와 복수 개의 실행 파일들로 분리해서 빌드하도록 하겠습니다.

라이브러리 만들기

일단 MyClient로부터 라이브러리로 분리해낼 부분들을 먼저 정합니다. 소스 파일들 중 MyClient.c 를 제외한 부분이 될텐데요, 이를 LIB_SRCS라고 하겠습니다. 그리고 MyClient.c에 있는 내용들 중에서도 서버쪽과의 프로토콜에 해당하는 부분, 그래서 다른 테스트 프로그램에서 공용으로 사용될 부분은 MyProtocol.c 라는 별도의 파일로 분리해서 저장하는 것으로 하죠. 그리고 빌드할 라이브러리의 이름은, 일단 libMyProtocol.a 라고 하겠습니다. 그럼 라이브러리 빌드를 위한 변수와 규칙은 다음과 같이 되어야 할 겁니다. 다음을 보세요.

AR = ar
RANLIB = ranlib

LIB_FULL_NAME = libMyProtocol.a

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

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

별거 없습니다. ar 을 사용해서 컴파일된 .o 파일들을 하나의 archive 파일을 묶고, ranlib을 실행해서 symbol index를 생성, 정적 라이브러리로 만드는 작업입니다. (ar과 ranlib이 무엇인지 궁금하신 분들은 man 내용을 참고하시거나, 각종 검색 엔진에서 검색해 보시면 되겠습니다. 매뉴얼과 검색의 생활화!!! 다 아시죠?). 그리고 ar과 ranlib을 직접 쓰는 대신, 이를 변수로 선언해서 이를 사용하도록 한 것이고요.

실행파일을 라이브러리와 링킹하기

일단 라이브러리가 이렇게 빌드되고 나면 이제 남는 것은 main() 함수가 있는 .c 파일들을 컴파일해서 이를 $(LIB_NAME)과 링킹하는 것입니다. 먼저 라이브러리에서 한 것과 비슷하게 소스파일, 목적 파일, 실행파일들을 각각 TARGET_SRCS, TARGET_OBJS, TARGET_NAMES를 정의해 보죠.

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

그런데 .c 로부터 .o 를 컴파일하는 것은 make의 내장 규칙에 의해 실행될 테니 문제 없지만, 각각의 .o를 libMyClient.lib을 링킹해서 실행 파일들을 생성하는 규칙의 작성은, 음… 좀 까다롭습니다.

규칙의 작성에 앞서, .o를 libMyClient.lib을 링킹해서 실행 파일들을 생성하기 위한 gcc 명령을 먼저 보죠. 다음과 같을 겁니다.

gcc -o MyClient MyClient.o -L. -lMyProtocol -lpthread
gcc -o TestConnection TestConnection.o -L. -lMyProtocol -lpthread
...

참고로, gcc에서 -L 옵션은 링킹할 라이브러리를 찾을 경로를 지정합니다. 따라서 -L. 은 앞서 만들어진 libMyProtocol.a가 현재 디렉토리에 존재하기 때문에 현재 디렉토리를 라이브러리 검색 경로에 지정하는 것이죠.

이를 make에서 수행하기 위해 필요한 Makefile의 규칙들은 다음과 같습니다.

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

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

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

MyClient: MyClient.o
    $(CC) -o $@ MyClient.o $(LIB_DIRS) $(LIBS)
TestConnection: TestConnection.o
    $(CC) -o $@ TestConnection.o $(LIB_DIRS) $(LIBS)
TestDrinking: TestDrinking.o
    $(CC) -o $@ TestConnection.o $(LIB_DIRS) $(LIBS)

(1)는 링킹할 때 사용할 단순한 라이브러리 이름인데요, 이는 (4)에서 사용됩니다. (2)는 lib 과 .a 까지 다 있는, 파일 시스템 상의 라이브러리 파일 이름이고, (7)에서 규칙의 타겟으로 사용되고요.

(4)는 이전에 정의된 LIBS에다 -lMyClient 를 추가한 것입니다. 참고로 Makefile에서 변수 다음에 = 를 쓰면 변수를 새로 정의하는 것이고요, += 를 사용하면 기존 값에다 추가하는 것입니다. (5)의 LIB_DIR은 라이브러리 검색 경로를 지정하는 변수이고요.

(11) ~ (16)은 각 프로그램을 만들기 위한 규칙들입니다. 빌드는 됩니다만, 개별 프로그램 하나 하나에 대해 거의 똑같은 별도의 규칙들을 작성하는 건 좀 내키지 않습니다. 규칙의 타겟을 가리키는 $@를 사용해서 선행조건을 일반화해서 하나의 규칙으로 만들 수 있지 않을까요? 다음과 같이 말입니다.

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

각 규칙에서 $@가 MyClient, TestConnection, TestDrinking을 각각 가리킬 테니까, $@.o 는 각각 MyClient.o, TestConnection.o, TestDrinking.o 를 가리킬 테고요, 그럼 저 규칙 하나로 멋지게 빌드가 될 것 같습니다. 와우! 하지만 불행히도, 위의 규칙을 실행하면 다음과 같은 결과가 나옵니다.

$ make
gcc      -o .o
gcc: no input files
make: *** [.o] 오류 1

이는 make의 $@ 같은 자동 변수는, 레시피에서만 값을 가지기 때문입니다. 따라서 선행조건에서는 빈 문자열을 나타내고, 결과적으로 gcc는 입력 파일을 주지 않은 채, .o 라는 이름으로 컴파일하라는 명령만 받은 셈이니 gcc가 불평하는 게 당연한 것이죠.

비록 최종으로 원하는 결과를 얻지는 못했지만, 중요한 거 하나를 생각해 냈군요. 선행조건에 $@ 에 해당하는 것만 표현할 수 있으면 하나의 규칙으로 정리가 가능하다는 것입니다. 다행히, make는 정확하게 그에 해당하는 기능을 가지고 있는데요, Secondary Expansion이 바로 그것입니다.

Secondary Expansion

Second Expansion을 알기 위해서는 먼저 make가 Makefile을 어떻게 읽어 들이는 지에 대한 좀더 구체적인 이해가 필요합니다.

make는 크게 2개의 단계로 Makefile을 읽어 들입니다. 첫 번째 단계는 read-in phase로서, make는 입력으로 지정된 Makefile의 모든 내용을, include된 다른 파일들까지 포함해서 모두 읽어 들인 후, 모든 변수들과 규칙들을 정의하고, 규칙들의 타겟들과 선행조건들의 의존성 그래프를 구조화합니다. 그리고 두 번째 단계, target update phase에서, make는 이러한 내부 구조들을 사용해서 어떤 타겟이 다시 만들어져야 하는지, 그리고 어떤 규칙들이 실행되어야 하는 지를 결정합니다.

이 과정에서 변수의 값이 결정되는 것을 expansion이라고 하는데, 보통의 경우는 대부분 read-in phase에서 expansion이 일어납니다. 하지만 make는 특정 규칙들에 대한 선행조건 목록에 한해서, expansion을 나중으로 미룰 수 있는 기능을 제공하는데요, 이를 secondary expansion이라 합니다.

Makefile 내에서 .SECONDEXPANSION: 이 선언되어 있으면, 이후 규칙의 선행조건 목록에서 $$()로 참조되는 변수의 expansion은 read-in phase에서 일어나지 않고, 단지 $ 하나를 떼면서 target update phase로 보류됩니다

알듯말듯, 좀 어려운가요? 역시 개발자한테는 예제가 빠르죠. 다음을 보시기 바랍니다. (해당 예제는 gnu manual site에서 인용했습니다.)

.SECONDEXPANSION:
AVAR = top
ONE_FILE: $(AVAR)
TWO_FILE: $$(AVAR)
AVAR = bottom

(2)에서 AVAR은 top 으로 지정되어 있습니다. (3)의 $(AVAR)은 read-in phase에서 expansion이 일어나게 되고, 따라서 ONE_FILE의 선행조건은 top이 됩니다. target-update phase에서도 달라지는 게 없구요.

하지만 (4)의 $$(AVAR)은 read-in phase에서는 $ 하나만 떼고 expansion이 target-update phase로 보류됩니다. 즉, read-in phase에서 TWO_FILE의 선행조건은 그냥 문자열 그대로 “$(AVAR)”이 되는 거지요. 이후 (5)에서 AVAR는 bottom으로 지정됩니다. 그리고 난 다음 target-update phase에서 (4)의 $(AVAR)에 대한 second expansion이 일어나고, 그 결과 TWO_FILE의 선행조건은 bottom 이 되는 것입니다.

사실 위 예제는 second expansion의 극적인 효과를 확인하는 것으로는 좋지만, 그 이상의 의미는 없습니다. 중요한 건 second expansion을 통해, 우리가 필요로 하는 규칙을 만들 수 있다는 거죠. 바로 앞의 단락에서 필요로 했던, 여러 개의 실행 binary를 만들어내는 하나의 규칙 말입니다. 다음을 보세요.

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

$@는 선행조건에서 빈 문자열을 나타내지만, .SECONDEXPANSION: 으로 지정된 상태에서의 $$@.o는 second expansion에 의해 make가 규칙을 실행하는 단계에서 각각 MyClient.o, TestConnection.o, TestDrinking.o 로 치환될 수 있습니다. 네, 우리가 앞서 필요로 한 바로 그 규칙입니다.

참고로 위 규칙의 레시피에서 $< 는 $@ 와 마찬가지로 make에서 내장한 자동변수인데요, 이는 선행조건의 첫 번째 항목을 나타냅니다. 따라서 $$@.o 에 의해 치환되는 MyClient.o, TestConnection.o, TestDrinking.o가 되는 것이죠.

결과적으로 바로 이 규칙 하나로 TARGET_NAMES로 지정된 실행 파일들이 모두 compile되고 libMyCleint.a 와 링킹됩니다. (위 예제에서 $@.o 와 $< 는 동일한 결과를 가져옵니다만, 정확하게 가리키는 $< 쓰는 것이 더 낫겠지요.)

남은 건, depend와 clean에 TARGET_NAMES와 LIB_NAME 관련된 부분을 추가하는 건데요, 이건 뭐 누워서 떡 먹기죠. 먼저 depend 규칙은, gcc -MM 할 때 TARGET_SRCS를 추가하기만 하면 됩니다. 마찬가지로, clean도 $(LIB_NAME)와 $(TARGET_NAMES), 그리고 $(TARGET_OBJS)들이 같이 삭제되도록, rm -f 명령에 추가만 하면 끝입니다. 다음과 같이 말이지요.

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

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

자 이제까지 나온 모든 것을 하나로 정리하면 예제 4.1과 같습니다.

예제 4.1
DEPEND_FILE = depend_file

CC = gcc
AR = ar
RANLIB = ranlib

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_DIR = -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) $@

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

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

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

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

(34)과 (41)에서, 반복적으로 사용되는 $(LIB_SRCS) $(TARGET_SRCS) 도 거슬리는 깔끔한 (성격의) 분이라면 이를 $(SRCS)라고 별도로 정의해서 사용해도 좋겠네요.

참고로, (41)에 쓰인 $(strip string) 은 string의 앞뒤에 있는 공백문자들을 제거하고 내부에 있는 하나 이상의 공백문자들을 단일 스페이스로 교체하는 make 내장 함수입니다. 그냥 ifneq ($(LIB_SRCS) $(TARGET_SRCS),) 로 쓰게 되면, $(LIB_SRCS)와 $(TARGET_SRCS) 사이에 공백 하나로 인해, $(LIB_SRCS) 와 $(TARGET_SRCS) 가 모두 지정되지 않았음에도 해당 조건이 ” 와 다르다고 판별되어 (42)의 include 문이 실행되는데, $(strip)을 써서 이를 막는 것이죠.

이제 라이브러리에 .c 소스 파일 추가할 때에는 LIB_SRCS에 해당 파일을 추가하면 되고, 실행 파일을 위한, main() 함수가 있는 소스 파일을 추가할 때는TARGET_SRCS 에 추가하면 됩니다. 아, 물론 소스가 바뀌면, make depend 한번 돌려주는 것 잊지 말아야겠죠?