6P by GN⁺ | ★ favorite | 댓글 1개
  • Makefile은 파일 변경을 기준으로 필요한 빌드 단계만 다시 실행해, 대형 프로젝트의 C/C++ 컴파일을 자동화하는 데 주로 쓰임
  • Make는 대상과 의존성의 파일시스템 타임스탬프를 비교해, 대상이 없거나 의존성이 더 최신일 때만 명령을 실행함
  • 기본 규칙은 targets: prerequisites와 탭으로 들여쓴 명령으로 이루어지며, 공백 들여쓰기는 make 실패로 이어질 수 있음
  • 변수, 와일드카드, 자동 변수, 패턴 규칙, 조건문, 함수, 재귀 make, include, .PHONY, .DELETE_ON_ERROR로 빌드 흐름을 확장할 수 있음
  • Cookbook 예제는 src/의 C/C++ 소스를 build/ 산출물로 만들고, -MMD -MP-include자동 의존성 관리를 구성함

Makefile이 필요한 상황

  • Makefile은 대형 프로그램에서 어떤 파일을 다시 컴파일해야 하는지 판단하는 데 사용됨
  • 튜토리얼의 중심 사례는 C/C++ 파일 컴파일
  • 파일 변경 여부에 따라 일련의 명령을 실행해야 하는 작업에도 Make를 컴파일 외 용도로 활용할 수 있음
  • C/C++의 다른 빌드 도구로 SCons, CMake, Bazel, Ninja가 소개됨
  • Java에는 Ant, Maven, Gradle이 있고, Go, Rust, TypeScript 같은 언어는 자체 빌드 도구를 가짐
  • Python, Ruby, 순수 JavaScript 같은 인터프리터 언어는 파일이 바뀌어도 다시 컴파일할 필요가 없어 Makefile 같은 도구가 필수적이지 않음

Make의 기본 실행 모델

  • 예제는 make가 설치된 터미널에서 Makefile 파일을 만든 뒤, 해당 디렉터리에서 make를 실행하는 방식으로 동작함
  • 기본 규칙은 아래 형태를 따름
    • targets: prerequisites
    • 탭으로 시작하는 command
  • 대상(target) 은 보통 파일 이름이며, 명령은 해당 대상을 만들기 위한 단계임
  • 의존성(prerequisites) 은 명령 실행 전에 존재해야 하는 파일이며, dependencies라고도 부름
  • Makefile의 명령 줄은 반드시 탭 문자로 들여써야 하며, 공백을 쓰면 make가 실패함

대상과 의존성의 시간 비교

  • make hellohello 대상에 연결된 명령을 실행하지만, hello 파일이 이미 있으면 명령을 실행하지 않음
  • 대상과 파일 이름은 직접 연결되며, 일반적으로 대상의 명령은 대상과 같은 이름의 파일을 만듦
  • blah: blah.c 규칙에서는 blah가 없거나 blah.cblah보다 더 최신일 때만 컴파일 명령이 실행됨
  • Make는 변경 이력을 직접 추적하지 않고 파일시스템 타임스탬프를 휴리스틱으로 사용함
  • 파일을 수정한 뒤 수정 시간을 과거로 바꾸면 Make가 해당 파일이 바뀌지 않았다고 잘못 판단할 수 있음

기본 타깃과 clean

  • make에 대상을 지정하지 않으면 Makefile의 첫 번째 대상이 기본 대상으로 실행됨
  • all 대상은 여러 대상을 한 번에 만들 때 쓰이며, 첫 번째 규칙으로 두면 make만 실행해도 전체 대상이 빌드됨
  • clean은 보통 빌드 산출물을 삭제하는 대상으로 쓰이지만, Make의 특별한 예약어는 아님
  • clean이 첫 번째 대상도 아니고 다른 대상의 의존성도 아니면 make clean처럼 명시적으로 호출해야 실행됨
  • clean이라는 파일이 실제로 존재하면 clean 대상이 실행되지 않을 수 있어, 뒤에서 .PHONY로 해결함

변수와 문자열 처리

  • Make 변수는 문자열만 가질 수 있으며, 튜토리얼은 일반적으로 := 사용을 권장함
  • 변수 참조는 $(name) 또는 ${name} 형식을 사용함
  • 작은따옴표와 큰따옴표는 Make 자체에서는 특별한 의미가 없고, 변수에 들어가는 문자일 뿐임
  • 변수 정의 방식은 크게 두 가지임
    • =: 재귀 변수로, 사용될 때 변수를 찾아 확장함
    • :=: 단순 확장 변수로, 정의 시점에 이미 정의된 값만 확장함
  • ?=는 아직 설정되지 않은 변수에만 값을 설정함
  • +=는 변수에 값을 추가함
  • 정의되지 않은 변수는 빈 문자열로 취급됨
  • 줄 끝의 공백은 제거되지 않으며, 한 칸짜리 공백 변수를 만들려면 $(nullstring) 같은 기법을 사용할 수 있음

와일드카드와 자동 변수

  • Make에서 *%는 모두 와일드카드로 불리지만 의미가 다름
  • *는 파일시스템에서 일치하는 파일 이름을 찾으며, 직접 변수 정의에 쓰기보다 $(wildcard *.o)처럼 wildcard 함수로 감싸는 편이 안전함
  • *가 아무 파일과도 일치하지 않으면 wildcard 함수 안이 아닌 경우 그대로 남을 수 있음
  • %는 규칙과 특정 함수에서 사용되며, 일치한 부분은 stem으로 불림
  • 자주 쓰이는 자동 변수는 아래와 같음
    • $@: 대상 이름
    • $?: 대상보다 최신인 의존성
    • $^: 모든 의존성
    • $<: 첫 번째 의존성

암묵 규칙과 패턴 규칙

  • Make는 C 컴파일을 위한 암묵 규칙을 갖고 있어, 명시적으로 컴파일 명령을 쓰지 않아도 빌드가 진행될 수 있음
  • 대표적인 암묵 규칙은 아래 형태임
    • n.on.c에서 $(CC) -c $(CPPFLAGS) $(CFLAGS) $^ -o $@ 형태로 생성됨
    • n.on.cc 또는 n.cpp에서 $(CXX) -c $(CPPFLAGS) $(CXXFLAGS) $^ -o $@ 형태로 생성됨
    • nn.o에서 링커 명령으로 생성됨
  • 암묵 규칙에서 중요한 변수는 CC, CXX, CFLAGS, CXXFLAGS, CPPFLAGS, LDFLAGS
  • 정적 패턴 규칙은 targets...: target-pattern: prereq-patterns ... 형태로, 대상에서 %가 일치한 stem을 의존성 패턴에 대입함
  • 패턴 규칙은 %.o: %.c처럼 대상에 %를 포함하며, 직접 만든 암묵 규칙처럼 사용할 수 있음
  • 이중 콜론 규칙 ::은 같은 대상에 여러 규칙을 정의할 수 있지만 드물게 쓰임

명령 실행과 오류 처리

  • 명령 앞에 @를 붙이면 해당 명령 자체가 출력되지 않음
  • make -s를 실행하면 각 줄 앞에 @를 붙인 것처럼 동작함
  • 각 명령 줄은 새 셸에서 실행되는 효과를 가지므로, 한 줄의 cd ..는 다음 줄에 영향을 주지 않음
  • 같은 셸 상태를 유지하려면 cd ..; echo \pwd``처럼 한 줄에 쓰거나 백슬래시로 이어야 함
  • 기본 셸은 /bin/sh이며, SHELL=/bin/bash처럼 바꿀 수 있음
  • 셸 변수의 $를 Makefile 안에서 쓰려면 $$를 사용함
  • 오류 처리는 실행 방식에 따라 달라짐
    • make -k: 오류가 있어도 가능한 다른 작업을 계속 실행함
    • 명령 앞의 -: 해당 명령 오류를 무시함
    • make -i: 모든 명령에 오류 무시를 적용함
  • ctrl+c로 Make를 중단하면 Make가 방금 만든 최신 대상을 삭제함

재귀 make와 환경 변수

  • Makefile 안에서 다시 make를 호출할 때는 make 대신 특수 변수 $(MAKE)를 사용함
  • $(MAKE)는 make 플래그를 전달하며, 플래그의 영향을 적절히 처리함
  • Make가 시작되면 실행 당시 설정된 환경 변수를 Make 변수로 자동 생성함
  • export 지시어는 변수를 모든 recipe의 셸 명령 환경에 설정함
  • 하위 make에서도 변수를 쓰려면 해당 변수를 export해야 함
  • .EXPORT_ALL_VARIABLES를 사용하면 모든 변수를 export할 수 있음
  • make clean run test처럼 여러 대상을 한 번에 지정하면 clean, run, test 순서로 실행됨

조건문과 함수

  • 조건문은 ifeq, ifneq, ifdef, ifndef, else, endif로 구성됨
  • ifdef는 변수 참조를 확장하지 않고, 변수가 정의되어 있는지만 확인함
  • $(MAKEFLAGS)findstring을 사용해 make -i 같은 플래그 전달 여부를 검사할 수 있음
  • Make 함수는 주로 텍스트 처리에 사용되며, $(fn, arguments) 또는 ${fn, arguments} 형태로 호출함
  • 주요 함수는 아래와 같음
    • subst: 문자열 치환
    • patsubst: 패턴에 맞는 단어 치환
    • foreach: 공백으로 구분된 단어 목록 변환
    • if: 첫 번째 인자가 비어 있지 않으면 두 번째 인자, 아니면 세 번째 인자 사용
    • call: 변수를 기본 함수처럼 호출하고 $(1), $(2) 같은 매개변수를 사용
    • shell: 셸을 호출하지만 개행을 공백으로 바꿈
    • filter: 목록에서 특정 패턴에 맞는 요소 선택
    • filter-out: 특정 패턴에 맞지 않는 요소 선택

include, vpath, .PHONY, .DELETE_ON_ERROR

  • include filenames...는 하나 이상의 다른 Makefile을 읽도록 함
  • includegcc-M 같은 옵션으로 생성한 Makefile을 읽을 때 유용함
  • vpath <pattern> <directories>는 특정 의존성 파일을 찾을 디렉터리를 지정함
  • VPATH 변수로 비슷한 검색 경로 동작을 전역적으로 지정할 수 있음
  • 백슬래시 \는 긴 명령을 여러 줄에 나눠 쓰는 데 사용됨
  • .PHONY를 대상에 추가하면 Make가 해당 대상을 같은 이름의 파일과 혼동하지 않음
  • .DELETE_ON_ERROR는 규칙 실행 중 명령이 0이 아닌 종료 상태를 반환하면 해당 규칙의 대상을 삭제함
  • .DELETE_ON_ERRORPHONY처럼 바로 앞 대상에만 적용되는 것이 아니라 모든 대상에 적용됨

Makefile Cookbook 예제

  • Cookbook 예제는 중간 규모 프로젝트에 맞는 Makefile 템플릿을 제공함
  • src/ 폴더에 C/C++ 파일을 넣으면 Makefile이 컴파일 대상과 의존성을 자동으로 결정함
  • 주요 변수 구성은 아래와 같음
    • TARGET_EXEC := final_program
    • BUILD_DIR := ./build
    • SRC_DIRS := ./src
  • SRCSfind$(shell ...)을 사용해 *.cpp, *.c, *.s 파일을 찾음
  • OBJS는 각 소스 파일 앞에 BUILD_DIR을 붙이고 .o를 추가해 빌드 산출물 경로를 만듦
  • DEPS.o.d로 바꿔 의존성 Makefile 목록을 만듦
  • INC_DIRSsrc/ 아래 모든 디렉터리를 찾고, INC_FLAGS는 각 디렉터리에 -I 접두사를 붙임
  • CPPFLAGS := $(INC_FLAGS) -MMD -MP는 GCC가 .d 의존성 파일을 만들도록 구성함
  • C와 C++ 소스는 각각 별도 패턴 규칙으로 build/ 아래에 .o 파일을 만들며, 필요한 디렉터리는 mkdir -p $(dir $@)로 생성함
  • clean.PHONY로 선언되고 rm -r $(BUILD_DIR)로 빌드 디렉터리를 삭제함
  • -include $(DEPS).d 파일을 포함하되, 처음 빌드처럼 파일이 없을 때 발생하는 오류를 억제함

댓글과 토론

Hacker News 의견들
  • 잘 알려지지 않았지만 유용한 make 플래그가 몇 가지 있음. 출력 동기화는 타깃이 끝난 뒤에야 stdout/stderr를 출력하게 해서, 병렬 빌드 때 로그가 뒤섞여 읽기 어려워지는 문제를 줄여줌
    make --output-sync=recurse -j10
    바쁜 다중 사용자 시스템에서는 -j 작업 수만으로는 부족할 수 있어서, 부하 평균 기준으로 병렬성을 제한할 수도 있음: make -j10 --load-average=10
    타깃 스케줄 순서를 무작위화하는 --shuffle 도 CI에서 Makefile의 누락된 의존성을 잡는 데 유용함: make --shuffle # or --shuffle=seed/reverse

    • “잘 알려지지 않았다”면 make 작성자들이 옵션 목록을 어딘가에 정리해서 프로그램과 함께 배포하면 좋겠음. 사용자가 읽을 수 있는 텍스트 파일이나 조판 언어 같은 형태면 지식 접근성이 훨씬 좋아질 듯함
    • 가장 자주 쓰는 건 -B 로, 조건 없이 전체를 다시 빌드하게 만드는 옵션임
    • make -j가 머신을 몇 번씩 DoS 상태로 만드는 걸 봐서, 거의 버그처럼 여김
    • 바쁜 다중 사용자 시스템이라면 운영체제 스케줄러가 처리할 수 없는 건가 싶음
    • 유용하긴 해도 이식 가능하지 않음. 배포하지 않는 개인 장난감 프로젝트 밖에서는 쓰지 않는 게 좋음
  • 1985년쯤 Boston University Graphics lab에서 Makefile로 애니메이션용 3D 렌더러 생성을 구동하던 사람을 봤음. Lisp 쪽 사람이었고 초기 절차적 생성과 3D 배우 시스템을 다루고 있었는데, Makefile은 총 10줄 정도로 아주 우아했음
    단순한 파일 날짜 의존성만으로 수백 개 애니메이션을 생성했고, Lisp가 각 프레임의 3D 형태를 만들면 Make가 프레임을 생성했음
    1985년이라 지금 3D와 애니메이션에서 당연하게 여기는 것들이 거의 없던 시절이라 모두를 놀라게 했고, 이후 Iron Giant의 3D 렌더러를 썼으며 Coraline에서도 핵심 역할을 했던 것으로 기억함. Brian Gardner였음

  • Make는 경력 초기에 배워서 정말 다행이라고 느끼는 도구 중 하나임. 지금은 많이 쓰지 않지만, 명령형 시스템보다 선언형 시스템이 가진 힘을 보여줬기 때문임
    이 아이디어가 다른 작업에도 자연스럽게 확장된다는 걸 깨달았음. 이 사이트 상단 그림을 보면 작성자도 비슷하게 느낀 듯한데, 전통적인 레시피처럼 스크립트식 명령 순서로 쓰는 것보다 Makefile처럼 선언적으로 보면 요리 레시피를 더 잘 이해할 수 있음
    관련 글: https://blog.gpkb.org/posts/cooking-with-make/
    레시피를 항상 Makefile처럼 읽을 수 있게 적어 부엌에 가져가는데, 이렇게 조판하거나 표시해 본 사람이 있는지 궁금함. 새 레시피를 읽을 때 스크립트를 Makefile로 머릿속에서 변환하지 않아도 되어 시간이 많이 절약될 것 같음

    • 이 접근의 좋은 점은 사용자가 사실상 의존성 그래프를 직접 해석하고 실행자가 되므로 더 많은 통제권을 갖는다는 데 있음. 요리 예시로 보면 선언형 구조가 다음에 무엇을 할지 선택지가 열려 있는 지점을 더 잘 드러내고, 설거지가 쉬운 순서처럼 Makefile에 정식으로 쓰이지 않은 외부 제약도 반영할 자유를 줌
      물론 대가는 의존성 그래프를 직접 풀어야 한다는 것임. 그냥 미리 직렬화된 순차 단계를 따라가고 싶을 때는 더 많은 일을 떠안게 됨
  • 글에서는 대부분 레시피를 .PHONY로 표시하지 않는다고 하며, 튜토리얼에서 굳이 다루지 않는 이유처럼 쓰는데 그건 약한 변명임. 도구는 올바른 사용법을 가르쳐야 함
    팀에서는 make를 작업 실행기로 쓰기 때문에 모든 레시피에 .PHONY를 추가하고 유지하는 걸 두고 한소리 들었음
    Clark Grubb의 Makefile 스타일 가이드가 좋음: https://clarkgrubb.com/makefile-style-guide
    이 스타일 가이드를 쓰는 사람이 있는지 궁금함. phony 레시피는 선언 지점에서 표시하는 편인지, 파일 상단에 거대한 목록으로 두는 편인지도 궁금함. 이를 강제하는 린터가 있으면 좋겠음

    • 읽어봤는데 전반적으로 좋은 문서임. 다만 몇 가지는 동의하지 않음
      -o pipefail을 관성적으로 쓰는 건 별로임. Pipefail은 쓸모가 있지만, 파이프라인에서 사람들이 가장 흔히 하는 작업인 grep으로 출력 필터링을 깨뜨림. 필요한 레시피에만 개별적으로 넣는 편이 낫다
      파일이 아닌 타깃을 .PHONY로 표시하는 건 엄밀히 맞지만 대개 필요하지 않음. 타깃이 많으면 Makefile에 불필요한 장황함을 더하므로, 필요할 때만 추가하는 편이 낫다고 봄
      여러 출력 파일을 만드는 레시피에서는 예전엔 패턴 규칙이 맞지 않을 때 더미 파일/플래그 파일이 표준이었지만, GNU Make 4.3부터는 그룹 타깃을 네이티브로 지원함. Ubuntu 22.04 LTS에도 들어 있음: https://www.gnu.org/software/make/manual/html_node/Multiple-...
  • 최근 흥미로운 점은 CMake가 C++20 모듈을 쓰는 프로젝트에는 Makefile이 부적합하고 Ninja가 맞다고 판단했다는 것임. https://cmake.org/cmake/help/latest/manual/cmake-cxxmodules....
    기본적으로 타깃 의존성을 정적으로 정의하기가 너무 어렵거나 불가능하다고 보는 듯함. 이제는 clang-scan-deps 같은 도구로 동적으로 처리함: https://llvm.org/devmtg/2019-04/slides/TechTalk-Lorenz-clang...

    • 틀렸다면 정정해주길 바라지만, 이 Makefile 관련 제한은 전적으로 CMake의 선택이거나 적어도 Makefile 생성기에 지원을 추가할 자원봉사자가 부족했던 결과로 보임. Ninja 자체도 C++ 모듈을 지원하지 않음: https://github.com/ninja-build/ninja/issues/2457
      사실 Ninja는 모든 의존성을 정적으로 정의해야 하므로, 이 점에서는 일반 Make보다 기능이 더 적음
    • 솔직히 모듈은 재앙에 가까움
  • Make는 대형 C 코드베이스의 빌드 도구로는 제자리가 있음. 하지만 종종 범용 “프로젝트별 작업 실행기”처럼 다루는데, 거기에는 잘 맞지 않음. 단순한 조건문도 어렵기 때문임
    예를 들어 Terraform을 Make로 감싸려는 선의의 시도를 여러 번 봤지만, 끝이 좋지 않았음

    • Make는 범용 작업 실행기가 아님. 선형 셸 스크립트를 선언적 의존성으로 바꾸는 범용 방법에 가까움. 셸을 위한 일반 도구임
    • 대형 C 코드베이스용 빌드 도구라는 말도 이제는 맞지 않다고 봄. 지난 20년 동안 더 견고하고 잘 정의된 빌드 시스템들이 만들어졌으니 업데이트할 때가 됨
    • 좋은 범용 작업 실행기가 있나? 수정: “작업 실행기”라는 말을 완전히 오해한 것 같음
  • Makefile이 지저분해지는 부분을 대체하는 훌륭한 현대적 도구로 just가 있음: https://github.com/casey/just

    • Make의 “짧은 셸 스크립트 목록” 측면은 대체하지만, 실제로 유용한 부분인 “다시 실행해야 하는 규칙만 실행”하는 기능은 대체하지 않음
    • 다른 선택지도 있음: Task(Go) https://github.com/go-task/task, Cake(C#) https://github.com/cake-build/cake, Rake(Ruby) https://github.com/ruby/rake
      완전히 다른 개념으로는 8개월 전 HN에서 다뤘던 Makedown도 있음: https://news.ycombinator.com/item?id=41825344
    • 이 도구들이 스스로를 make의 대안으로 자리매김하긴 하지만, 개인적으로는 완전히 다르고 비교 대상도 아니라고 봄. make는 산출물 생성과 이미 만들어진 것을 다시 빌드하지 않는 데 중심이 있고, just는 명령 실행기임
    • Make를 명령 실행기로 쓸 때의 주된 장점은 “어디에나” 설치된 표준 도구라는 점임. 대체 도구들이 더 쓰기 좋아 보여도, 추가 도구 설치를 정당화할 만큼 큰 이점을 준다고 느껴본 적은 없음
    • Task도 또 다른 대안임. 다만 개인적으로는 단순한 C 취미 프로젝트에만 써봐서, 규모가 커져도 잘 버티는지는 말하기 어려움: https://taskfile.dev/
  • Makefile은 훌륭하지만 너무 몰입하지 않는 게 좋음. 몇 년 전 순수 GNU Make 프레임워크를 만들려다가 사실상 autoconf를 다시 발명하고 있다는 걸 깨달았고, 그때서야 GNU autotools가 왜 만들어졌는지 이해했음
    Makefile은 묘하게 Lisp 같은 튜링 타핏임. GNU Make에는 메타프로그래밍 기능까지 있어서, Makefile 안에 불경한 시스템을 메타프로그래밍하고 싶은 충동을 참기 어렵다. GNU Make가 워낙 널리 깔려 있다는 점도 유혹을 키움

  • Make의 대안인 Task를 만든 사람이자 유지보수자 중 한 명임. 8년 넘게 존재해 왔고 계속 발전 중이니, 새로운 걸 찾고 있다면 한번 써보면 좋겠음. 질문도 편하게 해도 됨
    https://taskfile.dev/
    https://github.com/go-task/task

  • tup을 써본 사람이 있는지 궁금함: https://gittup.org/tup/ex_dependencies.html
    파일 시스템 접근을 바탕으로 의존성을 자동 판별하는 빌드 시스템이라, 어떤 컴파일러나 도구와도 함께 쓸 수 있음