Bibix (2) 빌드 시스템에 대한 여러가지 생각
이 포스팅에서는 비빅스를 만들면서 생각한 몇가지..를 아무렇게나 정리해보려고 한다.
빌드 시스템의 핵심은?
make 이래로 대부분의 빌드 시스템들은 내부적으로 각 빌드 타겟을 만들기 위해 수행되어야 하는 작업들의 그래프를 갖고있다. 이걸 흔히들 빌드 그래프라고 부르는 것 같다. Makefile에는 그래서 각 타겟마다 그 타겟을 실행하기에 앞서 실행되어야 하는 다른 타겟의 목록을 명시적으로 적도록 하고 있다. 그렇게 정의된 타겟들의 의존성을 계산해서 빌드 그래프를 만들고 실행 순서를 자동으로 계산한 다음 계산된 순서에 따라 실행하는 것이 사실 make의 핵심이라고 할 수 있을 것이다. 그런 면에서 모든 빌드 툴의 기본 개념은 make와 크게 다르지 않다고 본다.
빌드 그래프는 특성상 방향성이 있는 싸이클이 없는 그래프(directed acyclic graph, 줄여서 DAG)의 형태를 갖는다. 그래프에 싸이클이 있으면 그냥 어느 타겟부터 빌드해야할 지 알 수 없기 때문에 자연스러운 일이다.
비빅스는 (일종의) 인터프리터
나는 비빅스를 일종의 인터프리팅 언어이자 인터프리터라고 생각하면서 설계하고 구현했다. 하지만 일반적인 언어나 일반적인 인터프리터와는 달리 빌드 스크립트 자체가 하나의 빌드 그래프가 되고, 그래서 실행 순서가 일반적인 언어와는 다르다. 대부분의 프로그래밍 언어는 위에서부터 아래로 순차적으로 실행하는 것이 일반적이다. 하지만 비빅스에서는 빌드 스크립트를 하나의 그래프로 해석하고, 목표가 되는 노드를 지정하면 그 노드를 완수하기 위해 필요한 다른 노드들을 먼저 실행한다. 그래서 만약 목표가 되는 노드는 설령 가장 상단에 정의되어 있더라도 가장 마지막에 실행된다.
blaze와 거기서 파생된 bazel나 buck 등의 빌드 스크립트는 starlark라는 언어로 정의된다. 이 언어는 파이썬과 거의 똑같이 생긴 언어인데, 얼핏 보기에는 비빅스와 개념적으로 큰 차이가 없어보이지만 잘 생각해보면 개념적으로 비빅스와는 상당히 다르다. 비빅스는 스크립트 자체가 하나의 빌드 그래프인 반면, starlark는 위에서 아래로 실행되면서 호출된 함수가 내부 로직을 거쳐 빌드 그래프를 만들어내는 방식이기 때문이다. gradle도 starlark와 비슷하게 동작한다. 즉 그루비 혹은 코틀린으로 작성되는 gradle의 빌드 스크립트는 일반적인 프로그램을 실행하듯 실행되고, 그 프로그램은 gradle의 내부 API를 호출해서 빌드 그래프를 생성하는 방식이다.
starlark나 gradle의 방식에는 여러가지 장점이 있다. 일단 무엇보다 유연성이다. 그루비나 코틀린같은 general purpose 프로그래밍 언어, 혹은 그에 준하는 starlark와 같은 언어를 사용할 수 있으니 루프나 조건식을 사용해서 짧은 코드로 복잡한 빌드 그래프를 만들어낼 수 있을 것이다.
한가지 예를 들면, starlark에서는 다음과 같은 빌드 스크립트가 가능하다.
for x in ['1', '2', '3']:
for y in ['a', 'b', 'c']:
java_library(
name = "lib_$x$y",
srcs = ["$x$y.java"],
)
만약 디렉토리 안에 1a.java
, 1b.java
, 1c.java
, …, 3c.java
와 같은 파일들이 있고, 각 파일들을 이용해 lib_1a
, lib_1b
, …, lib_3c
와 같은 java_library 모듈을 만들고자 한다면 이런 식으로 루프를 사용할 수 있다. gradle에서도 비슷한 코드가 가능할 것이다.
이런 코드는 흔히 사용되지는 않지만 때때로 유용하게 쓰이는 경우가 있다. 예를 들면 빌드할 때 참조하는 라이브러리 몇 개를 변경해가면서 빌드해야 하는 경우이다. 타겟이 되는 컨피그를 리스트로 정의해 놓고, 리스트에 새로운 타겟을 추가하면 자동으로 새로운 빌드 타겟이 만들어지게 한다거나 할 수도 있다.
물론 이런 기능에는 제약이 붙는다. starlark의 경우 빌드 스크립트가 무한 루프에 빠지지 않도록 하기 위해서 무한 루프를 정의할 수 없게 되어 있다. 파이썬에는 있는 while문이 없고, for문에서는 유한한 리스트만 순회할 수 있게 되어 있다. (gradle에서 이 문제를 어떻게 해결하고 있는지는 잘 모르겠다)
나는 의도적으로 비빅스에서 이런식의 모듈 정의를 할 수 없게 만들었다. 비빅스의 문법에는 for문같은 것 자체가 없기 때문에 애당초 위와같은 빌드 스크립트는 만들 수 없다. 그 이유는 크게 두가지였는데, 하나는 모듈 이름이 되도록 그대로 빌드 스크립트에 나타났으면 했기 때문이고, 또 하나는 빌드 스크립트를 작성하는 사람보다 읽어야 하는 사람이 편했으면 했기 때문이었다.
빌드 도중에 오류가 발생하는 경우, 문제 해결의 첫 단초는 그 오류가 발생한 지점을 특정하는 것이다. 그런데 위에서처럼 복잡한 로직으로 타겟을 정의할 수 있게 하면 “lib_2b” 모듈에서 문제가 발생했을 때 그것이 어디서 어떤 과정으로 정의된 것인지 찾기가 어렵다. 그래서 나는 빌드 스크립트에 모든 타겟의 이름이 명시적으로 등장해서 빌드 스크립트를 열어서 Ctrl-F로 타겟의 위치를 찾을 수 있게 하고 싶었다.
또, 빌드 룰을 저렇게 복잡하게 적을 수 있게 하면 “lib_2b”가 위의 for문 안에서 정의되는 것을 알았다고 해도 실제로 그 룰이 어떤 인자를 받아서 실행되는지 알기가 어렵다. 나는 빌드 스크립트는 작성하는 사람보다 읽을 사람이 편한 쪽이 좋다고 생각했다. 보통은 빌드 스크립트를 작성한 사람조차도 한번 동작하기 시작하면 빌드 스크립트는 크게 건드릴 일이 잘 없다. 그래서 빌드 스크립트에 문제가 생긴 경우에, 빌드 스크립트의 내용을 제대로 이해하는 사람이 잘 없는 경우가 많은데, 그러면 위처럼 복잡한 for문이 등장하는 빌드 스크립트는 상당히 껄끄러울 수 있다.
만약 이런 기능이 빌드 스크립트의 크기를 현격히 줄여주고 사용성을 크게 높여준다면 마냥 무시할 수는 없겠지만 내 경험상으로는 저런 for문으로 아낄 수 있는 코드 양은 정말 많아봐야 십수줄정도에 불과했기 때문에 이런 편의성은 과감히 포기하기로 했다.
만약 정말 정말 진정으로 이와 같은 기능이 필요하다면, for문을 허용하기보다는 list comprehension과 같은 기능을 추가하고, 리스트의 n번째 값을 얻어올 수 있게 하는 기능을 추가할 필요가 있을 지도 모르겠다. 하지만 그렇게 되면 현재는 비빅스에 존재하지 않는 “숫자”라는 개념이 추가되어야 하기 때문에 역시 간단한 일은 아니다.
비빅스의 타입 시스템
비빅스를 인터프리터로 규정하긴 했지만 사실 언어로써 많은 기능을 갖고 있진 않다. 빌드를 실제로 수행하는데 필요한 대부분의 복잡한 과정은 “규칙”이라고 명명된 JVM으로 컴파일된 코드로 위임하게 된다. 다르게 말하면 비빅스는 비빅스가 정의한 형태의 자바(혹은 코틀린, 그루비, 스칼라 등 JVM 기반 언어의) 클래스/메소드의 형태로 구현된 “규칙”들을 이어 붙이는 와이어링(wiring) 언어라고도 볼 수 있다.
문제는 이 규칙들이 입력으로 받는 값과 출력해내는 값을 어떻게 연결시킬까였다. 즉, 자바 코드가 참조할 수 있는 의존성은 그것이 코틀린으로 쓰여진 코드든, 스칼라로 쓰여진 코드든, 아니면 미래에 나올 어떤 새로운 언어로 작성된 코드든, JVM으로 컴파일될 수만 있으면 어떤 코드든 참조할 수 있어야 하는데, 이런 특성을 어떻게 구현할 것인가 하는 것이 문제였다. 이 문제에 대한 해답을 찾지 못해서 예전에 비빅스를 만들려다 포기했던 적이 있었다.
그때는 “artifact”라는 단어에 꽂혀서 규칙이 만들어내는 값을 “artifact”라는 형태로 abstraction하면 뭔가 깔끔하게 해결될 것 같다고 생각했다. 하지만 결국 비빅스를 새로 만들면서 “artifact”라는 단어는 없어졌다. 처음에 생각했던 것은, 모든 규칙은 그 결과를 하나의 디렉토리에 써야하고, 그 디렉토리를 “artifact”라고 부르도록 하는 것이었는데, 설계를 하다보니 모든 규칙이 디렉토리를 생성할 필요는 없었다. 그리고 규칙을 실행한 결과물이 단순한 디렉토리 위치가 아니라 보다 복합적인 데이터인 경우도 많았다. 그래서 일반적인 프로그래밍 언어에서 사용하는 “class”라는 개념이 더 적절하다고 생각되어서 클래스 개념을 도입했다.
비빅스에서는 다음과 같이 12종류의 값이 지원된다.
- boolean.
true
,false
두가지 중 하나의 값을 가질 수 있다. - string. 문자열이다. 문자열 리터럴에서는
"$variable"
${plugin.rule1()}
와 같은 string interpolation을 지원한다. - path. 경로.
- file. 파일. 위의 경로는 실제로 존재하든 존재하지 않든 상관없이 경로를 나타내지만, 파일은 실제로 존재하는 파일을 나타낸다. 디렉토리도 안된다.
- directory. 디렉토리. 파일과 마찬가지로 실제로 존재하는 디렉토리 경로를 나타낸다.
- enum. enumeartion값. 사용자가 정의한 enumeration 타입의 값을 나타낸다.
- list. 말 그대로 리스트.
- set. 말 그대로 셋(집합). 리스트와 사실상 거의 동일하지만 동일한 값을 제거된다.
- tuple. 튜플.
- named tuple. 이름이 붙은 튜플.
- class instance. 사용자가 지정한 클래스의 값을 나타낸다.
- none. null값을 나타낸다.
클래스 타입은 다음과 같이 정의할 수 있다.
class ClassPaths(cps: set<path>)
class ClassPkg(origin: ClassOrigin, cpinfo: CpInfo, deps: set<ClassPkg>) {
// resources는 classpath의 일부로 취급
as ClassPaths = resolveClassPkgs([this])
}
super class ClassOrigin {MavenDep, LocalLib, LocalBuilt}
class MavenDep(repo: string, group: string, artifact: string, version: string)
class LocalLib(path: path)
class LocalBuilt(objHash: string, builderName: string)
클래스라는 개념의 어려움
초창기 bibix의 클래스 정의 문법은 아래와 같았다. 의도한 바는 바로 위에서 보인 ClassPaths, ClassPkg, ClassOrigin, MavenDep, LocalLib, LocalBuilt 클래스들의 정의와 똑같았다.
class ClassPaths = set<path>
class ClassPkg extends ClassPaths =
(origin: ClassOrigin, cps: set<path>, deps: set<ClassPkg>) {
as ClassPaths = resolveClassPkgs([this])
}
class ClassOrigin = {MavenDep, LocalBuilt, LocalLib}
class MavenDep = (repo: string, group: string, artifact: string, version: string)
class LocalBuilt = (desc: string)
class LocalLib = (path: path)
이 때의 개념은 클래스의 body를 별도의 타입으로 정의한다는 개념이었다. 즉, ClassPaths
라는 클래스는 실제로 set<path>
라는 값을 래핑하고, ClassPkg
는 (origin: ClassOrigin, cps: set<path>, deps: set<ClassPkg>)
라는 named tuple을 래핑한다는 개념이었다. ClassOrigin
은 {MavenDep, LocalBuilt, LocalLib}
라는 union 타입으로 정의할 수 있게 하였다. Union type은 coercion시에 앞에서부터 매칭을 시도하도록 되어 있기 때문에, 이렇게 만들면 내가 의도한 스칼라나 코틀린 등의 sealed class와 비슷한 개념으로 쓸 수 있지 않을까 생각했었다.
그런데 현실은 그렇게 녹록치 않았다. 너무 유연한 coercion이 문제였다. Coercion 로직에서 (desc: string)
이라는 싱글 네임드 튜플이 사실상 스트링과 비슷한 것으로 다루어지기 때문에 LocalLib
의 값이 ClassOrigin
으로 전달되면 이 값이 스트링으로 묶여서 LocalBuilt
값이 되어버렸던 것이다. 다시 말하면, ClassOrigin
을 요구하는 위치에 LocalLib("/hello/world")
라는 값이 들어가면, 그 값을 MavenDep
으로 coerce하려고 시도했다 실패하고, 그 다음 LocalBuilt
로 coerce하려고 하면 LocalLib("/hello/world")
-> (path: "/hello/world")
-> "/hello/world"
-> (desc: "/hello/world")
-> LocalBuilt("/hello/world")
의 과정을 거쳐 coercion에 성공하고, 그래서 결과적으로 LocalLib("/hello/world")
가 LocalBuilt("/hello/world")
가 되는 괴상한 일이 벌어졌던 것이다.
그래서 결국 코틀린에서 data class와 같은 개념에 해당하는 일반 class와, sealed class에 해당하는 super class로 분리하고, 클래스는 기본적으로 필드 목록을 갖도록 수정했다. 거기에 맞춰서 class 정의에 등장하는 ‘=’도 제거했다.
그리고 “as” 기능이 있기 때문에 “extends”는 크게 의미가 없다는 생각이 들어서 “extends” 기능은 제거하였다.
다른 빌드 시스템들과의 비교?
기본적으로 나의 비교 대상은 자바 세계에서 많이 사용되는 빌드 시스템들이다. 내가 JVM 기반 언어를 주로 사용하기 때문이다. 그러면 ant, maven, gradle, sbt가 첫번째 비교 대상이 될 것이다.
ant는 make와 유사하다. (솔직히 써본 적은 없어서 잘 모른다) 사용자가 명시적으로 각 타겟마다 먼저 실행되어야 하는 타겟의 목록을 명시하고, 각 타겟에서 해야 할 일을 직접 나열해야 한다. 게다가 이 모든 정의를 xml로 해야한다. 그래서 단순한 작업을 할 때도 빌드 정의가 상당히 복잡하고 장황해 질 수 있다.
maven의 경우 일반적으로 프로젝트의 소스코드 구조를 src/main/java, src/test/java와 같은 형태로 고정하고, 주로 maven central 등에서 dependency를 자동으로 갖고 오는데 초점이 맞춰져 있기 때문에 pom.xml 파일의 구조가 매우 단순하고 명료하다. 하지만 protobuf와 같은 별도의 툴을 연동하기는 쉽지 않고, 역시 XML로 빌드 스크립트를 정의해야 하는 것이 번거롭다.
gradle의 경우엔 빌드 규칙을 만들기 위해, 빌드 그래프를 조작하는 API를 이용하는 그루비 스크립트를 작성하도록 했다. 그래서 확장성이 좋고, xml을 사용하지 않기 때문에 maven보다 빌드 스크립트가 보기에 좋기는 하지만, 나는 빌드 그래프의 구조가 너무 숨겨진다는 느낌을 받았다. 멀티 프로젝트를 만들기가 다소 번거로운 것도 내 기준에선 문제였다.
sbt는 빌드 스크립트를 스칼라로 쓰게 되어 있는데, 스칼라의 복잡한 문법들을 최대한 활용해서 스크립팅을 하게 해 놓아서 사용하기가 만만치 않다. 그런데 막상 몇몇 부분들(e.g. dependsOn)은 API로 구현되지도 않아서 못해서 자체 문법의 스트링으로 써야 한다. 의욕이 너무 앞섰던 빌드 툴이 아닌가 생각한다.