Make 기반 빌드 시스템 (3): 소스 목록 관리를 조금 편리하게

소스 목록 관리를 조금 편리하게

이전 글에서 간단한 예제를 가지고, make의 기본적인 동작 방식을 알아 보았습니다. 소켓 기능을 사용해서 간단한 서버를 만드는 것이었죠.

대단히 짧은 기간에 make로 빌드한 MyClient의 기본 동작에 문제 없음을 확인했다는 기쁨도 잠시, 이번엔 PM 또는 영업사원, 혹은 마케팅 담당자? 하여간에 별로 이쁘지 않은 사람이 와서는 이렇게 떠들어 댑니다. MyClient가 아주 괜찮은 프로그램이라며, 여러 개의 서버들로과의 동시 접속을 처리해 주는 기능만 지원 해주면, 어디 가서 데모해서 계약을 따 올 수 있다고 말이죠… 비슷한 지난 경험들로 생각하면 그닥 신뢰가 가지는 않지만, 매출을 올릴 수 있다는 데다, 이게 이런 건 당신한테는 일도 아니잖아, 나 한번만 도와주라… 결국 동시 접속 처리 기능을 추가해야 하는 상황이 됩니다. 흠… 별로 달갑진 않지만, 어쩝니까, 구현해야죠. (아아 이게 바로 그 개발자 인생이죠.)

동시 접속 처리 기능이라… 멀티 쓰레딩 구조로 바꾸는 게 구현하기 좀 까다롭기는 하지만, 리눅스에서 지원되는 pthread library와, mutex 나 semaphore로 동기화만 잘 해주면 된다는 걸 아는 개발자한테는, 사실 아주 어려운 일은 아닐 겁니다. MyThread.c, MyThread.h, MyMutex.c, MyMutex.h, MySemaphore.c, MySemaphore.h 이렇게 총 6개의 파일을 후다닥 작성한 다음, 이제 Makefile에 추가하려 하는데, 문제는 이렇게 추가된 소스 파일들을 예제 2.2에 추가하려니, 해야 할 일이 좀 된다는 거죠.

첫번 째로 일단 (2)의 OBJS 선언부에 MyThread.o MyMutex.o MySemaphore.o 를 추가해야 하고, 두번 째로 다음으로 (15) 이후에 새로 추가된 .c 파일들의 의존성을 나타내는 규칙들을 작성해햐 합니다.

첫 번째 단계는 그다지 어려울 게 없지만, 두 번째 단계는 #include 한 거에 맞는 규칙을 작성해햐 한다는 게 좀 부담스럽습니다. 한번이라면 눈 딱 감고 노가다 뛸 수도 있겠지만, #include가 추가될 때마다 의존성 규칙을 수정해야 할텐데, 이건 보통 인내심의 개발자라면 받아들이기 힘든 노가다입니다. 갑자기 멀티 쓰레딩 기능이 추가된 마당에, 앞으로 또 어떤 일이 벌어질 지 모르는 것 아니겠어요? 그런 노가다를 반복적으로 할 수는 없죠.

gccmakedep 사용해서 의존성 규칙 추가하기

그러자면 먼저 의존성을 처리하기 위한, 좀 편리한 방법이 필요합니다. 소스 파일들 사이의 의존 관계를 따지는 일이라… 각종 스크립트 언어들과 정규식 표현들과 같은 여러 가지 재능들을 겸비한 능력자라면, 한번 도전해 볼 수 있는 일이긴 합니다. (벌써 편집기를 여셨다고요? 워워, 잠깐만 참으세요.) 사실 이건 컴파일러의 기본 기능입니다. 따라서 이 일을 가장 잘 할 수 있는 건 당연히 컴파일러입니다. C 소스 파일이니까 각종 조건부 컴파일 옵션도 따져 줘야 하고, 현재 디렉토리 외에 gcc 옵션 중 -I 옵션에 의해 지정되는 외부 include 경로까지 따져야 합니다.

네, 이건 꼭 gcc의 기능에 의존해야만 하는 일입니다.

게다가 gcc 는 주어진 소스 파일들 사이의 의존 관계를 생성해 주는 옵션을 제공하고 있습니다. gcc -M 이 바로 그 옵션인데요, 이를 이용해서 소스 파일들의 의존 관계를 규칙으로 만들어서 Makefile 내에 자동으로 삽입시켜 주는 프로그램이 있습니다. 바로 gccmakedep 입니다.

gccmakedep의 사용법은 간단합니다. 그냥 소스 파일들 목록과 컴파일에 필요한 여러 옵션들(gcc 를 이용하니까 당연한 겁니다.)을 인자로 주고 gccmakedep 를 실행만 하시면 됩니다. 그러면, gccmakedep는 gcc한테 부탁해서 의존성을 알아내고 이를 현재 디렉토리의 Makefile 파일 끝부분에 추가해 주기까지 합니다. (만약 Makefile 이름을 별도로 하고 싶으면 -f makefile_name 옵션을 주면 됩니다.)

백번 듣는 것보다는 한번 보는 것이, 그리고 실행해 보는 것이 나은 법! gccmakedep을 예제 2.2이 있는 디렉토리에서 다음과 같이 실행해 보세요.

$ gccmakedep MySocket.c MyClient.c

그러면 Makefile의 내용이 다음과 같이 바뀌게 됩니다.

예제 3.1
CC = gcc
OBJS = MySocket.o MyClient.o
TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $(TARGET) $(OBJS)

clean:
    rm -f $(OBJS) $(TARGET)

MySocket.o : MySocket.c MySocket.h
MyClient.o : MyClient.c MySocket.h
# DO NOT DELETE
MySocket.o: MySocket.c MySocket.h
MyClient.o: MyClient.c MySocket.h

(17) ~ (19)까지가 추가된 부분입니다. gccmakedep는 친절하게도 Makefile 끝 부분에 # DO NOT DELETE 라는 줄을 추가한 다음 그 밑에 소스 코드들 사이의 의존성을 나타내는 규칙들을 작성해 줍니다. 따라서 (15) ~ (16) 사이에 손으로 작성한 부분은 필요가 없으니, 그냥 삭제해 버리면 되겠네요.  

depend 규칙 추가

gccmakedep를 써서 의존성 규칙이 자동으로 포함되게 하는 것은 좋습니다. 그런데, 매번 손으로 gccmakedep를 실행시키는 것은 그 자체로 번거로울 뿐더러, 소스 코드 파일들이 변경되었을 때 실수할 가능성이 높습니다. (저는 요런 일 할 때의 제 자신을 그다지 신뢰하지 않습니다. 네? 본인은 안 그럴 것 같다고요? 설마, 그럴리가요…) 그럼, 그런 실수 가능성을 없애기 위해서 어떻게 하는 게 좋을까요? 그렇습니다. Makefile 내에 포함시키면 간단해 지겠지요.

예제 3.2
CC = gcc

OBJS = MySocket.o MyClient.o
SRCS = MySocket.c MyClient.c

TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $(TARGET) $(OBJS)

depend:
    gccmakedep $(SRCS)

clean:
    rm -f $(OBJS) $(TARGET)

예제 3.2를 보시면 (15) ~ (16)의 depend라는 규칙을 추가했습니다. 이 녀석은 선행조건이 없기 때문에 make depend 하고 실행할 때 gccmakedep 를 실행합니다. 단, 소스 파일들을 타이핑하는 것 대신, $(OBJS)를 선언한 것처럼 (4)의 SRCS라는 변수를 별도로 선언한 다음, $(SRCS)를 입력했습니다. 이렇게 하면 새로운 소스 코드를 추가할 때 OBJS 와 SRCS를 한번에 추가하면 되니까 아무래도 실수 가능성이 줄겁니다. 줄긴 하는데, 그래서 이번 장 처음의 상황에 비하면 많이 좋아지긴 했지만, 거의 똑같고 단지 확장자만 바꾼 OBJS 와 SRCS를 작성한다는 게 좀 마음에 걸립니다.

뭐라고요? 현재 사용하시는 편집기에서 .o 를 .c로 바꾸는 건 일도 아니라구요? 네, 여러분의 편집기와 그걸 사용하는 여러분의 능력을 무시하는 건 결코 아닙니다만, 사실 그 일은 make가 더 정확하게 잘 할 수 있거든요.

make는 변수들을 조작하는 여러 기능을 가지고 있는데요,변수 치환 기능을 이용한 다음 한줄로 SRCS로부터 OBJS를 만들어 낼 수 있습니다.

OBJS = $(SRCS:%.c=%.o)

척 봐도 짐작 가능하시죠? 내용인즉슨, SRCS 변수 내용 중에서 .c 인 부분을 .o 로 바꾸라는 내용입니다. 따라서 예제 3.2의 (3) ~ (4)은 다음과 같이 바뀔 수 있습니다.

SRCS = MySocket.c MyClient.c
OBJS = $(SRCS:%.c=%.o)

음, 이제 깔끔해 졌습니다. 소스 코드가 추가되면 그냥 SRCS 에 .c 파일들만 쭉 추가하면 됩니다. (저같이 타이핑이 귀찮으신 분은, 터미널에서 ls 한 결과를 붙여 넣어도 됩니다.) 그리고 나서 make depend 한번 실행해 주면 끝입니다. 그리고 소스 코드 내에 새로운 #include 가 추가될 때마다 그냥 make depend 만 한번 실행해 주면 되니까, 의존성 누락으로 인한 어이없는 컴파일 에러 및 디버깅할 일이 없어지고, 그만큼 칼퇴근 가능성이, 아주 약간은 높아지겠군요. 좋습니다.

링킹 라이브러리 추가

그럼 이제 할 일은 Makefile의 SRCS 에다 새로 추가되는 .c 파일들을 넣기만 하면 되는 거죠. 다음과 같이 말입니다.

...
SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyClient.c
OBJS = $(SRCS:%.c=%.o)
...

그런데 이러고 나서 빌드하니, 다음의 메시지가 나옵니다.

gcc -o MyClient MySocket.o MyClient.o MyMutex.o MySemaphore.o MyThread.o
MySemaphore.o: In function `NewSemaphore':
MySemaphore.c:(.text+0x8f): undefined reference to `sem_init'
MySemaphore.o: In function `DeleteSemaphore':
MySemaphore.c:(.text+0x11c): undefined reference to `sem_destroy'
MySemaphore.o: In function `LockSemaphore':
MySemaphore.c:(.text+0x150): undefined reference to `sem_wait'
MySemaphore.o: In function `UnlockSemaphore':
MySemaphore.c:(.text+0x181): undefined reference to `sem_post'
MyThread.o: In function `RunThread':
MyThread.c:(.text+0x1e9): undefined reference to `pthread_attr_setstacksize'
MyThread.c:(.text+0x26e): undefined reference to `pthread_create'
MyThread.o: In function `QuitThread':
MyThread.c:(.text+0x321): undefined reference to `pthread_join'
collect2: ld returned 1 exit status

아차, 링커가 내뿜는 에러군요. semaphore와 pthread 관련 함수를 썼으니, 링킹 시에 pthread 라이브러리를 옵션을 줘야 하는데 그 부분이 빠져 있기 때문입니다. 따라서 링킹 에러를 없애는 건, 예제 4의 $(TARGET) 규칙의 레시피에다 -lpthread를 추가해 주면 끝(참고로 gcc에서의 라이브러리 링크는, -l 뒤에다 .확장자를 제외한 라이브러리 이름을 주면 됩니다.)입니다만, 프로그램 개발 과정에 필요한 라이브러리들이 한둘이 아닐테죠. 이 참에 링킹할 라이브러리들을 처리하기 위해 별도의 변수를 하나 추가하고, 링킹할 때 이를 사용하는 게 좋겠습니다.

예제 3.3
CC = gcc

SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyClient.c
OBJS = $(SRCS:%.c=%.o)

LIBS = -lpthread

TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $(TARGET) $(OBJS) $(LIBS)

depend:
    gccmakedep $(SRCS)

clean:
    rm -f $(OBJS) $(TARGET)

(6)의 LIBS라는 변수를 하나 선언해 두고, 이를 -lphtread로 설정한 다음, (15)의 링킹 명령어에 이를 추가합니다. 이후 make depend 실행한 다음 make를 실행합니다. 네, 이번엔 문제 없이 잘 빌드됩니다. 그리고 앞으로는 추가로 링킹할 라이브러리가 생기면 그냥 LIBS에다 추가만 하면 끝입니다.

의존성 규칙를 Makefile에서 분리하기

이제, 딱 하나만 고려하면 될 것 같습니다. 혼자 개발하는 거면 이제까지 한 얘기로 충분합니다. 그렇지만, 이제는 전산 수업의 과제도 여러 명이 조 짜서 해야 하는 세상입니다. 웬만한 조직에서는 여려 개발자가 함께 협업을 하는 게 보통이겠지요. 그게 빌드 시스템이랑 무슨 상관이람? 아시겠지만, 여러 명이 동시에 소프트웨어 개발을 하다보면 버전관리 툴의 사용은 필수입니다. (사실 혼자서 작업할 때도 버전관리 툴을 사용하는 건 필수죠.)

어이 거기, 고개 숙이시고 계신 분… 네? 버전관리 툴을 안 쓰신다고요? 사는데 지장이 없다고요? 만약에 아직 버전관리 툴이 없는 상태에서 개발중이시라면, make 빌드 시스템 구축 전에 버전관리 툴부터 구축하시기를 권해드립니다. 지금 당장이요. 찾아보면 훌륭한 기능을 갖췄으면서도 공짜로 쓸 수 있는 버전관리 툴이 많이 있고요, 구축 및 사용과 관련된 훌륭한 가이드 문서들도 많이 있습니다. 조금만 시간 들여서 버전관리 툴을 구축해 놓으면 말로 헤아릴 수 없는 혜택을 보실 겁니다.

흠… 예전에 버전관리 툴 없던 곳에 몸 담았던 암울한 기억에 잠깐 흥분했네요. 다시 본론으로 돌아와서, Makefile이랑 협업과 버전관리 툴이 도대체 웬 상관이냐고 하실 수 있습니다만… 상관이 있을 수도 있습니다.

딴게 아니라 이겁니다. gccmakedep를 사용하면 Makefile 내에 의존성 규칙들이 추가됩니다. 보통 Makefile은 소스와 함께 같이 버전관리 툴에 포함됩니다. 당연하지요. 모든 개발자가 버전관리 툴에서 받아온 소스를 빌드하면 동일한 결과가 나와야 할테니까요. 그런데 Makefile 내에 의존성 부분이 포함되어 있으면 이게 약간 문제가 될 소지가 있습니다.

예를 들어 디지털 녹화기나 네트워크 카메라 같은 임베디드 시스템에 탑재될 소프트웨어를 개발한다고 가정해 봅시다. 메인 칩이나 하드웨어 사양에 따라 기능과 포함되는 소스가 조금씩 달라질 수 있을 겁니다. 칩 업체에서 제공되는 특정 헤더 파일들이 포함되었다 빠졌다 할 수도 있고요. 그럴 때마다 의존성 규칙이 달라지기 때문에, make depend 해서 Makefile이 변경되어야 합니다.

또 새 기능을 추가할 때면, 각 플랫폼에 맞춰 빌드하고 테스트를 해야 하는데, 플랫폼마다 Makefile이 달라질, 이 부분이 변경 히스토리에 포함되면 좋을 게 없습니다. Makefile의 변경 이력 추적할 때는, 소스 파일의 추가, 삭제 그리고 컴파일 옵션의 변경 등과 같은 실제적으로 의미 있는 것만 나오는 게 좋은데, 기계적으로 생성가능한 의존성 부분이 들어감으로 인해 변경량이 많아 져서 눈에 자꾸 거슬립니다.

이걸 막으려면 개발자들이 테스트 다 하고 나서, 버전관리 툴에 커밋(또는 체크인)하기 전에 Makefile에서 이 부분을 빼고 커밋해 줘야 하는데, 맨날 야근에 찌든 개발자들은 이 부분을 깜빡하기 쉽습니다. 아주 가끔이라도 이상한 의존성 규칙이 있어서 이상하게 빌드되면, 아아 정말 생각만으로도 갑갑합니다.

앓는 소리는 다 빼고, 경험적으로 봤을 때도, 소스 코드 사이의 의존성 부분은 Makefile에서 빼는 게 좋더라는 겁니다. 아니 그럼, 손으로 수작업하면 안 되고, gcc 를 이용해서 의존성 규칙을 만들어 써야 한다더니, 어쩌라고요 싶으신가요? (흠흠… 두 가지를 만족하는 방법이 있으니까 이렇게 잘난 척 얘기하는 겁니다.)

우리의 친구 make는 정말이지 다재다능한 녀석이어서, 우리가 (마치 c 파일처럼) Makefile 에서 다른 파일을 include 할 수도 있습니다. gcc 를 이용해서 의존성 규칙을 만들어 사용하되 Makefile 에서 제외시키는 방법이 바로 이겁니다. 의존성 파일은 외부에다 만들고 이를 Makefile 내에 포함시키는 거지요. 먼저 의존성 규칙을 외부에다 만들려면 다음과 같이 하면 됩니다. gcc -MM 실행결과를 depend_file 이라는 파일에다 저장하는 것이죠.

$(CC) -MM $(SRCS) > depend_file

그 결과는 다음과 같습니다. 당연한 결과죠.

$ cat depend_fle
MySocket.o: MySocket.c MySocket.h
…
MyClient.o: MyClient.c MySocket.h

이제 이 depend_file을 Makefile 내에 포함만 시키면 되겠네요, 그건 Makefile 내에 다음 내용을 넣는 것으로 할 수 있습니다.

...
include depend_file
...

이렇게 하면 이는 Makefile 내에 depend_file을 직접 입력한 것과 동일한 효과를 지닙니다. 원하는 바를 다 이룬 것 같습니다만, 아직 약간의 문제가 남아 있습니다. 만약에 depend_file이 없는 경우에는 make가 이것 때문에 실행을 멈춘다는 것입니다. 즉, make 실행하기 전에 반드시 make depend 한번 해 주도록 강제하는 것이죠. 이게 일반 개발자한테는 좋은 수도 있겠지만, 개발용이 아니라 단순히 일회성 빌드만 수행해야 하는 경우, 예를 들어 릴리즈용으로 빌드하는 경우라면, depend 하느라 쓸데없이 시간만 잡아 먹을 필요는 없겠지요. gcc -MM 을 실행하는 거란 것 기억나시죠? 소스 파일이 많아지고 include 하는 회수와 include path가 많아지면 의존성 찾는 데도 꽤 시간이 걸립니다.

당연히 make는 이런 것도 다 고려하고 있습니다. include 앞에 – 를 붙이면, 파일이 없는 경우 그냥 무시하고 넘어갑니다. 따라서 이렇게 고치면 되겠지요.

-include depend_file

그리고 이왕 빌드 시간을 조금이라도 줄이자는 차원에서 하나 더 덧붙이자면, 사실 의존성 규칙은 컴파일하고 링킹할 때만 필요한거지, clean 또는 depend 같은 규칙을 실행할 때는 필요가 없습니다. 따라서 불필요하게 depend_file을 읽어들일 필요가 없게끔 해서 나쁠 건 없을 겁니다. 다음과 같이요.

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(SRCS),)
-include depend_file
endif
endif
endif

제가 Makefile에서도 조건문을 검사할 수 있다는 건 아직 얘기하지 않았죠? Makefile에서 특정 조건이 만족될 때만 시행하고 싶은 경우에는 ifeq ( condition ) … endif 를, 그 반대라면 ifneq 을 쓰면 됩니다. 그리고 make는 MAKECMDGOALS 라는 내장 변수를 가지고 있는데요, 이는 make 실행할 때 인자로 지정한 규칙이 되겠습니다. 아무 것도 지정되지 않으면 빈 값이 됩니다.

따라서 위의 내용은 실행 시 지정된 규칙이 clean이 아니고, depend도 아니면서, SRCS 파일이 빈 값이 아닐 때만, 그리고 depend_file이 있을 때에만, include 하게 됩니다. 이로서 불필요한 depend_file를 포함하는 일을 피할 수 있는 거지요.

마지막으로 clean 할 때 depend_file도 같이 삭제되도록 하는 것만 넣으면 끝입니다.

clean :
    rm -f $(OBJS) $(TARGET) $(DEPEND_FILE)

이상의 모든 내용을 적용한 게 예제 3.4 입니다.

예제 3.4
DEPEND_FILE = depend_file

CC = gcc

SRCS = MySocket.c MyMutex.c MySemaphore.c MyThread.c MyClient.c
OBJS = $(SRCS:%.c=%.o)

LIBS = -lpthread

TARGET = MyClient

.SUFFIXES : .c .o

all : $(TARGET)

$(TARGET) : $(OBJS)
    $(CC) -o $@ $(OBJS) $(LIBS)

depend :
    $(CC) -MM $(SRCS) > $(DEPEND_FILE)

clean :
    rm -f $(OBJS) $(TARGET) $(DEPEND_FILE)

ifneq ($(MAKECMDGOALS), clean)
ifneq ($(MAKECMDGOALS), depend)
ifneq ($(SRCS),)
-include $(DEPEND_FILE)
endif
endif
endif

이제 버전관리 툴에 들어가는 Makefile에는 의존성 관련 부분이 모두 빠집니다. 대신 각 개발자가 버전관리 툴로부터 소스를 받아온 다음 make depend만 한번 수행해 주면 됩니다. 개발용이 아니라 릴리즈를 위해 빌드할 때처럼 한번만 빌드하는 경우라면, make depend 할 필요 없이 그냥 make 하면 그만입니다.

네, 원하는 대로 다 되었네요. 다음 글에서는 라이브러리와 실행 파일을 분리하는 것에 대해 정리해 보도록 하죠.