Bibix (1) 소개
나는 최근에 bibix라는 빌드 시스템을 만들었다. 과거 완료형으로 쓰기엔 아직 해야할 일도 많고 알고 있는 문제점도 많아서 부적절하단 생각이 들지만, 일단 동작하게는 만들어놨다.
세상에 이미 빌드 시스템이 얼마나 많은데 그런걸 또 만드냐? 라는 생각이 들 수 있다. 아마 대부분의 사람이 그럴 것이고, 사실 맞는 말이다.
그런데도 이런 프로젝트를 시작한건 구글에 입사하고 나서 blaze라는 사내 빌드 시스템을 사용해보고 나서였다. blaze는 몇가지 단점이 있긴 하지만 그럼에도 매우 좋은 빌드 툴이다. 여러 장점 중에서도 개인적으로는 여러 언어를 하나의 빌드 시스템에서 무리 없이 지원한다는 점이 마음에 들었다.
나는 대학원에 다닐때까지만 해도 svn도 잘 쓸 줄 몰라서 코드를 그냥 날짜별로 압축 저장해서 관리하던 사람이었고, 빌드 툴에 대해서도 크게 신경을 쓰지 않았다. 빌드는 이클립스같은 IDE를 쓰면서 그런 툴에서 해주는대로 사용했는데, 사실 불편하다고 느낀 적이 없었다. 왜냐하면 그때 내가 만드는 프로그램들은 나 혼자서 작업하는데다, 그렇게 규모가 큰 것도 아니었고, 무엇보다도 한 언어로만 만들었기 때문이다. 대학원에서 석사를 하면서 했던 프로젝트의 코드는 처음에는 모두 파이썬으로 작성했었고, 후에 전체적으로 재개발하면서는 자바로 작성했다. C++로 작성한 별도의 툴도 있었지만 코드가 몇백줄 정도 되는 파일 한 개짜리 프로그램이라 빌드가 복잡하지 않았다.
그 뒤로 회사에 다니면서부터 git이나 maven같은 툴들을 사용하기 시작했다. 회사에서는 주로 자바로만 작업을 했기 때문에 빌드 시스템은 maven만으로도 부족함이 없었다. 그러다 개인 프로젝트로 jparser 프로젝트를 시작한 뒤로는 스칼라를 사용하게 되면서 스칼라 build tool, 줄여서 sbt라고 불리는 툴을 사용했다. (사실 maven에 스칼라 플러그인을 붙여서 사용해도 되고, gradle에도 잘 동작하는 스칼라 플러그인이 있어서 어느쪽을 써도 큰 문제는 없었을 것이다)
그 뒤에 이직을 한 뒤에 회사에서 gradle을 사용하긴 했는데, 그다지 마음에 들지는 않았다. maven은 빌드 룰을 xml로 적어야 해서 보기에 예쁘지 않다는 단점이 있는 반면, pom.xml 파일의 구조 자체는 매우 단순하고 이해하기 쉬웠다. 그래서 한때는 xml을 짧게 쓸 수 있는 툴같은걸 만들면 maven도 꽤나 쓸만하지 않을까 라는 생각을 해본 적도 있었다. 반면 gradle은 그루비 스크립트(지금은 코틀린도 지원하지만)로 빌드 스크립트를 적게 되어있기 때문에 조금 직관적이지 않게 느껴졌다. (물론 결국은 나도 maven을 버리고 gradle을 쓰긴 했다. xml은 정말 상대하고 싶지 않기 때문에..)
그러다 구글에 입사하면서 blaze를 보고 상당히 인상을 받았다. 구글의 사내 시스템 중에는 독자적인 시스템이 매우 많다. 심지어는 소스 관리 시스템도 자체 개발한 시스템을 사용하고, 빌드 툴도 blaze라는 자체 개발 툴을 사용한다. 내 추측이지만 아마도 구글이 생겼을 당시에는 지금 시중에 나와있는 좋은 툴들이 없어서 자체 개발해서 사용하다보니 이렇게 되지 않았을까 싶다.
이 blaze라는 빌드 툴은 구글의 독특한 소스 관리 시스템과도 연동되어 있다. 구글은 사내의 모든 소스 코드를 하나의 리포지토리에서 관리한다. 2016년에 이미 자신들의 글에서 10억개의 파일(그 중 소스 파일은 900만개), 20억 줄의 코드, 3500만 개의 커밋이 하나의 리포지토리에서 관리되고 있고 매일 평균 4만개의 커밋이 새로 추가되고 있다고 밝혔으니 지금은 그보다 훨씬 더 많은 양의 코드를 한 리포지토리에서 관리하고 있을 것이다. 일반적인 git 리포지토리는 그렇게 크지 않다. 아마 그렇게 큰 리포지토리를 지원하지도 않을 것이다. 내가 아는 제일 큰 git 리포지토리는 linux 커널 코드인데, 이 글을 작성하는 시점에 커밋이 109만개, 파일은 약 6만개가량인 것으로 보인다. 어마어마한 규모지만, 구글 리포지토리의 수십억개의 파일이라는 숫자에 비하면 매우 작은 규모다.
구글의 단일 리포지토리에 들어있는 프로젝트는 기본적으로 blaze를 이용해서 빌드된다. (예외가 있는지는 모르겠다) 그리고 blaze는 각 모듈의 모든 dependency가 구글의 단일 리포지토리 안에 있다고 가정한다. 그래서 이 단일 리포지토리에는 구글 밖에서 만들어진 코드이더라도 구글의 프로젝트에서 필요한 코드는 모두 들어가 있고 blaze로 빌드될 수 있도록 만들어야 한다. 즉, 구글의 프로젝트에서 사용하는 모든 써드파티 코드도 다 한 리포지토리 안에 들어가 있고, 모두 blaze 빌드 스크립트가 포함되어 있다. 예를 들어서, 만약 리눅스 커널 코드를 구글의 다른 프로젝트에서 사용하고 싶다면 리눅스 커널 코드도 그 단일 리포지토리에 들어가야 하고, 리눅스 커널 코드 역시 blaze로 빌드할 수 있도록 만들어야 한다. (실제로 리눅스 커널 코드가 구글의 단일 리포지토리에 들어 있는지는 모르겠다) 그리고 각 모듈은 blaze BUILD 파일에 의존하는 다른 모듈들을 디렉토리 경로와 모듈 이름으로 지정하도록 되어 있다.
상황이 이렇다 보니, blaze는 태생적으로 여러 언어로 작성된 프로그램들을 무리없이 빌드할 수 있도록 설계되었다. maven처럼 코드가 전부 자바로 작성되어 있다고 가정할 수가 없었던 것이다. 또한 파일의 레이아웃에 대해서도 어떤 가정을 할 수 없었다. 다시 말해, maven처럼 src/main/java에 소스 코드가 다 들어있을 거라고 가정할 수 없었다.
그래서 blaze의 빌드 스크립트 파일(BUILD
라는 파일 이름을 갖는다)은 이런식으로 생겼다.
cc_binary(
name = "hello-world",
srcs = ["hello-world.cc"],
)
java_binary(
name = "ProjectRunner",
srcs = ["src/main/java/com/example/ProjectRunner.java"],
main_class = "com.example.ProjectRunner",
deps = [":greeter"],
)
java_library(
name = "greeter",
srcs = ["src/main/java/com/example/Greeting.java"],
)
- 이 예제는 blaze의 오픈소스 버전인 bazel 문서에서 갖고온 것이다. blaze와 bazel은 매우 유사하다.
이렇게 C++과 자바 모듈이 한 디렉토리에 있을 수 있고, 한 빌드 스크립트에서 정의될 수 있고, 파일의 위치를 사용자가 마음대로 자유롭게 적을 수 있다. “ProjectRunner”를 정의할 때 “deps”(dependencies의 줄임말)로 “:greeter”를 지정하고 있다. 콜론은 같은 디렉토리의 다른 모듈을 나타내기 때문에, ProjectRunner 모듈이 아래에 정의된 “greeter” 모듈에 의존하고 있다는 것을 나타낸다. blaze(혹은 bazel)는 이런 정보들을 모아서 빌드 그래프를 만들고, ProjectRunner를 빌드하기 전에 greeter를 먼저 빌드한다.
이렇게 심플한 개념으로 강력한 시스템을 만들 수 있다니. 워낙 간단한 구조라 처음엔 별로 대단치 않게 느껴졌는데, 생각할수록 잘 만든 시스템이라는 생각이 들었다. 다만 한가지, maven에서 “src/test”라는 디렉토리를 기본 디렉토리로 지정했기 때문에 많은 사람들이 유닛 테스트를 까먹지 않고 넣게 되었다고 생각하는데, 그런 고려는 없었다. 구글은 대신 그런 부분들은 단일 리포지토리에 코드를 넣기 전에 거쳐야 하는 프로세스에서 해소하고 있다. 깃허브로 비유를 하자면 풀 리퀘스트를 보내서 머지되기 전에 절차적으로 코드 리뷰와 자동화된 코드 검사 시스템을 통해 유닛 테스트를 사실상 강제하고 있다는 것이다. blaze에서 유닛 테스트를 만들고자 하면 blaze에 “java_library”나 “java_binary”처럼 “java_test”도 있기 때문에 테스트 코드도 동일하게 “java_test” 모듈로 정의해서 실행하면 된다.
이런 구조가 특히 유용한 경우가 바로 protocol buffer같은 툴을 사용할 때였다. 줄여서 보통 protobuf나 proto라고도 부르는 이 프로젝트 역시 구글에서 사내용으로 만들어 쓰다가 나중에 오픈소스화된 프로젝트인데, 매우 유용해서 내 개인 프로젝트에서도 즐겨 사용한다. protobuf는 그 자체가 일종의 언어로, 네트웍 등으로 주고받을 데이터의 구조를 프로토버프의 언어로 정의하면 프로토버프 컴파일러로 여러 언어(C++, C#, 자바, 코틀린, 파이썬 등)로 데이터 구조 및 파서와 시리얼라이저를 생성해준다. gradle에서도 프로토버프를 사용할 수는 있는데 사용하기가 그리 편리하지는 않다. bazel에서는 이렇게 쓸 수 있다. (출처)
java_proto_library(
name = "person_java_proto",
deps = [":person_proto"],
)
cc_proto_library(
name = "person_cc_proto",
deps = [":person_proto"],
)
proto_library(
name = "person_proto",
srcs = ["person.proto"],
deps = [":address_proto"],
)
proto_library(
name = "address_proto",
srcs = ["address.proto"],
deps = [":zip_code_proto"],
)
proto_library(
name = "zip_code_proto",
srcs = ["zip_code.proto"],
)
프로토버프 정의 자체에도 dependency들을 정의할 수 있고, 그렇게 해서 만들어진 프로토 파일을 C++ 용으로 컴파일해주는 타겟, 자바 용으로 컴파일해주는 타겟을 만들었다. 이제 다른 java_library나 java_binary에서 이 프로토버프를 사용하려면 deps에 “person_java_proto”를 추가하면 그만이다.
이런 시나리오가 가능한 것 자체가 “다양한 언어를 지원하는” 빌드 스크립트라서 생겨나는 장점이다. blaze에서 여러 언어를 잘 지원하는 덕분에 프로토버프라는 언어와 자바라는 언어를 연동하는데 무리가 없는 것이다.
bazel의 빌드 스크립트는 파이썬의 하위 호환 언어인 starlark라고 하는 언어로 정의하게 되어 있다. 위에서 본 BUILD 파일의 내용들은 모두 이 starlark 언어로 작성된 것이다. 그래서 실은 for문을 사용한다거나 하는 것도 가능하다. 개인적으로는 이 기능은 gradle을 좋아하지 않는 것과 같은 이유로 크게 선호하지는 않는다. 하지만 그래도 bazel에서는 대체로 빌드 그래프의 형태가 드러나도록 스크립트를 작성하기 때문에 크게 거슬리는 지점은 아니었다.
2019년 초(벌써 3년이 넘었다니!), 새로운 개인 프로젝트를 시작했다. 뭐였는지는 솔직히 기억이 안 나는데, 여러 언어를 섞어서 뭔가 만들고 싶었던 것 같다. 위에서 본 것처럼 프로토버프로 인터페이스 메시지의 구조를 정하고, 서버와 클라이언트를 만들어 통신하게 하는 무언가였다. 서버와 클라이언트에서 서로 다른 언어를 사용하고자 했다. blaze에서 보았던 그 아름다운 빌드 스크립트를 내 개인 프로젝트에도 적용시키고 싶어서 bazel을 사용해보기로 했다.
하지만 구글 밖에서 bazel을 사용하는건 생각보다 간단치가 않았다. 가장 큰 문제는 구글 밖의 나에게는 구글의 단일 리포지토리가 없었다는 점이다. bazel을 사용하기 위해서는 내 로컬에 WORKSPACE 루트 디렉토리를 만들어야했다. bazel은 WORKSPACE라는 파일이 있는 지점을 구글의 단일 리포지토리의 루트처럼 인식하도록 되어 있다. 차이점은 구글의 단일 리포지토리에는 내가 필요로 하는 모든 라이브러리가 이미 잘 정리되어 들어가 있었지만, 구글 밖의 나에게는 작고 초라한 텅 빈 WORKSPACE밖에 없었다는 점이다.
그래서 내가 maven central에 있는 라이브러리를 쓰고자 하면, 그 라이브러리를 bazel의 WORKSPACE 디렉토리로 임포트해오는 과정이 필요했다. 그런데 이 과정부터 간단치가 않았다. 지금은 뭔가 개선이 된 것 같긴 한데 내 기억에 그 당시만 해도 별도의 스크립트를 실행했어야 했는데, 이 스크립트가 말을 잘 듣질 않았다.
또 구글의 초거대 단일 리포지토리는 단일 버전 정책이 적용되어 있다. 무슨 뜻인고 하면 구글의 단일 리포지토리에는 모든 코드의 최신 버전만 들어 있다는 뜻이다. maven에서 의존성을 추가할 때는 과거 버전에 의존성을 넣을 수 있다. 예를 들어 A라는 라이브러리의 현재 최신 버전이 2.0.0이어도 내 프로그램은 A의 1.5.0 버전에 의존하게 만들 수 있다는 뜻이다. 하지만 구글의 단일 리포지토리는 과거 버전을 갖고 있지 않고 항상 최신 버전만 사용해야하고, 그래서 blaze와 bazel에도 “버전”이라는 개념 자체가 없다.
이 단일 버전 정책은 매우 큰 장점과 단점을 동시에 갖고 있고, 사람마다 호불호가 크게 갈릴 수 있는 부분이다. 장점이라면 같은 이름을 가진 모듈이 동시에 두 개 이상 존재할 수 없다는 점이다. 예를 들어 JVM 프로그램을 만들어서 실행시키고자 한다면 같은 이름의 클래스가 두 개 로드될 수는 없기 때문에, 클래스의 이름 충돌이 생기면 어떤식으로든 해결을 해야만 한다. 단일 버전 정책때문에 구글의 단일 리포지토리에서는 이런 문제가 애당초 발생하지 않을 것이다. 하지만 대신 리포지토리에 각 artifact의 어떤 버전을 사용할 것인지를 모두 명시적으로 지정해주어야 했다. maven이 transitive dependency resolution 과정에서 한 프로젝트에 같은 artifact의 두 버전이 포함되어야 하는 것이 발견된 경우, maven은 현재 프로젝트에서 거리가 가까운 artifact의 버전을 사용한다. 이건 다소 임의적인 정책이라고 생각되고, 현실에서는 예상하지 못한 결과를 초래할 수도 있는 부분이긴 하다.
하지만 maven의 버전 충돌 문제에 대한 해결책이 임의적이든 어쨌든 거기에 맞춰서 만들어져있는 라이브러리들을 사용해서, 거기에 맞춰서 개발을 하고, 그로 인한 문제가 생기면 내가 책임져도 된다고 생각하는 나는 충돌이 생길 수 있는 모든 artifact의 버전을 미리 검토해야 한다는 점이 매우 고통스럽게 느껴졌다. 문제가 생기면 그때 해결하더라도 일단은 복잡한 문제는 잊고 싶은 나의 게으름 때문이다. 그래서 “좀더 구글 밖의 세상과 잘 어우러지는, bazel과 비슷한 빌드 시스템”을 만든다면 어떨까라는 생각에 닿게 되었다.
거기에 플러그인을 쉽게 만들 수 있게 해서 확장성을 높이고, 빌드 스크립트에서 빌드 그래프의 구조를 쉽게 볼 수 있도록 빌드 스크립트 언어를 설계한다는 두가지 목표가 추가되었다.
그래서 bibix의 개발에 돌입했다. 이름은 비비다라는 한국어와 mix라는 영어를 섞어서 bibix라고 지었다. 그 당시에도 jparser는 있었지만 아직 실제로 사용하기에는 부족한 부분이 너무 많아서, 파서는 손으로 만들고, 몇가지 개념을 개발하며 그럭저럭 돌아가는 빌드 툴을 만들었다. 하지만 타입 시스템 비슷한 무언가를 넣고 싶었는데 그 부분이 여의치 않았다. 쉽게 싫증을 내는 나의 프로젝트의 결말이 흔히 그렇듯 곧 열정을 잃고 접어두었다.
그러다 얼마전 뜬금없이 다른 일을 하다 보게 된 “artifact”라는 단어에 꽂혀서, 왠지 과거에 접어두었던 그 bibix의 타입 시스템에 이 이름을 갖다 붙이면 문제가 잘 해결될 것 같은 기분이 들었고 다시 개발에 돌입했다. 그간 꽤 성숙된 jparser를 사용해서 문법을 정의해서 파서를 만들었고, 3년 전에는 구현하지 못했던 확장 기능들도 구현할 수 있었다. 결국 artifact라는 이름은 최종적으로 bibix에서는 사라졌지만, 이 단어 덕분에 전반적인 개념이 꽤 깔끔하게 정리된 것 같다.
비빅스의 빌드 스크립트는 build.bbx
라는 이름을 갖는다. 아래는 bibix의 bibix 빌드 스크립트다. 원래는 잘 동작하던 스크립트였는데, 이번에 bibix를 새로 구현하면서 현재는 일부만 동작하는 스크립트다.
import bibix.plugins as protobuf
import bibix.plugins as ktjvm
import java
import maven
import jvm
namespace proto {
schema = protobuf.schema(
srcs = [
"bibix-core/src/main/protobuf/ids.proto",
"bibix-core/src/main/protobuf/repo.proto",
"bibix-core/src/main/protobuf/run_config.proto",
"bibix-core/src/main/protobuf/values.proto",
]
)
protoset = protobuf.protoset(schema=schema)
javalib = java.library(
srcs=protobuf.java(schema=schema),
deps=[maven.dep("com.google.protobuf", "protobuf-java", "3.19.4")],
)
kotlinlib = ktjvm.library(
srcs = protobuf.kotlin(schema=schema),
deps = [
maven.dep("com.google.protobuf", "protobuf-kotlin", "3.19.4"),
maven.dep("org.jetbrains.kotlin", "kotlin-stdlib-jdk8", kotlinVersion),
javalib,
],
optIns = ["kotlin.RequiresOptIn"]
)
}
core = ktjvm.library(
srcs = glob("bibix-core/src/main/kotlin/**/*.kt"),
deps = [
proto.javalib,
proto.kotlinlib,
jvm.lib("lib/bibix-ast-0.0.1-SNAPSHOT.jar"),
maven.dep("org.jetbrains.kotlin", "kotlin-stdlib-jdk8", kotlinVersion),
maven.dep("org.jetbrains.kotlin", "kotlin-reflect", kotlinVersion),
maven.dep("org.jetbrains.kotlinx", "kotlinx-coroutines-core", "1.6.1"),
maven.dep("org.jetbrains.kotlinx", "kotlinx-coroutines-jdk8", "1.6.1"),
maven.dep("org.scala-lang", "scala-library", "2.13.6"),
maven.dep("com.google.protobuf", "protobuf-java-util", "3.19.4"),
maven.dep("com.fasterxml.jackson.core", "jackson-databind", "2.13.1"),
maven.dep("com.fasterxml.jackson.module", "jackson-module-kotlin", "2.13.1"),
maven.dep("org.codehaus.plexus", "plexus-classworlds", "2.6.0"),
maven.dep("org.eclipse.jgit", "org.eclipse.jgit", "6.1.0.202203080745-r"),
maven.dep("org.apache.maven", "maven-resolver-provider", "3.8.5"),
maven.dep("org.apache.maven.resolver", "maven-resolver-connector-basic", "1.7.3"),
maven.dep("org.apache.maven.resolver", "maven-resolver-transport-file", "1.7.3"),
maven.dep("org.apache.maven.resolver", "maven-resolver-transport-http", "1.7.3"),
jvm.lib("lib/jparser-base-0.2.3.jar"),
jvm.lib("lib/jparser-fast-0.2.3.jar"),
jvm.lib("lib/jparser-naive-0.2.3.jar"),
jvm.lib("lib/jparser-utils-0.2.3.jar"),
],
)
uberJar = jvm.executableUberJar(
deps = [core],
mainClass = "com.giyeok.bibix.MainCli",
)
내가 원했던 쉬운 플러그인 개발, 쉬운 플러그인 사용(remote git 리포지토리에서 바로 플러그인을 가져와서 사용할 수 있다)은 내가 바라던대로 실현되었다. maven dependency resolution과의 자연스러운 연동, protobuf와의 연동이 구현되었고, 지금은 jvm.lib("lib/bibix-ast-0.0.1-SNAPSHOT.jar")
로 되어있는 부분은 곧 jparser 플러그인을 만들어서 대체할 것이다. 여기에 빌드 성능을 개선하기 위한 캐싱, 캐싱의 완전성을 위한 인풋 해싱, 패래럴 빌드 등도 구현했다.
지금은 이 툴이 IntelliJ와 같은 IDE에서 지원되지 않기 때문에 당분간은 코드 레이아웃을 gradle에서 쓰는 것과 비슷하게 유지하고 build.gradle
파일과 build.bbx
파일을 동시에 유지해야겠지만 그래도 jparser와 protobuf가 자연스럽게 연동되면 CLI에서 보조적인 용도로만 사용해도 쓸모가 있으리라고 생각한다.