앞 글에서 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 ... -lmsrc/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 ?= cc | fatal error U1036 — = 왼쪽에 이름이 너무 많다 |
SRC := $(wildcard src/*.c) | fatal error U1033 — 예기치 않은 = |
%.o: %.c | fatal error U1073 — '%.c'를 만드는 방법을 모른다 |
-include foo.d | fatal 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의 추론 규칙 표기와 지원 문법 범위를 확인했다.