Make 함수 분석 — wildcard·patsubst·foreach·shell
#왜 함수가 필요한가
Ch 4까지 우리는 파일 이름을 직접 나열했습니다.
SRCS := main.c hello.c utils.c config.c network.cOBJS := main.o hello.o utils.o config.o network.o새 파일이 생길 때마다 두 줄을 동시에 고쳐야 하고, 어딘가에서 한 줄을 놓치면 미묘한 빌드 사고가 납니다. 사람이 같은 정보를 두 번 적는 일은 거의 항상 에러의 씨앗입니다.
해결은 디스크에 있는 파일을 직접 묻는 것과 파일 이름을 자동 변환하는 것입니다. 둘 다 함수의 일입니다.
SRCS := $(wildcard src/*.c)OBJS := $(patsubst src/%.c,build/%.o,$(SRCS))wildcard는 파일 시스템에 실재하는 파일을 찾고, patsubst는 문자열 변환 규칙을 적용합니다. 이제 디렉터리에 .c 파일을 새로 추가하면 Makefile은 수정하지 않아도 즉시 그 파일을 빌드 대상에 포함합니다.
이 장은 Make가 가진 내장 함수 카탈로그입니다. 문자열·파일 이름·조건·셸·반복·동적 규칙 생성까지 — 처음 다 외울 필요는 없고, 이 자리에 어떤 도구가 있는지만 알아 두면 됩니다.
#함수 호출 문법
$(함수명 인자1,인자2,...)${함수명 인자1,인자2,...}괄호와 중괄호는 서로 바꿔 써도 됩니다. 함수명 뒤에 한 칸의 공백, 그 다음 첫 인자가 시작됩니다.
#공백이 인자에 포함된다
이 한 줄이 거의 모든 Make 함수 함정의 근원입니다.
# 인자 사이 공백이 인자 일부로 흡수됨$(subst a, b, text)# ^^ ^^# 두 공백이 각각 두 번째·세 번째 인자의 머리에 붙음위 식은 “a를 ’ b’로 치환”이 됩니다. 출력에서 b가 공백 한 칸씩 옮겨진 모양이 됩니다.
# 의도한 동작$(subst a,b,text)습관적으로 함수 호출에 공백을 넣지 마세요. 보기에는 답답해도, 공백이 의미를 바꾸는 언어에서는 이게 정답입니다. 다른 언어의 함수 호출 관행과 다르다는 점을 한 번 의식하면 그 뒤로는 안 헷갈립니다.
#문자열 함수
#subst — 단순 치환
$(subst from,to,text)text의 모든 from을 to로 바꿉니다. 와일드카드는 없습니다. 글자 그대로 비교.
FILES := foo.c bar.c baz.cOBJS := $(subst .c,.o,$(FILES))# OBJS = foo.o bar.o baz.osubst의 한계는 경계 인식이 없다는 것입니다. subst .c,.o,foo.cmd는 foo.omd가 됩니다. 단순한 치환에는 빠르고 명료하지만, 단어 경계가 중요하면 patsubst를 씁니다.
#patsubst — 패턴 치환
$(patsubst pattern,replacement,text)% 와일드카드를 쓰는 점이 subst와 다릅니다. 단어 단위로 매칭해 정밀합니다.
SRCS := src/foo.c src/bar.cOBJS := $(patsubst src/%.c,build/%.o,$(SRCS))# OBJS = build/foo.o build/bar.o단축 문법: 변수 참조 안에 직접 적을 수 있습니다.
OBJS := $(SRCS:src/%.c=build/%.o)이게 거의 같은 의미라서 실제 Makefile에서는 단축 문법을 더 자주 봅니다.
| 함수 | 매칭 | 예 |
|---|---|---|
subst | 글자 그대로 모든 발생 | $(subst .c,.o,main.c.bak) → main.o.bak |
patsubst | % 패턴, 단어 단위 | $(patsubst %.c,%.o,main.c) → main.o |
#strip — 공백 정리
앞뒤 공백 제거, 단어 사이 연속 공백을 한 칸으로 줄입니다.
VAR := hello worldCLEAN := $(strip $(VAR))# CLEAN = "hello world"가장 흔한 용도는 비어 있음 검사입니다. 변수에 공백만 들어가도 ifeq ($(VAR),)은 false이지만, $(strip)을 거치면 빈 문자열이 됩니다.
ifeq ($(strip $(EXTRA_FLAGS)),)$(info No extra flags set)endif#findstring — 부분 문자열 검색
$(findstring find,text)찾으면 find 그대로, 못 찾으면 빈 문자열을 돌려줍니다. 조건 검사에 그대로 쓸 수 있게 결과를 진리값처럼 만든 설계입니다.
ifneq ($(findstring debug,$(CFLAGS)),)# CFLAGS에 "debug"라는 부분 문자열이 어디든 들어 있음endiffindstring은 단어 경계를 무시합니다. findstring debug,prodebug-mode도 debug를 반환합니다. 정확한 단어 매칭은 filter를 씁니다.
#filter / filter-out — 패턴으로 솎기
$(filter pattern...,text)$(filter-out pattern...,text)여러 패턴을 한 호출에 줄 수 있고, 단어 단위로 매칭합니다.
FILES := main.c main.h utils.c utils.h config.mkSRCS := $(filter %.c,$(FILES)) # main.c utils.cHEADERS := $(filter %.h,$(FILES)) # main.h utils.hCODE := $(filter %.c %.h,$(FILES)) # main.c main.h utils.c utils.hfilter-out은 정반대로, 매칭되는 것을 제거합니다.
SRCS := main.c test_main.c utils.c test_utils.cPROD := $(filter-out test_%,$(SRCS)) # main.c utils.cTEST := $(filter test_%,$(SRCS)) # test_main.c test_utils.c테스트 코드 분리, 외부 라이브러리 제외 등 목록 정제가 필요한 모든 곳에서 등장합니다.
#sort — 정렬 + 중복 제거
$(sort list)알파벳 오름차순으로 정렬하면서 중복도 제거합니다.
FILES := z.c a.c m.c a.cSORTED := $(sort $(FILES)) # a.c m.c z.c자주 보는 활용은 디렉터리 목록 정리입니다.
OBJS := build/a/x.o build/a/y.o build/b/z.oDIRS := $(sort $(dir $(OBJS)))# DIRS = build/a/ build/b/ ← 중복 제거됨mkdir을 부를 디렉터리 목록을 뽑을 때 거의 매번 sort가 따라옵니다.
⚠️ 순서가 중요한 라이브러리 링크 순서나 의존성 순서에는
sort를 쓰면 안 됩니다. 의도와 다른 순서가 됩니다.
#단어 접근 — word, words, wordlist, firstword, lastword
목록을 위치 기반으로 조작합니다.
LIST := one two three four five
$(word 3,$(LIST)) # three$(words $(LIST)) # 5$(wordlist 2,4,$(LIST)) # two three four$(firstword $(LIST)) # one$(lastword $(LIST)) # five인덱스가 1부터 시작한다는 점이 다른 언어와 다릅니다. Make 매뉴얼은 “이게 학생들의 자연수 직관에 더 가깝다”는 설명을 곁들이지만, 실제로는 자주 헷갈리는 자리입니다. $(word 1,$(LIST))이 $(firstword $(LIST))와 같다는 사실을 기억해 두세요.
#파일 이름 함수
#dir, notdir — 경로 쪼개기
FILES := src/main.c include/header.h$(dir $(FILES)) # "src/ include/" ← 슬래시 포함$(notdir $(FILES)) # "main.c header.h"dir이 슬래시를 포함해서 돌려준다는 점이 흥미롭습니다. 그래서 $(dir foo.c)은 디렉터리 없는 foo.c에도 동작해 "./"을 반환합니다. 경로 합치기가 쉬워지는 설계입니다.
#basename, suffix — 확장자 분리
FILES := main.c utils.cpp config.h$(basename $(FILES)) # main utils config$(suffix $(FILES)) # .c .cpp .hbasename이 유닉스 basename 명령과 의미가 다르다는 점에 주의하세요. 유닉스 basename foo/bar.c는 bar.c를 돌려주지만, Make의 basename은 foo/bar(확장자 제거)를 돌려줍니다. 같은 단어가 의미를 달리하는 흔한 함정입니다.
#addprefix, addsuffix — 접두/접미사 붙이기
MODULES := main utils config
SRCS := $(addsuffix .c,$(MODULES))# main.c utils.c config.c
OBJS := $(addprefix build/,$(addsuffix .o,$(MODULES)))# build/main.o build/utils.o build/config.o함수를 중첩해 복잡한 변환을 만들 수 있습니다. 가독성이 떨어지면 단계를 변수에 풀어 적습니다.
#wildcard — 파일 시스템 조회
$(wildcard pattern)다른 함수들이 문자열만 다루는 반면, wildcard는 디스크에 있는 실제 파일을 검색합니다. Make에서 “파일이 있는지 알아내는” 유일한 1차 함수입니다.
SRCS := $(wildcard src/*.c)HEADERS := $(wildcard include/*.h)다중 패턴도 됩니다.
ALL := $(wildcard *.c *.cpp *.cxx)재귀 매칭(**)은 *Make 4.0+*에서 셸 globstar에 위임됩니다.
# 4.0 이상에서만 동작ALL := $(wildcard src/**/*.c)
# 호환성 있는 대안: shell + findALL := $(shell find src -name '*.c')shell 호출은 빌드 시작 시점에 한 번 도므로, 거의 같은 효과를 더 안정적으로 얻습니다.
💡
wildcard는 평가 시점에 즉시 디스크를 본다는 사실을 잊지 마세요. 그래서:=로 쓰면 Makefile 파싱 때 한 번 디스크 조회,=로 쓰면 변수 사용 때마다 매번 조회. 거의 항상:=가 답입니다.
#realpath, abspath — 경로 정규화
$(realpath path) # 심볼릭 링크 해제한 절대 경로 (존재해야 함)$(abspath path) # 절대 경로 (존재 여부 무관)realpath는 대상이 실제 존재하지 않으면 빈 문자열을 반환합니다. 그래서 “파일이 정말 거기 있는지” 검사용으로도 씁니다.
ifeq ($(realpath /opt/cuda/bin/nvcc),)$(error CUDA not installed)endif#조건 함수
조건 지시자(ifeq, ifdef, Ch 6)와 조건 함수($(if ...))는 다릅니다. 지시자는 Makefile 파싱 시점에 평가되고 한 번 결정되면 끝입니다. 함수는 변수 평가 시점에 매번 풀립니다.
#$(if ...)
$(if condition,then-part)$(if condition,then-part,else-part)condition이 비어 있지 않으면 then-part, 비어 있으면 else-part를 돌려줍니다. 다른 언어의 삼항 연산자와 같은 모양입니다.
DEBUG ?=CFLAGS := $(if $(DEBUG),-g -O0 -DDEBUG,-O2 -DNDEBUG)위 식에서 DEBUG=1 make로 호출하면 첫 분기, 아니면 두 번째 분기가 들어옵니다.
#$(or ...), $(and ...)
$(or expr1,expr2,...) # 첫 번째 비어 있지 않은 값$(and expr1,expr2,...) # 모두 비어 있지 않으면 마지막, 아니면 빈 문자열or은 기본값 채우기에 자주 씁니다.
CC := $(or $(CC),gcc) # 빈 CC면 gcc로 채움이건 ?=과 비슷하지만, 변수가 명시적으로 빈 문자열로 설정된 경우에도 동작합니다(?=은 완전히 미정의일 때만 동작).
#셸·반복·동적 함수
#$(shell ...) — 외부 명령 결과
$(shell command)셸을 통해 명령을 실행하고 표준 출력을 가져옵니다. 줄바꿈은 공백으로 바뀝니다.
DATE := $(shell date '+%Y-%m-%d')GIT_HASH := $(shell git rev-parse --short HEAD)NPROC := $(shell nproc)
CPPFLAGS += -DBUILD_DATE=\"$(DATE)\" -DGIT_HASH=\"$(GIT_HASH)\"주의할 점은 모든 호출이 Makefile 파싱 시점에 일어난다는 것입니다. 한 줄에 $(shell)이 10개 있으면 셸 10개를 띄웁니다. 비싼 명령(find, cmake, 네트워크 호출)이라면 결과를 변수에 저장해 한 번만 호출하도록 합니다.
# 비쌈: ALL_SOURCES를 참조할 때마다 find가 돈다 (지연 평가)ALL_SOURCES = $(shell find src -name '*.c')
# 한 번만 도는 형태ALL_SOURCES := $(shell find src -name '*.c'):=로 즉시 평가해 Makefile 파싱 시 단 한 번 도는 게 표준입니다.
#$(foreach var,list,text) — 반복
DIRS := src lib testCLEAN_DIRS := $(foreach d,$(DIRS),$(d)/build)# CLEAN_DIRS = src/build lib/build test/build목록을 돌면서 각 단어를 변수에 담아 text를 평가합니다. 텍스트 안에서 그 변수($(d))를 참조할 수 있습니다.
복잡한 변환은 foreach + call 조합이 표준입니다. 예: 각 모듈마다 src/X.c → build/X.o 규칙을 일괄 생성.
#$(call func,arg1,...) — 사용자 정의 함수
# 정의: $(1), $(2), ... 가 인자 자리make-obj = $(patsubst %.c,$(1)/%.o,$(2))
# 호출OBJS := $(call make-obj,build,main.c utils.c)# OBJS = build/main.o build/utils.o같은 변환을 여러 자리에서 재사용하고 싶을 때 씁니다. 인자가 위치 기반($(1), $(2))이라 가독성이 떨어지므로, 너무 복잡한 함수는 차라리 셸 스크립트로 빼는 게 낫습니다.
#define ... endef — 여러 줄 변수
$(eval)을 다루기 전에 여러 줄 텍스트를 변수에 담는 방법부터 봅시다. 일반 = 대입은 한 줄만 받습니다. 줄바꿈을 포함한 큰 블록을 변수에 담으려면 define을 씁니다.
define greetingHello, World!Welcome to Make.endef
test: @echo "$(greeting)"define과 endef 사이의 모든 줄이 한 변수의 값이 됩니다. 줄바꿈도 그대로 보존됩니다.
define 자체는 재귀적 변수(=)와 같은 의미입니다. 즉시 평가하려면 :=을 결합할 수 있습니다 (4.0+).
define version :=1.0.0endefdefine이 진가를 발휘하는 자리는 템플릿입니다. 매개변수가 들어가는 여러 줄짜리 텍스트를 정의하고, 그걸 $(call)로 실체화해 $(eval)에 넣어 동적 규칙을 만듭니다.
#$(eval text) — 동적 Makefile 생성
$(eval text)가장 강력하면서 가장 위험한 함수입니다. 문자열을 Makefile 코드로 해석해 그 자리에 새 규칙·변수를 추가합니다.
# 템플릿 정의define module-template$(1)_OBJS := $$(patsubst %.c,$$(BUILDDIR)/%.o,$$($(1)_SRCS))$(1): $$($(1)_OBJS) $$(CC) -o $$@ $$^endef
# 각 모듈마다 eval로 펼치기foo_SRCS := foo1.c foo2.cbar_SRCS := bar1.c
$(eval $(call module-template,foo))$(eval $(call module-template,bar))$$이 잔뜩 등장하는 이유는 eval 안에서 두 번 확장되기 때문입니다. 한 번은 call에서, 한 번은 eval에서. 두 번 모두 살리고 싶은 $은 $$로 이스케이프해야 합니다. 한 번만 풀고 싶은 $(예: $(1))은 한 번만 적습니다.
위 코드를 단계별로 풀어 보면:
$(call module-template,foo)—define안의$(1)을foo로 치환.$$은$로 한 번 줄어듦.- 결과 문자열:
foo_OBJS := $(patsubst %.c,$(BUILDDIR)/%.o,$(foo_SRCS))foo: $(foo_OBJS)$(CC) -o $@ $^
$(eval ...)이 이 텍스트를 Makefile 코드로 해석. 위 세 줄이 마치 사용자가 직접 적은 것처럼 규칙으로 추가됩니다.
eval은 플러그인식 빌드 시스템(buildroot, Yocto, OE-core, Kbuild의 일부)에서 활발하게 쓰입니다. 일반 프로젝트에서는 등장할 일이 드물지만, 메가 Makefile을 해독하려면 이 메커니즘을 알아야 합니다.
#MAKEFILE_LIST — “지금 어떤 Makefile에 있지?”
이 특수 변수는 현재까지 Make가 읽은 모든 Makefile의 경로 목록입니다. include로 합쳐진 서브 Makefile에서 자신의 위치를 알아내는 표준 관용구의 핵심입니다.
# 모듈에서 자기 디렉터리 찾기 — 비재귀적 Make의 표준 패턴THIS_DIR := $(dir $(lastword $(MAKEFILE_LIST)))
# 이제 THIS_DIR을 기준으로 소스 경로를 만들 수 있음SRCS += $(THIS_DIR)foo.c $(THIS_DIR)bar.c여기서 마법은 $(lastword $(MAKEFILE_LIST))입니다.
$(MAKEFILE_LIST)는Makefile mods/foo/module.mk mods/bar/module.mk같이 순서대로 모든 파일을 담고 있습니다.$(lastword ...)은 마지막 단어를 뽑습니다 — 즉 방금 include된 그 Makefile의 경로.$(dir ...)은 그 경로에서 디렉터리 부분만 추출.
각 서브 Makefile이 자기 디렉터리를 자동으로 알게 되어, 비재귀적 Make에서 모듈 단위 격리가 가능해집니다. (Ch 6에서 본 multi-module 패턴이 이 트릭 위에 서 있습니다.)
주의: THIS_DIR을 그 자리에서 즉시 평가하려면 *반드시 :=*을 써야 합니다. =로 적으면 $(lastword $(MAKEFILE_LIST))가 나중에 평가되어 그때의 마지막 파일을 가져오게 되는데, 그건 이미 다른 파일이 될 수 있습니다.
#$(error ...) / $(warning ...) / $(info ...) — 메시지
$(error msg) # 메시지 출력 + 즉시 중단$(warning msg) # 메시지만 출력, 계속 진행$(info msg) # 단순 정보 메시지가장 자주 쓰는 건 $(info)로, 변수 값 확인에 거의 매번 등장합니다.
$(info SRCS = $(SRCS))$(info OBJS = $(OBJS))$(info CFLAGS = $(CFLAGS))$(error)은 환경 검증에 좋습니다.
ifndef CC$(error CC must be set)endif
ifeq ($(realpath /opt/cuda/bin/nvcc),)$(error CUDA not installed at /opt/cuda)endif#실전 예시
함수를 본격 활용한 Makefile입니다.
CC := gccCFLAGS := -Wall -Wextra -g -std=c11 -O2CPPFLAGS := -Iinclude
SRCDIR := srcBUILDDIR := build
# 1. 소스 자동 감지 — 하위 디렉터리까지 (4.0+ globstar 또는 find)SRCS := $(shell find $(SRCDIR) -name '*.c')
# 2. .c → .o (디렉터리 구조 보존)OBJS := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRCS))
# 3. 필요한 빌드 디렉터리 목록 — 중복 제거OBJDIRS := $(sort $(dir $(OBJS)))
TARGET := $(BUILDDIR)/myapp
.PHONY: all clean info
all: $(TARGET)
$(TARGET): $(OBJS) $(CC) $(LDFLAGS) -o $@ $^ $(LDLIBS)
# 정적 패턴 + order-only로 디렉터리 보장$(OBJS): $(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(OBJDIRS) $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
# 디렉터리 일괄 생성$(OBJDIRS): mkdir -p $@
clean: $(RM) -r $(BUILDDIR)
# 디버깅용 — make info로 변수 확인info: $(info SRCS = $(SRCS)) $(info OBJS = $(OBJS)) $(info OBJDIRS = $(OBJDIRS)) $(info CFLAGS = $(CFLAGS)) @true핵심 패턴:
$(shell find ...)로 재귀적 소스 감지 (wildcard보다 호환성 좋음).patsubst로 경로 변환.sort+dir로 유일한 디렉터리 목록 추출.$(info ...)+info타겟으로 디버깅용 변수 덤프 (@true는 레시피가 비면 안 되어서 넣는 no-op).
#흔한 실수
#1. 쉼표 앞뒤에 공백
# 안 됨: 공백이 인자에 흡수됨$(patsubst %.c, %.o, $(SRCS))
# 됨$(patsubst %.c,%.o,$(SRCS))#2. wildcard가 빈 결과를 줌
SRCS := $(wildcard srcs/*.c) # 'srcs/' 오타 ('src/' 가 의도)# SRCS는 빈 문자열, 빌드가 "할 일 없음"으로 끝남$(info)로 즉시 확인하는 습관이 좋습니다.
SRCS := $(wildcard src/*.c)$(info SRCS = [$(SRCS)])빈 결과면 대괄호 사이가 비어서 한눈에 보입니다.
#3. shell이 줄바꿈을 공백으로 바꿈
FILES := $(shell find src -name '*.c')# 파일 이름에 공백이 있으면 단어 경계가 깨짐POSIX 파일 시스템에서는 거의 문제 안 되지만, 윈도우·macOS의 일부 경로에서는 사고가 납니다. 안전한 우회는 find -print0이지만 shell이 NULL 문자를 처리 못 하므로, 결국 공백 없는 경로 관행을 유지하는 게 답입니다.
#4. eval의 이중 확장
define rule-template$(1): $(2) echo $@ # 빈 문자열 (첫 확장에서 $@가 비어버림) echo $$@ # 정상: $@endefeval이 들어가는 자리에서는 유지하고 싶은 모든 $을 $$로 적는다는 규칙만 기억하세요.
#5. filter와 findstring 혼동
$(filter debug,debug release) # debug$(filter debug,debug-mode) # (빈 — 정확히 일치 안 함)$(findstring debug,debug-mode) # debug (부분 문자열 매칭)단어 경계가 중요하면 filter, 부분 검색이면 findstring.
#6. realpath를 존재 확인용으로 안 쓰고 변환용으로만 씀
ifeq ($(realpath build/output.bin),)$(error build/output.bin not built yet)endifrealpath는 대상이 존재하지 않으면 빈 문자열이라는 사실을 활용한 깔끔한 검사입니다. [ -e ... ] 같은 셸 호출보다 빠릅니다.
#정리
- 함수 호출:
$(함수명 인자,...). 쉼표 앞뒤 공백 금지. - 문자열:
subst(글자 그대로),patsubst(%패턴),filter(단어 매칭),filter-out(제외),sort(정렬+중복 제거),strip(공백 정리). - 파일 이름:
wildcard(디스크 조회),dir/notdir,basename/suffix,addprefix/addsuffix,realpath/abspath. - 조건 함수:
$(if),$(or),$(and)— 변수 확장 시점. - 셸:
$(shell ...)—:=로 한 번만 풀리게. - 반복·재사용:
$(foreach),$(call), 그리고 동적 규칙은$(eval). - 디버깅:
$(info),$(warning),$(error)셋 다 외워 두면 큰 Makefile 추적이 빨라진다.
#다음 장 예고
Ch 6: 조건문과 include에서는 Makefile 파싱 시점에 동작하는 조건 지시자(ifeq, ifdef, else, endif)와 외부 Makefile을 합치는 include / -include를 다룹니다. 자동 의존성 생성(-MMD -MP + include)도 여기서 마무리됩니다.
#참고 자료
관련 글
실전 Makefile 예제 — C/C++ 프로젝트용 기본 골격
기본 C/C++부터 라이브러리, 크로스 컴파일, 테스트 통합까지 — 실제 프로젝트에 그대로 쓰는 Makefile 패턴.
Make 조건문과 include — ifeq·ifdef·include·-include
파싱 시점 조건 분기, Makefile 분할, 그리고 -MMD -MP로 헤더 의존성을 자동 추적하는 표준 패턴.
Make 패턴 규칙과 암시적 규칙 — % 매칭 동작
% 한 글자로 100개의 규칙을 줄이는 패턴 규칙, Make 내장 암시적 규칙, 그리고 둘의 충돌·우선순위.