C++ 프로젝트용 Makefile 작성: 변수, 패턴 규칙, 의존성 자동 생성, 병렬 빌드
이 글의 핵심
Makefile의 규칙·변수·패턴 규칙 기본부터 헤더 의존성 자동 추적, make -j 병렬 빌드, 자주 생기는 문제까지 완전한 C++ 프로젝트 예제로 정리합니다.
들어가며
Makefile은 Make가 사용하는 빌드 자동화 스크립트입니다. 컴파일과 링킹을 자동화하여 개발 생산성을 높입니다.
Make의 핵심 아이디어는 단 하나, 파일 수정 시각 비교입니다. 타겟 파일이 없거나, 의존성 중 하나라도 타겟보다 최근에 수정됐으면 명령을 실행하고, 아니면 건너뜁니다. 이 단순한 규칙 덕분에 파일 100개 중 하나만 고쳤을 때 그 파일만 다시 컴파일하는 증분 빌드가 됩니다. 반대로 말하면, Make가 모르는 의존성(규칙에 적지 않은 헤더 등)은 바뀌어도 재빌드되지 않습니다. 이 글의 대부분은 “Make에게 의존성을 정확히 알려 주는 방법”에 관한 것입니다.
증분 빌드·병렬(make -j)은 CMake가 생성하는 Makefile/Ninja와도 같은 맥락입니다. 언어 수준에서는 Rust Cargo·npm 스크립트·Go 빌드가 각각 다른 철학으로 캐시·병렬을 다룹니다. 전체 스택 비교는 C++ 빌드 시스템 완전 비교를 보세요.
타겟·전제 조건·명령으로 이루어진 규칙
가장 간단한 Makefile
# Makefile
myapp: main.cpp
g++ main.cpp -o myapp
clean:
rm -f myapp
# 빌드
make
# 정리
make clean
출력:
g++ main.cpp -o myapp
규칙 문법
# 타겟: 의존성
# 명령어 (반드시 탭으로 시작!)
target: dependencies
command
# 예시
main.o: main.cpp
g++ -c main.cpp -o main.o
구성 요소:
- 타겟(target): 생성할 파일 또는 작업 이름
- 의존성(dependencies): 타겟을 만들기 위해 필요한 파일들
- 명령어(command): 실행할 쉘 명령어 (반드시 탭으로 시작)
make를 인자 없이 실행하면 파일에서 처음 나오는 타겟이 기본 타겟이 됩니다. 그래서 관례적으로 맨 위에 all: 타겟을 둡니다. 명령어의 각 줄은 별도의 셸에서 실행된다는 점도 중요합니다. cd build를 한 줄에 쓰고 다음 줄에서 make를 부르면 두 번째 줄은 원래 디렉터리에서 실행되므로, 같은 셸에서 실행하려면 cd build && make처럼 한 줄에 이어 써야 합니다.
CXX·CXXFLAGS 변수와 자동 변수
컴파일러와 플래그 변수
# 변수 정의
CXX = g++
CXXFLAGS = -std=c++17 -Wall -O2
INCLUDES = -I./include
LIBS = -lpthread -lm
# 변수 사용
myapp: main.cpp
$(CXX) $(CXXFLAGS) $(INCLUDES) main.cpp -o myapp $(LIBS)
clean:
rm -f myapp
.PHONY: clean
$@·$<·$^ 자동 변수
CXX = g++
CXXFLAGS = -std=c++17 -Wall
# 자동 변수
# $@: 타겟 이름
# $<: 첫 번째 의존성
# $^: 모든 의존성
myapp: main.o util.o
$(CXX) $^ -o $@
# $^ = main.o util.o
# $@ = myapp
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
# $< = main.cpp (첫 번째 의존성)
# $@ = main.o (타겟)
자동 변수 정리표
| 변수 | 의미 | 예시 |
|---|---|---|
$@ | 타겟 이름 | myapp |
$< | 첫 번째 의존성 | main.cpp |
$^ | 모든 의존성 | main.o util.o |
$? | 타겟보다 새로운 의존성 | main.o |
위 예제에서 명령 줄 사이에 넣은 # $^ = ... 주석은 탭으로 시작하므로 Make 주석이 아니라 셸에 넘어가는 명령으로 취급됩니다. 셸이 #을 주석으로 처리해 해는 없지만, make가 그 줄을 화면에 그대로 출력합니다. 설명용 주석은 규칙 위에 탭 없이 두는 것이 깔끔합니다.
변수 대입 연산자도 두 가지를 구분해야 합니다. =는 사용할 때마다 값을 다시 전개하고(recursive), :=는 정의하는 순간 한 번 전개해 고정합니다(simple). SRCS = $(wildcard *.cpp)처럼 =로 정의하면 $(SRCS)를 쓸 때마다 디렉터리를 다시 훑고, $(shell ...)을 =로 정의하면 쓸 때마다 명령이 다시 실행됩니다. 파일 목록이나 셸 결과처럼 한 번만 계산하면 되는 값은 :=가 빠르고 예측하기 쉽습니다. ?=는 값이 없을 때만 대입해서 make CXX=clang++처럼 명령줄에서 덮어쓸 기본값을 둘 때 씁니다.
단일 파일부터 라이브러리 링크까지 Makefile 늘려 가기
파일 하나짜리 프로젝트
# 컴파일러 설정
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
# 타겟
TARGET = myapp
# 빌드
$(TARGET): main.cpp
$(CXX) $(CXXFLAGS) main.cpp -o $(TARGET)
# 실행
run: $(TARGET)
./$(TARGET)
# 정리
clean:
rm -f $(TARGET)
# 파일이 아닌 타겟
.PHONY: clean run
사용법:
make # 빌드
make run # 빌드 후 실행
make clean # 정리
여러 소스 파일 프로젝트
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
TARGET = myapp
# 오브젝트 파일
OBJS = main.o calculator.o utils.o
# 링킹
$(TARGET): $(OBJS)
$(CXX) $(OBJS) -o $(TARGET)
# 컴파일
main.o: main.cpp calculator.h utils.h
$(CXX) $(CXXFLAGS) -c main.cpp
calculator.o: calculator.cpp calculator.h
$(CXX) $(CXXFLAGS) -c calculator.cpp
utils.o: utils.cpp utils.h
$(CXX) $(CXXFLAGS) -c utils.cpp
# 정리
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: clean
%.o: %.cpp 패턴 규칙
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
TARGET = myapp
# 모든 .cpp 파일 찾기
SRCS = $(wildcard *.cpp)
OBJS = $(SRCS:.cpp=.o)
# 링킹
$(TARGET): $(OBJS)
$(CXX) $^ -o $@
# 패턴 규칙: 모든 .cpp → .o
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: clean
패턴 규칙 설명:
%.o: %.cpp: 모든.cpp파일을.o로 컴파일$<: 첫 번째 의존성 (.cpp파일)$@: 타겟 (.o파일)
패턴 규칙은 편하지만 헤더 의존성 정보는 사라집니다. %.o: %.cpp에는 헤더가 없으므로, 이 상태에서 헤더를 고치면 재빌드되지 않습니다. 이 문제는 아래 “의존성 자동 생성”에서 해결합니다. $(wildcard *.cpp)는 Makefile을 읽는 시점의 파일 목록이라, 새 .cpp 파일을 추가하면 다음 make 실행 때 자동으로 포함되는 장점이 있는 반면, 실험용으로 잠깐 만든 파일까지 빌드에 들어가는 단점도 있습니다. 규모가 커지면 소스 목록을 명시적으로 적는 팀도 많습니다.
외부 라이브러리 링크
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
INCLUDES = -I./include
LDFLAGS = -lpthread -lm
TARGET = myapp
SRCS = $(wildcard src/*.cpp)
OBJS = $(SRCS:src/%.cpp=obj/%.o)
# 링킹
$(TARGET): $(OBJS)
$(CXX) $^ -o $@ $(LDFLAGS)
# 컴파일
obj/%.o: src/%.cpp
@mkdir -p obj
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
clean:
rm -rf obj $(TARGET)
.PHONY: clean
변수 이름은 관례를 따르면 좋습니다. GNU Make의 내장 규칙은 CPPFLAGS(전처리기 옵션, -I·-D), CXXFLAGS(컴파일 옵션), LDFLAGS(링커 옵션, -L), LDLIBS(링크할 라이브러리, -l)를 사용합니다. 위 예제처럼 -lpthread를 LDFLAGS에 넣어도 링크 명령의 끝에 오면 동작하지만, 라이브러리가 오브젝트 파일보다 앞에 오면 GNU ld는 undefined reference to 'pthread_create' 같은 링크 에러를 냅니다. 링커는 왼쪽에서 오른쪽으로 읽으며, 아직 필요하지 않은 라이브러리의 심볼은 버리기 때문입니다. 라이브러리를 LDLIBS로 분리해 항상 맨 끝에 두면 이 실수를 피할 수 있습니다.
조건문·함수·-MMD 의존성 자동 생성
ifeq로 Debug/Release 나누기
CXX = g++
TARGET = myapp
# DEBUG 변수에 따라 플래그 변경
ifeq ($(DEBUG),1)
CXXFLAGS = -std=c++17 -Wall -g -DDEBUG
else
CXXFLAGS = -std=c++17 -Wall -O2 -DNDEBUG
endif
$(TARGET): main.cpp
$(CXX) $(CXXFLAGS) main.cpp -o $(TARGET)
clean:
rm -f $(TARGET)
.PHONY: clean
사용법:
make # Release 빌드
make DEBUG=1 # Debug 빌드
조건부 플래그에는 함정이 하나 있습니다. Make는 플래그가 바뀐 것을 모르기 때문에, make로 릴리스 빌드한 뒤 make DEBUG=1을 실행하면 “이미 최신”이라며 아무것도 다시 컴파일하지 않습니다. 디버그 심볼이 있을 줄 알고 gdb를 붙였는데 최적화된 코드가 나오는 상황이 이것입니다. 빌드 타입을 바꿀 때는 make clean을 먼저 하거나, 빌드 타입별로 obj/debug/, obj/release/처럼 출력 디렉터리를 나누는 것이 확실합니다. CMake가 빌드 디렉터리를 소스와 분리하는 이유도 같습니다.
wildcard·patsubst 함수
CXX = g++
CXXFLAGS = -std=c++17 -Wall
# wildcard: 파일 패턴 매칭
SRCS = $(wildcard src/*.cpp)
# patsubst: 패턴 치환
OBJS = $(patsubst src/%.cpp,obj/%.o,$(SRCS))
# shell: 쉘 명령 실행
$(shell mkdir -p obj)
myapp: $(OBJS)
$(CXX) $^ -o $@
obj/%.o: src/%.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
clean:
rm -rf obj myapp
.PHONY: clean
-MMD로 헤더 의존성 자동 생성
CXX = g++
CXXFLAGS = -std=c++17 -Wall
SRCS = $(wildcard *.cpp)
OBJS = $(SRCS:.cpp=.o)
DEPS = $(OBJS:.o=.d)
myapp: $(OBJS)
$(CXX) $^ -o $@
# -MMD: 의존성 파일 생성
%.o: %.cpp
$(CXX) $(CXXFLAGS) -MMD -c $< -o $@
# 의존성 파일 포함
-include $(DEPS)
clean:
rm -f $(OBJS) $(DEPS) myapp
.PHONY: clean
설명:
-MMD: 헤더 의존성을.d파일로 생성-include: 의존성 파일을 포함 (없어도 에러 안남)- 헤더 파일 변경 시 자동으로 재컴파일
-MMD로 생성된 main.d에는 main.o: main.cpp util.h config.h 같은 규칙이 들어 있습니다. 컴파일러가 실제로 #include를 따라가며 만든 목록이라 사람이 적는 것보다 정확하고, 시스템 헤더는 제외합니다(-MD는 시스템 헤더까지 포함). 첫 빌드에는 .d 파일이 없지만 어차피 모든 .o를 새로 만들어야 하므로 문제가 없고, 그 컴파일 과정에서 .d가 만들어져 다음 빌드부터 쓰입니다. 여기에 -MP를 추가하면 각 헤더에 대해 빈 규칙이 함께 생성되어, 헤더 파일을 삭제하거나 이름을 바꿨을 때 No rule to make target 'util.h', needed by 'main.o'. Stop. 에러로 빌드가 멈추는 문제가 사라집니다.
탭·의존성·.PHONY·병렬 빌드에서 막히는 경우
missing separator: 탭 대신 스페이스
# ❌ 스페이스 사용 (에러!)
target:
command
# ✅ 탭 사용
target:
command
에러 메시지:
Makefile:2: *** missing separator. Stop.
해결 방법:
- 에디터 설정에서 탭을 스페이스로 변환하지 않도록 설정
- Makefile 모드 사용 (자동으로 탭 삽입)
이 에러는 코드를 웹 페이지나 채팅에서 복사해 붙여 넣을 때 가장 자주 납니다. 복사 과정에서 탭이 스페이스로 바뀌기 때문입니다. cat -A Makefile로 보면 탭은 ^I로 표시되므로 어느 줄이 스페이스인지 바로 확인할 수 있습니다. VS Code는 .editorconfig에 [Makefile] indent_style = tab을 두면 팀 전체 설정을 맞출 수 있고, GNU Make 3.82 이상에서는 .RECIPEPREFIX = >로 탭 대신 다른 문자를 명령 접두어로 쓸 수도 있지만 다른 사람이 읽기 어려워 잘 쓰지 않습니다.
헤더를 고쳐도 다시 빌드되지 않음
# ❌ 헤더 의존성 없음
main.o: main.cpp
g++ -c main.cpp
# util.h가 변경되어도 재컴파일 안 됨!
# ✅ 헤더 포함
main.o: main.cpp util.h config.h
g++ -c main.cpp
# util.h나 config.h 변경 시 자동 재컴파일
clean 파일이 있으면 make clean이 동작하지 않음
# ❌ clean이라는 파일이 있으면 실행 안 됨
clean:
rm -f *.o
# ✅ .PHONY 사용
.PHONY: clean
clean:
rm -f *.o
# clean 파일이 있어도 항상 실행됨
make -j에서만 드러나는 의존성 누락
# 순차 빌드 (느림)
make
# 병렬 빌드 (4개 작업 동시)
make -j4
# CPU 코어 수만큼 병렬 빌드
make -j$(nproc)
실전 팁:
- 병렬 빌드로 컴파일 시간 단축
- 의존성이 올바르게 설정되어야 병렬 빌드 가능
병렬 빌드에서만 드러나는 버그가 있습니다. 순차 빌드는 규칙이 파일에 적힌 순서대로 우연히 맞게 실행되지만, -j를 주면 의존성으로 명시되지 않은 순서는 보장되지 않습니다. 대표적인 예가 아래 “완전한 Makefile”의 rebuild: clean all입니다. make -j4 rebuild를 실행하면 clean과 all이 동시에 실행되어, 방금 만든 오브젝트 파일을 clean이 지워 버리거나 링크 단계에서 No such file or directory가 나는 식으로 실행할 때마다 다르게 실패합니다. 이런 타겟은 rebuild: ; $(MAKE) clean && $(MAKE) all처럼 재귀 호출로 순서를 강제합니다. 여러 규칙이 각자 mkdir -p obj를 실행하는 것도 병렬 빌드에서는 무해하지만, 디렉터리를 order-only 의존성($(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp | $(OBJ_DIR))으로 두면 한 번만 만들어져 더 깔끔합니다.
make -j처럼 숫자를 생략하면 작업 수 제한이 없어져 대형 프로젝트에서는 메모리가 부족해질 수 있으므로, -j$(nproc)처럼 코어 수를 명시하는 편이 안전합니다.
src·include·build로 나눈 프로젝트의 Makefile
프로젝트 구조
project/
├── Makefile
├── include/
│ ├── calculator.h
│ └── utils.h
├── src/
│ ├── main.cpp
│ ├── calculator.cpp
│ └── utils.cpp
└── obj/
└── (빌드 시 생성)
전체 Makefile
# 컴파일러 설정
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
INCLUDES = -I./include
LDFLAGS = -lpthread
# 디렉토리
SRC_DIR = src
OBJ_DIR = obj
INC_DIR = include
# 파일
SRCS = $(wildcard $(SRC_DIR)/*.cpp)
OBJS = $(patsubst $(SRC_DIR)/%.cpp,$(OBJ_DIR)/%.o,$(SRCS))
DEPS = $(OBJS:.o=.d)
# 타겟
TARGET = myapp
# 디버그 빌드
ifeq ($(DEBUG),1)
CXXFLAGS += -g -DDEBUG
else
CXXFLAGS += -O2 -DNDEBUG
endif
# 기본 타겟
all: $(TARGET)
# 링킹
$(TARGET): $(OBJS)
$(CXX) $^ -o $@ $(LDFLAGS)
@echo "빌드 완료: $(TARGET)"
# 컴파일 (의존성 자동 생성)
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp
@mkdir -p $(OBJ_DIR)
$(CXX) $(CXXFLAGS) $(INCLUDES) -MMD -c $< -o $@
# 의존성 파일 포함
-include $(DEPS)
# 실행
run: $(TARGET)
./$(TARGET)
# 정리
clean:
rm -rf $(OBJ_DIR) $(TARGET)
# 전체 재빌드
rebuild: clean all
# 도움말
help:
@echo "사용 가능한 타겟:"
@echo " make - Release 빌드"
@echo " make DEBUG=1 - Debug 빌드"
@echo " make run - 빌드 후 실행"
@echo " make clean - 정리"
@echo " make rebuild - 전체 재빌드"
@echo " make -j4 - 병렬 빌드 (4개)"
.PHONY: all run clean rebuild help
사용 예시:
make # Release 빌드
make DEBUG=1 # Debug 빌드
make -j4 # 병렬 빌드
make run # 실행
make clean # 정리
make rebuild # 재빌드 (병렬 빌드와는 함께 쓰지 말 것)
make help # 도움말
이 Makefile은 소스 디렉터리의 .cpp를 모두 찾아 obj/에 오브젝트와 .d 파일을 만들고, 헤더 의존성까지 추적합니다. 개인 프로젝트나 작은 라이브러리에는 이 정도면 충분합니다. 이 이상 필요해지는 시점은 대개 세 가지입니다. 외부 라이브러리를 플랫폼마다 다른 경로에서 찾아야 할 때, Windows(MSVC)에서도 빌드해야 할 때, 그리고 IDE가 프로젝트 구조를 이해하도록 compile_commands.json이 필요할 때입니다(Make에서는 bear -- make로 만들 수 있습니다). 이런 요구가 생기면 CMake로 옮기는 것을 고려할 때입니다.
Makefile 정리
핵심 요약
- 기본 문법: 타겟, 의존성, 명령어
- 변수:
CXX,CXXFLAGS, 자동 변수 ($@,$<,$^) - 패턴 규칙:
%.o: %.cpp로 자동화 - 함수:
wildcard,patsubst로 파일 처리 - 의존성 자동 생성:
-MMD로 헤더 의존성 관리
Makefile과 CMake 비교
| 특징 | Makefile | CMake |
|---|---|---|
| 복잡도 | 낮음 | 높음 |
| 크로스 플랫폼 | 제한적 | 우수 |
| 학습 곡선 | 완만 | 가파름 |
| 직접 제어 | 높음 | 낮음 |
| 적합한 프로젝트 | 작은 프로젝트 | 큰 프로젝트 |
make -n·-d로 규칙 디버깅
make -n: 명령어만 출력 (실행 안함)make -d: 디버그 정보 출력@echo변수 값 확인
자주 쓰는 make 명령
| 명령어 | 설명 |
|---|---|
make | 기본 타겟 빌드 |
make clean | clean 타겟 실행 |
make -j4 | 4개 작업 병렬 빌드 |
make -n | 명령어만 출력 (dry-run) |
make -B | 모든 타겟 강제 재빌드 |
이어서 볼 글
같이 보면 좋은 글
- CMake 3.28+ 프리셋과 모듈로 크로스 플랫폼 빌드 구성하기
- C++ CMake
- CMake find_package로 외부 라이브러리 연결하기
- CMake 타겟 기반 빌드
- Conan 기초: conanfile로 의존성 선언, 프로필, Remote, CMake 연동
자주 묻는 질문 (FAQ)
Q. 헤더만 수정했는데 make가 다시 빌드하지 않는 이유는 무엇인가요?
A. 규칙의 전제 조건에 .cpp 파일만 적혀 있으면 make는 헤더가 바뀐 사실을 알 수 없어 오브젝트 파일을 최신으로 판단합니다. 본문처럼 컴파일 시 -MMD로 헤더 의존성을 담은 .d 파일을 만들고 Makefile 끝에서 -include $(DEPS)로 읽어 들이면 헤더 변경도 재빌드 대상이 됩니다. 여기에 -MP를 함께 주면 헤더 파일을 삭제했을 때 존재하지 않는 전제 조건 때문에 make가 멈추는 문제도 막을 수 있습니다.