
Makefile을 쓸 필요는 없다. 어떤 용도든 더 나은 도구가 있다.
Makefile은 오래된 기술이다. 관성에 기대어 살아남았을 뿐이며, 문법은 불투명하고 오류 메시지는 도움이 되지 않는다. 이제 다른 도구로 옮길 때다.
현재 Makefile이 쓰이는 용도는 크게 두 가지라고 본다. C와 C++ 코드를 빌드하거나, 서로 의존하는 임의의 작업을 실행하는 일이다. 하지만 이제 Makefile은 어느 쪽에도 최선의 선택이 아니다.
1976년 벨 연구소에서 기존 셸 스크립트의 불편함을 해소하기 위해 make가 만들어졌다. make는 빌드 시스템의 시초였고, 빌드 그래프 같은 개념은 현대 빌드 시스템의 기반으로 남아 있다. 다만 이후 빌드 시스템의 사용 편의성은 크게 개선됐다.
먼저 당시 방식처럼 프로그램을 빌드하는 셸 스크립트를 작성해 보자.
#!/bin/sh
CFLAGS="-Wall"
# Compile the individual source files to object files
cc $CFLAGS -c -o main.o main.c
cc $CFLAGS -c -o lib.o lib.c
# Link the object files into an executable
cc -o app main.o lib.o
소스 파일을 빌드하는 cc 명령을 빠뜨리면, 직접 컴파일해 둔 오래된 파일을 링크하게 된다.
현대적인 bash 기능을 쓰면 스크립트를 상당히 개선할 수 있다.
#!/bin/bash
CFLAGS="-Wall"
SRCS = "main.c lib.c"
OBJS = ${SRCS/.c/.o}
for file in $SRCS; do
cc $CFLAGS -c -o ${file/.c/.o} $file
done
cc -o app $OBJS
bash 변수 확장으로 .c를 .o로 바꾸면 컴파일 명령과 링커 명령에서 같은 소스 목록을 재사용할 수 있다. 작은 예제에는 쓸 수 있지만, 변경 여부와 관계없이 실행할 때마다 모든 소스를 다시 빌드하므로 규모가 커지면 적합하지 않다.
이 문제를 해결하려고 Makefile이 등장했다.
CFLAGS = -Wall
SRCS = main.c lib.c
OBJS = $(SRCS:.c=.o)
app: $(OBJS)
$(CC) -o $@ $(OBJS)
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
스크립트와 Makefile의 차이는 무엇을 기술하는가에 있다. 스크립트는 최종 실행 파일을 만들기 위해 해야 할 단계를 순서대로 적고 매번 모두 실행하는 명령형 절차다. Makefile은 입력과 출력 사이의 의존성을 기술한다. 입력이 바뀌지 않았다면 불필요한 단계를 건너뛸 수 있다.
그 대가로 문법이 복잡해진다. 스크립트는 비교적 읽기 쉽지만 Makefile에는 마법 같은 기호가 가득하다. $@와 $<는 무엇일까? 익숙한 사람에게는 당연하겠지만, 나는 매번 구글을 찾아보거나, 기억이 나면 지식 저장소인 Obsidian을 뒤져야 한다.
같은 내용을 CMake로 쓰면 다음과 같다.
cmake_minimum_required(VERSION 3.10)
project(app C)
add_executable(app main.c lib.c)
또는 meson.build 파일로 쓸 수 있다.
project('simple', 'c')
executable('myexe', ['main.c', 'lib.c'])
CMake나 meson을 몰라도 이 스크립트가 무엇을 하려는지는 분명하다. 실제 실행 파일이나 라이브러리를 어떻게 빌드하는지 숨긴다는 아쉬움은 있지만, 의도는 쉽게 파악할 수 있다.
Make의 근본적인 문제는 빌드 그래프의 노드가 아니라 간선을 기술한다는 점이다. 한 파일을 다른 파일로 바꾸는 작업은 설명하지만, 그 변환으로 무엇을 만들려는지는 설명하지 않는다.
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
파일을 빌드하려면 %.c 노드와 %.o 노드가 있고, %.c에 $(CC) $(CFLAGS) -c -o $@ $<를 적용해 %.c에서 %.o로 간다고 설명하는 셈이다. 하지만 무엇을 이루려는지는 어느 부분에도 나타나지 않는다.
CMake에서는 실행 파일을 만들 때 add_executable(app main.c lib.c)라고 쓴다. 변환 과정이 아니라 최종 목표를 기술한다. 그렇다고 CMake가 완벽한 빌드 시스템이라는 뜻은 아니다. 다만 적어도 따라가기는 더 쉽다.
변환이 아니라 의도를 기술하는 빌드 시스템에는 Makefile 대부분이 갖추지 못한 특성이 있다. 병렬로 실행해도 안정적으로 작동한다는 점이다. 내가 겪은 Makefile 대부분은 병렬 실행에서 실패했다. 안정적으로 빌드하려면 스크립트가 다음처럼 되기도 한다.
make -j$(nproc) all || make -j$(nproc) all || make -j$(nproc) all
분명 사용자의 숙련도 문제일 수도 있다. 하지만 평범한 회사에서 일하는 보통의 C 프로그래머가 쓰기에는 너무 복잡한 도구라면 더는 좋은 도구가 아니다.
Make를 좋아하는 사람들은 새로 온 사람에게 자신의 Makefile이 얼마나 훌륭한지 말하곤 한다. 직장에서 마지못해 쓰는 형편없는 빌드 시스템보다 늘 낫다는 식이다.
하지만 내 경험이 대표적이라면, 그런 Makefile도 앞서 말한 ‘노드가 아닌 간선을 기술하는’ 문제를 여전히 안고 있다. 더 큰 문제는 다른 빌드 시스템만큼 복잡해졌는데도, 자체 빌드 시스템을 다른 사람에게 설명하는 데 필요한 문서는 하나도 만들지 않았다는 점이다.
새 라이브러리 대상을 추가하거나 코드 생성, Doxygen, 포매터, 린터 같은 사용자 지정 대상을 넣으려면 어떻게 해야 할까? 결국 작성자와 통화해 설명을 들어야 한다. 그 뒤에도 빌드를 믿지 못해 종종 rm -rf build/를 실행하게 된다. 병렬 실행도 제대로 되지 않아 컴파일 시간은 고통스러울 만큼 길어진다.
의존성 그래프라는 개념은 코드 컴파일뿐 아니라 여러 문제에 적용할 수 있다. Makefile에서 publish-docker 규칙이 build-docker 규칙에 의존한다고 표현하는 것도 자연스럽다. 출력이 실제 파일이 아님을 Make에 알리려고 .PHONY를 붙여야 하지만, 작동은 한다.
.PHONY: publish-docker
publish-docker: build-docker
docker image push my-image:latest
.PHONY: build-docker
build-docker:
docker image build -t my-image docker/my-image
들여쓰기에 공백 대신 탭을 쓰도록 편집기 설정을 바꾸는 걸 잊어 두어 번 시도했을 수 있지만, 그래도 작동한다.
그런데 두 번째 이미지를 빌드하려면 규칙을 복사해야 한다. Makefile을 쓰는 이유가 이런 중복을 피하기 위해서였는데, 복사하자니 찜찜하다.
.PHONY: publish-docker-foo
publish-docker-foo: build-docker-foo
docker image push my-foo-image:latest
.PHONY: build-docker-foo
build-docker-foo:
docker image build -t my-foo-image docker/my-foo-image
.PHONY: publish-docker-bar
publish-docker-bar: build-docker-bar
docker image push my-bar-image:latest
.PHONY: build-docker-bar
build-docker-bar:
docker image build -t my-bar-image docker/my-bar-image
규칙을 복사하는 대신 재사용하면서 이미지 이름을 매개변수로 넘기고 싶지만, 이제 막힌다. Make에 능숙한 사람이라면 마법 같은 명령으로 규칙을 생성하겠지만, 그런 방식은 얼마나 유지보수하기 쉬울까?
Make의 한계에 도달한 것이다. Make는 이런 용도로 만들어지지 않았다. 인자가 있는 작업을 다루는 실제 작업 실행기가 필요하다. 내가 주로 쓰는 도구는 just다. 같은 내용을 justfile로 쓰면 다음과 같다.
build-docker-foo: (build-docker "my-foo-image")
publish-docker-foo: (publish-docker "my-foo-image")
build-docker-bar: (build-docker "my-bar-image")
publish-docker-bar: (publish-docker "my-bar-image")
publish-docker {{ IMAGE_NAME }}:
docker image push {{ IMAGE_NAME }}
build-docker IMAGE_NAME:
docker image build -t {{ IMAGE_NAME }} docker/{{ IMAGE_NAME }}
이쪽이 낫지 않은가? just는 기본으로 --list를 통해 규칙 목록과 각 규칙의 문서 주석을 보여준다. 출력에서 규칙을 그룹으로 묶을 수 있고, 재귀 Makefile 방식 없이 다른 justfile을 가져올 수도 있다. 규칙 본문을 스크립트로 실행할 수 있어 각 줄 끝에 \를 붙일 필요도 없다.
그냥 더 나은 도구다. 취향에 더 잘 맞는 다른 도구도 많다. 이런 작업에 Make를 쓰지만 않으면 된다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요