Alert
이 글은 Claude Code의 도움을 받아 작성되었습니다
TL;DR
make는 파일의 수정 시각을 비교해 바뀐 부분만 다시 빌드하는 자동화 도구,Makefile은 그 작업 명세서- 핵심 구조는
target: prerequisites+ 탭으로 들여쓴recipe한 묶음(rule)- 동작의 본질은 의존성 그래프 + 타임스탬프 비교 → 필요한 작업만 골라 실행하는 증분 빌드
- 변수, 자동 변수(
$@ $< $^), 패턴 규칙(%), 함수까지 알면 짧고 재사용 가능한 빌드 스크립트 작성 가능- 요즘은 컴파일뿐 아니라 긴 명령어를 짧은 태스크로 묶는 태스크 러너로도 폭넓게 사용
1. make가 뭔가
make는 1976년 유닉스에서 등장한 빌드 자동화 도구다. “어떤 결과물을 만들려면 무엇이 필요하고, 어떤 명령을 실행해야 하는지”를 규칙으로 적어두면, make가 그 규칙을 읽어 필요한 작업만 골라서 실행한다.
그 규칙을 적어두는 파일이 Makefile이다. 프로젝트 루트에 Makefile(또는 makefile)이라는 이름으로 두면 make 명령이 자동으로 찾아 읽는다.
원래 목적은 C/C++ 컴파일이었다. 소스 파일이 100개인 프로젝트에서 1개만 수정했을 때 전체를 다시 컴파일하면 시간이 낭비된다. make는 파일 수정 시각을 비교해 바뀐 것과 거기에 의존하는 것만 다시 빌드한다. 이걸 증분 빌드(incremental build)라고 한다.
요즘은 컴파일 외에 자주 쓰는 긴 명령어를 짧은 이름으로 묶는 태스크 러너로도 많이 쓴다. 긴 docker run ... 한 줄을 make up으로 줄이는 식이다. 언어와 무관하게 거의 모든 프로젝트에서 통하는 공통 인터페이스라는 점이 장점이다.
make의 종류와 macOS의 함정
가장 널리 쓰이는 구현은 GNU Make다.
BSD 계열(FreeBSD 등)이 쓰는 BSD make(bmake)와는 함수·문법 일부가 다르다.
macOS의/usr/bin/make도 BSD가 아니라 GNU Make 3.81이다.
단 2006년 버전으로, 3.82부터 라이선스가 GPLv2 → GPLv3로 바뀌자 Apple이 GPLv3를 피하려고 직전 버전에 묶어둔 것이다(/bin/bash가 3.2에 멈춘 것과 같은 이유). 그래서.ONESHELL,.RECIPEPREFIX,$(file ...)같은 3.82+ 기능이 빠져 있다.
최신 GNU Make가 필요하면brew install make로 설치하며, 시스템make와 충돌을 피하려gmake라는 이름으로 깔린다. 이 글은 GNU Make 기준이다.
2. 기본 구조 - rule
Makefile의 모든 것은 rule(규칙) 단위로 이루어진다. 하나의 rule은 세 부분으로 구성된다.
target: prerequisites
recipe- target: 만들려는 결과물 또는 작업 이름 (예:
app,build,test) - prerequisites: target을 만들기 전에 필요한 것들 (의존성). 다른 target이거나 파일 이름
- recipe: 실제로 실행할 셸 명령. 반드시 탭(Tab) 한 칸으로 들여쓴다
가장 흔한 실수 - 탭 vs 스페이스
recipe는 스페이스가 아니라 탭 문자로 들여써야 한다. 스페이스로 쓰면
Makefile:N: *** missing separator. Stop.에러가 난다. 에디터가 탭을 스페이스로 바꾸지 않도록 설정해야 한다.
간단한 예시를 보자.
hello:
echo "Hello, Make!"실행은 make <target> 형식이다.
make hello
# echo "Hello, Make!"
# Hello, Make!make만 입력하면 파일 맨 위에 있는 target(기본 target)이 실행된다.
3. 동작 원리 - 의존성과 타임스탬프
make의 핵심은 단순한 명령 실행기가 아니라는 점이다. 의존성 그래프를 만들고, 파일 수정 시각을 비교해 정말 필요한 작업만 실행한다.
다음 rule을 보자.
app: main.o utils.o
gcc -o app main.o utils.o
main.o: main.c
gcc -c main.c
utils.o: utils.c
gcc -c utils.cmake app을 실행하면 make는 이렇게 판단한다.
app을 만들려면main.o,utils.o가 필요하다 → 먼저 그것들을 확인main.o는main.c에 의존한다.main.c가main.o보다 최신이면 다시 컴파일, 아니면 건너뜀- 모든 의존성이 준비되고 의존성 중 하나라도
app보다 최신이면app을 다시 링크
즉 target보다 prerequisite가 더 최근에 수정됐을 때만 recipe를 실행한다. main.c만 고쳤다면 utils.c 컴파일은 건너뛴다. 이것이 증분 빌드의 원리다.
왜 빠른가
아무것도 바꾸지 않고
make를 다시 실행하면make: 'app' is up to date.만 출력되고 끝난다. 모든 결과물이 의존 파일보다 최신이라 할 일이 없기 때문이다. 큰 프로젝트일수록 이 절약 효과가 크다.
4. .PHONY - 파일이 아닌 target
build, test, clean처럼 실제 파일을 만들지 않는 target이 있다. 이런 건 단지 명령 묶음의 이름이다. 문제는 우연히 clean이라는 이름의 파일이 디렉터리에 있으면, make가 “clean 파일이 이미 최신이다”라고 판단해 recipe를 실행하지 않는다는 점이다.
이를 막으려면 .PHONY로 “이 target은 파일이 아니다”라고 선언한다.
.PHONY: build test clean
build:
go build -o app .
test:
go test ./...
clean:
rm -f app.PHONY로 선언된 target은 타임스탬프 비교를 건너뛰고 항상 실행된다. 파일을 만들지 않는 모든 task용 target에는 붙이는 게 안전하다.
5. 변수
반복되는 값은 변수로 묶는다. 사용할 때는 $(VAR) 또는 ${VAR}로 참조한다.
CC = gcc
CFLAGS = -Wall -O2
TARGET = app
$(TARGET): main.c
$(CC) $(CFLAGS) -o $(TARGET) main.c대입 연산자가 여러 개 있고 동작이 다르다. 이 차이를 모르면 빌드가 의도와 다르게 돈다.
| 연산자 | 이름 | 동작 |
|---|---|---|
= | 재귀적 대입 | 사용하는 시점에 우변을 평가 (지연 평가) |
:= | 단순 대입 | 정의하는 시점에 즉시 평가 |
?= | 조건부 대입 | 변수가 아직 정의되지 않았을 때만 대입 |
+= | 추가 | 기존 값 뒤에 덧붙임 |
A = hello
B = $(A) world # B는 사용 시점에 평가됨
A = hi # 이후 A를 바꾸면 B도 바뀜 → "hi world"
C := $(A) world # := 는 이 줄에서 즉시 고정됨환경 변수나 명령행에서 값을 주입할 수도 있다. 명령행 인자가 Makefile 내부 정의보다 우선한다.
make build CFLAGS="-O0 -g"?= 의 쓸모
PORT ?= 8080처럼 쓰면, 외부에서make run PORT=3000으로 덮어쓸 수 있고 안 주면 기본값을 쓴다. 설정 가능한 기본값을 만들 때 유용하다.
6. 자동 변수
recipe 안에서 target과 prerequisite를 매번 손으로 적으면 길고 실수하기 쉽다. make는 이를 대신하는 **자동 변수(automatic variable)**를 제공한다.
| 변수 | 의미 |
|---|---|
$@ | 현재 target 이름 |
$< | 첫 번째 prerequisite |
$^ | 모든 prerequisite (중복 제거) |
$? | target보다 최신인 prerequisite들 |
$* | 패턴 규칙에서 %에 매칭된 부분 |
앞의 C 예제를 자동 변수로 다시 쓰면 훨씬 간결해진다.
app: main.o utils.o
gcc -o $@ $^ # $@ = app, $^ = main.o utils.o
%.o: %.c
gcc -c $< -o $@ # $< = 입력 .c, $@ = 출력 .o7. 패턴 규칙
위 예제의 %.o: %.c가 **패턴 규칙(pattern rule)**이다. %는 와일드카드로, 임의의 문자열에 매칭된다. “어떤 .c 파일이든 같은 이름의 .o로 컴파일하는 방법”을 한 번에 정의한다.
%.o: %.c
gcc -c $< -o $@이 규칙 하나면 main.o, utils.o, foo.o를 각각 따로 적지 않아도 make가 알아서 대응되는 .c를 찾아 컴파일한다. 파일이 늘어나도 Makefile은 그대로다.
8. 함수
make에는 텍스트를 다루는 내장 함수가 있다. $(함수명 인자,...) 형식으로 호출한다. 파일 목록을 동적으로 만들 때 특히 유용하다.
# 현재 디렉터리의 모든 .c 파일 찾기
SRCS = $(wildcard *.c)
# .c 확장자를 .o로 치환
OBJS = $(patsubst %.c,%.o,$(SRCS))
# 셸 명령 결과를 변수로
DATE = $(shell date +%Y-%m-%d)
app: $(OBJS)
gcc -o $@ $^자주 쓰는 함수는 다음과 같다.
$(wildcard 패턴): 패턴에 맞는 파일 목록 반환$(patsubst 패턴,치환,문자열): 패턴 치환 ($(SRCS:.c=.o)단축 문법도 동일)$(shell 명령): 셸 명령을 실행해 출력을 값으로$(filter 패턴,목록)/$(filter-out ...): 목록 필터링$(foreach 변수,목록,본문): 반복
9. 조건문과 include
OS나 환경에 따라 다르게 동작해야 할 때 조건문을 쓴다. 조건문 키워드(ifeq, else, endif)는 들여쓰지 않는다(탭 금지).
ifeq ($(OS),Windows_NT)
RM = del
else
RM = rm -f
endif
clean:
$(RM) app다른 Makefile을 끌어다 쓸 때는 include를 사용한다. 환경별 설정을 별도 파일로 분리할 때 흔하다.
include config.mk
-include .env.mk # 앞에 - 를 붙이면 파일이 없어도 에러 안 남10. 실전 패턴
self-documenting help target
target이 많아지면 무엇이 있는지 잊는다. 주석을 파싱해 도움말을 자동 출력하는 패턴이 널리 쓰인다.
.PHONY: help
help: ## 사용 가능한 명령 목록 출력
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
build: ## 애플리케이션 빌드
go build -o app .
test: ## 테스트 실행
go test ./...make help를 치면 각 target과 ## 뒤의 설명이 정렬되어 나온다.
@ 의 의미
recipe 줄 앞에
@를 붙이면 그 명령 자체를 화면에 출력하지 않고 결과만 보여준다.@echo형태로 자주 쓴다. 기본적으로make는 실행하는 명령을 그대로 에코하기 때문이다.
Docker 명령 래핑
언어 무관 태스크 러너로 쓰는 대표 사례다.
.PHONY: up down logs
up: ## 컨테이너 백그라운드 실행
docker compose up -d
down: ## 컨테이너 중지 및 제거
docker compose down
logs: ## 로그 실시간 확인
docker compose logs -f11. 자주 만나는 함정
- 탭 vs 스페이스: recipe는 반드시 탭.
missing separator에러의 90%가 이것 - 각 recipe 줄은 별도 셸에서 실행된다: 한 줄에서
cd해도 다음 줄에선 원위치다. 이어서 실행하려면&&로 한 줄에 잇거나 줄 끝에\로 연결한다
deploy:
cd build && \
./deploy.sh- 변수 즉시/지연 평가 혼동: 5번 표 참고. 순서에 민감하면
:=를 쓴다 .PHONY누락: task target에 안 붙이면 동명 파일이 있을 때 실행이 막힌다$를 셸에 전달할 때: Makefile에서$는 변수 시작 문자다. 셸 변수$VAR를 recipe에 쓰려면$$VAR로 이스케이프한다
print:
@for f in *.txt; do echo "$$f"; done12. make vs 다른 빌드 도구
make는 범용적이고 가볍지만 언어별 전용 도구가 더 풍부한 기능을 제공하기도 한다.
| 도구 | 영역 | 특징 |
|---|---|---|
| make | 범용 | 언어 무관, 어디나 설치됨, 의존성+증분 빌드. 문법이 다소 까다로움 |
| Maven / Gradle | JVM | 의존성 관리·라이프사이클 내장 → Maven vs Gradle |
| npm scripts | Node.js | package.json의 scripts로 태스크 정의 |
| Just | 범용 | make의 태스크 러너 부분만 떼어내 단순화한 현대적 대안 |
| CMake / Bazel | 대규모 C++ 등 | 빌드 설정을 생성하거나 대규모 모노레포에 특화 |
태스크 러너로만 쓴다면 make의 까다로운 증분 빌드 문법을 다 알 필요는 없다. recipe 대부분이 셸 스크립트이므로 Bash 문법에 익숙하면 그대로 활용할 수 있다.
13. 정리
make는 의존성 그래프 + 타임스탬프 비교로 필요한 작업만 실행하는 빌드 자동화 도구Makefile의 기본 단위는target: prerequisites+ 탭으로 들여쓴recipe- 파일을 안 만드는 task target에는
.PHONY를 붙인다 - 변수·자동 변수·패턴 규칙·함수를 쓰면 짧고 확장 가능한 스크립트가 된다
- 컴파일 자동화든 명령어 래핑이든, “공통 진입점”으로 한 프로젝트의 작업을 표준화하는 데 유용하다