본문으로 건너뛰기
GNU Make · 5/7

Make 함수 분석 — wildcard·patsubst·foreach·shell

· Hawk · 8분 읽기

#왜 함수가 필요한가

Ch 4까지 우리는 파일 이름을 직접 나열했습니다.

SRCS := main.c hello.c utils.c config.c network.c
OBJS := 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모든 fromto로 바꿉니다. 와일드카드는 없습니다. 글자 그대로 비교.

FILES := foo.c bar.c baz.c
OBJS := $(subst .c,.o,$(FILES))
# OBJS = foo.o bar.o baz.o

subst의 한계는 경계 인식이 없다는 것입니다. subst .c,.o,foo.cmdfoo.omd가 됩니다. 단순한 치환에는 빠르고 명료하지만, 단어 경계가 중요하면 patsubst를 씁니다.

#patsubst — 패턴 치환

$(patsubst pattern,replacement,text)

% 와일드카드를 쓰는 점이 subst와 다릅니다. 단어 단위로 매칭해 정밀합니다.

SRCS := src/foo.c src/bar.c
OBJS := $(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 world
CLEAN := $(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"라는 부분 문자열이 어디든 들어 있음
endif

findstring은 단어 경계를 무시합니다. findstring debug,prodebug-modedebug를 반환합니다. 정확한 단어 매칭은 filter를 씁니다.

#filter / filter-out — 패턴으로 솎기

$(filter pattern...,text)
$(filter-out pattern...,text)

여러 패턴을 한 호출에 줄 수 있고, 단어 단위로 매칭합니다.

FILES := main.c main.h utils.c utils.h config.mk
SRCS := $(filter %.c,$(FILES)) # main.c utils.c
HEADERS := $(filter %.h,$(FILES)) # main.h utils.h
CODE := $(filter %.c %.h,$(FILES)) # main.c main.h utils.c utils.h

filter-out은 정반대로, 매칭되는 것을 제거합니다.

SRCS := main.c test_main.c utils.c test_utils.c
PROD := $(filter-out test_%,$(SRCS)) # main.c utils.c
TEST := $(filter test_%,$(SRCS)) # test_main.c test_utils.c

테스트 코드 분리, 외부 라이브러리 제외 등 목록 정제가 필요한 모든 곳에서 등장합니다.

#sort — 정렬 + 중복 제거

$(sort list)

알파벳 오름차순으로 정렬하면서 중복도 제거합니다.

FILES := z.c a.c m.c a.c
SORTED := $(sort $(FILES)) # a.c m.c z.c

자주 보는 활용은 디렉터리 목록 정리입니다.

OBJS := build/a/x.o build/a/y.o build/b/z.o
DIRS := $(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 .h

basename유닉스 basename 명령과 의미가 다르다는 점에 주의하세요. 유닉스 basename foo/bar.cbar.c를 돌려주지만, Make의 basenamefoo/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 + find
ALL := $(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 test
CLEAN_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 greeting
Hello, World!
Welcome to Make.
endef
test:
@echo "$(greeting)"

defineendef 사이의 모든 줄이 한 변수의 값이 됩니다. 줄바꿈도 그대로 보존됩니다.

define 자체는 재귀적 변수(=)와 같은 의미입니다. 즉시 평가하려면 :=을 결합할 수 있습니다 (4.0+).

define version :=
1.0.0
endef

define이 진가를 발휘하는 자리는 템플릿입니다. 매개변수가 들어가는 여러 줄짜리 텍스트를 정의하고, 그걸 $(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.c
bar_SRCS := bar1.c
$(eval $(call module-template,foo))
$(eval $(call module-template,bar))

$$이 잔뜩 등장하는 이유는 eval 안에서 두 번 확장되기 때문입니다. 한 번은 call에서, 한 번은 eval에서. 두 번 모두 살리고 싶은 $$$로 이스케이프해야 합니다. 한 번만 풀고 싶은 $(예: $(1))은 한 번만 적습니다.

위 코드를 단계별로 풀어 보면:

  1. $(call module-template,foo)define 안의 $(1)foo로 치환. $$$로 한 번 줄어듦.
  2. 결과 문자열:
    foo_OBJS := $(patsubst %.c,$(BUILDDIR)/%.o,$(foo_SRCS))
    foo: $(foo_OBJS)
    $(CC) -o $@ $^
  3. $(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 := gcc
CFLAGS := -Wall -Wextra -g -std=c11 -O2
CPPFLAGS := -Iinclude
SRCDIR := src
BUILDDIR := 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 $$@ # 정상: $@
endef

eval이 들어가는 자리에서는 유지하고 싶은 모든 $$$로 적는다는 규칙만 기억하세요.

#5. filterfindstring 혼동

$(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)
endif

realpath대상이 존재하지 않으면 빈 문자열이라는 사실을 활용한 깔끔한 검사입니다. [ -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)도 여기서 마무리됩니다.

#참고 자료