한 줄 정의

빌드 시스템은 소스 코드를 바이너리로 변환하는 도구이지만, 규모가 커지면 그 본질은 ‘의존성 관리’로 바뀝니다. 이 장의 핵심 주장은 엔지니어에게서 ‘어떻게 빌드할지’를 정할 자유를 빼앗아 시스템에 위임할 때 오히려 속도·정확성·확장성이 올라간다는 것입니다. 그 분기점이 태스크(task) 중심에서 아티팩트(artifact) 중심으로의 전환이며, Bazel(구글 내부 Blaze의 오픈소스 버전)이 그 대표입니다.

쉽게 말하면

내비게이션을 떠올려 봅니다.

태스크 기반 빌드 시스템은 옛날 수동 길 안내입니다. “여기서 좌회전, 다음 신호에서 직진, 세 번째 골목에서 우회전…” 매 단계를 사람이 직접 지정합니다. 길이 막히거나 도중에 도로가 바뀌면 사람이 경로를 처음부터 다시 계산해야 합니다. 명령 하나하나를 내가 통제하니 자유롭지만, 목적지가 멀고 복잡해질수록 감당이 안 됩니다.

아티팩트 기반 빌드 시스템은 목적지만 찍는 내비게이션입니다. “여기로 가줘”라는 무엇(what) 만 말하면, 경로 선택·실시간 교통 반영·우회 같은 어떻게(how) 는 시스템이 알아서 합니다. 내가 단계를 통제하는 자유는 사라졌지만, 그 대가로 시스템이 전체 그림을 알기에 막히는 길을 피하고 여러 경로를 동시에 계산해 최적을 골라줍니다.

이 장 전체는 결국 “빌드의 통제권을 사람에게서 시스템으로 넘겨라” 는 이야기입니다. 사람이 매 단계를 지시하는 한, 시스템은 그 지시가 무슨 일을 하는지 알 수 없어 병렬화도 캐싱도 재현도 함부로 못 합니다. 무엇을 빌드할지만 선언하고 방법은 시스템에 맡길 때, 비로소 빌드를 수천 대 컴퓨터에 흩뿌리는 분산 빌드까지 갈 수 있습니다.

왜 중요한가?

빌드 시스템은 모든 개발자가 하루에 수십~수백 번 이용하며, 많은 경우 생산성을 좌우하는 결정적 도구입니다. 구글 사내 설문에서 직원의 83%가 빌드 시스템에 만족한다고 답해 조사한 19개 도구 중 4위에 올랐을 정도로(평균 만족도 69%) 엔지니어들이 사랑하는 인프라입니다.

규모가 작을 때는 빌드 시스템 자체가 필요 없습니다. 대학을 갓 졸업한 주니어가 1~2주 동안 몇백 줄을 작성하는 수준이라면 javac *.java 같은 컴파일러 직접 호출로 충분합니다. 문제는 규모가 커지면서 정확히 무엇이 무너지는가입니다. 이 장은 그 붕괴 과정을 단계별로 보여준 뒤, 각 단계의 고통을 어떤 빌드 시스템 세대가 해결하는지를 따라갑니다.

훌륭한 빌드 시스템은 두 가지를 동시에 최적화합니다.

  • 속도: 개발자가 명령 하나로 빌드를 수행하고 몇 초 안에 결과 바이너리를 얻을 수 있어야 합니다.
  • 정확성: 소스 파일과 입력 데이터가 같다면, 누가 어떤 컴퓨터에서 빌드하더라도 항상 동일한 결과가 나와야 합니다.

과거 빌드 시스템들은 속도와 정확성 사이에서 절충하려다 빌드 결과가 일관되지 못한 문제를 겪었습니다. Bazel의 목표는 둘 중 어느 하나도 희생하지 않고 언제나 효율적이면서 일관된 빌드를 수행하는 것입니다.

빌드 시스템은 사람만 쓰는 게 아니다

구글에서는 대부분의 빌드가 자동으로 이루어집니다. 거의 모든 개발 도구가 빌드 시스템과 연계되어 막대한 혜택을 돌려줍니다. 사람 개입 없이 코드가 자동 빌드·테스트되어 프로덕션에 배포되고, 개발자 변경은 자동 테스트되어 코드 리뷰로 전달됩니다. 변경이 트렁크에 병합되기 전 재테스트되어 파괴적 변경이 스며들기 어렵고, 저수준 라이브러리 제작자는 변경을 전체 코드베이스 대상으로 테스트해 수백만 개의 테스트를 모두 통과하는지 확인할 수 있습니다. 한 번에 수만 개 소스 파일을 건드리는 대규모 변경(LSC, Large-Scale Change)조차 안전하게 서브밋·테스트할 수 있습니다.

핵심 내용

빌드 시스템이 없다면 — 규모가 만드는 점진적 붕괴

빌드 시스템의 가치를 이해하려면, 그것이 없을 때 규모가 커지며 무엇이 깨지는지를 봐야 합니다.

1단계 — 컴파일러로 충분한 시절

소스 코드가 모두 같은 디렉터리에 있다면 명령 하나로 전부 컴파일할 수 있습니다.

javac *.java

가장 단순한 예에서는 이것이 필요한 전부입니다. 하지만 코드가 조금만 늘어도 곧바로 복잡해집니다. javac는 현재 디렉터리의 하위까지는 알아서 찾지만, 다른 프로젝트와 공유하는 라이브러리처럼 파일시스템의 다른 곳에 저장된 코드는 찾지 못합니다. 자바 외의 언어를 빌드하는 방법도 모릅니다. 커다란 시스템은 조각마다 다른 언어로 작성되고 그 조각들이 거미줄처럼 얽혀 있어서, 특정 언어용 컴파일러 하나만으로는 전체를 빌드할 수 없습니다. 게다가 컴파일러는 외부 의존성(자바라면 서드파티 JAR)을 다루는 방법을 전혀 모릅니다.

2단계 — 셸 스크립트가 출동한다

전용 빌드 시스템까지는 필요 없어 보이고, 셸 스크립트 몇 개면 빌드 순서 관리에 문제가 없을 것 같습니다. 잠시는 괜찮지만 더 많은 문제가 튀어나오기까지 오래 걸리지 않습니다.

  • 지루해집니다. 시스템이 복잡해지면 코딩에 쏟는 시간만큼을 빌드 스크립트에 쏟아야 합니다. 셸 스크립트 디버깅은 고통스럽고 꼼수에 꼼수가 쌓입니다.
  • 느립니다. 옛 버전 라이브러리에 의존하는 실수를 막으려면 매번 정확한 순서로 빌드해야 하는데, 변경된 부분만 다시 빌드하는 로직을 스크립트로 직접 관리하기에는 복잡하고 오류가 스며듭니다.
  • 릴리스가 한 무더기 일거리가 됩니다. jar 명령 인수를 정리하고, 결과를 업로드하고, 중앙 리포지터리에 추가하고, 문서를 갱신해 사용자에게 알려야 합니다.
  • 재현이 안 됩니다. 하드 드라이브가 깨지면 전체 시스템을 다시 만들어야 하는데, 소스 코드는 버전 관리해도 라이브러리·도구의 설치 위치와 버전, 오래전 설정한 환경변수까지 똑같이 복원하기는 어렵습니다.
  • 사람마다 환경이 미묘하게 다릅니다. 새 개발자가 합류할 때마다 부트스트래핑을 거쳐야 하고, 한 사람 컴퓨터에서 잘 되던 게 다른 사람 컴퓨터에서는 안 되는 일이 자주 생깁니다.

결국 cron으로 매일 밤 빌드를 돌리는 자동 빌드 시스템을 만들지만, 고통스러운 설정 과정은 피할 수 없고 사람이라면 바로 처리했을 작은 문제들을 놓치게 됩니다. 매일 아침 지난밤 빌드가 깨져 있는 걸 목격하며 시간을 허비합니다. 이 시점이 되면 간단한 방식으로는 더 이상 지탱할 수 없으므로 제대로 된 빌드 시스템에 투자해야 합니다.

반복되는 단 하나의 주제 — 의존성

위 모든 문제에서 반복되는 주제는 ‘이걸 하려면 저게 필요해’ 패턴, 즉 의존성입니다. 작업 사이의 의존성(릴리스 완료 표시 전에 문서 업로드), 아티팩트 사이의 의존성(내 코드를 빌드하려면 최신 라이브러리 필요), 자기 코드베이스 내부 의존성, 다른 팀·서드파티가 소유한 외부 의존성 등 종류는 다양합니다. 이 의존성을 관리하는 일이 빌드 시스템 구축에서 가장 기본이 되는 작업입니다. 본인이 작성한 코드를 관리하는 건 쉽지만 외부 의존성 관리는 훨씬 어렵다는 것이 핵심입니다.

태스크 기반 빌드 시스템 — 자유의 대가

앞의 셸 스크립트가 바로 가장 기본적인 태스크 기반 빌드 시스템(task-based build system) 의 예입니다. 여기서 기본 작업 단위는 태스크 입니다. 각 태스크는 어떤 로직을 수행하는 일종의 스크립트이며, 다른 태스크와의 의존 관계를 명시해 반드시 먼저 수행되도록 할 수 있습니다. Ant, Maven, Gradle, Grunt, Rake 같은 시스템들이 대표적입니다.

이들은 셸 스크립트 대신 빌드 파일(build file) 을 사용합니다. 다음은 Ant 매뉴얼에서 발췌한 build.xml 예시입니다.

<project name="MyProject" default="dist" basedir=".">
    <description>
      simple example build file
    </description>
    <!-- 전역 속성 설정 -->
    <property name="src" location="src"/>
    <property name="build" location="build"/>
    <property name="dist" location="dist"/>
 
    <target name="init">
      <!-- 타임스탬프 생성 -->
      <tstamp/>
      <!-- 컴파일러가 사용할 빌드 디렉터리 구조 생성 -->
      <mkdir dir="${build}"/>
    </target>
 
    <target name="compile" depends="init"
            description="compile the source">
      <!-- ${src}의 자바 코드를 컴파일하여 ${build}에 저장 -->
      <javac srcdir="${src}" destdir="${build}"/>
    </target>
 
    <target name="dist" depends="compile"
            description="generate the distribution">
      <mkdir dir="${dist}/lib"/>
      <!-- ${build} 안의 모든 파일을 jar로 패키징 -->
      <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
    </target>
 
    <target name="clean" description="clean up">
      <delete dir="${build}"/>
      <delete dir="${dist}"/>
    </target>
</project>

<target> 이 태스크이고, depends 속성으로 의존하는 다른 태스크를 명시합니다. 이 의존 관계를 그래프로 그리면 다음과 같은 비순환 그래프(DAG)가 됩니다.

flowchart LR
    Dist --> Compile --> Init
    Clear

ant dist 를 실행하면 Ant는 distcompileinit 순으로 의존성을 거슬러 올라가 가장 안쪽(init)부터 차례로 실행합니다. 태스크 이름 하나만 알려주면 나머지 순서는 Ant가 알아서 처리합니다. 셸 스크립트와 본질적으로 크게 다르지 않지만, 빌드 단계를 서로 다른 디렉터리에 만든 후 연결하고, 기존 태스크에 의존하는 새 태스크를 손쉽게 추가할 수 있다는 게 진전입니다.

어두운 면 — ‘너무 많은 힘’이라는 양날의 검

문제는 이 도구들이 엔지니어에게 어떤 스크립트든 태스크로 정의할 수 있게 허용한다는 점입니다. 매우 강력하지만, 빌드 스크립트가 커질수록 다루기 어려워집니다. 한마디로 ‘엔지니어에게는 너무 많은 힘을, 시스템에는 충분하지 못한 힘’ 을 줍니다. 시스템은 스크립트가 무슨 일을 하는지 알 수 없으므로 각 빌드 단계를 매우 보수적으로 실행할 수밖에 없고, 결국 세 가지 근본적 한계로 이어집니다.

① 빌드 단계를 병렬로 실행하기 어렵다. 요즘 컴퓨터는 멀티코어가 기본이라 이론상 여러 단계를 병렬 실행할 수 있습니다. 하지만 태스크 A가 B와 C에 의존하고 B·C가 서로 의존하지 않더라도, 실제 구현에서 둘이 상태 추적용으로 같은 파일을 써서 충돌할 수 있습니다. 시스템은 이를 알아낼 방법이 없으므로, 충돌 위험을 안고 가든가 전체 빌드를 단일 스레드로 제한해야 합니다. 강력한 개발자 컴퓨터도 제대로 못 쓰고, 여러 대를 활용하는 분산 빌드는 꿈도 못 꿉니다.

② 증분 빌드를 수행하기 어렵다. 좋은 빌드 시스템은 작은 변경으로 전체를 처음부터 다시 빌드하지 않도록 증분 빌드(incremental build) 를 합니다. 하지만 태스크는 무슨 일이든 할 수 있어서 이미 실행됐는지 확인할 방법이 없습니다. 변경 여부를 엔지니어가 직접 정의하게 한 시스템도 있지만, C++처럼 파일에서 다른 파일을 직접 인클루드하는 언어에서는 입력 소스를 파싱하지 않고서는 변경 여부를 완벽하게 지정하는 게 불가능합니다. 영리한 엔지니어가 찾아낸 꼼수 때문에 다시 실행됐어야 할 태스크가 실행되지 않으면 원인을 찾기 힘든 혼란에 빠지고, 결국 매번 클린 빌드를 하게 되어 증분 빌드를 도입한 목적에 역행합니다.

③ 스크립트를 유지보수·디버깅하기 어렵다. 빌드 스크립트도 똑같은 코드인데 그만큼 주목받지 못해 버그가 꼬이기 쉽습니다. “태스크 A가 B의 출력 파일에 의존하는데 B 소유자가 출력 위치를 바꿔도 A가 깨질 때까지 아무도 모름”, “개발자가 자기 컴퓨터에만 있는 도구 설치 경로·환경변수를 가정해 다른 사람이 실행하면 실패”, “인터넷 다운로드·타임스탬프 같은 비결정적 요소 때문에 빌드가 재현 불가” 등이 흔한 버그입니다.

핵심은 이렇습니다. 빌드 중에 실행되는 임의의 코드를 엔지니어가 작성할 수 있다는 것은, 빌드를 빠르고 정확하게 수행하는 데 필요한 정보 일부가 시스템에서 누락된다는 뜻입니다. 이 문제를 풀려면 엔지니어 손에 주어진 힘 일부를 빼앗아 시스템에 맡겨야 합니다. 즉, 시스템이 ‘태스크를 실행한다’는 개념에서 벗어나 ‘아티팩트를 만들어낸다’는 쪽으로 이동해야 합니다.

아티팩트 기반 빌드 시스템 — what만 말하고 how는 맡긴다

해법은 시스템이 정의한 몇 개의 태스크만 주고, 엔지니어는 한정된 방식으로 설정만 하게 하는 것입니다. 엔지니어는 여전히 무엇(what) 을 빌드할지 시스템에 정해주지만, 어떻게(how) 빌드할지는 시스템이 알아서 결정합니다. 이것이 Blaze와 그 파생 시스템들(Bazel, Pants, Buck)이 택한 아티팩트 기반 빌드 시스템(artifact-based build system) 입니다.

태스크 기반과 마찬가지로 빌드 파일은 존재하지만, 그 속이 다릅니다. 튜링 완전한 스크립트 언어로 명령을 하나하나 기술하는 대신, 빌드할 아티팩트들과 그 사이의 의존성, 빌드 방식에 영향을 주는 몇 가지 옵션을 설명하는 선언적 매니페스트(declarative manifest) 입니다. 명령줄에서 빌드 대상만 지정하면 시스템이 나머지 컴파일 단계를 설정·실행·스케줄링합니다. 어떤 도구를 언제 실행할지를 빌드 시스템이 완전히 통제하므로, 정확성을 보장하면서 훨씬 효율적으로 실행할 수 있습니다.

함수형 프로그래밍과 같은 발상

자바·C·파이썬 등 명령형 언어는 순서대로 실행되는 일련의 문장을 정의합니다 — 태스크 기반 빌드 시스템이 실행 단계를 차례로 정의하는 것과 같습니다. 반면 하스켈·ML 등 함수형 언어는 수학 방정식의 나열에 더 가깝습니다. 수행할 계산을 설명하되, 그 계산을 정확히 언제 어떻게 수행할지는 컴파일러에 맡깁니다. 빌드 시스템은 실질적으로 소스 파일(과 컴파일러)을 입력으로 받아 바이너리를 출력하는 커다란 수학 함수입니다. 그래서 함수형 개념을 가져와 빌드 시스템을 구축하면, 함수형 언어가 그렇듯 병렬화가 쉽고 정확성이 보장됩니다.

구체적인 예 — Bazel의 BUILD 파일

Bazel은 빌드 파일로 보통 BUILD 라는 이름을 씁니다.

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
 
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
        "@com_google_common_guava_guava//jar",
    ],
)

BUILD 파일은 타깃(target) 을 정의합니다. 위 예에서는 java_binaryjava_library 두 종류를 정의했습니다. 모든 타깃은 시스템이 생성할 수 있는 아티팩트 하나에 대응합니다. 바이너리 타깃은 실행 가능한 바이너리를, 라이브러리 타깃은 다른 바이너리·라이브러리가 쓸 수 있는 라이브러리를 생성합니다. 모든 타깃은 다음 속성을 가집니다.

  • name — 명령줄이나 다른 타깃에서 가리킬 수 있는 이름
  • srcs — 아티팩트를 생성하기 위해 컴파일해야 하는 소스 파일들
  • deps — 먼저 빌드하여 현재 아티팩트에 링크해야 하는 다른 타깃들(의존성)

의존성은 같은 패키지 안(:mylib), 같은 소스 계층구조의 다른 패키지(//java/com/example/common), 소스 계층구조 밖의 서드파티(@com_google_common_guava_guava//jar) 모두 지정할 수 있습니다. 개별 소스 계층구조는 워크스페이스(workspace) 라 부르며 루트에 있는 WORKSPACE 파일로 식별됩니다.

bazel build :MyBinary 를 깨끗한 리포지터리에서 처음 실행하면 다음 순서로 빌드합니다.

  1. 워크스페이스의 모든 BUILD 파일을 파싱하여 아티팩트 사이의 의존성 그래프를 만듭니다.
  2. 그래프로 MyBinary전이 의존성(transitive dependency) 을 결정합니다. 즉 MyBinary 가 의존하는 모든 타깃, 다시 그 타깃들이 의존하는 모든 타깃을 반복해서 구합니다.
  3. 의존성이 없는 타깃부터 차례로 빌드합니다(외부 의존성은 다운로드). 한 타깃의 의존성이 모두 빌드되면 그 타깃을 빌드하며, 이를 MyBinary 의 전이 의존성이 모두 빌드될 때까지 반복합니다.
  4. 마지막으로 MyBinary 를 빌드하고, 3단계에서 빌드한 모든 의존성과 링크해 최종 실행 바이너리를 만듭니다.
태스크 기반과 무엇이 결정적으로 다른가

결과물은 결국 똑같은 바이너리입니다. 하지만 빌드 과정에서 시스템이 무슨 일이 일어나는지를 안다는 점에서 두 가지 강력한 차이가 생깁니다.

  • 안전한 병렬 실행. 3단계에서 Bazel은 각 타깃이 자바 라이브러리를 생성한다는 것, 즉 사용자 정의 임의 스크립트가 아니라 자바 컴파일러만 실행하면 된다는 사실을 압니다. 그래서 단계를 병렬 실행해도 안전하다고 결론 내려 멀티코어에서 비약적으로 빨라집니다. 실행 전략을 온전히 빌드 시스템에 맡긴 아티팩트 기반에서만 얻을 수 있는 장점입니다.
  • 변경 없으면 즉시 재사용. 아무 변경 없이 bazel build :MyBinary 를 두 번째 실행하면 Bazel은 1초 내로 “최신 상태”라며 끝냅니다. 자바 컴파일러는 오직 입력에만 의존해 결과물을 만들므로, 입력이 같으면 기존 결과를 재사용해도 된다는 것을 알기 때문입니다. 이 분석을 모든 수준에 적용해, MyBinary.java 가 변하면 MyBinary 만 다시 빌드하고 mylib 은 재사용하며, //java/com/example/common 이 변하면 그에 의존하는 mylib·MyBinary 는 다시 빌드하지만 무관한 otherlib 은 재사용합니다. 최소한의 아티팩트만 다시 빌드하면서 전체 빌드를 정확히 수행합니다.
Bazel이 추가로 푸는 세 가지 문제

빌드를 함수형으로 재구성한 토대 위에서, Bazel은 태스크 기반이 안고 있던 나머지 비결정성까지 닫아갑니다.

문제해법핵심 메커니즘
로컬에 설치된 도구의 위치·버전이 컴퓨터마다 다름도구를 의존성으로 취급모든 타깃이 컴파일러로의 의존 관계를 갖고, 없으면 다운로드. 도구가 바뀌면 의존하는 모든 아티팩트를 다시 빌드. 플랫폼 차이는 툴체인(toolchain) 으로 흡수
태스크가 선언 안 한 파일을 몰래 읽거나 써서 충돌환경 격리(샌드박싱)모든 액션이 파일시스템 샌드박스에서 격리 실행. 선언한 입력만 읽고, 선언 안 한 출력에 쓰면 즉시 버려짐. 네트워크 통신도 차단
외부 의존성이 언제든 바뀌어 빌드 재현 불가·보안 위험외부 의존성을 명시적으로 드러내기외부 의존성 각각의 암호화 해시(cryptographic hash) 를 워크스페이스 매니페스트에 기록. 실제 해시와 다르면 다시 다운로드하거나 빌드 실패

여기서 액션(action) 은 Bazel에서 가장 낮은 수준의 조합 가능한 단위입니다. 필요한 입력·출력을 선언하고 특정 실행 파일을 수행하며, 자신이 선언한 입력·출력만 사용한다면 무슨 일이든 할 수 있습니다. Bazel은 액션을 스케줄링하고 결과를 캐싱합니다. Bazel은 많이 쓰는 언어 몇 가지와 기본 호환되지만, 엔지니어는 커스텀 규칙(rule) 으로 타깃 종류를 확장할 수 있습니다. 규칙 작성자는 규칙이 요구하는 입력과 생성할 출력, 수행할 액션을 선언합니다.

외부 의존성에 의존하는 것은 위험하다

현 워크스페이스 바깥 파일에 의존하면, 이 파일들은 언제든 변경될 수 있어 빌드 시스템이 계속 ‘최신’ 여부를 확인해야 합니다. 외부 변경에 워크스페이스 소스가 적절히 대응하지 못하면 빌드 문제를 재현할 수 없게 되어, 알 수 없는 이유로 빌드가 하루는 잘 되다가 다음 날 실패하는 일이 반복됩니다. 더 심각하게는 외부 의존성 소유자가 서드파티라면 소프트웨어 공급망 공격(supply chain attack) 에 노출됩니다 — 공격자가 서드파티 서버를 감염시켜 우리가 의존하는 파일을 악성 파일로 바꿔치기하면 빌드 환경과 아티팩트의 제어권이 넘어갈 수 있습니다. 그래서 의존성 파일들을 소스 관리에 넣지 않고도 빌드 시스템이 인지하게 하되, 의존성 변경은 중앙에서 의식적으로 한 번만 이뤄져야 합니다.

분산 빌드 — 한 대로는 감당 못 하는 규모

구글 코드베이스는 20억 라인 이상이고, 간단한 바이너리조차 수만 개의 빌드 타깃에 의존하는 일이 흔합니다. 이 규모에서는 아무리 좋은 컴퓨터라도 단 한 대에 빌드를 맡겨서는 답이 없습니다. 하드웨어 한 대가 끌어낼 수 있는 물리적 힘 이상을 해낼 수 없기 때문입니다. 유일한 방법은 분산 빌드(distributed build) — 단위 작업을 여러 컴퓨터에 뿌려 빌드한 후 취합해 최종 결과를 만드는 기술입니다. 빌드 단위를 충분히 작게 쪼갤 수 있다면 아무리 큰 빌드라도 원하는 시간 안에 끝낼 수 있습니다. 이 확장성이 구글이 아티팩트 기반 빌드 시스템에 투자한 가장 큰 이유입니다.

원격 캐싱 — 한 번 빌드한 결과를 모두가 공유

가장 단순한 형태는 원격 캐시만 이용하는 것입니다.

flowchart TB
    Dev1["개발자 컴퓨터"] <--> Cache["공유 캐시"]
    Dev2["개발자 컴퓨터"] <--> Cache
    CI["CI 서버"] <--> Cache

빌드를 수행하는 모든 시스템(개발자 컴퓨터, CI 서버)이 공통의 원격 캐시를 참조합니다. 공유 캐시는 빠른 로컬 네트워크의 Redis일 수도, 구글 클라우드 스토리지일 수도 있습니다. 아티팩트를 빌드할 때마다 시스템은 먼저 원격 캐시에 해당 아티팩트가 이미 있는지 확인합니다. 있으면 새로 빌드하는 대신 다운로드하고, 없으면 직접 빌드한 뒤 캐시에 추가합니다. 자주 변경되지 않는 저수준 라이브러리는 한 번 빌드되면 수많은 사용자에게 공유되어 빌드 운영 비용을 크게 절감합니다.

이게 작동하려면 빌드 시스템이 빌드를 완벽하게 재현할 수 있어야 합니다. 모든 타깃에 대해 필요한 입력 집합을 결정할 수 있고, 같은 입력이면 어떤 머신에서든 정확히 같은 결과가 나와야 다운로드한 아티팩트가 직접 빌드한 결과와 동일함을 보장할 수 있습니다. 캐시 키는 해당 타깃과 입력값들의 해시 모두입니다 — 둘 이상의 엔지니어가 동시에 같은 타깃에 다른 수정을 가할 수 있으므로, 캐시는 각각에 필요한 버전을 충돌 없이 전해줘야 하기 때문입니다.

원격 캐시가 항상 이득은 아니다

아티팩트를 다운로드하는 시간이 새로 빌드할 때보다 빨라야 원격 캐시가 의미 있습니다. 캐시 서버가 빌드 머신과 멀리 떨어져 있으면 오히려 더 느릴 수 있습니다. 원격 캐시를 도입하려거든 네트워크 지연시간을 잘 고려하고, 캐시가 실제로 성능을 개선해주는지 실험해보는 게 좋습니다.

원격 실행 — 빌드 ‘작업’ 자체를 흩뿌린다

원격 캐시는 진정한 분산 빌드가 아닙니다. 캐시가 사라지거나 저수준 라이브러리를 변경하면 여전히 모든 빌드를 로컬에서 해야 합니다. 원격 실행(remote execution) 은 빌드를 하는 ‘실제’ 작업들을 여러 워커(worker) 에 나눠 수행하는 기술입니다.

flowchart TB
    Dev1["개발자 컴퓨터"] --> Master["빌드 마스터"]
    CI["CI 서버"] --> Master
    Dev2["개발자 컴퓨터"] --> Master
    Master --> W1["워커"]
    Master --> W2["워커"]
    Master --> W3["워커"]

각 사용자(개발자, 자동 빌드 시스템)의 빌드 도구가 중앙 빌드 마스터 에 요청을 보냅니다. 마스터는 빌드를 구성하는 액션들을 스케줄링하고 워커 풀을 활용합니다. 개별 워커는 마스터가 지정한 액션을 사용자가 명시한 입력값으로 수행하고 아티팩트를 생성하며, 이 아티팩트는 그 결과가 필요한 다른 워커들에 공유됩니다.

이 모델이 작동하려면 아티팩트 기반 빌드 시스템의 모든 요소가 한데 모여야 합니다. 빌드 환경은 필요한 모든 것을 완벽하게 자기 기술(self-descriptive) 해야 워커들이 사람 개입 없이 동작하고, 빌드 프로세스는 완벽하게 자급자족(self-contained) 해야 각 단계를 어떤 컴퓨터에서든 실행할 수 있으며, 결과는 완벽하게 결정적 이어야 워커들이 서로의 결과를 믿고 이용할 수 있습니다. 태스크 기반 시스템에서는 이 각각을 보장하기가 너무 어려워서 안정적인 원격 실행 시스템을 구축하는 게 거의 불가능합니다.

분산 빌드 @ 구글

구글은 2008년부터 원격 캐시·원격 실행을 지원하는 분산 빌드 시스템을 써왔습니다. ObjFS 는 구글 버전의 원격 캐시로, 백엔드는 결과를 빅테이블(Bigtable)에 저장하고 프런트엔드 objfsd(FUSE 데몬)는 개발자가 빌드 결과를 마치 로컬 파일인 듯 브라우징하게 해줍니다. 단, 내용은 사용자가 요청할 때만 주문형(on-demand)으로 다운로드해 네트워크·디스크 사용을 크게 줄이고, 빌드 결과를 로컬에 저장할 때보다 빌드가 두 배 빨라집니다. Forge 는 원격 실행 시스템으로, Blaze단 Forge 클라이언트가 데이터센터의 Scheduler에 액션을 전송하면 Scheduler가 캐싱·큐잉을 관리하고 거대한 Executor 풀이 액션을 실행해 결과를 ObjFS에 직접 저장합니다. 이 시스템은 구글의 모든 빌드를 도맡아 매일 수백만 번의 빌드로 수PB의 결과물을 만들어냅니다.

모듈과 의존성 설계 — 시스템을 켰다고 끝이 아니다

아티팩트 기반 시스템을 도입했다고 자동으로 빨라지지는 않습니다. 프로젝트를 어떤 크기의 모듈로 나누고 의존성을 어떻게 구성하느냐가 빌드 성능과 작업량에 지대한 영향을 줍니다.

작은 모듈과 1:1:1 규칙

전체 프로젝트를 하나의 모듈로 만들면 외부 의존성을 추가할 때 말고는 BUILD 파일을 건드릴 일이 없어 편하지만, 전체 빌드를 매번 하나의 작업으로 수행해야 합니다 — 병렬·분산·캐싱이 모두 불가능합니다. 반대로 소스 파일 각각을 모듈로 만들면 캐시와 분산 빌드를 최대로 활용하지만, 참조를 추가할 때마다 의존성 목록을 갱신해야 해 유지보수가 끔찍해집니다.

구글은 일반적인 크기보다 작은 모듈을 훨씬 선호합니다. 프로덕션 바이너리는 대체로 수천 개 타깃에 의존하고, 중간 규모 팀도 수백 개 타깃을 소유합니다. 자바처럼 패키징 개념이 견고한 언어는 디렉터리 하나가 보통 패키지 하나·타깃 하나·BUILD 파일 하나를 가집니다(Pants에서는 이를 1:1:1 규칙 이라 부릅니다). 프로젝트가 커질수록 작은 타깃의 효과가 나타납니다 — 분산 빌드가 빨라지고, 타깃을 다시 빌드하는 빈도가 줄며, 변경된 타깃과 연관된 테스트만 추려 수행하면 되어 테스트량도 크게 줄어듭니다.

가시성 최소화

가시성(visibility) 은 자신에게 의존할 수 있는 타깃의 범위를 지정하는 속성으로, 본질적으로 의존성과 반대되는 개념입니다. 타깃 A가 B에 의존하려면 먼저 B가 자신을 A에 드러내야 합니다. public 타깃은 워크스페이스 내 모든 타깃이 참조할 수 있고, private 타깃은 같은 BUILD 파일과 허용목록에 명시된 타깃만 참조할 수 있습니다. 대부분의 언어가 그렇듯 가시 범위는 가능한 한 좁히는 게 좋습니다. 구글은 다른 모든 팀이 널리 쓰는 라이브러리만 public 으로 지정하고, 대부분의 BUILD 파일에는 private 이 아닌 타깃이 단 하나뿐입니다.

내부 의존성과 엄격한 전이 의존성

내부 의존성 은 같은 소스 리포지터리에서 빌드되며 ‘버전’ 개념이 없습니다 — 하나의 타깃과 그 내부 의존성은 언제나 같은 커밋/리비전에서 빌드됩니다. 주의할 점은 전이 의존성 입니다.

flowchart LR
    A --> B --> C

A가 B를 의존하고 B가 다시 공통 라이브러리 C를 의존할 때, A가 C의 클래스를 직접 사용할 수 있을까요? 기반 도구에서는 문제없습니다 — B와 C 모두 A에 링크되므로 A는 C의 심볼을 봅니다. Blaze도 오랫동안 이를 허용했지만, 구글이 성장하며 문제가 됐습니다. B를 리팩터링해 C에 의존하지 않게 바꾸면, B를 통해 C를 쓰던 A가 동작하지 않습니다. 타깃의 의존 관계가 마치 공개된 계약처럼 작용해 안전하게 변경할 수 없게 되고, 의존 관계가 복잡하게 얽혀 빌드가 느려집니다.

구글은 엄격한 전이 의존성 모드(strict transitive dependency mode) 를 도입해 이를 해결했습니다. 직접 의존하지 않는 심볼을 참조하면 빌드가 실패하고, 문제의 의존성을 자동으로 추가하는 셸 명령어를 오류 메시지에 덧붙여줍니다. 이 변경을 코드베이스 전체에 적용해 모든 타깃이 자신이 필요로 하는 의존성을 명시적으로 선언하도록 리팩터링하는 데는 수년이 걸렸지만, 불필요한 의존성이 줄어 빌드가 훨씬 빨라졌고 엔지니어들이 안 쓰는 의존성을 마음 놓고 제거할 수 있게 됐습니다. Bazel은 자바 코드에 엄격한 전이 의존성을 기본 적용합니다.

외부 의존성 — 자동이 아니라 수동으로

외부 의존성 은 빌드 시스템 바깥에서 빌드·저장된 아티팩트로, 아티팩트 리포지터리에서 직접 가져와 씁니다. 내부 의존성과 결정적 차이는 ‘버전’이 있고, 그 버전이 프로젝트 소스와 독립적으로 매겨진다는 점입니다.

외부 의존성 버전은 자동·수동 두 방식으로 관리할 수 있습니다.

관점자동 관리수동 관리
버전 지정호환 범위만 명시(1.+), 시스템이 최신을 다운로드정확한 버전을 빌드 파일에 명시(1.1.4)
통제권버전이 언제 바뀌는지 통제 불가변경을 버전 관리, 언제 바뀌었는지 추적 가능
위험잘 되던 빌드가 어느 날 갑자기 실패, 원인 추적 어려움안정성이 약간의 관리 오버헤드 값을 함
적합 규모작은 프로젝트엔 편리조금만 커지거나 여러 명이면 수동이 유리

Bazel은 모든 의존성 버전을 수동 지정하도록 합니다. 중간 규모 프로젝트만 해도 수동 버전 관리가 주는 안정성이 약간의 오버헤드 이상의 값어치를 하기 때문입니다.

원-버전 규칙 (The One Version Rule)

같은 라이브러리라도 버전이 다르면 다른 아티팩트로 표현되므로, 이론상 같은 외부 의존성의 다른 버전을 다른 이름으로 선언해 타깃마다 원하는 버전을 쓸 수 있습니다. 구글은 이 방식이 실전에서 문제가 많음을 깨닫고 모든 서드파티 의존성에 원-버전 규칙 을 강제했습니다. 여러 버전을 허용하면 다이아몬드 의존성(diamond dependency) 이 생기기 때문입니다.

flowchart TB
    A1[A] --> B1[B]
    A1 --> C10["C 1.0"]
    B1 --> C10
    A2[A] --> B2[B]
    A2 --> C10b["C 1.0"]
    B2 --> C20["C 2.0"]

위 그림처럼 A가 B와 C 1.0을 의존하는데 B를 리팩터링해 C 2.0을 쓰게 바꾸면, A는 C의 1.0과 2.0 모두에 의존하는 충돌에 빠집니다. 원-버전 규칙은 타깃에 서드파티 라이브러리를 추가해도 기존 의존성이 모두 같은 버전을 가리키므로 평화롭게 공존하게 만듭니다.

전이 외부 의존성과 벤더링

외부 의존 대상의 전이 의존성은 특히 다루기 어렵습니다. Maven·Gradle 같은 도구는 기본적으로 전이 의존성을 재귀적으로 다운로드해, 프로젝트에 추가한 의존성 하나가 수십 개의 아티팩트를 끌어옵니다. 무척 편리하지만, 둘 이상의 라이브러리가 같은 서드파티의 상이한 버전을 끌어와 원-버전 규칙을 위배하고 다이아몬드 의존성을 낳을 수 있습니다.

이 때문에 Bazel은 전이 의존성을 자동 다운로드하지 않고, 전역 파일 하나에 리포지터리의 외부 의존성 전부와 각각의 정확한 버전을 기록하게 합니다(Maven 전이 의존성을 추적해 이 파일을 자동 생성하는 도구를 제공). 편의성과 확장성 사이의 트레이드오프이며, 조직과 코드베이스가 성장할수록 수동 관리 비용이 자동 관리가 낳는 문제를 수습하는 비용보다 저렴해집니다.

서드파티 아티팩트는 본질적으로 두 위험을 안습니다. 가용성 위험(availability risk) — 리포지터리에 접속할 수 없으면 빌드 전체가 멈춥니다. 보안 위험(security risk) — 공격자가 서드파티 시스템을 점령해 아티팩트를 악성 버전으로 대체할 수 있습니다. 둘 다 필요한 아티팩트를 통제 가능한 서버에 미러링하고 각 아티팩트의 해시를 소스 리포지터리에 명시함으로써 예방할 수 있습니다. 더 나아가 의존성을 아예 소스·바이너리 형태로 프로젝트에 복사해 직접 버전 관리하는 것을 벤더링(vendoring) 이라 합니다. 벤더링은 외부 의존성을 내부 의존성으로 전환하는 효과를 내며, 구글은 모든 서드파티 라이브러리를 소스 트리 루트의 third_party 디렉터리에 체크인합니다. 단, 이는 구글의 소스 관리 시스템이 거대한 모노리포를 감당하게 맞춤 제작됐기에 가능한 것으로, 다른 조직에서는 올바른 선택지가 아닐 수 있습니다.

비교 / 트레이드오프

빌드 시스템 3세대 비교
관점DIY(셸/직접 호출)태스크 기반(Ant·Maven·Gradle)아티팩트 기반(Bazel·Buck·Pants)
기본 단위명령임의 스크립트(태스크)선언적 타깃(아티팩트)
통제 주체사람이 모든 단계사람이 태스크 정의시스템이 how 결정
병렬 실행불가어려움(태스크가 뭘 하는지 모름)안전하게 가능
증분 빌드사실상 불가까다롭고 깨지기 쉬움입력 해시로 정확
분산 빌드불가거의 불가가능(원격 캐시·실행)
적합 규모소스 한두 개짜리중간 규모, 단일 머신거대 코드베이스, 다수 개발자
잃는 것재현성·확장성유연성(임의 스크립트)
핵심 트레이드오프 — 유연성 vs 확장성

세대를 거슬러 올라가는 마이그레이션의 트레이드오프는 한결같이 유연성 입니다. DIY→태스크 기반으로 가면 복잡한 빌드를 자동화하고 재현성이 좋아지는 대신 빌드 파일을 직접 작성해야 합니다. 태스크 기반→아티팩트 기반으로 가면 거대한 빌드를 분산할 수 있는 대신 엔지니어가 진짜 프로그래밍 언어로 범용 태스크를 작성하는 길이 막힙니다.

대부분의 프로젝트에서 이 트레이드오프는 실보다 득이 큽니다. 처음부터 아티팩트 기반을 염두에 두고 설계하면 문제 될 일이 거의 없지만, 태스크 기반에서 마이그레이션하기는 쉽지 않습니다. 그래서 현재 빌드에서 속도·정확성 문제가 불거지지 않았다면 굳이 옮길 가치는 없을 수도 있습니다. 다만 소스 파일 하나짜리 같은 사소한 프로젝트라면 아티팩트 기반의 오버헤드가 더 클 수 있어, 빌드 시스템 내장 언어(Go 등)나 컴파일러 직접 호출이 오히려 적합합니다.

내 생각

  • ‘사람의 자유를 뺏어 시스템에 넘긴다’는 게 이 장의 한 문장. 백엔드에서도 같은 패턴이 반복됩니다 — 자유로운 raw SQL 대신 ORM·쿼리 빌더로 제약을 걸면, 시스템이 쿼리를 이해해 캐싱·N+1 감지·자동 페이징을 해줍니다. 통제권을 시스템에 넘긴 만큼 시스템이 똑똑해집니다.

  • 아티팩트 기반 = 선언형, 태스크 기반 = 명령형. Terraform·Kubernetes 매니페스트가 ‘how가 아니라 what을 선언’해서 멱등성·드리프트 감지·병렬 적용을 얻는 것과 정확히 같은 구조입니다. 빌드든 인프라든, 선언형으로 가야 시스템이 최적화 여지를 갖습니다.

  • 재현성이 분산의 전제조건. “같은 입력이면 어떤 머신에서든 같은 결과”가 보장돼야 원격 캐시·원격 실행이 성립합니다. 컨테이너 이미지를 빌드 입력 해시로 태깅해 CI 캐시를 공유하는 것이 바로 이 원리이고, 비결정적 빌드(타임스탬프·네트워크 의존)는 분산의 적입니다.

  • 원-버전 규칙은 모노리포의 핵심 규율. 다이아몬드 의존성은 마이크로서비스 폴리글랏 환경에서 가장 자주 터지는 의존성 지옥입니다. 사내 라이브러리를 BOM(Bill of Materials)으로 버전 고정해 모든 서비스가 같은 버전을 쓰게 하는 것이 현실적인 원-버전 규칙의 적용입니다.

  • 벤더링은 ‘편하지만 비싸다’. third_party에 전부 체크인하는 건 구글의 맞춤 모노리포라 가능한 것입니다. 일반 조직에서는 사내 아티팩트 미러(Nexus·Artifactory)에 해시 검증을 거는 정도가 가용성·보안 위험을 합리적으로 막는 선입니다.

관련 개념

  • Ch16 버전 관리와 브랜치 관리 — 모노리포·트렁크 기반 개발이 원-버전 규칙과 벤더링의 전제입니다. 빌드 재현성도 결국 ‘어떤 커밋에서 빌드했는가’에 달려 있어 VCS와 직결됩니다
  • Ch15 폐기 — 엄격한 전이 의존성으로 안 쓰는 의존성을 안전하게 제거하는 이야기가, 시스템을 깨끗하게 유지하는 폐기의 관점과 만납니다