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 정리

핵심 요약

  1. 기본 문법: 타겟, 의존성, 명령어
  2. 변수: CXX, CXXFLAGS, 자동 변수 ($@, $<, $^)
  3. 패턴 규칙: %.o: %.cpp로 자동화
  4. 함수: wildcard, patsubst로 파일 처리
  5. 의존성 자동 생성: -MMD로 헤더 의존성 관리

Makefile과 CMake 비교

특징MakefileCMake
복잡도낮음높음
크로스 플랫폼제한적우수
학습 곡선완만가파름
직접 제어높음낮음
적합한 프로젝트작은 프로젝트큰 프로젝트

make -n·-d로 규칙 디버깅

  • make -n: 명령어만 출력 (실행 안함)
  • make -d: 디버그 정보 출력
  • @echo 변수 값 확인

자주 쓰는 make 명령

명령어설명
make기본 타겟 빌드
make cleanclean 타겟 실행
make -j44개 작업 병렬 빌드
make -n명령어만 출력 (dry-run)
make -B모든 타겟 강제 재빌드

이어서 볼 글


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 헤더만 수정했는데 make가 다시 빌드하지 않는 이유는 무엇인가요?

A. 규칙의 전제 조건에 .cpp 파일만 적혀 있으면 make는 헤더가 바뀐 사실을 알 수 없어 오브젝트 파일을 최신으로 판단합니다. 본문처럼 컴파일 시 -MMD로 헤더 의존성을 담은 .d 파일을 만들고 Makefile 끝에서 -include $(DEPS)로 읽어 들이면 헤더 변경도 재빌드 대상이 됩니다. 여기에 -MP를 함께 주면 헤더 파일을 삭제했을 때 존재하지 않는 전제 조건 때문에 make가 멈추는 문제도 막을 수 있습니다.