Makefile과 빌드
Node.js 프로젝트에서 package.json의 scripts는 빌드, 테스트, 린팅 같은 반복 작업을 명령 하나로 실행하게 해준다. Go 프로젝트에서 이 역할을 하는 것이 Makefile이다. Go는 번들러도, 트랜스파일러도 필요 없다. go build 하나면 바이너리가 나온다. 하지만 빌드 옵션, 테스트, 린팅, 크로스 컴파일을 매번 타이핑하는 것은 비효율적이다. Makefile이 이를 해결한다.
왜 Makefile인가
Node.js에서는 npm run build, npm test, npm run lint 등을 package.json에 정의한다:
{
"scripts": {
"build": "esbuild src/index.ts --bundle --outdir=dist",
"test": "jest",
"lint": "eslint .",
"fmt": "prettier --write ."
}
}
Go 프로젝트에는 package.json이 없다. go.mod는 의존성만 관리하고 스크립트 기능이 없다. 대신 Makefile을 쓴다:
build:
go build -o bin/server ./cmd/server
test:
go test ./...
lint:
golangci-lint run
fmt:
gofmt -w .
make build, make test, make lint. Node.js의 npm run과 같은 역할이다.
Makefile은 Go 전용 도구가 아니다. C/C++ 시대부터 존재한 범용 빌드 도구다. Go 커뮤니티가 Makefile을 선호하는 이유는 단순하다. 의존성 없이 대부분의 시스템에 이미 설치되어 있고, 셸 명령을 그대로 쓸 수 있으며, 파일 의존성 기반 증분 빌드를 지원한다.
Makefile 기본 문법
Makefile은 target, dependency, recipe 세 요소로 구성된다:
target: dependency1 dependency2
recipe command
- target -- 만들려는 것의 이름. 파일명이거나 추상적인 작업명이다.
- dependency -- target을 만들기 전에 먼저 실행해야 하는 다른 target.
- recipe -- 실행할 셸 명령. 반드시 탭 문자로 들여쓰기해야 한다. 스페이스는 안 된다.
탭 문자 규칙은 Makefile 초심자가 가장 많이 겪는 함정이다. 에디터가 탭을 스페이스로 변환하면 make가 실패한다.
파일을 생성하지 않는 target은 .PHONY로 선언한다:
.PHONY: build test lint fmt clean
build:
go build -o bin/server ./cmd/server
clean:
rm -rf bin/
.PHONY가 없으면 build라는 파일이 디렉토리에 존재할 때 make build가 "이미 최신"이라며 아무것도 하지 않는다. Go 프로젝트에서 대부분의 target은 파일이 아닌 작업이므로 .PHONY를 습관적으로 선언한다.
변수를 정의하고 참조할 수 있다:
BINARY=bin/server
CMD=./cmd/server
build:
go build -o $(BINARY) $(CMD)
실전 Makefile 구성
Go 프로젝트에서 자주 사용하는 target을 모아보면:
.PHONY: build test lint fmt run clean vet
BINARY := bin/server
CMD := ./cmd/server
build:
go build -o $(BINARY) $(CMD)
run: build
./$(BINARY)
test:
go test -v -race ./...
vet:
go vet ./...
lint: vet
golangci-lint run
fmt:
gofmt -w .
goimports -w .
clean:
rm -rf bin/
go clean -cache
run은 build를 dependency로 가진다. make run을 실행하면 먼저 build가 실행되고, 성공하면 바이너리를 실행한다. lint는 vet에 의존하므로 make lint를 실행하면 go vet이 먼저 돌고, 이어서 golangci-lint가 실행된다.
Node.js 대응 관계:
| Makefile target | npm script 대응 | 설명 |
|---|---|---|
make build | npm run build | 빌드 |
make run | npm start | 실행 |
make test | npm test | 테스트 |
make lint | npm run lint | 린팅 |
make fmt | npm run fmt | 포매팅 |
make clean | rm -rf dist/ | 빌드 산출물 삭제 |
핵심적인 차이가 하나 있다. Node.js의 npm run build는 webpack, esbuild, tsc 같은 번들러나 트랜스파일러를 호출한다. 수백 개의 소스 파일을 하나의 번들로 합치고, TypeScript를 JavaScript로 변환하고, tree shaking으로 불필요한 코드를 제거한다. Go의 make build는 go build 하나를 호출한다. 번들러가 필요 없다. 컴파일러가 의존성 분석, 데드코드 제거, 단일 바이너리 생성을 모두 처리한다.
go build 옵션
go build의 주요 옵션:
# 출력 파일명 지정
go build -o bin/myapp ./cmd/server
# 모든 패키지 빌드 (바이너리 생성 없이 컴파일만 확인)
go build ./...
# 경쟁 상태 감지기 포함 빌드
go build -race -o bin/myapp ./cmd/server
# 캐시 무시하고 전체 재빌드
go build -a ./cmd/server
# 빌드 과정 상세 출력
go build -v ./cmd/server
# 컴파일러/링커 명령 확인
go build -x ./cmd/server
-race 플래그는 13편에서 다룬 경쟁 상태 감지기를 활성화한다. 개발/테스트 빌드에서 사용하고, 프로덕션 빌드에서는 성능 오버헤드 때문에 제외하는 것이 일반적이다.
크로스 컴파일
Node.js 애플리케이션은 Node.js 런타임이 설치된 곳이면 어디서든 실행된다. 플랫폼별 빌드라는 개념 자체가 거의 없다. Go는 네이티브 바이너리를 생성하므로 대상 OS와 아키텍처에 맞게 빌드해야 한다.
Go의 크로스 컴파일은 환경변수 두 개면 된다:
# Linux AMD64용 빌드 (macOS에서 실행)
GOOS=linux GOARCH=amd64 go build -o bin/server-linux ./cmd/server
# Windows용 빌드
GOOS=windows GOARCH=amd64 go build -o bin/server.exe ./cmd/server
# Linux ARM64용 빌드 (AWS Graviton, Raspberry Pi 등)
GOOS=linux GOARCH=arm64 go build -o bin/server-arm64 ./cmd/server
별도의 크로스 컴파일 툴체인 설치가 필요 없다. Go 컴파일러 자체가 모든 대상 플랫폼의 코드를 생성할 수 있다. C/C++에서 크로스 컴파일 환경을 구축하는 고통과 비교하면 극적으로 간단하다.
Makefile에 크로스 컴파일 target을 추가하면:
.PHONY: build-all
PLATFORMS := linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
build-all:
@for platform in $(PLATFORMS); do \
os=$${platform%/*}; \
arch=$${platform#*/}; \
output=bin/server-$${os}-$${arch}; \
echo "Building $$output"; \
GOOS=$$os GOARCH=$$arch go build -o $$output ./cmd/server; \
done
make build-all로 네 개의 플랫폼에 대한 바이너리를 한 번에 생성한다.
지원하는 GOOS/GOARCH 조합 목록은 다음 명령으로 확인한다:
go tool dist list
ldflags — 빌드 시 변수 주입
Node.js에서는 환경변수로 애플리케이션에 설정값을 전달한다. process.env.VERSION 같은 식이다. Go에서는 빌드 시점에 변수 값을 바이너리에 직접 주입할 수 있다. -ldflags(linker flags)를 사용한다:
// main.go
package main
import "fmt"
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
func main() {
fmt.Printf("version: %s\ncommit: %s\ndate: %s\n", version, commit, date)
}
go build -ldflags "-X main.version=1.2.3 -X main.commit=abc123 -X main.date=2026-03-15" -o bin/server ./cmd/server
-X 플래그는 패키지경로.변수명=값 형태로 문자열 변수의 값을 덮어쓴다. 빌드 시점에 결정되어 바이너리에 포함되므로 런타임에 환경변수를 읽는 것과 달리 변경할 수 없다.
Makefile에서 git 정보를 자동으로 주입하는 패턴:
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
build:
go build -ldflags "$(LDFLAGS)" -o bin/server ./cmd/server
make build를 실행하면 바이너리에 현재 git tag, commit hash, 빌드 시각이 포함된다. CI/CD 파이프라인에서 특히 유용하다. 배포된 바이너리가 어떤 커밋에서 빌드되었는지 바이너리 자체에서 확인할 수 있다.
바이너리 크기를 줄이려면 디버그 정보를 제거하는 ldflags를 추가한다:
go build -ldflags "-s -w" -o bin/server ./cmd/server
-s는 심볼 테이블을, -w는 DWARF 디버그 정보를 제거한다. 바이너리 크기가 20-30% 줄어든다. 디버깅이 필요 없는 프로덕션 배포에 적합하다.
build tag — 조건부 컴파일
특정 조건에서만 포함할 코드를 지정할 수 있다. 파일 상단에 //go:build 지시자를 추가한다:
//go:build linux
package platform
func DataDir() string {
return "/var/lib/myapp"
}
//go:build darwin
package platform
func DataDir() string {
return "/Library/Application Support/myapp"
}
GOOS=linux로 빌드하면 첫 번째 파일만, GOOS=darwin으로 빌드하면 두 번째 파일만 포함된다. 같은 함수 DataDir()이 두 파일에 정의되어 있지만, 한 번에 하나만 컴파일되므로 중복 정의 에러가 발생하지 않는다.
OS/아키텍처 외에 커스텀 tag도 사용할 수 있다:
//go:build integration
package user_test
import "testing"
func TestDatabaseIntegration(t *testing.T) {
// 실제 데이터베이스에 연결하는 느린 테스트
}
# 일반 테스트만 실행 (integration tag가 없는 파일)
go test ./...
# integration 테스트 포함
go test -tags=integration ./...
Makefile에 반영하면:
test:
go test -v -race ./...
test-integration:
go test -v -race -tags=integration ./...
make test는 빠른 단위 테스트만, make test-integration은 통합 테스트를 포함한다. CI 파이프라인에서 단계를 나눌 때 유용하다.
build tag의 논리 연산도 지원한다:
//go:build linux && amd64
//go:build !windows
//go:build integration || e2e
&&는 AND, ||는 OR, !는 NOT이다.
Node.js에서 비슷한 패턴은 환경변수에 따른 조건부 import다. if (process.env.NODE_ENV === 'test') require('./mock') 같은 식이다. 하지만 이는 런타임 분기다. 프로덕션 번들에 테스트 코드가 포함될 수 있다. Go의 build tag는 컴파일 타임에 파일을 제외하므로 바이너리에 불필요한 코드가 포함되지 않는다.
완성된 Makefile 예시
지금까지 다룬 내용을 하나의 Makefile로 종합하면:
.PHONY: build run test test-integration lint fmt vet clean build-all
BINARY := bin/server
CMD := ./cmd/server
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
build:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(CMD)
run: build
./$(BINARY)
test:
go test -v -race ./...
test-integration:
go test -v -race -tags=integration ./...
vet:
go vet ./...
lint: vet
golangci-lint run
fmt:
gofmt -w .
goimports -w .
clean:
rm -rf bin/
go clean -cache
build-all:
@for platform in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do \
os=$${platform%/*}; \
arch=$${platform#*/}; \
GOOS=$$os GOARCH=$$arch go build -ldflags "$(LDFLAGS)" \
-o bin/server-$$os-$$arch $(CMD); \
done
package.json scripts 대비 장점은 dependency 체인, 변수 치환, 셸 명령의 자유로운 조합이 가능하다는 것이다. 단점은 문법이 직관적이지 않고, 탭/스페이스 구분 같은 함정이 있다는 것이다.
webpack 설정, babel plugin 조합, TypeScript 컴파일러 옵션 -- 이런 것들이 go build 한 줄로 대체된다. Makefile은 그 단순한 명령들을 조직화하는 얇은 레이어일 뿐이다.