모든 규모의 Git
대규모 Git 리포지토리 호스팅은 악몽과도 같습니다. Linus Torvalds가 _지옥에서 온 정보 관리자_의 첫 버전을 설계했을 때(실제로 Git의 태그라인입니다. 확인해 보세요), 그가 염두에 둔 사용 사례는 아주 구체적이었습니다. 바로 자기 자신이었습니다. 그는 Linux 커널 개발에 사용되던 분산 버전 관리 시스템인 BitKeeper를 대체하고자 했습니다. 물론 그 대체재도 분산형이어야 했습니다. 커널은 독특한 소프트웨어 프로젝트입니다. 다양한 하위 시스템마다 서로 다른 관리자가 있어 매우 분산되어 있습니다. 분산 버전 관리 시스템은 이런 워크플로우에 자연스럽게 잘 맞습니다.
20년이 지난 지금 Git은 업계 표준이 되었지만, 사실 분산형이라는 특성은 장점보다 걸림돌에 가깝습니다. 일반적인 오픈 소스 소프트웨어 프로젝트는 분산형 워크플로우로 운영되지 않습니다. 일반적인 기업은 말할 것도 없습니다. 오프라인 작업이나 push 지연 등 분산 모델의 여러 장점을 활용하지만, 중앙 집중식 호스트에 크게 의존합니다. 그리고 Git 리포지토리 호스팅은 생각보다 훨씬 어렵습니다.
Git에서 어려운 점은 무엇인가요?
Git 리포지토리를 대규모로 호스팅할 때의 어려움은 Git 자체의 설계에 내재되어 있습니다. 분산형 버전 관리 시스템에서는 리포지토리의 모든 인스턴스가 동일합니다. Git 서버의 리포지토리라고 해서 개발자 노트북의 리포지토리와 다른 특별한 점은 없습니다. 처음에는 Git 리포지토리 호스팅이 간단해 보일 수 있습니다(디스크에 있는 리포지토리 복사본 앞에 HTTP 데몬을 두기만 하면 Git 서버가 됩니다!). 하지만 실제로는 확장성과 안정성 측면에서 상당한 과제가 많습니다.
일반적인 Git 리포지토리에서는 코드와 메타데이터(파일, 커밋, 트리)가 압축되어 _packfile_에 저장됩니다. packfile은 로컬 머신에서는 다루기 편리하지만 서버에서 대규모로 관리하기에는 적합하지 않은 단순한 바이너리 직렬화 형식입니다. Packfile은 Git 저장소와 Git 네트워킹 모두의 기본 구성 요소입니다. 리포지토리에 데이터를 push하거나 fetch하면 packfile 형태로 전송됩니다.
Git은 설계상 이렇게 동작하지만, 꼭 그럴 필요는 없다고 생각해도 무리는 아닙니다. 결국 Git 클라이언트는 제어할 수 없지만(적어도 사용자를 불편하게 하고 상당한 마찰을 초래하지 않고서는), 자체 서버 내부에서는 원하는 무엇이든 할 수 있습니다. packfile을 반드시 사용해야 하는 것은 아닙니다. Linus가 와서 확인하지는 않을 테니까요. 유일한 제약은 모든 Git 작업에서 네트워크를 통해 _packfile_을 주고받아야 한다는 점입니다.
수년간 Git 리포지토리를 대규모로 호스팅해 온 기업들은 이 packfile 기반 설계가 가용성과 확장성 모두의 주요한 제약이라는 사실을 알게 되었습니다. Packfile은 Git이 접근하려면 파일 시스템에 존재해야 하는 대용량 바이너리 파일입니다. 디스크의 리포지토리 앞에 HTTP 서버를 두는 단순한 방식에는 한계가 매우 큽니다. 이상적으로는 리포지토리가 여러 디스크와 여러 머신에 존재해야 합니다(이렇게 하면 많은 Git 작업을 병렬로 실행할 수 있고 서버가 충돌해도 리포지토리를 계속 사용할 수 있습니다). 하지만 어떻게 해야 할까요?
이를 달성하는 방법은 복잡도가 낮은 순서대로 크게 세 가지입니다. 파일 시스템을 분산하거나, packfile을 분산하거나, Git 자체를 분산하는 것입니다.
packfile 없는 Git
Git은 콘텐츠 주소 지정 방식의 데이터 저장소입니다. Git 리포지토리의 모든 객체(blob, tree, commit 등)는 콘텐츠의 SHA-1을 키로 사용합니다. 이는 분산 키-값 저장소(키는 SHA-1, 값은 실제 객체)와 직관적으로 매우 잘 맞으며, 리포지토리 스토리지를 깔끔하게 수평 확장할 수 있는 방법이 될 수 있습니다. 하지만 실제로는 작동하지 않습니다.
문제는 Git 리포지토리의 실제 구조가 방향 비순환 그래프(줄여서 DAG)라는 점입니다. SHA를 통해 어떤 객체든 조회할 수 있지만, repo에서 가장 단순한 작업조차 수행하려면 DAG를 한 단계씩 실제로 순회해야 합니다.
리포지토리의 최근 변경 사항을 나열하는 작업을 하려면 commit을 처리해야 합니다. commit을 처리하면 해당 tree의 루트를 가리키는 포인터를 얻습니다. 그 tree에서는 각 파일과 하위 tree를 가리키는 포인터를 얻습니다. 원래 commit에서는 부모 commit(히스토리에서 그 이전에 있는 commit)을 가리키는 포인터를 얻습니다. 핵심은 이 순회의 모든 단계에서 이전 포인터를 fetch하기 전까지 다음 포인터의 값을 알 수 없다는 점입니다. 모든 fetch에 분산 저장소까지의 왕복이 필요하다면 비용은 순식간에 크게 증가합니다.
객체 수준에서 Git을 분산하는 이 방식은 이전에도 여러 차례 시도되었지만, 대규모 환경에서는 종종 실패했습니다. 가장 유망한 구현은 제 전 멘토인 Shawn Pearce가 Google의 버전 관리 시스템 팀에서 일할 때 시도한 것입니다. 그의 접근 방식은 분산 해시 테이블에 객체를 저장하는 것이었습니다. 이는 Java로 작성된 맞춤형 Git 구현인 JGit 덕분에 가능했습니다. 여느 훌륭한 Java 라이브러리처럼 JGit은 일반 Git 리포지토리의 모든 세부 사항을 추상화할 수 있을 만큼 다양한 인터페이스, 팩토리, 인터페이스 팩토리를 제공하며, 여기에는 디스크의 packfile을 DHT로 대체하는 것도 포함됩니다. 시스템은 작동했고 일반 Git 작업에는 충분히 좋은 결과를 보였지만, Git 프로토콜의 한계(다시 말해 서버에서 데이터를 어떻게 저장하든 네트워크를 통해 _packfile_을 전송해야 함) 때문에 git clone 성능이 설계 전체를 폐기할 만큼 나빴습니다.
GitHub와 파일 시스템
Git이 Linux 커널이라는 울타리를 벗어나기 시작한 지 몇 년 후, 샌프란시스코에서 패기 넘치는 스타트업이 탄생했습니다. GitHub는 2008년에 "더 이상 골칫거리가 아닌 Git 리포지토리 호스팅"이라는 매우 선견지명 있는 태그라인을 내건 소셜 코딩 플랫폼으로 설립되었습니다. 농담이 아닙니다. 직접 확인해 보세요. 2008년 당시에도 Git의 분산형 설계에도 불구하고(혹은 어쩌면 그 설계 때문에) Git 리포지토리를 사용자 친화적으로 만들려면 중앙 집중식으로 호스팅할 방법이 필요하며, 이를 구현하는 일은 매우 고통스럽다는 폭넓은 공감대가 있었습니다. GitHub는 이를 바꾸기로 했습니다.
이 플랫폼은 처음에는(그리고 지금도 대부분) Rails 모놀리스였습니다. 최초 버전은 Ruby 서버와 그 옆 디스크에 저장된 리포지토리 복제본을 갖춘 단일 고성능 머신에서 실행되었습니다. Rails 앱의 확장은 쉽습니다. 인스턴스를 더 배포하면 됩니다. 하지만 이 경우에는 Git이 관련되어 있으므로, 이들은 곧 여기서 해결하려는 반복되는 질문에 부딪혔습니다. Rails 앱이 디스크의 Git 리포지토리에 접근해야 한다면, 어떻게 그 복제본을 더 많이 배포할 수 있을까요?
알뜰한 이단아들로 이루어진 초기 GitHub 시스템 엔지니어들은 확장성 과제를 해결할 수 있는 가장 단순한 접근 방식을 시도했습니다. _파일 시스템_을 분산하는 데 집중한다면(packfile이나 Git 자체를 분산하는 대신), Rails 앱을 변경하지 않고 Git으로 이상한 작업을 하는 대신 계속 증가하는 사용자 기반을 위한 더 많은 기능을 제공하는 데 시간을 쓸 수 있다는 생각이었습니다. 매우 실용적인 접근이었습니다. 하지만 작동하지 않았습니다.
팀은 Git 데이터를 위한 분산 파일 시스템에 여러 접근 방식을 시도했습니다. 가장 명백한 방식인 NFS를 사용해 모든 리포지토리를 중앙 집중식 서버에 저장하는 방식은 빠르게 폐기되었습니다. 기본 Git 구현은 느린 개발자 노트북의 로컬 파일 시스템에서 적절한 성능을 보장하는 파일 시스템 의미 체계(잠금, 분할, 읽기, 동기화 등)에 관해 많은 가정을 하지만, 네트워크 파일 시스템에서 어떻게 동작하는지는 전혀 고려하지 않습니다. 느렸고 버그도 많았습니다.
이후에는 (솔직히 돌이켜 보면 끔찍한) 블록 수준에서 파일 시스템을 복제하는 기술로 추가 시도가 이루어졌습니다. GFS를 사용한 단기간의 배포도 있었고, DRBD를 기반으로 한 더 장기간의 배포도 있었습니다. 모두 한계에 부딪혔습니다. 일상적으로 운영하기에 끔찍했고, 뛰어난 성능으로 이를 보상하지도 못했습니다. 모든 문제는 디스크상의 packfile 설계로 귀결됩니다.
Git의 그래프형 데이터 구조가 왕복 작업을 엄청나게 비싸게 만드는 방식은 이미 살펴봤습니다. 안타깝게도 매우 유사한 원칙이 디스크에 저장된 기본 데이터에도 적용됩니다. DAG에서 객체의 배치와 _packfile_에 배치되는 방식 사이에는 상관관계가 없습니다. _packfile_을 생성할 때 사용되는 핵심 휴리스틱은 크기를 최소화하는 것입니다. 객체는 pack 전체에 무작위로 배치되고 압축되며, 결정적으로 온전한 형태로 저장되는 경우가 드뭅니다. 대부분의 객체는 동일한 packfile 내 다른 객체를 기반으로 한 델타로 저장됩니다. 그래프 데이터 구조에서 수많은 논리적 이동을 따라간 후 개별 객체를 읽으려면, 디스크 형식에서 물리적 이동도 따라가야 합니다.
리포지토리에서 수행되는 모든 Git 작업마다 발생하는 이처럼 수 기가바이트의 데이터를 가로지르는 무작위 순회는 네트워크 파일 시스템과 잘 맞지 않습니다(파일 수준에서 복제하든 블록 수준에서 복제하든). 속도가 기어가듯 느려지는 일을 피하려면 전체 파일을 로컬에 캐싱해야만 합니다. 하지만 동일한 파일 시스템에 수십만 개의 리포지토리가 있으면 캐싱은 선택지가 아닙니다.
결국 GitHub의 시스템 엔지니어들은 어려운 결정을 내리고 파일 시스템 분산을 포기했습니다. 리포지토리를 전용 파일 서버에 둘 수 있도록 RPC 시스템을 개발하기 시작했고, 모든 작업을 원격으로 수행하도록 Rails 앱을 업데이트했습니다. 이는 상당한 수준의 수평 확장성을 제공했지만, 가용성이나 가장 바쁜 리포지토리의 성능 문제는 해결하지 못했습니다. 결국 각 리포지토리는 여전히 단일 머신에만 저장되어 있었기 때문입니다.
Spokes와 일관성
Spokes는 2013년경 GitHub에서 처음 개발되었으며, 이후 업계 표준으로 자리 잡았습니다. 대부분의 Git 호스팅 서비스는 아키텍처에 Spokes 접근 방식(Git 리포지토리의 애플리케이션 수준 복제)을 변형해 사용합니다. Spokes가 오랫동안 잘 작동해 온 주된 이유는 시간이 지나며 최적인 것으로 입증된 세 가지 근본적인 선택을 했기 때문입니다:
- Git 자체를 분산하지 않고 packfile 수준에서 작동합니다.
- 모든 데이터를 로컬 NVMe 디스크에 실제 Git 리포지토리 형태로 저장합니다.
- Git 데이터를 복제하되, 모든 복사본의 동기화 상태를 일관되게 유지합니다.
방금 살펴본 packfile 전반의 무작위 읽기 패턴 때문에, 모든 기본 Git 작업을 빠르게 유지하려면 일반 Git 리포지토리를 NVMe 드라이브에 저장하는 것이 사실상 필수입니다. 또한 데이터를 Git 클라이언트가 기대하는 형태로 변환할 필요가 없으므로 clone도 효율적으로 유지됩니다. 더불어 특이한 리포지토리에서 작동하도록 Git 포크를 직접 유지 관리하는 대신, Git 위에서 제품을 만드는 데 집중할 수 있습니다.
데이터의 모든 복사본을 일관되게 동기화된 상태로 유지하는 것 역시 매우 중요합니다. 이는 직접 어려움을 겪으며 알게 되는 사실이지만, Git 클라이언트는 정말로 최종 일관성과 잘 맞지 않습니다. 로컬 Git 클라이언트가 commit을 push한 뒤 fetch 직후 이를 읽지 못한다면 심각한 문제입니다. Git은 이런 상황을 매우 혼란스러워합니다. CI pipeline을 100개의 runner에서 실행하는데 그중 3개가 리포지토리를 clone한 후 테스트해야 할 commit을 찾지 못한다면, 이 역시 심각한 문제입니다. 사용자 경험도 매우 나빠집니다.
클라이언트든 백엔드든 Git 리포지토리의 최종 일관된 뷰를 다루는 데는 많은 함정이 있습니다. 따라서 Spokes는 시스템이 항상 완전히 일관된 상태를 유지하도록 보장하기 위해 매우 높은 복잡성 비용을 감수합니다. 이것이 정확히 무엇을 의미하는지 살펴보겠습니다.
Spokes는 합의 기반 분산 시스템입니다. 서로 다른 서버에 Git 리포지토리의 여러 복제본을 저장하는 방식으로 작동합니다. 새 데이터를 push할 때마다 오케스트레이터가 push를 팬아웃하여 리포지토리의 모든 인스턴스가 복사본을 받도록 합니다. 이 "팬아웃"은 고전적인 합의 알고리즘인 3PC(3단계 커밋)로 동기화되므로, 과반수 노드가 이를 승인할 때에만 push가 수락됩니다.
Spokes가 3PC를 사용하는 방식을 더 자세히 설명하기 전에 Git push가 작동하는 방식을 이해해야 합니다. Git push는 _packfile_과 _참조 트랜잭션_이라는 두 구성 요소로 이루어집니다. 이미 살펴본 packfile에는 리포지토리에 push하는 객체(변경 사항이 포함된 blob, tree, commit)가 들어 있습니다. 트랜잭션은 하나 이상의 참조(예: 작업 중인 branch)가 방금 push한 commit을 가리키도록 업데이트하여 실제로 변경 사항을 리포지토리에 게시합니다.
이러한 분리는 여기서 매우 유용합니다. push된 commit은 이를 가리키는 참조가 업데이트되기 전까지는 표시되지 않기 때문입니다(Git 용어로는 "도달 가능"). 즉, packfile을 모든 host에 동시에 팬아웃하고(여기서는 동기화할 필요가 없습니다) 그런 다음 packfile보다 훨씬 작아 더 빠르게 동기화할 수 있는 참조 트랜잭션으로 3단계 커밋을 수행하여 push에 대한 합의를 구현할 수 있습니다. Git 자체는 참조 트랜잭션 준비를 지원합니다. 참조를 잠그고 기존 값이 예상한 값인지 확인한 다음, 트랜잭션에 대한 commit 또는 abort 명령을 받을 때까지 잠금을 유지할 수 있습니다.
이 설계를 통해 모든 push가 모든 복제본에 걸쳐 완전히 동기화되도록 보장합니다. 그러면 모든 복제본이 항상 최신 상태이므로 읽기(fetch, clone)를 어느 단일 복제본으로든 안전하게 라우팅할 수 있습니다.
이것이 본질적으로 Spokes의 작동 방식이며, 지난 13년간 상당히 잘 작동해 왔습니다. 물론 Spokes도 완벽하지는 않습니다. 완벽한 시스템은 없습니다. 2026년에는 사람들이 Git 리포지토리를 사용하는 방식이 크게 바뀌었고, 그 과정에서 분산 시스템 구축에 관한 많은 중요한 교훈을 얻었습니다. 시간과 경험은 Spokes의 선택 중 어떤 것이 최적인 것으로 드러났고 어떤 것이 그렇지 않았는지를 보여주었습니다.
결정적인 것으로 드러난 한 가지 결함은 3PC의 제한적인 수평 확장성입니다. Spokes가 처음 출시되었을 때는 리포지토리당 복제본 세 개가 최적의 균형점이었습니다. 일반적인 리포지토리는 여유 역량을 남기면서 세 개의 사본으로 서비스할 수 있었고, 한 대의 머신이 다운되더라도 계속 push를 수락할 만큼 충분한 중복성도 확보할 수 있었습니다.
2026년에는 상황이 크게 달라졌습니다. 이제 기업의 일반적인 리포지토리는 거대한 모노레포입니다. 이런 repo의 트래픽을 처리하기에는 복제본 세 개로 충분하지 않으며, 특히 CI에서는 더욱 그렇습니다. 물론 두려운 규모에 따른 꼬리 지연을 제외하면 Spokes를 복제본 세 개 이상으로 실행하지 못할 이유는 없습니다. 3단계 커밋은 Git 트랜잭션 모델과 매우 자연스럽게 맞아떨어지지만, 합의 알고리즘으로서는 근본적인 한계가 있습니다. 각 단계의 지연 시간은 클러스터 내 서버 중 가장 느린 서버에 의해 결정됩니다. 클러스터에 복제본을 더 추가할수록 push 처리량은 더 떨어집니다.
이 확장성 제약은 반대 방향에서도 적용됩니다. 에이전트가 대규모로 Git 리포지토리와 작업할 때는 수많은 작은 리포지토리를 만들어 모노레포 밖에서 작업하는 경우가 많습니다. 이들 중 상당수는 일회용이고, 대부분은 거의 사용되지 않습니다. Spokes는 이러한 리포지토리 각각에 여전히 복제본 세 개를 요구하기 때문에 이 경우에도 어려움을 겪습니다. 대부분 유휴 상태인 복제본 세 개는 줄일 수도 없습니다. 줄이면 시스템이 완전히 일관된 상태를 유지할 수 없고 데이터 손실이 발생할 수 있기 때문입니다. 3단계 커밋에서는 최소치는 항상 너무 높고 최대치는 너무 낮습니다.
처음에는 알 수 없지만 직접 겪고 나면 고통스러울 정도로 분명해지는 또 다른 결함은 Spokes가 대규모 운영에 까다로울 수 있다는 점입니다. 디스크의 리포지토리가 항상 합의를 위한 소스 오브 트루스이므로, 모든 리포지토리의 모든 사본은 매우 중요합니다. 리포지토리는 가축이 아니라 반려동물처럼 다뤄야 합니다.
우선 이는 모든 리포지토리의 위치를 정확히 파악해야 한다는 뜻입니다. 이로 인해 각 리포지토리를 복제된 모든 머신에 매핑하는 매우 큰 라우팅 테이블을 유지해야 하는 외부 데이터베이스에 대한 종속성(그리고 잠재적인 가용성 문제)이 생깁니다. 또한 모든 리포지토리의 체크섬을 계산하고, 리포지토리가 디스크에서 유효한 상태로 유지되도록 해당 테이블의 체크섬을 지속적으로 업데이트해야 합니다. 리포지토리에 문제가 발생하면(믿으세요. 나쁜 일은 항상 일어납니다 — 실제로 Git은 매우 까다로울 수 있습니다) 이를 탐지하고 정상 상태로 되돌릴 복구 작업을 예약해야 합니다. 그리고 매우 신속하게 처리해야 합니다! 다시 말하지만 디스크의 리포지토리가 소스 오브 트루스이기 때문입니다. 손상된 사본은 누락된 사본만큼이나 문제입니다. 세 사본 중 두 개가 손상되면 시스템은 더 이상 push를 수락할 수 없습니다. 정족수가 없기 때문입니다.
연속성
연속성(줄여서 Cnt)은 Cursor에서 개발한 Git 스토리지 시스템입니다. 접근 방식은 매우 명확합니다. Spokes가 잘한 모든 점에서 배우고, 오랜 시간을 거치며 문제라고 밝혀진 부분을 해결하는 것입니다.
_Cnt_는 단순한 시스템입니다(단순하지 않은 시스템은 운영하기도 쉬울 수 없습니다). 그 기반이 되는 핵심 프리미티브는 S3 호환 객체 스토리지에 저장하는 선행 기록 로그입니다. 프로덕션에서는 S3에서 직접 실행하지만, 어떤 클라우드에도 배포할 수 있도록 설계했습니다.
리포지토리가 push를 받으면 이를 S3에 WAL 항목으로 저장합니다. 완전히 영속되기 전에는 절대로 push에 성공 응답을 보내지 않습니다. 각 push는 별도의 객체로 저장되며, push된 packfile을 디스크에 쓰는 동시에 S3에 업로드합니다. 하지만 WAL 항목을 업로드하는 것만으로는 공개되지 않습니다. 로컬 리포지토리 복사본에서 해당 참조 트랜잭션을 성공적으로 준비하고, 스토어 내 별도 객체인 WAL 인덱스 파일에 WAL 항목을 가리키는 포인터를 기록해야 비로소 push가 표시됩니다. 이로써 모든 push는 선형화 가능해야 합니다.
push마다 S3 쓰기를 단 한 번만 수행하지 않으려 합니다. 사용량이 많은 리포지토리에서는 S3 PUT 작업의 지연 시간이 push 처리량의 확실한 상한으로 작용하기 때문입니다. 세심하게 조정한 일괄 처리 구현과 복제본 정족수가 아닌 단일 로컬 리포지토리와만 참조 트랜잭션을 동기화하면 된다는 조건 덕분에, 디스크가 허용하는 속도만큼 빠르게 push를 수집할 수 있는 시스템을 만들었습니다.
물론 리포지토리의 로컬 복사본은 매우 빠른 NVMe 드라이브에 저장된 일반 Git 리포지토리입니다. Spokes가 하는 것과 같은 방식을 사용합니다. Spokes가 이 부분은 정확히 잘했다고 생각하기 때문입니다. 이를 통해 업스트림 Git 클라이언트와 다양한 성능 최적화를 비롯한 Git 커뮤니티의 훌륭한 OSS 저작물을 모두 재사용할 수 있습니다. Git으로 이상한 작업을 하는 대신 새 기능을 배포하는 데 집중할 수 있습니다.
합의
Spokes 클러스터를 운영하기 어려운 이유 중 하나는 각 서버에서 모든 리포지토리의 위치를 정확히 추적해야 한다는 점입니다. _Cnt_는 이를 완전히 다르게 처리합니다. 모든 리포지토리는 어디에 있을까요? 답은 "어디에나"입니다. 중요하지 않습니다! 리포지토리를 디스크의 웜 캐시처럼 취급하지만, 소스 오브 트루스는 항상 S3의 선행 기록 로그입니다. 시스템은 상태 비저장이며, 운영해야 할 라우팅 테이블도 없습니다(관계형 데이터베이스도 없으니 — 그야말로 축복입니다). 호스트에서 리포지토리에 접근할 때 로컬 디스크에 없다면 WAL에서 구체화하면 됩니다. 이를 매우 효율적으로 수행할 수 있지만, 낭비이므로 물론 항상 이렇게 하지는 않습니다. 프로덕션에서는 rendezvous hashing을 사용해 리포지토리 ID를 해당 리포지토리가 있을 것으로 예상되는 노드 목록에 매핑합니다. 리포지토리를 라우팅하는 데 필요한 상태는 리포지토리 ID와 클러스터 내 현재 정상 노드 집합뿐입니다. 하지만 이 상태가 동기화되지 않더라도(예: 노드가 비정상 상태가 되는 경우) 전혀 문제없습니다. 다음 노드 어디에서든 리포지토리를 구체화하면 됩니다.
합의는 어떨까요? 리더 선출은요? 특정 리포지토리의 기본 노드는 어느 서버일까요? 이것 역시 중요하지 않습니다! 여기에는 상태도 합의도 없습니다. 어떤 서버든 기본 노드가 될 수 있습니다. 선행 기록 로그의 모든 업데이트는 S3의 원자적 compare-and-swap(CAS) 작업으로 동기화되므로, 리포지토리의 어떤 인스턴스가 push를 받더라도 항상 안전합니다. 다시 말해 라우팅과 마찬가지로 임의의 서버가 기본 노드 역할을 맡게 하는 것이 가장 효율적인 방식은 아닙니다(CAS 재시도로 인해 push가 지연될 수 있습니다). 따라서 실제로는 rendezvous hashing의 순위 목록에서 첫 번째 서버를 항상 기본 노드로 선택합니다. 하지만 배포, 장애 조치, 일시적인 네트워크 문제 같은 예외적인 경우에는 정확히 어느 서버가 기본 노드인지 신경 쓰지 않습니다. 시스템은 성능이 저하된 상태에서도 항상 정확하고, 정상 상태에서는 항상 빠르도록 설계되었습니다.
복제
S3에 선행 기록 로그를 저장하면 규모 확장에 있어 무한한 가능성이 열립니다. S3의 확장성은 타의 추종을 불허하며 모든 복제본이 그곳에서 직접 최신 상태를 따라잡을 수 있으므로, 말 그대로 복제본을 얼마든지 둘 수 있습니다. 클러스터 전체에 가십 UDP 패킷을 전송해 낙관적 복제를 수행합니다. 패킷에는 각 복제본이 매 push 후 S3에서 직접 최신 상태를 따라잡는 데 필요한 모든 메타데이터가 포함됩니다. 시공간을 넘어 화면 너머에서 여러분이 "말도 안 돼"라고 중얼거리는 소리가 들립니다. "UDP는 신뢰할 수 있는 전송 방식이 아니잖아." 물론 그렇습니다. 분산 시스템에서는 그 무엇도 신뢰할 수 없습니다! 네트워크도, 라우팅도, 토폴로지도 신뢰할 수 없습니다. 하지만 괜찮습니다. 중요하지 않기 때문입니다. 각 복제본은 자신이 최신 상태를 따라잡은 WAL 인덱스의 마지막 버전 ETag를 알고 있습니다. 복제본에서 읽기 작업을 수행하면 예상한 ETag를 사용해 S3에 조건부 GET 요청을 보냅니다. 본문 없는 304 응답은 최신 상태라는 뜻이며, 즉시 fetch 또는 clone을 처리할 수 있습니다(메타데이터만 처리하는 S3 작업이므로 평균 10ms 미만으로 거의 즉시 완료됩니다). 200 응답에는 최신 버전의 WAL 인덱스가 포함되며, 이를 사용해 최신 상태를 따라잡은 후 읽기 요청을 처리합니다.
복제 UDP 패킷이 유실되거나 토폴로지가 변경되어 엉뚱한 서버에 도착해도 상관없습니다. 모든 복제본에서의 모든 읽기는 소스 오브 트루스인 S3를 기준으로 검증되므로 완전히 일관됩니다. 이 시스템은 성능이 저하된 상태에서도 항상 정확하고, 정상 상태에서는 항상 빠르도록 설계되었습니다.
이는 두 가지를 의미합니다. 첫째, 시스템이 항상 일관되므로 그 위에 인프라를 구축하기가 간단합니다. 우리(에이전트, 웹 인터페이스, 클라이언트)는 항상 리포지토리의 전역적으로 일관된 상태를 확인합니다. 또한 시스템은 양방향으로 확장되므로 모든 리포지토리에 꼭 맞는 수의 복제본이 제공됩니다. 대규모 모노레포는 CI 작업의 모든 부하를 처리하도록 수백 개의 복제본에 걸쳐 배포할 수 있습니다. 에이전트가 만든 수백만 개의 작은 리포지토리는 각각 하나의 복제본으로 처리할 수 있습니다. S3가 소스 오브 트루스이므로 가용성을 보장하기 위해 두 개 이상은 필요하지 않습니다. 사실 유휴 리포지토리에는 하나조차 필요하지 않습니다. 복제본이 한동안 트래픽을 받지 않으면 노드 디스크에서 가비지 컬렉션하고, 다음 fetch 요청이 들어올 때 WAL에서 다시 구체화합니다.
컴팩션
선행 기록 로그는 주기적으로 컴팩션해야 합니다. 로그가 무한정 커지도록 둘 수는 없습니다. 전체 복원 시 모든 항목을 재생하므로 항목이 많을수록 비용이 커집니다.
공교롭게도 일반 Git 리포지토리도 WAL 기반은 아니지만 주기적인 컴팩션이 필요합니다. 앞서 Git 리포지토리의 기본 저장 단위는 _packfile_이라고 살펴봤습니다. 리포지토리의 원격 복제본에 push하거나 로컬 복제본으로 fetch할 때마다 새 packfile이 생성됩니다. 이는 무한정 확장될 수 없습니다. 각 packfile에는 포함된 객체를 Git이 효율적으로 찾을 수 있도록 자체 인덱스가 연결되어 있지만, 이 조회는 개별 packfile 단위에서만 효율적입니다. 특정 객체를 찾을 때 리포지토리에 packfile이 100개 있다면, packfile 중 하나에서 객체를 찾을 때까지 각각의 인덱스를 열어 객체를 조회해야 합니다. 수백 또는 수천 번 수행해야 한다면 효율적인 작업도 더는 효율적이지 않습니다.
최신 Git은 이를 우회하는 데 매우 능숙해졌습니다. 이제 멀티팩 인덱스와 증분 기하 컴팩션을 지원합니다. 하지만 결국에는 디스크의 Git 리포지토리를 다시 패킹해야 합니다. 역사적으로 이는 Spokes 같은 시스템에서 지속적인 가용성 문제였습니다. 다시 패킹은 증분 방식으로 수행하더라도 CPU를 매우 많이 사용하는 작업이며, 시스템의 모든 복제본에서 수행해야 하기 때문입니다. 동일한 리포지토리에 대해 두 개 이상의 Spokes 노드에서 유지 관리 작업을 실수로 트리거하면 리포지토리 장애 조치가 쉽게 발생합니다.
여기서는 컴팩션 비용을 분산합니다. 컴팩션은 기본 노드만 수행하며, 그 결과는 디스크의 리포지토리와 WAL 모두에 적용됩니다. 모든 복제본은 WAL을 따르므로 컴팩션 이벤트도 따릅니다. 복제본은 다시 패킹하지 않고, 이미 컴팩션된 pack을 S3에서 다운로드하여 CPU 대신 대역폭을 사용합니다.
확장
복제와 컴팩션은 Git 스토리지 시스템이 부하 상황에서 얼마나 잘 동작하는지를 결정하는 두 가지 핵심 요소입니다. 앞서 살펴본 것처럼 둘은 본질적으로 연결되어 있습니다. 리포지토리가 초당 더 많은 push를 수신할수록 읽기 성능은 저하됩니다. Git 작업의 효율성을 유지하려면 모든 push의 packfile을 컴팩션해야 하기 때문입니다. 이러한 push를 복제하는 경우 컴팩션 결과도 복제하거나, 각 복제본에서 독립적으로 컴팩션을 수행해야 합니다.
_Continuity_의 WAL 우선 설계는 완전한 일관성을 갖춘 수평 확장성을 제공합니다. 원하는 수만큼 복제본을 배포할 수 있으며, 읽기 전용 Git 작업의 처리량은 복제본 수에 비례해 선형적으로 증가합니다. 클러스터의 모든 복제본이 완전히 일관되므로 Git 프로토콜(clone, fetch)뿐 아니라 Origin이 리포지토리 상에서 수행하는 모든 RPC 작업(웹 UI 상호작용, REST API, 모든 에이전트 기반 인터페이스 등)도 확장할 수 있습니다.
최대 100개의 복제본을 대상으로 합성 스트레스 테스트를 실행했으며, push 처리량 저하 없이 읽기 작업이 일관되게 선형 확장되는 것을 확인했습니다.
클러스터의 push 처리량은 S3에서 WAL을 업데이트할 수 있는 지연 시간에 따라 달라집니다. S3 Standard를 사용하면 컴팩션을 수행하고 컴팩션된 데이터를 다른 모든 노드에 복제하면서 초당 최대 120회의 push를 처리할 수 있습니다. 또한 PUT 작업의 지연 시간이 훨씬 낮은 S3 Express One Zone에 고성능 클러스터를 배포했습니다. 여기에서는 초당 300회 이상의 push를 수신할 수 있으며, 사실상 Git이 디스크 데이터를 컴팩션하는 속도가 병목입니다. 컴팩션의 영향을 줄이기 위해 데이터를 디스크에 배치하는 혁신적인 방법을 연구하고 있습니다. 목표는 엄격한 내구성 및 일관성 보장을 완화하지 않으면서 Git 리포지토리가 코드를 수신할 수 있는 속도를 계속 최적화하는 것입니다.
- S3 Standard
- S3 Express One Zone
Cursor의 모노레포인 everysphere의 push/clone 처리량.
모든 push는 선형화 가능하며, 확인 응답 전에 외부 스토리지에 저장됩니다.
모든 clone은 완전히 일관됩니다.
진실의 원천인 WAL
S3는 훌륭한 기술입니다. S3 API를 통해 개척된 블롭 스토리지라는 개념은 대규모 데이터 스토리지 시스템을 위한 매우 강력한 구성 요소임이 입증되었으며, 이는 Git 리포지토리 호스팅에도 분명히 적용됩니다. 여기서 제시하는 설계는 여러 면에서 새롭지만, packfile을 블롭으로 저장하는 최초의 설계는 아닙니다. Azure DevOps(Microsoft가 자체 GitHub의 경쟁 제품으로 만든 서비스)는 packfile은 블롭 스토리지에, 참조는 관계형 데이터베이스(MS SQL Server)에 저장하는 매우 성공적인 Git 스토리지 시스템을 갖추고 있습니다. 이와 같은 시스템에는 많은 절충점이 있습니다. 관계형 데이터베이스는 대규모 참조 트랜잭션에도 잘 확장됩니다. 하지만 관계형 데이터베이스를 운영해야 한다는 부담이 따릅니다. 우리는 Git 데이터의 일관성이 다른 어떤 고려 사항보다 중요하다고 굳게 믿습니다. 이것이 외부 데이터베이스에 의존하지 않는 WAL 기반 시스템을 설계하게 된 결정적인 이유입니다.
프로덕션 환경의 Git 리포지토리에서는 많은 문제가 발생할 수 있습니다. 저장된 데이터의 손상, 다시 패킹 중의 버그, push 중의 경합 등이 있습니다. 온갖 예외 사례의 집합이라고 할 수 있습니다. 이들 대부분은 Git 업스트림에서 해결되었습니다. 하지만 전부는 아닙니다. OSS로 널리 배포된 시스템이라 해도 버그가 없는 시스템은 없습니다. 당사의 일관성 모델은 리포지토리에서 발생하는 모든 기본 동작을 추적할 수 있도록 보장합니다. push가 WAL에 완전히 영속되기 전에는 절대 성공 응답을 보내지 않습니다. 모든 push를 선형화합니다. 접근하는 모든 리포지토리의 모든 뷰는 항상 완전히 일관됩니다. 모든 push가 WAL에 기록되므로, 리포지토리가 거쳐 온 모든 상태를 확인할 수 있습니다. 모든 push와 모든 다시 패킹에 대한 완전한 이력 데이터를 보유합니다. 모든 복제본을 되돌리거나 앞으로 빠르게 이동할 수 있습니다. 참조만 저장하는 데이터베이스든 모든 객체 데이터를 저장하는 데이터베이스든, 어떤 외부 데이터베이스와도 상태를 동기화할 필요가 없습니다. Git에서 버그가 발생하면(발생 여부가 아니라 발생 시점의 문제입니다) 정확히 무슨 일이 일어났는지 파악하고 되돌릴 수 있습니다. 또한 Git에 이미 존재하는 버그와 별개로, 이 모든 과정에서 기성 도구를 사용해 디스크의 일반 Git 리포지토리에서 모든 Git 작업을 수행하므로 새로 도입하는 버그는 극히 적습니다.
Origin
누군가의 소스 코드를 호스팅하는 일이 얼마나 중요한지 우리는 절실히 알고 있습니다. 이 블로그 게시물을 읽고 이해하는 분이라면 누구나 그 중요성을 잘 알고 계실 것이라 생각합니다. 개발자가 Git 리포지토리에 push하거나 pull할 수 없다면 기업은 멈춰 설 수 있습니다. CI 시스템이 5분간 중단될 때 발생하는 생산성 손실을 금액으로 산정하기는 어렵지만, 어떤 기준으로 보아도 막대한 규모입니다.
Agent는 소프트웨어를 다루는 방식을 근본적으로 바꾸었으며, 여러 면에서 이 상황을 더 악화시켰습니다. 더 많은 코드, 더 많은 PR, 더 많은 CI 실행. 버전 관리는 이 모든 것의 핵심이며, 단기간에 바꾸기 가장 어려운 부분일 수 있습니다.
Cursor에서도 수개월 동안 내부적으로 이러한 어려움을 겪어 왔으며, 이를 해결하고 고객에게도 도움이 될 플랫폼을 구축하는 데 상당한 고민과 노력을 기울였습니다. 현재 우리는 더 높은 안정성, 더 나은 성능, 더 큰 확장성으로 원활하게 전환할 수 있도록 지원하고, 마이그레이션 부담을 최대한 줄이는 데 집중하고 있습니다.
Origin은 실험이 아닙니다. 관련 과제의 규모를 깊이 이해하는 사람들이 수십 년간 이와 같은 시스템을 구축하며 쌓은 경험의 결과입니다. 효과가 입증된 엔지니어링 및 운영 철학을 갖추고 있으며, 버전 관리 환경의 변화에 맞춰 이를 계속 발전시키겠다는 확고한 의지도 있습니다.
여러분이 우리와 우리 플랫폼을 신뢰해 주시기를 바랍니다.