앞 글에서 make가 세운 모델을 봤다. 이번에는 실제 프로젝트를 make로 처음부터 빌드해 본다. 문법을 훑는 데서 끝내지 않고, 이 Makefile이 어디서 무너지는지까지 간다. 그 무너지는 지점이 다음 글(CMake)의 출발점이다.

예제 프로젝트 — textstat

이 시리즈는 같은 프로젝트를 도구만 바꿔 가며 빌드한다. 텍스트 통계를 내는 작은 CLI다.

textstat/
├── include/textstat.h        공용 헤더
├── src/count.c               줄·단어 세기
├── src/histogram.c           표준편차 (sqrt → -lm 필요)
├── src/platform_posix.c      디렉터리 나열 (POSIX)
├── src/platform_win32.c      디렉터리 나열 (Windows)
├── app/main.c                CLI 진입점
├── tests/test_count.c        자체 테스트
└── version.h.in              빌드 때 version.h로 생성

일부러 이렇게 잡았다. 파일 여덟 개짜리지만 실전에서 빌드 시스템을 고르게 만드는 요소가 전부 들어 있다 — 공용 헤더, 라이브러리와 실행 파일 분리, 외부 라이브러리 링크(-lm), OS별 소스 분기, 생성 파일, 테스트, 그리고 디버그/릴리즈 두 구성.

규칙 세 개짜리 문법

make의 문법은 사실상 이것 하나다.

타겟: 의존성
	레시피

textstat을 손으로 다 적으면 이렇게 된다.

textstat: app/main.o src/count.o src/histogram.o src/platform_posix.o
	cc -o textstat app/main.o src/count.o src/histogram.o src/platform_posix.o -lm
 
app/main.o: app/main.c include/textstat.h version.h
	cc -Iinclude -I. -c app/main.c -o app/main.o
 
src/count.o: src/count.c include/textstat.h
	cc -Iinclude -c src/count.c -o src/count.o
 
version.h: version.h.in
	sed 's/@VERSION@/1.0.0/' version.h.in > version.h

돌리면 make가 필요한 순서를 스스로 찾아낸다. textstat을 만들라고만 했는데 version.h부터 만든다.

$ make
sed 's/@VERSION@/1.0.0/' version.h.in > version.h
cc -Iinclude -I. -c app/main.c -o app/main.o
cc -Iinclude -c src/count.c -o src/count.o
cc -Iinclude -c src/histogram.c -o src/histogram.o
cc -Iinclude -c src/platform_posix.c -o src/platform_posix.o
cc -o textstat app/main.o src/count.o src/histogram.o src/platform_posix.o -lm
 
$ printf 'hello world\nsecond line\n' | ./textstat
lines=2 words=4 bytes=24 stddev=9.93

다시 만들지 말지는 타임스탬프가 정한다

한 번 더 돌리면 아무것도 하지 않는다.

$ make
make: 'textstat' is up to date.
 
$ touch src/count.c && make
cc -Iinclude -c src/count.c -o src/count.o
cc -o textstat app/main.o src/count.o ... -lm

src/count.c만 새로워졌으니 그것만 다시 컴파일하고, 결과가 바뀌었으니 링크를 다시 한다. make의 전부가 이 판단이다. 나머지 문법은 이 판단을 적게 쓰기 위한 장치다.

걸려 넘어지는 자리 셋

레시피 앞은 탭이어야 한다

스페이스로 들여쓰면 오류 한 줄만 나온다.

Makefile:2: *** missing separator.  Stop.

문법 오류라고 말해 주지 않아서 처음 만나면 한참 헤맨다. GNU Make는 .RECIPEPREFIX로 이 문자를 바꿀 수 있지만, 남이 읽을 Makefile이라면 그냥 탭을 쓰는 편이 낫다.

기본 타겟은 “맨 위 타겟”이다

이름 없이 make만 치면 파일에서 처음 나오는 타겟이 실행된다. 알파벳순도, all도 아니다.

$ cat Makefile.first
second:
	@echo "second 실행됨"
first:
	@echo "first 실행됨"
 
$ make -f Makefile.first
second 실행됨

그래서 관례적으로 all을 맨 위에 둔다.

.PHONY를 빠뜨리면 clean이 안 돈다

clean은 파일을 만들지 않는데도 make는 그것을 파일 이름으로 본다. 우연히 같은 이름의 파일이 있으면 이렇게 된다.

$ touch clean
$ make clean
make: 'clean' is up to date.

파일이 이미 있고 의존성이 없으니 “할 일 없음”이 맞다고 판단한 것이다. .PHONY: clean을 선언하면 이름이 아니라 동작으로 취급한다.

반복을 줄이는 장치들

변수 — 대입 기호가 네 가지다

LATER  = $(WHO)      # 재귀 확장: 쓰는 시점에 평가
NOW   := $(WHO)      # 단순 확장: 이 줄에서 평가
WHO    = 세계
FLAGS := -O2
FLAGS += -Wall       # 덧붙이기
CC    ?= cc          # 이미 정의돼 있으면 손대지 않음
$ make show
LATER = 세계          ← WHO가 아래에 정의됐는데도 값이 들어온다
NOW   =               ← 그 줄을 지날 때 WHO가 비어 있었다
FLAGS = -O2 -Wall
CC    = cc
 
$ make show CC=clang
CC    = clang         ← ?= 는 밖에서 준 값을 이기지 못한다

=:=의 차이가 실제로 값을 바꾼다. 특별한 이유가 없으면 :=를 기본으로 쓰는 편이 예측하기 쉽다.

자동 변수 — 규칙 안에서 쓰는 대명사

  $@ (타겟)            = bundle
  $< (첫 의존성)       = a.txt
  $^ (의존성 전체)     = a.txt b.txt c.txt
  $? (타겟보다 새 것)  = b.txt

$?는 증분 빌드에서 유용하다. 방금 바뀐 것만 골라 처리할 수 있다.

와일드카드·치환 참조·패턴 규칙

SRC := $(wildcard src/*.c)     # 파일 목록을 직접 적지 않는다
OBJ := $(SRC:.c=.o)            # .c → .o 로 이름만 바꾼다
 
%.o: %.c                       # "모든 .o는 같은 이름의 .c에서 만든다"
	$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@

이 셋을 쓰면 앞의 장황한 Makefile이 규칙 몇 개로 줄어든다. 그런데 줄이는 순간 문제가 하나 생긴다.

줄였더니 깨졌다 — 선언하지 않은 의존성

패턴 규칙으로 바꾸고 처음부터 빌드하면 이렇게 실패한다.

$ make
  CC   app/main.c
app/main.c:2:10: fatal error: version.h: No such file or directory
    2 | #include "version.h"
      |          ^~~~~~~~~~~
compilation terminated.
make: *** [Makefile:31: app/main.o] Error 1

손으로 적을 때는 app/main.o: app/main.c include/textstat.h version.h라고 version.h를 의존성에 써 뒀다. 패턴 규칙 %.o: %.c에는 그 정보가 없다. make는 version.h를 먼저 만들 이유를 모른다.

고치는 방법은 순서 전용 의존성(order-only prerequisite)이다. | 뒤에 적으면 “먼저 만들어라, 하지만 이게 새롭다고 해서 다시 컴파일하지는 마라”가 된다.

$(APP_OBJ): | version.h

버전 문자열만 바뀌었다고 전체를 다시 컴파일할 이유는 없으니, 일반 의존성보다 이쪽이 맞다.

헤더 의존성은 컴파일러에게 물어본다

더 조용한 문제가 남아 있다. 패턴 규칙에는 헤더가 없으므로, 공용 헤더를 고쳐도 아무것도 다시 만들지 않는다. 빌드는 성공하는데 결과물이 옛것이다 — 1976년에 make가 풀려던 바로 그 사고가 되돌아온다.

헤더 목록을 손으로 관리하는 건 답이 아니다. #include는 계속 늘고 중첩된다. 컴파일러가 이미 아는 정보를 받아 쓴다.

CPPFLAGS := -Iinclude -I. -MMD -MP
DEP := $(OBJ:.o=.d)
-include $(DEP)

-MMD는 컴파일하면서 의존 관계를 .d 파일로 뱉는다.

$ head -2 src/count.d
src/count.o: src/count.c include/textstat.h
include/textstat.h:

두 번째 줄이 -MP가 넣어 준 것이다. 헤더를 삭제했을 때 “그런 타겟이 없다”로 빌드가 멈추는 걸 막아 준다. 앞의 -include는 파일이 아직 없어도(첫 빌드) 오류 없이 넘어가라는 뜻이다.

이제 헤더를 고치면 필요한 것이 전부 다시 만들어진다.

$ touch include/textstat.h && make
  CC   app/main.c
  CC   src/count.c
  CC   src/histogram.c
  CC   src/platform_posix.c
  AR   libtextstat.a
  LINK textstat

완성된 Makefile

여기까지를 모으면 이렇다. 실제로 도는 파일이다.

CC       ?= cc
CFLAGS   ?= -O2 -Wall -Wextra
CPPFLAGS := -Iinclude -I. -MMD -MP
LDLIBS   := -lm
 
LIB_SRC  := src/count.c src/histogram.c src/platform_posix.c
LIB_OBJ  := $(LIB_SRC:.c=.o)
APP_OBJ  := app/main.o
TEST_OBJ := tests/test_count.o
DEP      := $(LIB_OBJ:.o=.d) $(APP_OBJ:.o=.d) $(TEST_OBJ:.o=.d)
 
.PHONY: all test clean
all: textstat
 
libtextstat.a: $(LIB_OBJ)
	@ar rcs $@ $^
 
textstat: $(APP_OBJ) libtextstat.a
	@$(CC) -o $@ $(APP_OBJ) libtextstat.a $(LDLIBS)
 
run_tests: $(TEST_OBJ) libtextstat.a
	@$(CC) -o $@ $(TEST_OBJ) libtextstat.a $(LDLIBS)
 
test: run_tests
	@./run_tests
 
$(APP_OBJ): | version.h        # 생성 헤더는 순서 전용으로
 
%.o: %.c
	@$(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
 
version.h: version.h.in
	@sed 's/@VERSION@/1.0.0/' $< > $@
 
-include $(DEP)
 
clean:
	@rm -f textstat run_tests libtextstat.a $(LIB_OBJ) $(APP_OBJ) $(TEST_OBJ) $(DEP) version.h

병렬로도 문제없이 돈다. 의존 관계를 제대로 적어 두면 얻는 이득이다.

$ make clean && make -j8
  GEN  version.h
  CC   src/count.c
  CC   src/histogram.c
  CC   src/platform_posix.c
  CC   app/main.c
  AR   libtextstat.a
  LINK textstat
 
$ make test
test_count: OK

반대로 말하면 의존 관계를 대충 적은 Makefile은 -j를 붙이는 순간 깨진다. 순차 실행에서는 우연히 맞던 순서가 사라지기 때문이다.

안 될 때 물어보는 법

$ make -n                          # 실행하지 않고 계획만 출력
cc -Iinclude -I. -MMD -MP -O2 -Wall -Wextra -c src/count.c -o src/count.o
ar rcs libtextstat.a src/count.o src/histogram.o src/platform_posix.o
 
$ make --debug=b                   # 왜 다시 만드는지
 Prerequisite 'src/count.c' is newer than target 'src/count.o'.
 Successfully remade target file 'src/count.o'.

“왜 안 만들지” 또는 “왜 자꾸 만들지”는 대부분 이 두 명령으로 끝난다. 변수 값까지 보려면 make -p가 있다.

그래서 왜 여기서 멈추게 되는가

여기까지는 make가 잘 해냈다. 문제는 이식성이다. 예제에 있는 platform_win32.c-lm을 제대로 다루려면 Makefile이 이렇게 변한다.

BUILD ?= release
ifeq ($(BUILD),debug)
  CFLAGS := -O0 -g -Wall -Wextra -DDEBUG
else ifeq ($(BUILD),release)
  CFLAGS := -O2 -DNDEBUG
else
  $(error BUILD은 debug 또는 release여야 한다: $(BUILD))
endif
 
UNAME_S := $(shell uname -s)
ifeq ($(OS),Windows_NT)
  PLATFORM_SRC := src/platform_win32.c
  LDLIBS :=
  EXE := .exe
else ifeq ($(UNAME_S),Linux)
  PLATFORM_SRC := src/platform_posix.c
  LDLIBS := -lm
else ifeq ($(UNAME_S),Darwin)
  PLATFORM_SRC := src/platform_posix.c
  LDLIBS :=                    # macOS는 libm이 libSystem에 있어 -lm이 필요 없다
else
  $(error 지원하지 않는 플랫폼: $(UNAME_S))
endif

동작은 한다. 구성별로 디렉터리를 나눠 두면 디버그와 릴리즈가 따로 남는다.

$ make -f Makefile.port                    # release
  LINK build/release/textstat   (BUILD=release, Linux)
$ make -f Makefile.port BUILD=debug
  LINK build/debug/textstat   (BUILD=debug, Linux)
$ make -f Makefile.port BUILD=fast
Makefile.port:11: *** BUILD은 debug 또는 release여야 한다: fast.  Stop.

문제는 이 분기들이 전부 추측이라는 점이다.

  • -lm이 필요한지 확인하지 않는다. 플랫폼 이름을 보고 짐작할 뿐이다
  • 외부 라이브러리(zlib, OpenSSL…)를 찾는 코드가 없다. 경로를 손으로 적어야 하고, 그 경로는 남의 기계에서 다르다
  • 컴파일러가 -Wextra를 지원하는지 모른 채 넘긴다
  • 조건 분기가 플랫폼 × 구성 × 컴파일러로 곱해진다

1991년에 Autotools가 풀려던 문제가 정확히 이것이다. ./configure는 짐작하지 않고 시험 컴파일을 해서 확인한다.

윈도우에서는 아예 다른 언어다

가장 결정적인 벽은 따로 있다. 위 Makefile을 마이크로소프트의 nmake에 물리면 첫 줄부터 죽는다. GNU 확장 문법을 한 줄씩 시험한 결과다.

문법nmake 결과
CC ?= ccfatal error U1036= 왼쪽에 이름이 너무 많다
SRC := $(wildcard src/*.c)fatal error U1033 — 예기치 않은 =
%.o: %.cfatal error U1073'%.c'를 만드는 방법을 모른다
-include foo.dfatal error U1034 — 구분 기호가 없다
.PHONY: all오류는 없지만 그냥 무시된다

nmake에도 추론 규칙은 있지만 표기가 다르고(.c.obj:), 병렬 빌드 옵션이 없으며, GNU의 함수들(wildcard, patsubst, shell)이 없다. 즉 윈도우를 지원하려면 Makefile을 하나 더 쓰고 둘을 동기화해야 한다.

그리고 MSVC는 명령줄 문법 자체가 다르다. cc -c foo.c -o foo.o가 아니라 cl /c foo.c /Fofoo.obj다. 조건 분기로 덮을 수 있는 차이가 아니다.

정리 — make를 쓸 자리와 떠날 자리

쓸 자리: 파일 몇 개짜리 프로젝트, 한 플랫폼, 단발 작업 자동화. 이때 make는 가장 적은 비용으로 붙는다. 규칙 세 줄이면 시작한다.

떠날 자리: 위 셋 중 둘 이상이 참일 때다.

  • 여러 OS를 지원한다 (특히 윈도우 + MSVC)
  • 외부 라이브러리를 찾아 링크해야 한다
  • 디버그/릴리즈 같은 구성이 여럿이고 IDE에서도 열어야 한다

다음 글 CMake로 옮기기에서는 같은 textstat을 CMake로 옮긴다. 위에서 손으로 짠 조건 분기, 헤더 의존성, 테스트 타겟, 생성 파일이 각각 어떻게 사라지는지 — 그리고 CMake가 대신 무엇을 요구하는지 본다.

이 글의 명령과 출력은 WSL2 우분투 22.04의 GNU Make 4.3에서, nmake 결과는 Visual Studio 2026(MSVC 14.51)에서 직접 실행해 얻은 것이다. 오류 메시지는 실제 출력이며, 한국어 로케일로 나온 nmake 메시지는 오류 코드와 뜻만 옮겼다.

참고

  • What is a Makefile and how does it work? (Sachin Patil, opensource.com, CC BY-SA 4.0) — 규칙의 세 요소부터 변수·패턴 규칙까지 어떤 순서로 익히는 게 좋은지 구성을 참고했다. 이 글의 예제와 출력은 별도로 작성·실행한 것이다.
  • GNU Make 매뉴얼 — 순서 전용 의존성(|), .PHONY, 대입 연산자별 확장 시점, 자동 변수의 정의를 확인했다.
  • GCC — Preprocessor Options-MMD-MP가 각각 무엇을 생성하는지 확인했다.
  • NMAKE 참조 (Microsoft Learn) — nmake의 추론 규칙 표기와 지원 문법 범위를 확인했다.