tools.build 가이드 — 빌드를 프로그램으로 다루기
tools.build 가이드 — 빌드를 프로그램으로 다루기
tools.build는 Clojure 프로젝트를 빌드하기 위한 함수 모음 라이브러리예요. 이 라이브러리는 빌드 프로그램(build program) 안에서 사용돼서, 사용자가 호출할 수 있는 target 함수들을 만들어 내는 용도로 쓰여요. 전용 API 문서는 clojure.github.io/tools.build에서 따로 볼 수 있어요.
출처: Clojure 공식문서
본문
빌드는 프로그램이에요
tools.build 뒤에 깔린 철학은 단순해요. 프로젝트 빌드는 본질적으로 하나의 프로그램이라는 거죠. 즉 프로젝트 소스 파일에서 하나 이상의 산출물(artifact)을 만들어 내는 일련의 지시(instructions)라고 볼 수 있어요. 그래서 이 프로그램을 우리가 가장 좋아하는 언어인 Clojure로 작성하고 싶어 해요. tools.build는 빌드에 흔히 필요한 함수들을 모아 놓은 라이브러리라서, 이 함수들을 유연하게 엮어 쓸 수 있어요.
빌드 프로그램을 작성하는 건 다른 선언적(declarative) 접근법보다 코드가 조금 더 필요하지만, 그만큼 먼 미래까지 쉽게 확장하거나 커스터마이즈할 수 있어요. 프로젝트가 자라나는 만큼 함께 자라는 빌드를 만들 수 있죠.
설정
설치 단계는 따로 없어요. tools.build는 그냥 빌드 프로그램이 사용하는 라이브러리일 뿐이거든요. deps.edn에 tools.build를 의존성으로 포함하고, 빌드 프로그램의 소스 경로를 담은 alias를 만들면 돼요.
빌드는 Clojure CLI에서 프로젝트의 "tool"로 쉽게 실행되도록 설계돼 있어요(-T 옵션). CLI에서 "tools"란 기능을 제공하는 프로그램이고, 프로젝트의 deps나 classpath를 사용하지 않아요. -T:an-alias로 실행한 tool은 프로젝트의 모든 deps와 paths를 제거하고, "."을 path로 추가한 다음, :an-alias에 정의된 다른 deps나 paths만 포함해요.
그래서 deps.edn에 빌드 classpath를 정의하고 빌드 소스 경로를 포함하는 alias를 만들어야 해요. 예를 들어 이렇게요:
{:paths ["src"] ;; 프로젝트 paths
:deps {} ;; 프로젝트 deps
:aliases
{;; clj -T:build function-in-build 로 실행
:build {:deps {io.github.clojure/tools.build {:git/tag "TAG" :git/sha "SHA"}}
:ns-default build}}}
가장 최신의 TAG와 SHA는 github.com/clojure/tools.build#release-information에서 확인할 수 있어요.
참고: 이 가이드의 git deps와 CLI 예시는 Clojure CLI 1.10.3.933 이상을 기준으로 해요.
아까 말했듯이 -T로 tool을 실행하면 classpath가 프로젝트의 :paths와 :deps를 포함하지 않게 만들어져요. -T:build를 쓰면 :build alias의 :paths와 :deps만 사용하게 되죠. 루트 deps.edn은 여전히 포함돼서 Clojure도 함께 딸려 들어와요(Clojure는 사실 tools.build의 의존성으로도 들어오지만요). 여기 :paths는 지정하지 않았으니 추가 path는 없지만, -T가 기본적으로 프로젝트 루트 "."을 path로 넣어 줘요.
그래서 clj -T:build jar를 실행하면 이런 유효 classpath가 만들어져요:
"."(-T가 추가)org.clojure/clojure(루트deps.edn의:deps에서) 그리고 전이적 depsorg.clojure/tools.build(:buildalias의:deps에서) 그리고 전이적 deps
:ns-default는 classpath에서 지정한 함수를 찾을 기본 Clojure 네임스페이스를 정해 줘요. 유일한 로컬 path가 기본값 "."이니, 빌드 프로그램은 프로젝트 루트의 build.clj에 있을 거라고 예상하면 돼요. :build alias의 :paths를 통한 path 루트와, 그 path 루트에서 상대적인 빌드 프로그램의 네임스페이스는 전적으로 우리가 통제할 수 있어요. 프로젝트의 하위 디렉터리에 두고 싶다면 그렇게 해도 되죠.
그리고 마지막으로, 커맨드라인에서 빌드에서 실행할 함수를 지정해요. 여기서는 jar죠. 이 함수는 build 네임스페이스에서 실행되고, -X와 같은 인자 전달 방식으로 만들어진 맵을 받아요. 즉 인자가 키-값 쌍을 번갈아 나열하는 방식으로 주어지죠.
이 가이드의 나머지 부분에서는 흔한 사용 사례 하나하나를 보여 주고, tools.build 프로그램으로 어떻게 해결하는지 살펴볼게요.
소스 라이브러리 jar 빌드
가장 흔한 Clojure 빌드는 Clojure 소스 코드를 담은 jar 파일을 만드는 거예요. tools.build로 이를 하려면 다음 task들을 사용할 거예요:
create-basis— 프로젝트 basis를 만들기 위해 (참고: 부수 효과로 deps를 내려받아요)copy-dir— Clojure 소스와 리소스를 작업 디렉터리로 복사write-pom— 작업 디렉터리에 pom 파일을 작성jar— 작업 디렉터리를 jar 파일로 묶기
build.clj는 이렇게 생겼어요:
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'my/lib1)
(def version "0.1.0") ;; 또는 파일에서 읽어 오는 등
(def class-dir "target/classes")
(def jar-file (format "target/%s-%s.jar" (name lib) version))
;; delay로 부수 효과(artifact 다운로드)를 늦춤
(def basis (delay (b/create-basis {:project "deps.edn"})))
(defn clean [_]
(b/delete {:path "target"}))
(defn jar [_]
(b/write-pom {:class-dir class-dir
:lib lib
:version version
:basis @basis
:src-dirs ["src"]})
(b/copy-dir {:src-dirs ["src" "resources"]
:target-dir class-dir})
(b/jar {:class-dir class-dir
:jar-file jar-file}))
여기서 눈여겨볼 점이 몇 가지 있어요:
- 이건 그냥 평범한 Clojure 코드예요. 에디터에서 이 네임스페이스를 로드해서 REPL에서 대화형으로 개발할 수 있어요.
- 단일 목적 프로그램이라서, 위쪽 var들에 공유 데이터를 모아 두는 것도 좋아요.
- 우리는 "target" 디렉터리에 빌드하고 "target/classes"에 jar 내용물을 모으기로 선택했는데, 이 경로들에는 아무 특별한 점이 없어요 — 전적으로 우리가 통제할 수 있죠. 여기서 이 경로들과 다른 것들을 여러 곳에 반복했지만, 필요하다고 느껴지는 만큼 중복을 제거해도 돼요.
- tools.build의 task 함수들을 조합해서
build/jar같은 큰 함수를 만들어 사용자가 호출하게 했어요. 이 함수들은 파라미터 맵을 받는데, 여기서는 설정 가능한 파라미터를 제공하지 않기로 했지만, 물론 넣을 수도 있어요.
deps.edn 파일은 이렇게 생겼어요:
{:paths ["src"]
:aliases
{:build {:deps {io.github.clojure/tools.build {:git/tag "TAG" :git/sha "SHA"}}
:ns-default build}}}
그리고 이 빌드는 이렇게 실행할 수 있어요:
clj -T:build clean
clj -T:build jar
이 둘을 커맨드라인에서 한 번에 함께 실행할 수 있길 기대하고 있지만, 그건 아직 진행 중인 작업이에요.
컴파일된 uberjar 애플리케이션 빌드
애플리케이션을 만들 때는 전체 앱과 라이브러리를 컴파일해서 전부 단일 uberjar로 묶는 게 흔해요.
메인 Clojure 네임스페이스에 (:gen-class)가 있어야 한다는 점이 중요해요. 예를 들어:
(ns my.lib.main
;; 필요한 :require 와/또는 :import 절들
(:gen-class))
그리고 그 네임스페이스에는 이런 함수가 있어야 해요:
(defn -main [& args]
(do-stuff))
컴파일된 uberjar를 위한 예시 빌드는 이렇게 생겼어요:
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'my/lib1)
(def version "0.1.0") ;; 또는 파일에서 읽어 오는 등
(def class-dir "target/classes")
(def uber-file (format "target/%s-%s-standalone.jar" (name lib) version))
;; delay로 부수 효과(artifact 다운로드)를 늦춤
(def basis (delay (b/create-basis {:project "deps.edn"})))
(defn clean [_]
(b/delete {:path "target"}))
(defn uber [_]
(clean nil)
(b/copy-dir {:src-dirs ["src" "resources"]
:target-dir class-dir})
(b/compile-clj {:basis @basis
:ns-compile '[my.lib.main]
:class-dir class-dir})
(b/uber {:class-dir class-dir
:uber-file uber-file
:basis @basis
:main 'my.lib.main}))
이 예시는 compile-clj가 메인 네임스페이스를 컴파일하도록 지시해요(기본적으로 소스는 basis의 :paths에서 로드돼요). 컴파일은 전이적이라서, 컴파일되는 네임스페이스가 로드하는 모든 네임스페이스도 함께 컴파일돼요. 코드가 동적으로 또는 선택적으로 로드된다면 네임스페이스를 추가로 지정해야 할 수도 있어요.
deps.edn과 빌드 실행은 앞선 예시와 똑같아요.
uber jar 빌드는 이렇게 만들 수 있어요:
clj -T:build uber
이 빌드의 출력은 target/lib1-0.1.0-standalone.jar 위치의 uberjar예요. 이 jar에는 이 프로젝트의 컴파일된 버전과 모든 의존성이 함께 들어 있어요. uberjar의 매니페스트는 my.lib.main 네임스페이스(이 네임스페이스에는 -main 메서드가 있어야 해요)를 가리키고, 이렇게 호출할 수 있어요:
java -jar target/lib1-0.1.0-standalone.jar
파라미터화된 빌드
위의 빌드들에서는 빌드의 어떤 측면도 파라미터화하지 않고, 어떤 함수를 호출할지만 골랐어요. 그런데 dev/qa/prod 구분이나 버전, 또는 다른 요인을 위해 빌드를 파라미터화하는 게 유용할 때가 있어요. 커맨드라인에서 함수를 이어서 호출하는 걸 고려하면, 빌드 함수들 사이에 공통 파라미터 집합을 정해 두고 각 함수가 그 파라미터를 계속 넘겨주는 게 좋아요.
예를 들어, 로컬 개발 환경을 설정하기 위한 추가 dev 리소스 집합을 포함하는 파라미터화를 생각해 볼게요. 이를 나타내기 위해 간단한 :env :dev 키-값 쌍을 쓸 거예요:
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'my/lib1)
(def version "0.1.0") ;; 또는 파일에서 읽어 오는 등
(def class-dir "target/classes")
(def jar-file (format "target/%s-%s.jar" (name lib) version))
(def copy-srcs ["src" "resources"])
;; delay로 부수 효과(artifact 다운로드)를 늦춤
(def basis (delay (b/create-basis {:project "deps.edn"})))
(defn clean [params]
(b/delete {:path "target"})
params)
(defn jar [{:keys [env] :as params}]
(let [srcs (if (= env :dev) (cons "dev-resources" copy-srcs) copy-srcs)]
(b/write-pom {:class-dir class-dir
:lib lib
:version version
:basis @basis
:src-dirs ["src"]})
(b/copy-dir {:src-dirs srcs
:target-dir class-dir})
(b/jar {:class-dir class-dir
:jar-file jar-file})
params))
deps.edn과 호출의 다른 측면은 그대로예요.
:dev 환경을 활성화하는 호출은 이렇게 생겼어요:
clj -T:build jar :env :dev
키-값 파라미터가 jar 함수로 전달돼요.
Java / Clojure 혼합 빌드
대부분 Clojure인 프로젝트에 Java 구현 클래스 한두 개를 도입해야 하는 경우가 꽤 자주 일어나요. 이 경우 Java 클래스를 컴파일해서 Clojure 소스와 함께 포함시켜야 해요. 이 설정에서는 Clojure 소스가 src/에 있고 Java 소스가 java/에 있다고 가정할게요(실제로 어디에 두는지는 물론 우리 마음이에요).
이 빌드는 Java 소스와 Clojure 소스에서 컴파일된 클래스로 jar를 만들어요.
(ns build
(:require [clojure.tools.build.api :as b]))
(def lib 'my/lib1)
(def version "0.1.0") ;; 또는 파일에서 읽어 오는 등
(def class-dir "target/classes")
(def jar-file (format "target/%s-%s.jar" (name lib) version))
;; delay로 부수 효과(artifact 다운로드)를 늦춤
(def basis (delay (b/create-basis {:project "deps.edn"})))
(defn clean [_]
(b/delete {:path "target"}))
(defn compile [_]
(b/javac {:src-dirs ["java"]
:class-dir class-dir
:basis @basis
:javac-opts ["--release" "11"]}))
(defn jar [_]
(compile nil)
(b/write-pom {:class-dir class-dir
:lib lib
:version version
:basis @basis
:src-dirs ["src"]})
(b/copy-dir {:src-dirs ["src" "resources"]
:target-dir class-dir})
(b/jar {:class-dir class-dir
:jar-file jar-file}))
여기서 compile task는 이 라이브러리의 prep task로도 쓸 수 있어요.
git 커밋 수에서 프로젝트 버전 계산하기
Clojure 프로젝트에서 자주 쓰는 기법 중 하나는 끊임없이 늘어나는 git 커밋 수를 버전 문자열의 세 번째 부분으로 쓰는 거예요. tools.build는 이를 돕는 b/git-count-revs API 함수를 포함하고 있어요.
여기서 빌드의 고정 부분은 하드코딩되고(템플릿 파일 등에서 읽어 올 수도 있어요), git 커밋 수가 세 번째 버전 구성 요소로 계산돼요:
(def version (format "1.2.%s" (b/git-count-revs nil)))
task 문서
자세한 task 문서는 API 문서를 참고해요.
다른 자료들
이 자료들은 프로젝트와 빌드의 다른 측면을 다뤄요:
- Building Projects: tools.build and the Clojure CLI — clojure-doc.org
- Ecosystem: Library Development and Distribution — clojure-doc.org
- Practicalli - Clojure Projects