GoSuda

Git Monorepo 재고찰: 인간과 Coding Agents를 위한 CAS-Native 아키텍처

By Lemon Mint views ...

모든 성장하는 엔지니어링 조직은 결국 같은 갈림길에 이르게 된다. 처음에는 수십 개의 작은 저장소로 시작하며, 이때는 모든 것이 단순해 보인다. 각 라이브러리는 자체 이슈 트래커, 자체 CI 파이프라인, 커피 한 모금을 마시기 전에 끝나는 git clone을 가진다. 그러다 부담이 찾아온다. 핵심 네트워킹 패키지의 구조체 하나를 바꾸면, 나머지 오후 시간을 여섯 개의 하위 저장소에 걸친 버전 범프 풀 리퀘스트를 여는 데 쓰게 되고, 위상 정렬 순서대로 CI 대기열이 비워지기를 기다리게 된다.

모든 것을 전통적인 Git 모노레포로 옮기면, 조율의 고통을 물리적 고통으로 맞바꾸게 된다. 커밋 이력이 깊어지고 과거 리비전에 바이너리 자산이 쌓이면서 git fetch는 점점 느려진다. 작업 트리는 기가바이트 단위로 부풀어 오른다. 머지않아 누군가는 일상적인 개발을 견딜 만한 수준으로 유지하기 위해 맞춤형 가상 파일시스템 데몬, 전용 코드 리뷰 서버, 또는 원격 빌드 워커 집단을 구축하자고 제안하게 된다.

자율형 코딩 에이전트가 팀에 합류하면 두 가지 실패 양상은 모두 더 악화된다. 폴리레포에서는 에이전트가 교차 프로젝트 호출자를 볼 수 없고, 여러 저장소에 걸쳐 자격 증명을 관리해야 한다. 전통적인 모노레포에서는 작업을 초기화할 때마다 에이전트 컨테이너의 디스크와 네트워크 대역폭이 고갈된다. 또한 동일한 브랜치를 동시에 건드리는 여러 에이전트는 끝없는 리베이스 경쟁을 일으킨다.

우리는 세 번째 선택지를 원했다. google3 아키텍처에서 영감을 얻어, 우리는 다음과 같은 간단한 질문을 던졌다. 표준 Git 기반 구조와 범용 객체 스토리지만으로, 단일 트렁크 원자적 변경, 1초 미만의 희소 온보딩, 계층적 OWNERS 거버넌스, 독립형 오픈소스 미러를 모두 얻을 수 있는가?

그 답은 그렇다였으며, 맞춤형 파일시스템 데몬이나 서드파티 리뷰 장비가 필요하지 않았다. 필요한 것은 Git을 그 내부 본질대로, 즉 Merkle 트리 기반 내용 주소 저장소(Content-Addressable Store, CAS)로 사용하는 것뿐이었다.

느린 Git 모노레포의 핵심에 놓인 오해

개발자들이 Git이 대형 모노레포로 확장되지 않는다고 불평할 때, 대부분 Git의 객체 데이터베이스에서 한계에 부딪히는 것이 아니다. .git/objects 내부에서 Git은 SHA-1(또는 SHA-256) 내용 해시를 키로 하는 세 가지 불변 기본 요소를 저장한다.

  1. blob: 단일 파일의 원시 바이트 내용.
  2. tree: 파일 이름과 권한을 blob 또는 하위 tree 해시에 매핑하는 디렉터리 목록.
  3. commit: 하나의 루트 tree 해시, 0개 이상의 부모 commit 해시, 그리고 작성자 메타데이터를 가리키는 작은 텍스트 헤더(보통 200~300바이트).

내용 주소 저장소로서 이 설계는 놀랍도록 효율적이다. 모노레포에 50,000개의 파일이 있고 그중 한 파일의 한 줄을 수정하면, 새 커밋은 나머지 49,999개 파일의 정확히 같은 blob과 tree 해시를 재사용한다.

그렇다면 전통적인 Git 모노레포는 왜 무겁게 느껴질까? 관례상 하나의 참조인 refs/heads/main이 서로 충돌하는 세 가지 역할을 떠안기 때문이다.

  • 모든 프로젝트의 전체 통합 디렉터리 트리를 보유한다.
  • 첫날까지 거슬러 올라가는 전체 부모 연결 역사 커밋 체인을 끌고 다닌다.
  • 모든 git clone과 git fetch에서 기본적으로 Git 서버가 광고하고 협상하는 네임스페이스인 refs/heads/* 안에 존재한다.

개발자나 CI 러너가 refs/heads/main을 가져올 때마다, 클라이언트와 서버는 수천 개의 과거 리비전에 걸친 커밋 그래프 도달 가능성을 협상한다. 누군가 3년 전 실수로 100 MB 아카이브를 커밋하고 다음 날 삭제했다면, 그 blob은 여전히 refs/heads/main에서 도달 가능한 과거 tree에 매달려 있게 된다.

참조 네임스페이스를 네 개의 계층으로 분리하기

Git의 wire 프로토콜은 표준 clone 중에 refs/heads/*와 refs/tags/*만 광고한다. refs/trunk/*이나 refs/ws/* 같은 사용자 정의 네임스페이스 아래의 참조는 서버의 정확히 같은 객체 저장소에 존재하며, 같은 packfile 델타 압축의 이점을 누리고, 명시적으로 fetch하거나 push할 수 있다. 그럼에도 기본 clone에는 아무런 추가 부담을 주지 않는다.

하나의 저장소 안에서 네 개의 참조 네임스페이스에 책임을 나누어 배분함으로써 병목은 사라진다.

1refs/heads/main          Control plane only (~20 KB: rules, .gitconfig, sync config, scripts)
2refs/trunk/main          Parentless snapshot commit pointing to the latest unified root tree
3refs/trunk/history       Linear parent-linked commit chain sharing the exact root tree SHA-1
4refs/ws/<actor>/<id>     Isolated workspace snapshots (changelists) per developer or agent
5refs/heads/land/<id>     Ephemeral merge-queue trigger deleted immediately upon landing

1. 컨트롤 플레인(refs/heads/main)

기본 브랜치에는 애플리케이션 소스 코드가 전혀 들어 있지 않다. 저장소 운영 규칙(AGENTS.md, README.md), 워크플로 별칭을 담은 공유 .gitconfig, Starlark 동기화 구성(sync.star), 서버 측 병합 스크립트만을 추적한다. 기본 브랜치를 clone하면 약 20 KB가 전송되며 몇 밀리초 안에 완료된다.

2. 부모가 없는 트렁크 스냅샷(refs/trunk/main)

refs/trunk/main은 조직 전체의 완전하고 통합된 소스 트리(//web/*, //net/*, //ipc/*, //devtools/*, //corp/*, //third_party/*, //experimental/*)를 가리키지만, 그 커밋 객체는 -p 부모 플래그 없이 생성된다.

refs/trunk/main은 부모가 0개(parents = [])이므로 이를 fetch하는 데 커밋 그래프 협상이 전혀 필요하지 않다. Git 서버는 역사를 순회하지 않으며, 단일 헤드 커밋과 현재 디렉터리 tree 객체들만 보낸다. 지난달의 과거 바이너리나 삭제된 파일은 refs/trunk/main에서 도달할 수 없으므로 전혀 전송되지 않는다.

3. 선형 감사 이력(refs/trunk/history)

개발자는 회귀를 조사할 때 여전히 git blame과 git log가 필요하다. 변경이 반영될 때마다 병합 스크립트는 정확히 같은 루트 $NEW_TREE SHA-1 해시를 가리키는 두 개의 커밋 객체를 생성한다.

 1# 즉각적인 depth-1 워크스페이스 체크아웃을 위한 부모 없는 스냅샷 커밋
 2SNAP=$(git commit-tree "$NEW_TREE" -m "$RAW_MSG")
 3
 4# 선형 git log 및 git blame을 위한 부모 연결 커밋
 5HIST=$(git commit-tree "$NEW_TREE" -p "$OLD_HIST" -m "$RAW_MSG")
 6
 7git push --atomic origin \
 8  --force-with-lease="refs/trunk/main:${OLD_MAIN}" \
 9  --force-with-lease="refs/trunk/history:${OLD_HIST}" \
10  "${SNAP}:refs/trunk/main" \
11  "${HIST}:refs/trunk/history"

refs/trunk/main과 refs/trunk/history를 모두 유지하면 저장소의 저장 공간이 두 배가 되는가? 전혀 그렇지 않다. 두 커밋이 동일한 $NEW_TREE 해시를 참조하므로, Git의 내용 주소 저장소는 tree와 blob 객체를 100% 중복 제거한다. 부모가 없는 스냅샷의 추가 저장 비용은 250바이트짜리 커밋 헤더 하나뿐이다.

4. 격리된 워크스페이스(refs/ws/<actor>/<id>)

진행 중인 작업 브랜치는 refs/heads/*가 아니라 refs/ws/<actor>/<id>에 push된다. 수백 개의 코딩 에이전트가 1분마다 체크포인트를 저장하더라도 브랜치 드롭다운을 어지럽히거나 사람 엔지니어의 git fetch를 느리게 만들지 않는다.

다섯 개의 명령으로 온보딩하기: 희소(sparse) + blobless 체크아웃의 작동 방식

이 참조 구조에서는 새 개발자나 일시적인 코딩 에이전트 컨테이너가 사용자 정의 CLI나 FUSE 드라이버를 설치할 필요가 없다. 온보딩은 다섯 개의 표준 Git 명령으로 끝난다.

 1# 1. blob 필터링과 희소 모드를 활성화하여 20 KB 컨트롤 플레인 clone
 2git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git
 3cd monorepo
 4
 5# 2. 저장소 별칭을 로드하고 refs/trunk/*를 연결
 6git config include.path ../.gitconfig
 7git trunk-init
 8
 9# 3. 이 작업에 필요한 도메인 디렉터리 선언
10git sparse-checkout set net/relay ipc/codec
11
12# 4. 최신 트렁크 스냅샷을 기반으로 격리된 워크스페이스 생성
13git ws-new alice/optimize-framing

내부적으로 각 단계는 필요한 최소한의 작업만 수행한다.

  1. git clone --filter=blob:none --sparse는 refs/heads/main의 20 KB 컨트롤 플레인을 내려받는다.
  2. git trunk-init은 +refs/trunk/*:refs/remotes/origin/trunk/*를 설정하고 blobless fetch를 실행한다. Git은 refs/trunk/main의 tree 메타데이터(보통 수십 킬로바이트)를 내려받지만 파일 blob은 0개를 내려받는다.
  3. git sparse-checkout set net/relay ipc/codec은 Git의 cone 모드 인덱스 필터에 루트 디렉터리와 그 두 개의 하위 트리만 구체화하도록 지시한다.
  4. git ws-new은 origin/trunk/main을 기반으로 하는 새 브랜치로 전환한다. 바로 그 순간 Git은 net/relay와 ipc/codec 안의 blob 객체만 필요할 때 요청한다. 모노레포의 나머지 95%는 디스크에 전혀 내려받아지지 않는다.

향후 전망: refs/trunk/main을 가상 파일시스템(VFS)으로 마운트하기

blobless clone을 사용한 희소 체크아웃은 추가 클라이언트 도구 없이 일상적인 워크플로를 충족한다. refs/trunk/main은 이미 내용 주소 저장소 안의 순수한 Merkle 트리이므로, 이 아키텍처는 Linux에서는 FUSE, macOS에서는 FileProvider, Windows에서는 ProjFS를 통한 선택적 사용자 공간 가상 파일시스템(VFS)의 직접적인 기반도 제공한다.

  1. 즉시 전체 트리 마운트: 경량 로컬 마운트 데몬이 refs/trunk/main의 루트 tree 객체를 읽고, 밀리초 안에 모노레포 전체 디렉터리 계층을 작업 디렉터리에 투영하므로 git sparse-checkout set을 실행할 필요가 없어진다.
  2. 온디맨드 blob 폴트: 컴파일러, IDE 또는 에이전트가 open(2) 또는 read(2) 시스템 호출을 발행하기 전까지 파일 내용은 가상 상태로 남아 있다. 그러면 데몬은 해당 SHA-1 blob을 CAS 엔드포인트에서 가져와 로컬 NVMe 저장소에 캐시한다.
  3. 워크스페이스용 쓰기 시 복사(Copy-on-Write) 오버레이: 로컬 편집 내용은 경량 상위 오버레이 계층에 기록된다. git ws-save를 실행하면 해당 오버레이의 변경된 경로만 해시하고, 갱신된 blob과 tree 객체를 기록한 뒤, 수정되지 않은 디렉터리를 검사하지 않고 결과 커밋을 refs/ws/<actor>/<id>에 push한다.

순수 Git 위의 변경 목록(Changelist) 모델: 서버 측 인메모리 반영

전통적인 Git 워크플로에서 기능 브랜치를 병합하면 두 부모를 가진 병합 커밋이 생성되거나(역사가 얽힌 철도 차량기지처럼 된다), 개발자가 수동으로 리베이스하고 main에 push하는 다른 엔지니어와 경쟁해야 한다.

google3의 변경 목록(Changelist, CL) 모델에서 영감을 받아, 우리 아키텍처의 모든 워크스페이스는 트렁크 스냅샷에 대한 단일 원자적 변경을 나타낸다. 로컬에서 ws/alice/optimize-framing을 작업하는 동안 개발자나 에이전트는 열 개의 점진적 커밋(wip, fix tests, lint)을 만들 수 있다. 변경이 반영될 준비가 되면, 서버는 CI 러너에서 작업 디렉터리를 전혀 체크아웃하지 않고도 워크스페이스의 델타를 하나의 선형 트렁크 리비전으로 압축한다.

다음은 Git 2.40 이상의 plumbing을 사용하여 랜딩 스크립트(land.sh)가 메모리 안에서 실행되는 방식이다.

 1# 1. 워크스페이스가 분기된 루트 스냅샷 커밋 식별
 2BASE=$(git rev-list --max-parents=0 "$WORK_SHA")
 3
 4BASE_TREE=$(git rev-parse "${BASE}^{tree}")
 5MAIN_TREE=$(git rev-parse "${OLD_MAIN}^{tree}")
 6WORK_TREE=$(git rev-parse "${WORK_SHA}^{tree}")
 7
 8# 2. 작업 트리 체크아웃 없이 메모리에서 병합된 루트 트리 계산
 9if [ "$MAIN_TREE" = "$BASE_TREE" ]; then
10  NEW_TREE="$WORK_TREE"
11elif OUT=$(git merge-tree --write-tree --merge-base="$BASE_TREE" "$MAIN_TREE" "$WORK_TREE"); then
12  NEW_TREE=$(head -n1 <<<"$OUT")
13else
14  echo "Merge conflict detected; run git ws-sync to rebase." >&2
15  exit 1
16fi

2단계를 자세히 살펴보라.

  • 워크스페이스 브랜치에서 로컬 커밋을 몇 개 만들었든, 스크립트는 세 개의 트리 해시만 비교한다. 원래의 기준 스냅샷($BASE_TREE), 현재 트렁크 스냅샷($MAIN_TREE), 최종 워크스페이스 트리($WORK_TREE)이다.
  • Alice가 //net/relay를 편집하는 동안 refs/trunk/main이 //web/blog에서 앞으로 나아갔다면, git merge-tree --write-tree는 Git 객체 저장소 안에서 몇 밀리초 만에 결합된 루트 트리 $NEW_TREE를 계산한다. 러너의 파일시스템에는 아무 파일도 기록되지 않는다.
  • 마지막으로 --force-with-lease를 사용한 git push --atomic은 refs/trunk/main을 갱신하고, refs/trunk/history에 선형 커밋 하나를 추가하며, 랜딩 트리거 refs/heads/land/<id>를 단일 트랜잭션으로 삭제한다.

아키텍처 경계 강제하기: 4계층 가시성과 계층적 OWNERS

코드를 하나의 저장소로 통합하는 것은 커밋이 반영되기 전에 도메인 간 경계가 기계적으로 강제될 때만 효과가 있다. git merge-tree --write-tree가 $NEW_TREE를 생성한 직후이며 git push --atomic이 트렁크를 갱신하기 전에, 정책 엔진은 git diff-tree "$MAIN_TREE" "$NEW_TREE"에 대해 두 개의 O(delta) 검사를 실행한다.

1. 4계층 도메인 가시성

루트 트리는 네 개의 가시성 계층으로 구분된다.

디렉터리 접두사계층허용된 import금지된 importOWNERS 게이트
//web/*, //net/*, //ipc/*, //storage/*, //crypto/*, //ai/*, //devtools/*Public ProductionPublic Production, //third_party/*//corp/*, //experimental/*강제 적용
//third_party/*External Vendor자체 완결형 또는 //third_party/*third_party가 아닌 모든 도메인강제 적용
//corp/*Internal PrivatePublic Production, //corp/*, //third_party/*//experimental/*강제 적용
//experimental/<actor>/*Sandboxcorp가 아닌 모든 패키지//corp/*면제(자동 승인)

//web/*의 공개 패키지가 //corp/*의 내부 패키지나 //experimental/*의 졸업하지 않은 프로토타입을 import하려고 하면, 랜딩 작업은 refs/trunk/main을 건드리기 전에 중단된다.

2. Gerrit 서버 없이 하는 계층적 OWNERS 검증

상속 규칙이 있는 디렉터리 단위 OWNERS 파일을 강제하기 위해 전용 Gerrit 인스턴스가 필요하지는 않다.

  • $MAIN_TREE와 $NEW_TREE 사이에서 수정된 모든 경로에 대해, 검증기는 파일의 디렉터리에서 //OWNERS까지 위로 탐색한다.
  • 어떤 OWNERS 파일이든 set noparent를 선언하여 상위 디렉터리로부터의 상속된 승인을 차단하거나, per-file *.sql=@dba-team을 선언하여 파일 이름 글롭(glob)별로 승인 범위를 지정할 수 있다.
  • 변조 방지 불변식: 기존 OWNERS 파일은 항상 $NEW_TREE가 아니라 $MAIN_TREE(현재 트렁크 트리)에서 읽는다. 승인되지 않은 작성자는 같은 워크스페이스 커밋에서 OWNERS를 편집하여 스스로에게 권한을 부여할 수 없다.
  • 샌드박스 면제: //experimental/<actor>/* 아래의 모든 경로는 OWNERS 검사를 우회하므로, 엔지니어와 에이전트는 git ws-exp <actor> <project>로 자유롭게 프로토타이핑할 수 있고, 이후 git ws-graduate로 패키지를 프로덕션 도메인으로 졸업시킬 수 있다.

마찰 없는 코드 리뷰: 일회성 PR과 스냅샷 diff

refs/heads/main은 컨트롤 플레인만 보유하고 refs/trunk/main은 부모가 없는 커밋이므로, 워크스페이스 브랜치에서 main으로 표준 GitHub Pull Request를 바로 여는 것은 관련 없는 역사를 비교하려고 시도하게 된다. 우리는 서로 다른 변경에 맞춘 세 가지 리뷰 워크플로로 이 문제를 해결했다.

  1. 즉시 사용하는 두 점(two-dot) 워크스페이스 diff(git ws-save + Edge Diff Router) git ws-save로 워크스페이스를 push하면 경량 엣지 워커를 통해 refs/ws/<actor>/<id>가 노출된다. 이 워커는 refs/trunk/main과 워크스페이스 참조를 각각의 커밋 SHA로 해석하고, GitHub의 두 점 비교 화면(/compare/<trunk-sha>..<ws-sha>)으로 리다이렉트한다. 작은 패치나 에이전트가 생성한 변경의 경우, 리뷰어는 diff를 검토한 뒤 웹 UI에서 Land를 클릭할 수 있다.
  2. 일회성 GitHub Pull Request(git ws-pr) GitHub에서 줄 단위 리뷰를 하려면, git ws-pr이 루트 스냅샷 커밋($(git rev-list --max-parents=0 HEAD))을 refs/heads/base/<id>에 push하고 워크스페이스 끝 커밋을 refs/heads/review/<id>에 push한다. 그런 다음 review/<id>를 base/<id>와 비교하는 GitHub Pull Request를 연다. 기준 브랜치가 워크스페이스가 분기된 정확한 스냅샷이므로, GitHub의 Files changed 탭에는 워크스페이스에서 수정된 줄만 표시된다. 리뷰어가 Merge pull request를 클릭하면, 웹훅이 헤드 커밋을 refs/heads/land/<id>로 승격하고, 인메모리 랜딩 파이프라인을 실행하며, refs/heads/base/<id>와 refs/heads/review/<id>를 모두 자동으로 삭제한다.
  3. 외부 미러 Pull Request 오픈소스 기여자는 모노레포 워크플로를 배울 필요가 전혀 없다. 평소처럼 포크하고 독립형 미러 저장소를 대상으로 풀 리퀘스트를 연다.

O(delta) 양방향 동기화와 별도 프로덕션 브랜치

많은 조직은 외부 사용자가 모노레포를 건드리지 않고 go get, cargo install을 실행하거나 단일 프로젝트를 포크할 수 있도록 개별 패키지를 독립형 GitHub 저장소로 공개한다. 정적 블로그나 릴리스 게이트가 있는 웹 앱 같은 일부 프로젝트는 main과 나란히 별도의 production 브랜치도 유지한다.

우리의 Starlark 동기화 엔진은 외부 내보내기와 내부 기여를 모두 O(delta) 시간에 처리한다. 이 시간은 모노레포의 크기가 아니라 커밋에서 변경된 파일 수에 비례한다.

  • Blobless 인덱스 스플라이싱: 미러 저장소의 하위 트리를 모노레포 루트 트리에 끼워 넣거나 그 밖으로 뺄 때, 엔진은 GIT_NO_LAZY_FETCH=1을 설정하고 git update-index --index-info에 이어 git write-tree --missing-ok를 실행한다. 수정되지 않은 하위 트리와 blob은 CI 러너로 내려받지 않고 SHA-1 해시만으로 참조된다.
  • 델타 전용 blob 프리페칭: 내보낸 커밋을 독립형 미러에 push하기 전에, 스크립트는 git diff-tree -r --diff-filter=AM "$ext_parent" "$export_tree"를 실행하고, git cat-file --batch를 통해 수정된 파일의 이전 blob과 새 blob만 프리페치한다. 그러면 Git은 얇은 델타 pack을 계산하고 몇 초 안에 미러 저장소로 push할 수 있다.
  • 워크플로 및 릴리스 브랜치 보존: 외부 내보내기 중에 동기화기는 각 독립형 저장소 고유의 .github/workflows/ 디렉터리를 보존하므로, 공개 CI 러너가 미러에서 프로젝트 테스트를 실행한다. Starlark 구성의 각 워크플로는 main과 같은 명시적 브랜치를 대상으로 하므로, 별도의 production 브랜치에서 배포하는 저장소의 릴리스 게이트가 유지된다. 모노레포 랜딩은 미러의 main 브랜치와 동기화되며, main에 있는 자동화된 메타데이터나 번역 커밋은 웹훅을 통해 refs/trunk/main으로 다시 동기화된다. main을 production으로 승격하는 일은 여전히 명시적인 릴리스 단계로 남는다.

맺음말

고속 모노레포를 구축하는 데 표준 Git을 버리거나 복잡한 맞춤형 인프라 서버 집단을 운영할 필요는 없다. 이 아키텍처는 Git의 객체 저장소를 내용 주소 저장소로 취급하고, refs/heads/main을 refs/trunk/main 및 refs/trunk/history와 분리하며, git merge-tree --write-tree로 트리를 메모리에서 병합한다. 그 결과 사람 엔지니어와 자율 코딩 에이전트 모두 1초 이내에 워크스페이스를 clone할 수 있고, 수백 개의 프로젝트에 걸쳐 원활하게 확장할 수 있다.