Make 기반 빌드 시스템 (2): Makefile 기초

Makefile 기초

간단한 예제

리눅스 상에서 IDE 없이 C/C++로 어떤 실행 프로그램을 만들 때에는, 보통 gcc 와 make 를 가지고 시작합니다. 적당한 오픈 소스 프로젝트의 소스에 있는 Makefile를 기반으로 시작할 수 있겠지만, make에 대한 기본적인 공부도 할 겸, 일단은 간단한 예제로부터 시작해 보겠습니다.

간단한 네트워크 프로그램을 작성할 일이 생겼다고 해 보죠. 다행히 여러분은 이미 예전에 작성해 둔 소켓 모듈(MySocket.c, MySocket.h)들이 있기 때문에, 그리 어려운 일은 아닙니다. MyClient.c이라는 파일에다 MySocket.h를 include해서 socket 모듈에 있는 socket 관련 함수를 쓰고, 몇 가지 필요한 기능만 추가해서, MyClient로 빌드하면 끝입니다. 다음은 MyClient를 빌드하기 위한 Makefile입니다.

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

.SUFFIXES : .c .o

all : $(TARGET)

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

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

네, 공백 다 포함해도 13줄 밖에 안 됩니다. 처음 보는 분이라면 정말로 되나 싶을 수도 있을 테지만, 저 정도는 너무 우스운 분들이 대부분 일거란 것 쯤은 저도 압니다. 그런 분들은 그냥 재미삼아 따라 오시든가, 혹은 제가 허튼 소리 하는 건 아닌 지 지켜 보셔도 되겠습니다.

변수 (Variable)

먼저 (1) ~ (3) 사이에 있는 CC, OBJS, TARGET 들은 우리 소프트웨어 개발자에게는 그 이름도 익숙한 ‘변수’들입니다. 즉 Makefile에서 특정한 문자열을 표현하기 위해 사용합니다. (다른 문서에서는 매크로라고 부르기도 합니다.) 변수는 보통 = 에 의해 선언되고요, 이를 참조할 때에는 $(변수이름) 으로 하게 됩니다. 참고로 변수의 이름은 ‘:’, ‘#’, ‘=’, 또는 시작이나 끝에 공백문자를 담고 있지 않는, 문자들의 임의의 조합이면 됩니다. 하지만 알파벳, 숫자, 그리고 밑줄이 아닌 것을 담고 있는 변수 이름들은 피하는 것이 좋습니다. 미래에 다른 특수한 의미를 가질 수 있기 때문이죠. 또한 변수 이름은 대소문자를 구분합니다. 즉, ‘foo’, ‘FOO’, ‘Foo’ 는 모두 서로 다른 변수가 됩니다. 그리고 꼭 그래야만 하는 것은 아닌데, 관례적으로 Makefile에서 변수이름은 보통 대문자로 구성하고 길어질 때에는 ‘_’ 를 이용해서 읽기 좋게 만듭니다. 예를 들어 PROJECT_NAME 뭐 이런 식으로요. 변수는 일단 이정도로만 설명하도록 하고 다음으로 넘어가겠습니다.

확장자 규칙 (Suffix Rule)

(5)의 .SUFFIXES 는 make의 확장자 규칙인데요, GNU Make강좌의 3장에 잘 정리되어 있어, 그대로 인용해 봅니다. (http://wiki.kldp.org/KoreanDoc/html/GNU-Make/GNU-Make-3.html)

확장자 규칙이란, 간단히 말해서 파일의 확장자를 보고, 그에 따라 적절한 연산을 수행시키는 규칙이라고 말할 수 있다. 가령 .c 파일은 일반적으로 C 소스 코드를 가리키며, .o 파일은 목적 파일(Object file)을 말하고 있다. 그리고 당연히 .c 파일은 컴파일되어서 .o 파일이 되어야 하는 것이다. 여기서 한가지 변수가 등장하게 된다. .SUFFIXES 라고 하는 변수인데 우리가 make 파일에게 주의 깊게 처리할 파일들의 확장자를 등록해 준다고 이해하면 될 것이다. .SUFFIXES : .c .o 위의 표현은 ‘.c’ 와 ‘.o’ 확장자를 가진 파일들을 확장자 규칙에 의거해서 처리될 수 있도록 해준다.

Makefile 내에서 (1)과 같이 지정되면, 내가 작성한 Makefile에 별도의 규칙이 없을 경우에는, make에 내장된 기본 규칙 중에서, 확장자가 .c 및 .o 와 관련된 규칙을, make가 자동으로 적용해서 처리하게 됩니다. 다시 말해서 .c 로 된 C 소스 파일들이 .o 로 컴파일 되는 것이죠.

규칙(Rule)

(7) ~ (13) 내용은 Makefile에서 선언된 규칙입니다. 일반적으로 make의 규칙은 다음의 형식을 가지고 있습니다. (http://www.gnu.org/software/make/manual/make.html 에서 인용했습니다.)

target ... : prerequisites ...
[TAB]recipe
[TAB]...
[TAB]...

타겟(target)은 프로그램에 의해 생성해 내고자 하는 파일의 이름 또는 수행하고자 하는 액션(action)의 이름입니다. (9)에서의 타겟은 $(TARGET), 다시말해 MyClient 라는 실행 파일이 됩니다. 그리고 (12) 에서의 타겟은 clean 입니다.

선행조건(prerequisites)은 말 그대로 타겟을 만들어내거나 수행하기 위해 미리 갖춰야 하는 조건입니다. 동시에 이는 타겟이 선행조건에 의존적이라는 의미도 동시에 가지고 있습니다. (실제 다른 make 가이드 문서에서는 dependencies라고도 합니다.) 선행조건에 해당하는 파일이 존재하는 경우에는, 타겟 파일이 선행조건 파일보다 더 최신일 경우, 선행조건이 만족한 것으로 판단합니다.

그리고 레시피(recipe)는 선행조건이 만족될 때 make가 수행할 명령이 되는데요, 반드시 TAB 문자로 시작해야 합니다. (다른 make 가이드 문서에서는 command 라고 부르기도 합니다.)

일반적으로 규칙은 하나 이상의 선행조건들을 가질 수도 있고, 또는 선행조건이 아예 없을 수도 있습니다. 선행조건이 여러 개일 때에는, 당연히 모든 선행조건이 만족되어야만 레시피를 실행합니다.

마찬가지로 레시피도 여러 개가 될 수 있고요, 선행조건이 만족되면 몇 개가 되든 모든 레시피가 실행됩니다. 레시피가 하나도 없는 경우에는 뭐, 아무일도 하지 않고 해당 규칙이 처리된 것으로 간주됩니다.

예제 2.1에서는 안 나왔지만 타겟이 여러 개인 경우는 어떻게 될까요? 그때에는 각 타겟에 대해서, 하나의 타겟을 가지는(그리고 나머지 선행조건 및 레시피는 모두 동일한) 다수의 규칙들을 작성하는 것과 동일합니다. 즉 다음의 규칙을 보시죠.

$(OBJS) : MyCommon.h
    echo “$@ depends on MyCommon.h”

$(OBJS)는 MySocket.o MyClient.o 으로 정의되어 있기 때문에, 위의 규칙은 다음 두 개의 규칙으로 풀어 쓴 것과 동일합니다.

MySocket.o : MyCommon.h
    echo “$@ depends on MyCommon.h”

MyClient.o : MyCommon.h
    echo “$@ depends on MyCommon.h”

당연히 풀어 쓴 것 보다야 하나의 규칙이 훨씬 컴팩트해서 보기도 좋고 유지 보수도 용이합니다.. (아, 물론 타이핑도 적습니다.) 이제 실제 규칙의 처리는 어떻게 되는 지 알아 보도록 하겠습니다.

make의 규칙 처리

예제 2.1 에는 총 3개의 규칙이 있습니다.

  • (7)의 all 은 단순히 $(TARGET) 이라는 선행조건만 있고, 레시피는 없습니다.
  • (9)의 $(TARGET)은, $(OBJS)라는 선행조건을 가지고 있고, (10)의 레시피 하나를 가지고 있습니다.
  • (12)의 clean 은 선행조건 없이 하나의 레시피를 가지고 있습니다.

기본적으로 make은 입력으로 주어진 Makefile(특별한 입력이 없을 때에는 현재 디렉토리에 있는 Makefile이 입력 파일이 됩니다.)을 먼저 읽어 들여서, 변수들과 규칙들을 파악한 다음, make 실행 시 주어진 이름의 규칙을 찾아서 처리합니다. 만약 make 실행시 규칙 이름이 인자로 주어지지 않으면, make는 Makefile에서 만나는 첫 번째 규칙을 처리합니다. (예제 2.1에서는 all 입니다.)

규칙의 처리는 다음과 같은 딱 2개의 동작으로 이루어집니다.

  1. 선행조건이 없거나 모두 만족되면, 레시피들을 실행한다.
  2. 만족되지 않은 선행조건이 있으면, 해당 선행조건에 해당하는 규칙을 찾아서 처리한 다음 원래 규칙을 처리한다.

네, 참으로 심플합니다. 아, 인생 살아가는데 지켜야 할 규칙도 이렇게 간단하다면 얼마나 좋을까요… 하지만 세상은 그리 간단하지 않고, 특히나 소프트웨어 개발자로 살아가기는 더더욱 쉽지 않습니다. 그래서 make도 잘 알아야 하는 거지만 말입니다.

신세 한탄은 이쯤 하고, 예제 2.1의 Makefile을 인자로 줘서 make를 실행하면 다음과 같은 일이 일어납니다.

첫 단계로 make는 Makefile 을 읽어 들여서, 변수들과 규칙들을 구성하는데요, 이 과정에서 CC, LIBS, OBSJ, SRCS 그리고 TARGET등의 변수들, all, $(TARGET), clean 규칙들이 정의됩니다.

다음으로 Makefile에서 첫 번째로 만나는 규칙, 즉, all을 처리하려 합니다. all 은 $(TARGET) 이라는 선행조건을 가지는데, $(TARGET) 즉 MyClient라는 파일이 존재하지 않으므로, 이 선행조건은 만족되지 않은 것으로 간주되고, make는 $(TARGET) 규칙을 찾아서 이를 먼저 처리하려 합니다. 마찬가지로 $(TARGET)은 다시 $(OBJS)를 선행조건으로 가지므로 같은 방식으로 $(OBJS) 규칙을 찾아 처리하려 합니다.

$(OBJS)는 MySocket.o MyClient.o 이므로, make는 각각 MySocket.o와 MyClient.o 규칙이 있는 지 찾아 보지만, 불행히도 이것과 관련된 규칙은 Makefile 내에 존재하지 않습니다. 저 같은 보통 사람이라면 여기서 그만둘 테지만, 끈기 있으면서도 뛰어난 능력을 가진 우리의 친구, make는 좀더 많은 일을 해냅니다.

(5)에서 .SUFFIXES : .c .o 라고 선언했기 때문에, make는 확장자가 .c .o 인 파일들에 대해서는 별도의 규칙이 제공되지 않는 범위 내에서, 자신이 가지고 있는 확장자 규칙을 사용할 수 있습니다. make가 가지고 있는 .c 와 .o 확장자 규칙은 .c (C 소스 파일)를 컴파일해서 .o (목적 파일)를 만들어 내는 레시피를 가집니다. 이런 확장자 규칙 덕분에, 특별히 요청하지도 않았음에도 MySocket.o 가 다음과 같이 컴파일되고, 그 결과로 $(TARGET)의 선행 조건 중 MySocket.o 는 처리된 것으로 간주됩니다.

gcc -c -o MySocket.o MySocket.c

MySocket.o 와 마찬가지 과정을 거쳐, MyClient.o 도 MyClient.c 로부터 컴파일됩니다. 이제 $(OBJS)에 해당하는 파일들이 모두 존재하므로, 결과적으로 $(TARGET)의 모든 선행조건이 만족되었습니다. 따라서 드디어 (9)의 $(TARGET) 규칙의 레시피가 실행됩니다. 아마도, 2개의 목적 파일들로부터 최종 타겟인 MyClient를 linking하는 일이 되겠지요. 그런데, (10)의 레시피에는 낯선 부분이 있습니다.

$(CC) -o $@ $(OBJS)

$(CC)는 gcc고, $(OBJS)는 MySocket.o MyClient.o 인데, $@는 뭘까요. gcc를 좀 아시는 분이라면, gcc 의 인자 중 -o 다음에 나온 걸로 봐서 MyClient를 나타낼 거라고 짐작하실 겁니다. 맞습니다. 사실 $@는 make가 미리 내장하고 있는 내장 변수(make는 이외에도 많은 내장 변수를 가지고 있습니다)인데요, 이는 규칙에서 타겟을 가리킵니다. 즉, 여기서는 $(TARGET)과 동일합니다. (실제로 $@ 대신에 $(TARGET) 을 써도 상관 없습니다. 그렇지만 $@을 사용하게 되면 나중에 규칙 이름이 바뀌게 되어도, 예를 들어 TARGET 이 TARGETS로 바뀌는 경우에도, 레시피를 고치지 않는 장점이 있기 때문에, $@을 사용하는 게 좋습니다.)

$(TARGET)은 MyClient이기 때문에, 결과적으로 다음의 명령이 실행되어 최종 목적 파일인 MyClient 실행파일을 만들어집니다.

gcc -o MyClient MySocket.o MyClient.o

이제 $(TARGET) 규칙마저 처리되었기 때문에, 제일 처음 시작한 all 의 선행조건이 비로소 만족되었습니다. 따라서 all의 레시피가 실행되어야 할 테지만, all은 아무런 레시피를 가지고 있지 않고, 여기에 개입할만한 내장 규칙들도 없기 때문에, 드디어 make는 all의 처리를 완료하면서 실행을 종료하게 됩니다. 결과적으로 최종 실행 결과는 다음과 같습니다.

$ make
gcc    -c -o MySocket.o MySocket.c
gcc    -c -o MyClient.o MyClient.c
gcc -o MyClient MySocket.o MyClient.o

의존성 규칙

이제 make all 또는 그냥 make 치는 것만으로 MySocket.o, MyClient.o, MyClient 가 컴파일되고 링킹이 수행됩니다. 오예… 하지만 좀 더 들여다 봐야 할 게 있습니다. 바로 의존성 문제입니다.

여러분은 일단 make를 실행해서 MyClient가 잘 동작하는 것까지 확인했습니다. 뭐, 이정도 쯤이야 어깨를 한번 으쓱한 다음, 이젠 테스트를 위해 MySocket.c 와 MyClient.c에 임시로 추가했던 디버깅 메시지를 삭제합니다. 그러고나서, 다시 make 실행하면, 어떻게 될까요? 네, 맞습니다. MyClient.c 가 MyClient.o 보다 더 최신이므로, all → $(TARGET) → $(OBJS) → .c.o 규칙을 살피는 과정에서 컴파일과 링킹이 다시 잘 됩니다. 임시 디버깅 메시지가 없어진 것도 잘 확인했습니다.

좋습니다.이번엔 앞으로 추가할 기능에 대한 comment를 MySocket.h에 추가하고 저장한 다음, 확인차원에서 다시 make를 실행했습니다. 어? 다음의 메시지가 출력되네요.

$ make
make: Nothing to be done for `all'.

음, 뭔가 수상하죠? MyClient.c와 MySocket.c는 MySocket.h를 include하고 있으니, 분명히 모두 다시 컴파일되고 MyClient도 다시 링킹되어야 하는데, make가 아무일도 안하겠다는 메시지만 뱉어냅니다. 혹시나 해서 rm 명령어를 써서 .o 파일들과 MyClient를 지운 다음 다시 make를 하니, 이번에는 잘 되네요. 왜 그럴까요?

“뭐 이유 알 필요 있나요? 다 지우고 다시하면 된다면서요… 그냥 지우고 빌드하면 되는 거 아녜요?”

글쎄요, MyClient 같이 소스 파일이 몇개 안 되서, 컴파일 시간이 얼마 안 걸린다면, 뭐 하나 수정할 때마다 이전 빌드 결과 다 지우고 다시 빌드해도 상관 없습니다. 하지만, 그런 간단한 거만 짜는 거면 도대체 회사에서 왜 여러분 같은 고급 인력을 투입하겠습니까. 아마, 소스 파일 개수가 최소한 몇 천개 이상이고, 전체 코드 라인이 몇 십만, 몇 백만 라인 정도는 될 테지요. 코드 하나 수정할 때마다, 매번 전체 리빌드해야 한다면, 안 그래도 야근에다 휴일 업무까지… 우리네 개발자 인생이 얼마다 더 고달파 지겠습니까.

따라서 정확히 수정한 부분에 영향받는 부분들만 컴파일하는게 (당연히, 꼭) 필요합니다. MySocket.c 가 수정되면 MySocket.c만 다시 컴파일하고 MyClient를 링킹해야 합니다. 만약 MySocket.h가 수정되었을 때면, MySocket.c 와 MyClient.c 는 모두 다시 컴파일되어야 할 테고 MyClient도 다시 링킹되어야 할 거고요. .c 파일들은 잘 되는데 왜 .h 파일들은 안 될까요?

그건 예제 2.1 에 .h 파일들에 대한 의존성을 고려한 부분이 없기 때문입니다. 한번 빌드한 다음, 헤더 파일을 고치고 나서 다시 make를 수행해도 컴파일이나 링킹이 읽어나지 않습니다. 물론 make가 팽팽 놀고 있는 것은 아니고요,앞서 설명한 과정을 똑같이 수행합니다. 다만 선행조건들(에 해당하는 파일들, 즉 MyClient, MyClient.o, MySocket.o, MyClient.c, MySocket.c) 모두 존재하고 이미 모든 조건들이 만족된 상태이기 때문에, 수행될 레시피가 없을 뿐입니다.

이에 비해 .c 파일들을 수정했을 때는 .c .o 확장자 규칙의 타겟 파일(.o 파일)보다 선행조건 파일(.c 파일)이 더 최신이므로 레시피가 다시 수행되어 .o 파일이 다시 컴파일되고, 다시 .o 파일이 MyClient 파일보다 더 최신이므로, MyClient가 링킹되는 것입니다.

하지만 .h 파일의 경우에는 이와 관련된 규칙이 없습니다. 그래서 .h 파일이 변경되어도 모든 선행조건들이 이미 만족된 상태로 있기 때문에 make는 아무런 레시피도 수행하지 않는 것입니다. 따라서 다음의 규칙들을 추가로 제공해서, MyClient.c, MySocket.c 및 MySocket.h 사이의 의존성을 make에게 알려줄 수 있습니다.

MySocket.o : MySocket.c MySocket.h
MyClient.o : MyClient.c MySocket.h

위의 2개 규칙들은 모두 타겟과 선행조건으로만 이루어져 있고 레시피는 없습니다. 이는 단지 파일들 사이의 의존성만을 알려 주기 위한 규칙들입니다. MySocket.o규칙은 MySocket.c 와 MySocket.h라는 2개 파일들을 선행조건으로 가집니다. 따라서 make가 $(TARGET) 규칙을 처리하는 과정에서 MySocket.o 규칙을 살펴볼 때, MySocket.c 와 MySocket.h 를 선행조건으로 가진다는 것을 알았기 때문에, 선행조건에 해당하는 파일들(즉, MySocket.c 와 MySocket.h)의 time stamp들을 타겟(MySocket.o)의 time stamp과 비교해서 만약 타겟이 선행조건보다 더 오래되었을 경우에는, 레시피를 다시 적용해서 다시 컴파일하게 됩니다. MyClient.o 의 경우도 마찬가지이고요.

이러한 규칙들이 주어진 상태에서 MySocket.h 파일이 수정되면, 이제는 MySocket.o 와 MyClient.o 가 다시 컴파일되고, MyClient 파일이 이들 .o 파일들보다 더 오래되었기 때문에 다시 $(TARGET) 규칙의 레시피가 다시 수행되어 MyClient가 링킹되는 것입니다.

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

.SUFFIXES : .c .o

all : $(TARGET)

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

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

MySocket.o : MySocket.c MySocket.h
MyClient.o : MyClient.c MySocket.h

2개의 의존성 규칙까지 포함한 것이 예제 2.2입니다. 소스 파일 중 어떤 변경 사항이 생긴 후, make를 실행하면 규칙에 정의된 의존성에 따라 정확하게 꼭 필요한 컴파일 및 링킹 작업이 수행됩니다. 모든게 기대한 대로 움직입니다. 바로 이것이 바로 make를 사용하는 이유이고요, 우리가 Makefile의 규칙을 정확하게 작성해야 하는 이유입니다.

명시적인 규칙이 인자로 주어진 경우의 규칙 처리

만약 make clean이라고 실행하는 경우는 어떻게 될까요? make는 실행시 특정 규칙이 인자로 주어지면, 해당 규칙을 찾아서 처리합니다. 따라서 이 경우, 명시적으로 clean 이라는 규칙 이름이 주어졌기 때문에, Makefile을 읽어서 변수와 규칙들을 구성한 다음, clean이라는 규칙만 찾습니다. 찾고 보니 clean은 선행조건이 없으므로, 바로 (13) 레시피를 실행합니다.

rm -f $(OBJS)와 $(TARGET)

다시 말해 빌드 중간 결과물과 최종 결과물들이 삭제됩니다.

보통 일반적으로 Makefile에는 clean 이라는 규칙이 관행적으로 제공됩니다. 그래서 빌드결과 뭔가 찜찜한게 있으면 make clean한 다음 다시 빌드하곤 하죠.

뭐, 개발 머신의 성능과, 소스 규모에 따라서 다르겠지만, 소스 규모가 좀 큰 경우에는 일단 make clean하고 나면 빌드가 완료될 때까지, 꽤 시간이 걸리겠지요. 디버깅하다가 머리가 안 돌아가서 잠깐 바람을 쐬러 가고 싶을 때, 흡연자라면 눈치 안보고 담배 피러 가고 싶을 때 make clean; make 한번 쳐 둔다음, 어쩔 수 없다는 제스춰와 함께 잠시 나갔다 올 수도 있겠습니다.

하지만 이거 너무 자주 쓰면 음… 동료 및 상사의 곱지않은 눈총을 받을 수 있다는 것, 명심하세요.