현대 빌드 시스템(Bazel, Buck2 등)과 패키지 관리자(Nix, Guix 등)를 함께 사용할 때 마주치는 구조적 문제와 가능한 접근법들을 정리하고, 각 방식의 한계와 과제를 논의한다.
홈 2024-05-23 작성
최근에, 현대적인 빌드 시스템과 패키지 관리가 아직 해결된 문제가 아니라는 꽤나 끔찍한 사실을 자각하게 되었다. 이 글에서는 그 이야기를 해 보려 한다.
이 글은 여러분의 문제를 단번에 해결해 줄 혁신적인 해법을 제시하지 않는다. 대신 취할 수 있는 잠재적인 접근법들과, 그것들을 구현하려 할 때 맞닥뜨릴 수 있는 난관들을 소개한다. 이 글의 목표는 내 생각을 정리하고, 이 분야에서 일하는 사람이 고려해 보았으면 하는 쟁점들을 나열하는 것이다. 실질적인 액션 아이템을 찾고 있다면, 어쩌면 다른 곳을 찾는 편이 나을 수 있다.
내 (대체로 논쟁의 여지가 적은) 전제는 다음과 같다.
이 글은 위에서 언급한(또는 유사한) 시스템들에 어느 정도 익숙하다는 것을 전제로 한다.
이런 전제에서 보면, 누군가 자신의 프로젝트(개인, 오픈소스, 회사 프로젝트를 막론하고)에서 이런 것들을 조합해서 좋은 개발자 경험을 얻고 싶어하는 것은 충분히 합리적으로 보인다. 나 역시 직업적으로 Bazel과 Buck2 둘 다를 써 봤고, 꽤 헤비하게 Nix를 쓰는 입장이라, 이게 매우 자명해 보였는데, 막상 진지하게 구현 방법을 들여다보기 시작하면서 생각이 달라졌다.
“주변(ambient) 환경 vs 허메틱 빌드(hermetic build)”. 다시 말해 “패키지 관리자를 쓸 것인가 vs 모든 것을 빌드 시스템 안으로 들여올 것인가”의 문제다.
빌드에 필요한 의존성(서드파티 라이브러리, 언어 툴체인, sysroot 등 포함)을 가져오는 방법은 크게 두 가지다. 빌드 시스템이 주변 환경에서 그것들을 찾아 쓰도록 두는 것(사용자가 활성화한 환경, 사용자 전체 설정, 시스템 수준 설정, 혹은 이들의 조합 등으로 해석될 수 있다)과, 빌드 시스템이 의존성 관리를 스스로 떠맡는 것이다. 재현 가능한(reproducible) 패키지 관리자는 재현 불가능한 빌드 시스템을 상대해야 하므로, 빌드에 필요한 모든 것을 제공하는 허메틱/샌드박스 환경을 만들고, 실제 빌드 수행은 빌드 시스템에 맡긴다.
가장 눈에 띄는 접근은 패키지 관리자로 어떤 환경을 만든 뒤, 그 안에서 빌드를 실행하는 것이다. 하지만 이 방식에는 여러 가지 문제가 있다.
다른 한편으로, “세상의 전부를 빌드 시스템 안으로 가져오는” 방법이 있다. 한 번 빌드 시스템 안에 우리가 의존하는 각 패키지에 대응하는 1급(1st-class) 타깃을 만들어 두면, 위에서 언급한 문제 상당수는 사라진다. 나는 이것이 올바른 방향이라고 생각하지만, 다음과 같은 문제가 있다.
비용이 많이 든다. 어떤 접근을 택하든, 빌드에 포함된 각 의존성에 대해 1급 타깃을 만드는 일은, 수작업이든 툴링 개발이든 시간과 노력이 많이 든다. 올바른 파일 구조를 만드는 일, rpath 같은 속성을 조정하는 일, 기존 툴링(특히 린터와 LSP 서버 등)이 새로운 시스템과 호환되도록 만드는 일 등이 대표적인 도전 과제다.
패키지 관리자를 신뢰해야 한다. 적어도 허메틱하고 재현 가능해야 한다고 기대하지만, 많은 패키지 관리자가 이 기대를 충족하지 못한다.
가능한 접근법들에 대해 아직 열려 있는 질문들이 있다. 이 글 후반에서 논의하겠다.
실제로 “패키지”를 빌드할 때는 이 방식이 패키지 관리자 친화적이지 않다. 대부분의 패키지 관리자가 정말로 하고 싶은 일은, 빌드에 필요한 환경을 하나 제공하고, 그 결과로 어떤 아티팩트(결과물)를 받는 것이다. 특히 샌드박스가 개입하는 경우엔 더욱 그렇다. 패키지 관리자는 자신이 빌드 안에서 호출되고 있다는 것을 감지하지 못하기 때문에, 파일 시스템이나 네트워크 접근에 대해 어떤 예외도 두지 않는다. 표준적인 접근은, 빌드 시스템이 사전에 의존성 목록을 덤프하고, 패키지 관리자가 빌드에 필요한 것들을 제공하게 만드는 것이다. 하지만:
이런 식으로 동작하는 것에 대한 표준적인 해법은 (아직?) 존재하지 않는다.
따라서 다음과 같은 질문들이 남는다.
현실의 대부분은 여기 머물러 있다. 빌드를, 재현 가능하고 허메틱하길 바라는 어떤 환경 안에 래핑하는 것이다. 앞에서 말한 단점들이 있지만, 동시에 “어느 정도는 돌아간다”는 장점도 있다. 좋은 면도, 나쁜 면도 모두 포함해서.
이 방식의 큰 장점은, 기존 툴링과의 궁합이 좋다는 것이다. 대부분의 툴이 바로 이런 워크플로를 당연하게 전제하고 있기 때문이다.
대기업 메가코프의 모노레포에서는 현실적인 접근이다. 하지만 대부분의 프로젝트에 대해선 만족스럽지 않다. 무엇보다도 유지보수 비용이 너무 많이 들기 때문이다. 이런 비용은 대규모 조직 전체가 나눠 부담할 때는 정당화되지만, 스케일을 줄이면 금방 한계를 드러낸다.
그럼에도 불구하고, 새로운 툴링이 이 문제를 도와줄 수 있는지 보는 건 흥미로운 주제다. 지금(적어도 오픈하게 공개된) 의존성 관리 관련 작업의 거의 전부는, 개별 패키지를 전제로 하고 있다. 이런 패키지는 대개 다소 외부적인 저장소에서 제공되고, “모든 것을 벤더링하는(vendoring) 것”은 잘 고려되지 않는다. 최근 정적 링크(static linking)의 인기가 올라가고, 패키지의 다중 버전/다중 변종을 동시에 지원하는 기능이 늘어나면서, “벤더링을 훨씬 잘 지원하는 것”이 가치 있는 방향이라고 결론 내릴 수도 있을 것이다. Reindeer 같은 도구가 이런 방향을 어느 정도 탐색하고 있지만, 다른 언어가 개입하면(예: Rust에서 C 코드를 호출하는 경우) 이 접근은 깨지기 시작한다. 그런 언어들에 대한 빌드 규칙을 제공해야 하고, 그 규칙을 메인 빌드 시스템에 somehow 통합해야 하며, 이 일은 관여하는 생태계의 수에 비례해서 선형적으로 늘어난다. 이는 다른 접근법에도 어느 정도 해당되는 이야기지만, 적어도 패키지 관리자는 코드를 가져오고, 빌드하고, 실행 파일과 라이브러리를 (아마도) 편리한 방식으로 노출해 준다. 이렇게 되면 문제는 “세상을 전부 직접 빌드하는 것”에서 “세상을 소비하는 것”으로 축소된다.
이 글에서 제시한 많은 문제는, 빌드 시스템과 패키지 관리자가 서로를 단순히 호출하는 수준이 아니라, 좀 더 구조화된 방식으로 통신할 수 있다면 훨씬 풀기 쉬워질 것이다.
하지만 이는 기술적으로도 까다롭고, 사회적으로는 더더욱 어렵다. 여러 패키지 관리자와 빌드 시스템이 “표준적인 통신 방식”에 합의해야 하기 때문이다. 설계는 옳게 만들기까지 적어도 몇 번의 반복(iteration)이 필요할 텐데, 관련 당사자가 많을수록 그 속도는 느려질 수밖에 없다.
맥락을 위해, C++ 커뮤니티에서 진행 중인 CPS 프로젝트를 예로 들 수 있다. 이는 관련성이 높은 비교 사례인데, C++ 생태계는 매우 다양하고, 대부분의 언어와 달리 공통 툴체인에 표준화될 사치가 없었다. 언어에 중립적인 방식으로 패키지 관리자와 빌드 시스템을 통합하는 일은 이보다 훨씬 큰 도전 과제일 것이다. 그리고 이게 우려스러운 이유는, C++에 대해서만 놓고 봐도 (나는 CPS 자체에 대해 구체적인 의견은 없지만) 이 문제가 수십 년간 생태계의 고질적인 골칫거리였고, 가까운 시일 내에 해결될지 여전히 불투명하기 때문이다.
여기서 말하는 것은, 패키지 관리자가 모든 의존성을 자신이 관리하는 디렉터리(스토어)에 설치하고, 빌드 시스템은 자신의 스토어 안에서 그쪽으로 심링크를 거는 방식이다. 그리고 패키지 관리자가 제공하는 각 의존성에 대해, 빌드 시스템 안에 1급 타깃을 만든다.
앞에서 이야기했듯 이 접근은 이상적이지 않지만, 상대적으로 구현 비용이 낮고, 괜찮은 패키지 관리자만 있다면 “충분히 쓸 만한(good enough)” 결과를 낼 수 있다.
대표적인 고통 지점으로는, 빌드 시스템이 리모트 실행 중에 의존성을 머티리얼라이즈(materialize)하기 위해 필요한 정보를 충분히 갖고 있지 않다는 점, 그리고 패키지 관리자가 패키지에 대해 더 풍부한 메타데이터를 제공하지 않는 한, 복잡한 경우에는 타깃을 자동으로 유도해 내는 것이 어렵다는 점이 있다. 이런 타깃 정의를 수동으로 하는 비용은, 특히 일반 사용자가 이 시스템과 상호작용해야 한다면, 금세 감당하기 어려운 수준이 된다. 의존성 버전이 바뀔 때마다 이 정의들을 다시 손봐야 한다면, 그 부담은 더 커진다.
이 접근에서는, 패키지 관리자 쪽의 모든 의존성을 빌드 시스템의 스토어 안으로 복사한다. 이전 접근보다 더 비용이 많이 드는데, 패키지 관리자의 더 깊은 부분까지 파고들기 때문이다. 우리가 신경 쓰는 파일 레이아웃만 알면 되는 것이 아니라, 그것을 새로운 환경 안으로 격리된 형태로 가져오는 방법, 이 환경에서 쓸 수 있도록 “수리(fix-up)”하는 방법 등을 모두 알아야 한다. 또한 의존성을 전이적으로 처리해야 하고, rpath 같은 속성도 고쳐야 하므로, 자동화 난이도도 훨씬 올라간다. 그러면서도 자동화는 더욱더 중요해진다. 이런 일을 전부 수동으로 지정하고, 업데이트 때마다 다시 하는 것은 애초에 성립하지 않는다.
그럼에도 불구하고, 이 방법이야말로 논리적으로는 가장 “정확한(correct)” 빌드를 제공하는 접근이라고 할 수 있다. 빌드 시스템이 모든 의존성에 대한 완전한 통제권을 갖게 되고, 그 덕분에 허메틱 빌드, 리모트 실행 등에서 자신의 잠재력을 온전히 발휘할 수 있다.
이 영역에서 표준적인 해결책이 등장한다면, 그 가치는 매우 클 것이다.
현대 빌드 시스템은 현대 패키지 관리자와 많은 특성을 공유하므로, 아예 빌드 시스템을 패키지 관리자로 쓰는 방법도 생각해 볼 수 있다. 하지만:
반대로, 패키지 관리자를 빌드 시스템처럼 쓰는 쪽은 그다지 잘 작동하지 않을 것 같다고 본다. 패키지 관리자는 더 “매크로(macro)”한 사용 사례를 전제로 설계되어 있기 때문이다. Nix 생태계에는 예외적인 사례가 있긴 하다. 특히 Rust와 Haskell 빌드에 있어서, 각 의존성을 하나의 패키지로 Nix가 빌드하게 하고, cargo/cabal이 소스와 선다운로드된 의존성을 이용해 프로젝트 전체를 한 번에 빌드하는 방식에 의존하지 않는 접근이다. 이 방식에 대해서는 내가 잘 알지 못해 자세히 논의하진 않겠지만, 내 이해로는 경험이 그리 좋지 않다고 들었다. 성능 측면에서도 마찬가지이고(특히 리모트 실행 부재 때문에), 전체적인 사용성도 만족스럽지 못하다는 이야기다. 이런 고려를 염두에 두고 새 패키지 관리자를 처음부터 설계할 수도 있겠지만, 그쯤 되면 사실상 빌드 시스템이 아닌가?
어쩌면 우리는 현재 상태(Status quo)에서 크게 벗어나야 더 나은(적어도 로컬 최대점이 아닌) 지점에 도달할 수 있을지도 모른다. 빌드 시스템 영역에서 떠도는 아이디어 중 하나는, 1·3자(source)가 어디 있든 가상 파일 시스템(VFS)을 이용해 접근하는 것이다.
이 아이디어는, 의존성을 제공하는 일을 빌드 시스템/패키지 관리자에서 파일 시스템으로 옮김으로써, 문제의 일부를 그 밖으로 들어 올린다. 하지만 패키지 관리자의 나머지 역할(예: 버전 호환성 해석 및 해결)은 여전히 해결해야 할 과제로 남는다.
내가 여기서 앞선 접근들보다 이게 더 낫다고 주장하려는 것은 아니다. 다만 소프트웨어 빌드 문제의 형태가 계속해서 진화하고 있는 만큼, 이런 대안적 해법에도 마음을 열어 두는 것이 건강하다고 본다.
여러 접근법을 소개했으니, 이제 그것들이 실제로 얼마나 널리 쓰이고 있고, 어떤 도구들이 이를 지원하고 있는지 살펴보자.
대부분의 세계는, 사실상 어떤 해결책도 쓰지 않거나, 빌드를 어떤 환경 안에 래핑하는 수준에 머물러 있는 것 같다. (흔히 Docker를 써서, 빌드 의존성만 허메틱하게 제공하는 것이 아니라, 아예 전체 시스템을 감싸는 방식으로.)
몇몇 메가코프들은, 필요한 것을 전부 “그냥 벤더링”하고, 자사 빌드 시스템으로 그것들을 빌드한다.
다른 접근법들은 어떨까… 나는 순진하게도, 이제 2020년대가 되었으니 나머지 부분에 대해 괜찮은 방법이 나와 있을 거라고 생각했다. 하지만 그런 건 없었다. Nix를 너무 당연한 것으로 여기고, 직업적으로 Bazel(그리고 이제는 Buck2)까지 써 본 입장에서, 이 분야의 최첨단이 이 정도에 불과하다는 사실은 꽤 충격적이다.
Bazel에서 여러패키지 관리자를 사용하려는 프로젝트들이 있고, 비슷한 작업이 Buck2 쪽에서도 진행 중이지만, 이런 프로젝트들은 안정적이지도, 표준적이지도 않으며, 온갖 주의사항을 달고 있다. 박스에서 바로 꺼내 쓸 수 있는, 단순하고 빠르며 허메틱한 솔루션은 분명히 없다.
나도 잘 모르겠다. 글 서두에서 이야기했듯, 여기서 제기되는 대부분의 질문에 대해 나는 멋진 답을 가지고 있지 않다.
나는 이 영역에서 개인 시간에 실험을 하고 있긴 하지만, 그 결과물을 내놓을 거라고 기대하지는 않는다. 특히 혁신적인 무언가를 말할 수 있을 거라고는 더더욱 생각하지 않는다. 현재 시점에서, 나는 Nixpkgs 기반의 룰을 만들어서 Buck2로 툴체인을 꽤 손쉽게 빌드하는 정도까지는 해 뒀다. 이게 아무것도 아닌 건 아니지만, 여전히 “패키지 관리자 스토어로 링크 거는” 접근만 쓰고 있고, 내 생각에 이 방식은 최종 단계(endgame)는 아니다.
나는 현재 직업적으로도 훨씬 더 큰 프로젝트에 적합한 해결책을 찾는 일에 관여하고 있고, 전반적으로 이 분야의 진전이 있기를 기대하고 있다. 한편으로는, 이 문제들을 파고드는 열정적이고 뛰어난 사람들이 있어서 언젠가 진전이 있을 거라는 희망을 갖고 있다. 다른 한편으로는, 지난 5년 남짓 동안 이 분야의 바늘이 그다지 움직이지 않은 것처럼 보여서, 그 점은 또 우려스럽기도 하다.