어떤 규모에서도 동작하는 Git
TMT원문: Git at any scale — Vicent Martí, Cursor, 2026-08-18
Git 저장소를 대규모로 호스팅하는 일은 악몽입니다. 리누스 토르발스가 지옥에서 온 정보 관리자(실제로 Git의 공식 소개 문구입니다. 직접 확인해 보십시오)의 첫 버전을 설계할 때, 그의 머릿속에는 아주 구체적인 사용 사례 하나가 있었습니다. 바로 자기 자신이었습니다. 그는 리눅스 커널 개발에 쓰이던 분산 버전 관리 시스템 BitKeeper를 대체하고 싶어 했고, 당연히 그 대체재도 분산형이어야 했습니다. 커널은 특이한 소프트웨어 프로젝트입니다. 극도로 탈중앙화되어 있고, 수많은 서브시스템마다 서로 다른 메인테이너가 있습니다. 분산 버전 관리 시스템은 이런 작업 방식에 자연스럽게 들어맞습니다.
20년이 지난 지금 Git은 업계 표준이 되었지만, 사실 Git의 분산이라는 속성은 장점이라기보다 걸림돌에 가깝습니다. 평범한 오픈소스 프로젝트는 탈중앙화된 방식으로 돌아가지 않습니다. 평범한 회사는 더더욱 아닙니다. 이들은 분산 모델의 여러 장점(오프라인에서 작업하고, 푸시를 미뤄 둘 수 있는 것 등)을 누리면서도, 실제로는 중앙 호스트에 크게 의존합니다. 그리고 Git 저장소를 호스팅하는 일은, 알고 보면 엄청나게 어려운 일입니다.
Git은 무엇이 어려운가
Git 저장소를 대규모로 호스팅하기 어려운 이유는 Git 자체의 설계에 내재해 있습니다. 분산 버전 관리 시스템이라는 말은 저장소의 모든 인스턴스가 동일하다는 뜻입니다. Git 서버에 있는 저장소라고 해서 개발자 노트북에 있는 저장소와 다를 것이 하나도 없습니다. 얼핏 보면 이 덕분에 Git 호스팅이 간단해 보이지만(디스크에 저장소 사본을 두고 앞에 HTTP 데몬만 세우면 Git 서버 완성!), 실제로는 확장성과 안정성 면에서 어려운 문제가 많아 오히려 정반대입니다.
일반적인 Git 저장소에서 코드와 메타데이터(파일, 커밋, 트리)는 압축되어 팩파일(packfile)에 저장됩니다. 팩파일은 단순한 바이너리 직렬화 포맷으로, 로컬 머신에서 다루기에는 편리하지만 서버에서 대규모로 관리하기에는 적합하지 않습니다. 팩파일은 Git 저장 방식의 기본 단위이면서 동시에 Git 네트워킹의 기본 단위이기도 합니다. 저장소에 데이터를 푸시하거나 저장소에서 가져올 때, 데이터는 팩파일 형태로 전송됩니다.
Git은 설계상 이렇게 동작하지만, 꼭 그래야 하는 건 아니지 않냐고 생각할 법도 합니다. 어차피 Git 클라이언트는 우리가 통제할 수 없지만(사용자를 귀찮게 하고 마찰을 잔뜩 만들 각오가 아니라면), 우리 서버 담장 안에서는 무엇이든 마음대로 할 수 있으니까요. 팩파일을 꼭 써야 한다는 법은 없습니다. 리누스가 찾아와서 검사하는 것도 아니고요. 유일한 제약은 모든 Git 작업에서 네트워크로는 팩파일을 주고받아야 한다는 점뿐입니다.
여러 해에 걸쳐 Git 저장소를 대규모로 호스팅해 본 회사들은 이 팩파일 기반 설계가 가용성과 확장성 모두에 큰 제약이 된다는 것을 깨달았습니다. 팩파일은 Git이 접근하려면 파일시스템에 실제로 존재해야 하는 큰 바이너리 파일입니다. 디스크 위 저장소 앞에 HTTP 서버를 세우는 단순한 방식은 한계점이 매우 낮습니다. 이상적으로는 저장소가 여러 디스크, 여러 머신에 존재하기를 원할 겁니다(그래야 여러 Git 작업을 병렬로 처리할 수 있고, 서버 한 대가 죽어도 저장소를 계속 쓸 수 있습니다). 그런데 그걸 어떻게 할까요?
이를 이루는 접근법은 크게 세 가지가 있는데, 복잡한 순서대로 나열하면 이렇습니다. 파일시스템을 분산하거나, 팩파일을 분산하거나, Git 자체를 분산하는 것입니다.
팩파일 없는 Git
Git은 내용 주소 지정(content-addressable) 데이터 저장소입니다. Git 저장소의 모든 객체(blob, 트리, 커밋 등)는 그 내용의 SHA-1 해시를 키로 삼습니다. 직관적으로는 분산 키-값 저장소에 아주 잘 들어맞아 보이고(키는 SHA-1, 값은 실제 객체), 저장소 스토리지를 수평 확장하는 깔끔한 방법이 될 것 같습니다. 하지만 실제로는 동작하지 않습니다.
문제는 이렇습니다. Git 저장소의 실제 구조는 유향 비순환 그래프(DAG)입니다. 어떤 객체든 SHA로 조회할 수 있지만, 저장소에서 아무리 사소한 작업이라도 하려면 DAG를 한 걸음씩 따라 걸어야 합니다.
[다이어그램] 원격 키-값 저장소에서 객체를 읽어 가며 Git 히스토리를 순회하는 애니메이션. 커밋 그래프는 최신 커밋을 가리키는 ref 하나만 있는 빈 상태에서 시작합니다. 각 객체는 SHA로 GET 요청을 보내 네트워크 건너 저장소에서 받아 오고, 응답이 도착해야 그 객체가 참조하는 객체들이 점선 자리 표시로 나타나 다음 조회 대상이 드러납니다. 카운터가 가져온 객체 수와 순차 왕복 횟수를 집계합니다.
저장소의 최근 변경 내역을 나열하는 정도의 작업만 해도 커밋을 처리해야 합니다. 커밋을 처리하면 그 트리의 루트를 가리키는 포인터를 얻습니다. 그 트리에서 각 파일과 각 하위 트리를 가리키는 포인터를 얻습니다. 원래 커밋에서는 부모 커밋(히스토리에서 바로 앞에 오는 커밋)을 가리키는 포인터를 얻습니다. 결정적으로, 이 순회의 매 단계마다 앞 객체를 가져오기 전까지는 다음 포인터의 값을 알 수 없습니다. 매번 가져올 때마다 분산 저장소까지 왕복해야 한다면, 비용은 순식간에 감당할 수 없이 커집니다.
이렇게 객체 수준에서 Git을 분산하는 접근은 이전에도 여러 번 시도되었고, 규모가 커지면 대개 실패합니다. 가장 유망했던 구현은 제 옛 멘토인 Shawn Pearce가 Google의 버전 관리 시스템 팀에서 일할 때 시도한 것입니다. 그의 접근은 객체를 분산 해시 테이블(DHT)에 저장하는 방식이었습니다. 이것이 가능했던 건 순전히 Java로 작성된 별도 Git 구현체인 JGit 덕분이었습니다. 여느 유서 깊은 Java 라이브러리답게, JGit은 일반 Git 저장소의 모든 세부 사항을 추상화하고도 남을 만큼의 인터페이스와 팩토리, 심지어 인터페이스 팩토리까지 제공하므로, 디스크 위 팩파일을 DHT로 교체하는 것도 가능했습니다. 시스템은 동작했고 일반 Git 작업에서는 쓸 만한 결과를 냈지만, Git 프로토콜의 제약(다시 말하지만, 서버에서 데이터를 어떻게 저장하든 네트워크로는 팩파일을 보내야 합니다) 때문에 git clone 성능이 너무 나빠져 설계 전체를 폐기해야 했습니다.
GitHub와 파일시스템
Git이 리눅스 커널이라는 울타리를 벗어나기 시작하고 몇 년 뒤, 샌프란시스코에서 패기 넘치는 스타트업 하나가 태어났습니다. 2008년에 창립된 GitHub는 소셜 코딩 플랫폼이었고, 그 태그라인은 앞날을 내다본 듯했습니다. "Git 저장소 호스팅, 더 이상 골칫거리가 아닙니다." 이것도 농담이 아닙니다. 직접 확인해 보십시오. 저 옛날 2008년에도 이미, Git의 분산 설계에도 불구하고(어쩌면 그 설계 때문에) Git 저장소를 쓰기 편하게 만들려면 결국 중앙화된 호스팅이 필요하고 그 일이 몹시 고통스럽다는 데 폭넓은 공감대가 있었습니다. GitHub는 그것을 바꾸려 했습니다.
GitHub 플랫폼은 Rails 모놀리스로 시작했고, 지금도 대체로 그렇습니다. 초창기 버전은 성능 좋은 머신이긴 했어도 어쨌든 한 대에서, Ruby 서버와 저장소 디스크 사본을 나란히 두고 돌아갔습니다. Rails 앱의 확장은 쉽습니다. 인스턴스를 더 배포하면 됩니다. 하지만 이 경우에는 Git이 얽혀 있다 보니, 우리가 지금 풀려는 그 반복되는 질문에 금세 부딪혔습니다. Rails 앱이 디스크의 Git 저장소에 접근해야 한다면, 저장소 사본은 어떻게 더 배포할까요?
알뜰한 괴짜 집단이었던 GitHub 초기 시스템 엔지니어들은 확장 문제를 해결할 수 있는 가장 단순한 방법부터 시도했습니다. 팩파일이나 Git 자체가 아니라 파일시스템을 분산하는 데 집중하면, Rails 앱은 그대로 두고 Git으로 이상한 짓을 벌이는 대신 나날이 늘어나는 사용자를 위한 기능 개발에 시간을 쓸 수 있으리라는 계산이었습니다. 아주 실용적인 판단이었죠. 하지만 통하지 않았습니다.
팀은 Git 데이터를 위한 분산 파일시스템을 여러 방식으로 시도했습니다. 가장 뻔한 방법, 즉 모든 저장소를 중앙 서버에 두고 NFS로 접근하는 방식은 금세 폐기되었습니다. Git의 기본 구현은 파일시스템 의미론(잠금, 찢김 현상, 읽기, 동기화 등)에 대해 많은 가정을 깔고 있는데, 이 가정들은 느린 개발자 노트북의 로컬 파일시스템에서 그럭저럭 성능이 나오게 해 줄 뿐, 네트워크 파일시스템에서 어떻게 동작할지는 전혀 고려하지 않습니다. 느렸고, 버그투성이였습니다.
그다음에는 (솔직히 돌이켜 보면 끔찍한) 블록 수준 파일시스템 복제 기술들을 시도했습니다. GFS는 잠깐 배포해 봤고, DRBD 기반 배포는 좀 더 오래갔습니다. 전부 벽에 부딪혔습니다. 하루하루 운영하기가 끔찍했고, 그렇다고 성능이 좋아서 그 고생을 상쇄해 주지도 못했습니다. 결국 모든 것이 디스크 위 팩파일의 설계 문제로 귀결됩니다.
Git의 그래프형 자료구조가 왕복 비용을 감당하지 못할 수준으로 만든다는 것은 이미 봤습니다. 불행히도 아주 비슷한 원리가 디스크 위 데이터에도 적용됩니다. DAG에서 객체가 배치된 구조와 팩파일 안에서 객체가 놓이는 방식 사이에는 아무 상관관계가 없습니다. 팩파일을 생성할 때의 핵심 휴리스틱은 크기 최소화입니다. 객체는 팩 곳곳에 무작위로 배치되고, 압축되며, 결정적으로 통째로 저장되는 일이 드뭅니다. 대부분의 객체는 같은 팩파일 안 다른 객체 위에 델타로 저장됩니다. 객체 하나를 읽으려면 그래프 자료구조의 수많은 논리적 도약을 따라간 다음, 디스크 포맷 안에서 물리적 도약까지 따라가야 합니다.
[다이어그램]
git log --stat이 세로로 늘어선 커밋 DAG를 따라 내려가는 애니메이션. 각 커밋은 루트 트리와 샘플 파일 blob 네 개를 가리킵니다. 활성 행이 보이도록 히스토리가 스크롤되는 동안, 대응하는 팩파일 구간이 바이트 단위로 채워집니다. 델타 압축된 객체는 자기 구간을 먼저 읽고 이어서 각 베이스 구간을 읽습니다.
기가바이트 단위 데이터를 이렇게 무작위로 헤집는 일이 저장소에서 수행되는 모든 Git 작업마다 일어나는데, 이는 파일 수준 복제든 블록 수준 복제든 네트워크 파일시스템과는 도무지 맞지 않습니다. 기어가는 속도로 느려지지 않을 유일한 방법은 파일 전체를 로컬에 캐싱하는 것뿐입니다. 하지만 같은 파일시스템에 저장소가 수십만 개 있으면 캐싱은 선택지가 못 됩니다.
결국 GitHub의 시스템 엔지니어들은 이를 악물고 파일시스템 분산을 포기했습니다. 저장소를 전용 파일서버에 두는 RPC 시스템을 개발하기 시작했고, Rails 앱이 모든 작업을 원격으로 수행하도록 고쳤습니다. 이로써 수평 확장성은 상당히 확보되었지만, 가용성 문제나 가장 바쁜 저장소들의 성능 문제는 해결되지 않았습니다. 여전히 모든 저장소가 단 한 대의 머신에만 저장되어 있었으니까요.
Spokes와 일관성
Spokes는 2013년 무렵 GitHub에서 개발되었고, 이후 업계 표준이 되었습니다. 대부분의 Git 호스팅 서비스가 아키텍처에 Spokes 방식(Git 저장소의 애플리케이션 수준 복제)의 변형을 쓰고 있습니다. Spokes가 오랜 세월 잘 동작한 주된 이유는, 시간이 흐르며 최적이었음이 증명된 세 가지 근본적인 선택을 했기 때문입니다.
- Git 자체를 분산하지 않고 팩파일 수준에서 동작한다.
- 모든 데이터를 로컬 NVMe 디스크에 실제 Git 저장소 형태로 저장한다.
- Git 데이터를 복제하되, 모든 사본을 항상 일관되게 동기화한다.
방금 이야기한 팩파일의 무작위 읽기 패턴 때문에, 일반 Git 저장소를 NVMe 드라이브에 그대로 저장하는 것은 기본적인 Git 작업의 속도를 보장하기 위한 사실상의 필수 조건입니다. 데이터를 Git 클라이언트가 기대하는 형태로 변환할 필요가 없으니 클론도 효율적으로 유지됩니다. 또 이상한 형태의 저장소에서 동작하는 Git 포크를 직접 유지보수하는 대신, Git 위에 제품을 만드는 데 집중할 수 있습니다.
모든 데이터 사본을 항상 일관되게 동기화하는 것 역시 결정적으로 중요한 장점입니다. 이건 겪어 봐야 아는 사실인데, Git 클라이언트는 최종적 일관성(eventual consistency)과 정말 안 맞습니다. 로컬 Git 클라이언트가 커밋을 푸시했는데 곧바로 fetch했을 때 그 커밋이 안 읽히면 문제가 생깁니다. Git은 그 상황을 매우 혼란스러워합니다. CI 파이프라인을 러너 100대에서 돌리는데 그중 3대가 저장소를 클론한 뒤 테스트해야 할 커밋을 찾지 못하면 문제가 생깁니다. 사용자 경험으로도 최악이고요.
Git 저장소를 최종적 일관성 관점으로 다루는 일은 클라이언트 쪽이든 백엔드 쪽이든 날카로운 모서리투성이입니다. 그래서 Spokes는 시스템이 항상 완전히 일관되도록 아주 높은 복잡도 비용을 치릅니다. 그게 정확히 무슨 뜻인지 살펴봅시다.
Spokes는 합의 기반 분산 시스템입니다. Git 저장소 사본 여러 개를 서로 다른 서버에 저장하는 방식으로 동작합니다. 새 데이터를 푸시할 때마다 오케스트레이터가 푸시를 팬아웃하여 저장소의 모든 인스턴스가 사본을 받게 합니다. 이 팬아웃은 3PC(3단계 커밋)라는 고전적인 합의 알고리즘으로 동기화되어, 과반수 노드가 승인해야만 푸시가 수락됩니다.
[다이어그램] 코디네이터 하나와 참여자 다섯으로 구성된 인터랙티브 3단계 커밋 프로토콜. 트랜잭션 도중 아무 시점에나 각 노드를 온라인/오프라인으로 전환할 수 있고, 도중에 발생한 장애는 처음부터 다시 시작하는 대신 프로토콜의 실제 동작(정족수 미달로 중단, 불확실 상태에서 타임아웃으로 중단, PRE-COMMIT 발송 이후의 복구 커밋)을 그대로 재생합니다.
단계: 1 · 투표(VOTING) → 2 · 사전 커밋(PRE-COMMIT) → 3 · 커밋 실행(DO COMMIT)
Spokes가 3PC를 쓰는 방식을 더 이야기하려면, 먼저 Git 푸시가 어떻게 동작하는지 이해해야 합니다. Git 푸시는 두 부분으로 이루어집니다. 팩파일과 참조 트랜잭션입니다. 앞서 다룬 팩파일에는 저장소에 푸시하는 객체들(변경 사항이 담긴 blob, 트리, 커밋)이 들어 있습니다. 트랜잭션은 하나 이상의 참조(예: 지금 작업 중인 브랜치)가 방금 푸시한 커밋을 가리키도록 갱신함으로써, 변경 사항을 실제로 저장소에 공개하는 역할을 합니다.
이 분리가 여기서 아주 요긴합니다. 푸시된 커밋은 그것을 가리키는 참조가 갱신되기 전까지는 보이지 않기(Git 용어로 "도달 가능"하지 않기) 때문입니다. 즉, 팩파일은 모든 호스트에 동시에 팬아웃하고(여기서는 동기화가 필요 없습니다), 그다음에 참조 트랜잭션으로 3단계 커밋을 수행하는 식으로 푸시 합의를 구현할 수 있습니다. 참조 트랜잭션은 팩파일보다 훨씬 작고 동기화도 빠릅니다. Git 자체에 참조 트랜잭션을 준비하는 기능이 있습니다. 참조에 잠금을 걸고, 기존 값이 기대한 값인지 검증한 뒤, 트랜잭션의 커밋 또는 중단 명령을 받을 때까지 잠금을 유지할 수 있습니다.

[다이어그램] 코디네이터와 저장소 복제본 사이의 팩파일 업로드와 3단계 커밋을 보여 주는 네트워크 애니메이션. (조절 항목: 재생 속도 0.010x, 복제본 5개, 편도 지연 20ms)
이 설계로 모든 푸시가 모든 복제본에 걸쳐 완전히 동기화되도록 보장합니다. 그러면 읽기(fetch, clone)는 아무 복제본 하나로 안전하게 라우팅할 수 있습니다. 모든 복제본이 항상 최신이기 때문입니다.
이것이 Spokes의 핵심 동작 방식이고, 지난 13년간 꽤 잘 돌아갔습니다. 물론 Spokes도 완벽하지는 않습니다. 완벽한 시스템은 없으니까요. 2026년 현재 사람들이 Git 저장소를 쓰는 방식은 극적으로 달라졌고, 우리는 그 사이 분산 시스템 구축에 관한 중요한 교훈을 많이 배웠습니다. 시간과 경험은 Spokes의 선택 중 무엇이 최적이었고 무엇이 아니었는지를 가려 주었습니다.
치명적이라고 판명된 결함 하나는 3PC의 제한된 수평 확장성입니다. Spokes가 처음 나왔을 때는 저장소당 복제본 3개가 최적점이었습니다. 사본 3개면 평균적인 저장소의 트래픽을 여유 있게 감당하면서, 머신 한 대가 죽어도 푸시를 계속 받을 만큼의 중복성을 확보할 수 있었습니다.
2026년의 사정은 아주 다릅니다. 이제 엔터프라이즈 기업의 평균적인 저장소는 거대한 모노레포입니다. 복제본 3개로는 그런 저장소의 트래픽을, 특히 CI 트래픽을 감당할 수 없습니다. 물론 Spokes를 복제본 4개 이상으로 돌리지 못할 이유는 없습니다. 그 악명 높은 규모에서의 꼬리 지연(tail at scale)만 아니라면요. 3단계 커밋은 Git의 트랜잭션 모델에 아주 우아하게 들어맞지만, 합의 알고리즘으로서 근본적인 한계가 있습니다. 매 단계의 지연 시간이 클러스터에서 가장 느린 서버에 발목 잡힌다는 점입니다. 클러스터에 복제본을 더할수록 푸시 처리량은 더 나빠집니다.
이 확장성 제약은 반대 방향으로도 작용합니다. 에이전트가 Git 저장소를 대규모로 다룰 때는 모노레포 밖에서 움직이는 경우가 많습니다. 작은 저장소를 어마어마하게 많이 만드는데, 상당수는 일회용이고 대부분은 거의 손도 대지 않습니다. Spokes는 여기서 고전합니다. 이런 저장소 하나하나에도 여전히 복제본 3개를 요구하기 때문입니다. 거의 놀고 있는 복제본 3개인데, 그렇다고 줄일 수도 없습니다. 줄이면 시스템의 완전한 일관성이 깨져 데이터 손실이 가능해지기 때문입니다. 3단계 커밋에서는 바닥이 늘 너무 높고, 천장은 늘 너무 낮습니다.
미리 알아채기는 불가능하지만 겪고 나면 뼈저리게 자명해지는 또 다른 결함은, Spokes가 대규모에서 운영하기 험난하다는 점입니다. 디스크 위 저장소가 언제나 합의의 원천이기 때문에, 모든 저장소의 모든 사본이 하나같이 아주 중요합니다. 저장소를 가축이 아니라 반려동물처럼(pets, not cattle) 돌봐야 하는 것입니다.
이 말은 우선 모든 저장소가 정확히 어디에 있는지 알아야 한다는 뜻입니다. 모든 저장소가 어느 머신들에 복제되어 있는지 대응시키는 거대한 라우팅 테이블을 유지하는 외부 데이터베이스에 의존하게 되고, 그만큼 가용성 리스크도 하나 늘어납니다. 저장소마다 체크섬을 계산해 그 테이블에 끊임없이 갱신해 두어야, 디스크 위 저장소가 유효한 상태로 남아 있는지 확인할 수 있습니다. 저장소에 나쁜 일이 생기는 즉시(장담하는데, 나쁜 일은 늘 일어납니다. Git은 실제 운영에서 상당히 까다로울 수 있습니다) 이를 감지하고 복구 작업을 예약해 건강한 상태로 되돌려야 합니다. 그것도 아주 빨리요! 다시 말하지만 디스크 위 저장소가 진실의 원천이기 때문입니다. 손상된 사본은 없는 사본이나 마찬가지입니다. 사본 3개 중 2개가 손상되면 시스템은 더 이상 푸시를 받을 수 없습니다. 정족수가 안 되니까요.
Continuity
Continuity는 우리가 Cursor에서 개발한 Git 스토리지 시스템으로, 접근법이 아주 분명합니다. Spokes가 잘한 것은 전부 배우고, 여러 해가 지나 이제는 문제라고 알게 된 것들은 고치는 것입니다.
Continuity는 단순한 시스템입니다(단순하지 않은 시스템은 운영하기 쉬울 수 없습니다). 핵심 프리미티브는 쓰기 선행 로그(WAL, write-ahead log)이며, 이를 S3 호환 객체 스토리지에 저장합니다. 프로덕션에서는 S3에서 바로 돌리지만, 어떤 클라우드에도 배포할 수 있게 설계했습니다.
저장소가 푸시를 받으면 그 푸시를 WAL 엔트리로 S3에 저장합니다. 완전히 영속화되기 전까지는 절대 푸시를 승인하지 않습니다. 각 푸시는 별도 객체로 저장됩니다. 푸시된 팩파일을 디스크에 쓰는 동시에 S3에도 업로드합니다. 다만 WAL 엔트리를 업로드했다고 해서 공개되는 것은 아닙니다. 푸시는 저장소의 로컬 사본에서 참조 트랜잭션을 성공적으로 준비하고, 스토어에 별도 객체로 존재하는 WAL 인덱스 파일에 그 WAL 엔트리를 가리키는 포인터를 기록해야만 비로소 보이게 됩니다. 이 구조가 모든 푸시를 선형화 가능(linearizable)하게 강제합니다.
[다이어그램] walgit 푸시의 애니메이션. git 클라이언트가 프런트엔드로 팩파일을 스트리밍하면, 프런트엔드는 이를 인덱싱해 불변 WAL 엔트리 하나로 객체 스토어에 업로드합니다. 이어서 gitwal.pb 인덱스를 GET으로 받아 로컬 작업 사본에 새 팩파일 포인터를 덧붙이고, 참조를 잠근 뒤 조건부 PUT으로 인덱스를 되돌려 씁니다. 이 지점이 선형화 지점이며, 그 뒤에 로컬 참조 트랜잭션을 커밋합니다.
푸시 한 건마다 S3 쓰기를 한 번씩 하지는 않으려고 합니다. 바쁜 저장소에서는 S3 PUT 지연 시간이 곧 푸시 처리량의 상한이 되어 버리기 때문입니다. 배칭 구현을 세심하게 튜닝하고, 참조 트랜잭션을 복제본 정족수가 아니라 로컬 저장소 하나와만 동기화하면 되는 덕분에, 디스크가 허락하는 최대 속도로 푸시를 받아들일 수 있는 시스템이 되었습니다.
저장소의 로컬 사본은 물론 아주 빠른 NVMe 드라이브에 저장된 일반 Git 저장소입니다. 이 부분은 Spokes와 똑같이 합니다. Spokes가 정확히 맞게 만든 지점이라고 생각하기 때문입니다. 덕분에 업스트림 Git 클라이언트와 그 수많은 성능 최적화를 비롯해 Git 커뮤니티의 훌륭한 오픈소스 성과를 전부 재사용할 수 있습니다. Git으로 이상한 짓을 벌이는 대신 새 기능을 만드는 데 집중할 수 있게 해 줍니다.
합의
Spokes 클러스터 운영을 어렵게 만드는 요인 하나가 모든 저장소가 어느 서버에 있는지 추적하는 일이 매우 중요하다는 점이었습니다. Continuity는 이 부분을 전혀 다르게 처리합니다. 각 저장소는 어디에 사나요? 답은 "아무 데나"입니다. 상관없거든요! 디스크 위 저장소는 따뜻하게 데워진 캐시처럼 취급하고, 진실의 원천은 언제나 S3의 쓰기 선행 로그입니다. 시스템은 무상태이고, 라우팅 테이블도 없습니다(운영할 관계형 데이터베이스도 없습니다. 축복이 따로 없지요). 어떤 호스트에서 저장소에 접근했는데 로컬 디스크에 없으면, WAL에서 그냥 다시 구체화(materialize)하면 됩니다. 이 작업은 매우 효율적으로 할 수 있지만, 물론 매번 하고 싶지는 않습니다. 낭비니까요. 프로덕션에서는 랑데부 해싱으로 저장소 ID를 그 저장소가 있을 것으로 기대되는 노드 목록에 대응시킵니다. 저장소 라우팅에 필요한 상태는 저장소 ID와 클러스터의 현재 정상 노드 집합, 이것이 전부입니다. 이 상태가 어긋나도(예를 들어 노드 하나가 비정상이 되어도) 아무 문제 없습니다. 다음 순번 노드에 저장소를 다시 구체화하면 그만입니다.
그럼 합의는요? 선출은요? 특정 저장소의 프라이머리는 어느 서버인가요? 그것도 상관없습니다! 여기에는 상태도 합의도 없습니다. 어떤 서버든 프라이머리가 될 수 있습니다. 쓰기 선행 로그의 모든 갱신은 S3의 원자적 비교 후 교환(CAS, compare-and-swap) 연산으로 동기화되므로, 저장소의 어느 인스턴스가 푸시를 받아도 항상 안전합니다. 라우팅과 마찬가지로, 아무 서버나 프라이머리로 두는 것이 가장 효율적이지는 않습니다(CAS 재시도가 생겨 푸시가 지연될 수 있습니다). 그래서 실제로는 항상 같은 서버, 즉 랑데부 해싱 순위 목록의 첫 번째 서버를 프라이머리로 선택합니다. 하지만 배포, 페일오버, 순간적인 네트워크 장애 같은 예외 상황에서는 정확히 어느 서버가 프라이머리인지 신경 쓰지 않습니다. 이 시스템은 성능이 저하된 상태에서는 항상 올바르게, 건강한 상태에서는 항상 빠르게 동작하도록 설계되었습니다.
[다이어그램] walgit 프런트엔드 두 대가 gitwal.pb WAL 인덱스를 놓고 조건부 PUT을 겨루는 애니메이션. 먼저 두 프런트엔드가 각자의 불변 WAL 엔트리 팩을 객체 스토어에 업로드하는데, 이 업로드끼리는 충돌하지 않습니다. 이어 둘 다 If-Match e0으로 인덱스를 PUT하면 스토어는 하나만 수락합니다. 프런트엔드 A가 이겨 인덱스가 e1로 전진하고, B는 412 Precondition Failed를 받습니다. B는 e1 인덱스를 다시 받아 충돌하는 추가분을 버리고, 다음 시퀀스 번호로 엔트리를 다시 덧붙인 뒤 If-Match e1로 재시도하며, 스토어가 이를 수락해 인덱스는 e2로 전진합니다.
복제
쓰기 선행 로그를 S3에 두면 규모 면에서 가능성의 세계가 열립니다. 복제본을 말 그대로 몇 개든 둘 수 있습니다. S3의 확장성은 타의 추종을 불허하고, 모든 복제본이 곧장 거기서 최신 상태를 따라잡기 때문입니다. 복제는 낙관적으로 수행하는데, 클러스터 안에 가십(gossip) UDP 패킷을 돌리는 방식입니다. 패킷에는 각 복제본이 푸시 직후 S3에서 곧바로 따라잡는 데 필요한 메타데이터가 전부 들어 있습니다. "미친 짓이잖아요." 화면 너머 시공을 건너 웅얼거리는 소리가 들리는 듯합니다. "UDP는 신뢰할 수 있는 전송이 아닌데요." 물론 아닙니다. 분산 시스템에서 신뢰할 수 있는 건 아무것도 없습니다! 회선도, 라우팅도, 토폴로지도 신뢰할 수 없습니다. 그래도 괜찮습니다. 상관없거든요. 각 복제본은 자신이 따라잡은 WAL 인덱스 최신 버전의 ETag를 알고 있습니다. 복제본에서 읽기 작업이 수행되면, 기대하는 ETag를 담아 S3에 조건부 GET을 보냅니다. 본문 없는 304 응답이 오면(메타데이터만 다루는 S3 연산이라 평균 10ms 미만으로, 거의 즉각적입니다) 최신 상태라는 뜻이므로 fetch나 clone을 곧바로 서빙할 수 있습니다. 200 응답에는 WAL 인덱스의 최신 버전이 담겨 오므로, 그걸로 따라잡은 뒤 읽기를 서빙합니다.
[다이어그램] walgit 클러스터의 복제 과정 애니메이션. git 클라이언트가 프라이머리 프런트엔드에 푸시하면, 프라이머리는 불변 WAL 엔트리 하나를 객체 스토어에 업로드하고 gitwal.pb 인덱스를 ETag e1에서 e2로 전진시킵니다. 이어서 새 시퀀스 번호를 알리는 UDP 가십 데이터그램을 복제본에 보냅니다. 데이터그램이 도착하면 복제본은 즉시 객체 스토어에서 상태를 맞추므로, 이후 그 복제본으로 온 fetch의 조건부 GET은 304 Not Modified를 받아 로컬 사본에서 바로 서빙됩니다. 데이터그램이 유실되면 복제본은 낡은 상태로 남고, fetch의 조건부 GET이 새 인덱스와 함께 200을 반환하므로, 복제본은 객체 스토어에서 따라잡은 뒤 fetch를 서빙합니다.
복제용 UDP 패킷이 유실되든, 토폴로지가 바뀌어 엉뚱한 서버에 도착하든 상관없습니다. 모든 복제본의 모든 읽기는 진실의 원천인 S3와 대조해 검증되므로 완전히 일관됩니다. 이 시스템은 성능이 저하된 상태에서는 항상 올바르게, 건강한 상태에서는 항상 빠르게 동작하도록 설계되었습니다.
여기서 두 가지 의미가 따라 나옵니다. 첫째, 시스템이 항상 일관되므로 그 위에 인프라를 얹는 일이 아주 쉬워집니다. 우리는(에이전트도, 웹 인터페이스도, 클라이언트도) 언제나 저장소의 전역적으로 일관된 모습을 봅니다. 둘째, 시스템이 양방향으로 확장되기 때문에 저장소마다 딱 맞는 수의 복제본을 갖게 됩니다. 큰 모노레포는 수백 개 복제본에 배포해 CI 작업의 부하를 전부 받아낼 수 있습니다. 에이전트가 만들어 낸 수백만 개의 자잘한 저장소는 각각 복제본 하나로 서빙할 수 있습니다. S3가 진실의 원천이므로 가용성을 위해 둘 이상 둘 필요가 없습니다. 사실 놀고 있는 저장소는 그 하나조차 필요 없습니다. 복제본이 한동안 트래픽을 받지 않으면 노드 디스크에서 가비지 컬렉션해 버리고, 다음에 fetch가 들어올 때 WAL에서 다시 구체화하면 됩니다.
[다이어그램] 복제본 수가 저장소별 트래픽을 따라가는 Continuity 클러스터의 애니메이션. 클러스터 노드 행 위에 S3 객체 스토어 띠가 있고, 아래에는 지구본으로 표시된 웹 곳곳의 git 클라이언트 띠가 푸시와 풀을 쏟아냅니다. 저장소는 크기에 비례하는 색 사각형으로 표현됩니다. 큰 모노레포는 모든 노드에 복제되고, 중간 크기 저장소 셋은 복제본을 몇 개씩 유지하며, 100개가 훌쩍 넘는 자잘한 저장소는 노드 하나에 복제본 하나만 둡니다. 스토어 자체에는 저장소 모양이 전혀 없고 동일한 불투명 객체들, 즉 WAL 엔트리만 가득합니다. 푸시는 점으로 표시된 프라이머리 복제본에 도착해 스토어에 객체 하나를 덧붙이고, 풀은 아무 복제본이나 서빙합니다. 어느 순간이든 자잘한 복제본의 절반 이상이 노드 디스크에서 가비지 컬렉션되어(점선 빈 사각형) WAL에만 존재하며, 풀이 필요해지면 S3의 WAL 읽기로 즉시 되살아나고, 놀게 된 슬롯은 다시 사라집니다.
컴팩션
쓰기 선행 로그에는 주기적인 컴팩션(compaction)이 필요합니다. 로그가 무한정 자라게 둘 수는 없습니다. 전체 복원은 모든 엔트리를 재생하므로, 엔트리가 많을수록 비용도 커집니다.
공교롭게도 일반 Git 저장소 역시 주기적인 컴팩션이 필요합니다. Git이 WAL 기반이 아닌데도 그렇습니다. Git 저장소의 기본 저장 단위가 팩파일이라는 것은 앞서 봤습니다. 저장소의 원격 사본에 푸시하거나 로컬 사본으로 fetch할 때마다 새 팩파일이 하나 생깁니다. 이게 무한정 늘어나도 되는 구조는 아닙니다. 팩파일마다 자기 인덱스가 붙어 있어 담고 있는 객체를 효율적으로 찾을 수 있지만, 이 조회는 팩파일 단위로만 효율적입니다. 특정 객체를 찾는데 저장소에 팩파일이 100개 있다면, 그 객체가 나올 때까지 팩파일 하나하나의 인덱스를 열어 조회해야 합니다. 효율적인 연산도 수백 수천 번 반복해야 한다면 더는 효율적이지 않습니다.
요즘 Git은 이 문제를 우회하는 데 꽤 능숙해졌습니다. 이제 멀티 팩 인덱스와 증분 기하 컴팩션을 지원합니다. 하지만 결국에는 디스크 위 Git 저장소를 다시 패킹(repack)하는 부담을 감수해야 합니다. 역사적으로 이것이 Spokes 같은 시스템의 고질적인 가용성 문제였습니다. 리패킹은 증분으로 하더라도 CPU를 많이 잡아먹는 작업인데, 시스템의 모든 복제본에서 수행해야 하기 때문입니다. 같은 저장소에 대해 Spokes 노드 두 대 이상에서 유지보수 작업을 실수로 동시에 걸면 저장소가 쉽게 페일오버됩니다.
우리는 컴팩션 비용을 분할 상환합니다. 컴팩션은 프라이머리만 수행하고, 그 결과는 디스크 위 저장소와 WAL 양쪽에 적용됩니다. 모든 복제본이 WAL을 따르므로 컴팩션 이벤트도 그대로 따라갑니다. 복제본은 리패킹하지 않습니다. 이미 컴팩션된 팩을 S3에서 내려받을 뿐입니다. CPU 대신 대역폭을 쓰는 셈입니다.
[다이어그램] 증분 WAL 컴팩션의 애니메이션. 팩은 아래 정렬된 사각형으로 위로 자라며, 각각 색 점 옆에 체크섬이 붙어 있습니다. 작은 팩들이 컴팩션 경계선 오른쪽에 도착해 줄을 서고, 이전 배치는 이미 왼쪽으로 넘어가 있어 한 번에 최대 여섯 팩(경계선 앞 셋, 뒤 셋)이 보입니다. 도착이든 컴팩션이든 모든 쓰기는 다음 쓰기 전에 gitwal.pb의 250ms PUT으로 객체 스토어에 커밋됩니다. 팩이 여섯 개가 되면 경계선 뒤 셋이 중간 팩으로 컴팩션되고, 몇 번의 컴팩션 뒤 중간 팩이 베이스 팩으로 접히며 새 중간 팩이 자랍니다. 인덱스는 커밋된 모든 팩을 순증가하는 시퀀스 번호와 무작위 체크섬과 함께 나열합니다.
규모
복제와 컴팩션은 Git 스토리지 시스템이 부하에서 어떻게 버티는지를 결정하는 두 가지 핵심 요인입니다. 방금 봤듯 둘은 본질적으로 얽혀 있습니다. 저장소가 초당 받아들이는 푸시가 많아질수록 읽기 성능은 나빠집니다. Git 작업이 계속 효율적이려면 매 푸시의 팩파일을 컴팩션해야 하기 때문입니다. 이 푸시들을 복제한다면, 컴팩션도 함께 복제하거나 복제본마다 독립적으로 수행해야 합니다.
Continuity의 WAL 우선 설계는 완전한 일관성을 유지하는 수평 확장성을 제공합니다. 복제본을 원하는 만큼 배포할 수 있고, 읽기 전용 Git 작업의 처리량은 복제본 수에 비례해 선형으로 늘어납니다. 클러스터의 모든 복제본이 완전히 일관되기 때문에, Git 프로토콜(clone, fetch)은 물론 Origin이 저장소 위에서 수행하는 모든 RPC 작업(웹 UI 상호작용, REST API, 우리의 모든 에이전트 인터페이스 등)까지 확장할 수 있습니다.
복제본을 100개까지 늘려 합성 스트레스 테스트를 돌려 봤고, 푸시 처리량의 퇴행 없이 읽기가 일관되게 선형으로 확장되는 것을 확인했습니다.
클러스터의 푸시 처리량은 S3의 WAL을 얼마나 빠르게 갱신할 수 있느냐에 달려 있습니다. S3 Standard에서는 컴팩션과, 컴팩션된 데이터의 전체 노드 복제를 병행하면서 초당 최대 120건의 푸시를 유지할 수 있습니다. PUT 지연 시간이 훨씬 낮은 S3 Express One Zone에 고성능 클러스터도 배포해 봤는데, 거기서는 초당 300건이 넘는 푸시를 받아들일 수 있고, 사실상 Git이 디스크 데이터를 컴팩션하는 속도가 병목입니다. 컴팩션의 영향을 줄이는 혁신적인 디스크 데이터 배치 방식을 연구하고 있습니다. 엄격한 내구성과 일관성 보장을 조금도 늦추지 않으면서, Git 저장소가 코드를 받아들이는 속도를 계속 끌어올리는 것이 우리의 목표입니다.
Cursor의 모노레포
everysphere의 푸시/클론 처리량입니다.
모든 푸시는 선형화되며, 승인 전에 외부 스토리지에 영속화됩니다.
모든 클론은 완전히 일관됩니다.
진실의 원천, WAL
S3는 훌륭한 기술입니다. S3 API가 개척한 blob 스토리지라는 개념 자체가 대규모 데이터 스토리지 시스템의 강력한 구성 요소로 판명되었고, Git 저장소 호스팅에도 확실히 그렇습니다. 여기 소개한 설계는 여러 면에서 새롭지만, 팩파일을 blob으로 저장한 최초의 시스템은 아닙니다. Azure DevOps(마이크로소프트가 자사의 GitHub에 맞세운 마이크로소프트 자신의 경쟁 제품)는 팩파일을 blob 스토리지에, 참조를 관계형 데이터베이스(MS SQL Server)에 저장하는 꽤 성공적인 Git 스토리지 시스템을 갖고 있습니다. 이런 시스템에는 트레이드오프가 많습니다. 관계형 데이터베이스는 큰 참조 트랜잭션에는 잘 확장됩니다. 하지만 그 대가로 관계형 데이터베이스를 운영해야 합니다. 우리는 Git 데이터의 일관성이 다른 어떤 고려사항보다 중요하다고 굳게 믿습니다. 외부 데이터베이스에 의존하지 않는 WAL 기반 시스템을 설계하는 쪽으로 저울이 기운 결정적 이유가 바로 이것입니다.
프로덕션의 Git 저장소에서는 잘못될 수 있는 일이 많습니다. 저장 데이터의 손상, 리패킹 중의 버그, 푸시 경합. 한마디로 예외 상황의 집합체입니다. 이 중 대부분은 Git 업스트림에서 다듬어졌지만 전부는 아닙니다. 버그 없는 시스템은 없습니다. 오픈소스이고 널리 배포된 시스템이라도요. 우리의 일관성 모델은 저장소에 일어나는 모든 근본 연산을 추적하도록 보장합니다. WAL에 완전히 영속화되기 전까지는 절대 푸시를 승인하지 않습니다. 모든 푸시를 선형화합니다. 우리가 접근하는 모든 저장소의 모든 뷰는 언제나 완전히 일관됩니다. 모든 푸시가 WAL에 있으므로, 저장소가 거쳐 온 모든 상태를 들여다볼 수 있습니다. 모든 푸시와 모든 리팩에 대한 완전한 이력 추적(provenance) 데이터가 있습니다. 어떤 복제본이든 되감고 빨리 감을 수 있습니다. 참조만 저장하는 데이터베이스든 객체 데이터 전부를 저장하는 데이터베이스든, 외부 데이터베이스와 상태를 동기화할 필요가 없습니다. Git의 버그를 만나면(만날지 안 만날지가 아니라 언제 만나느냐의 문제입니다) 정확히 무슨 일이 있었는지 짚어 내고 되돌릴 수 있습니다. 그리고 Git에 이미 있는 버그 말고 우리가 새로 만들어 내는 버그는 아주 적습니다. 이 모든 과정에서 Git 작업은 전부 디스크 위 일반 Git 저장소에서, 기성 도구로 수행되기 때문입니다.
Origin
누군가의 소스 코드를 호스팅한다는 일이 얼마나 중요한지 우리는 뼈저리게 알고 있습니다. 이 글을 읽고 이해하는 분이라면 누구나 똑같이 알고 있으리라 생각합니다. 개발자가 Git 저장소에 푸시하거나 풀할 수 없으면 회사가 멈출 수 있습니다. CI 시스템이 5분 다운되었을 때의 생산성 손실은 돈으로 환산하기 어렵지만, 어떤 기준으로 재도 어마어마한 규모입니다.
에이전트는 우리가 소프트웨어를 다루는 방식을 근본적으로 바꿔 놓았고, 여러 면에서 이 상황을 더 악화시켰습니다. 더 많은 코드, 더 많은 PR, 더 많은 CI 실행. 버전 관리는 이 모든 것의 중심에 있으면서, 하루아침에 바꾸기가 아마도 가장 어려운 부분입니다.
우리는 Cursor 내부에서 여러 달째 이 어려움을 직접 겪어 왔고, 우리 스스로를 위해, 그리고 바라건대 고객을 위해서도 이 문제를 풀어 줄 플랫폼을 만드는 데 상당한 고민과 정성을 쏟았습니다. 지금 우리의 초점은 더 높은 안정성, 더 나은 성능, 더 큰 규모로 가장 매끄럽게 갈아탈 수 있는 길을 내고, 이전 과정을 최대한 고통 없이 만드는 데 있습니다.
Origin은 실험작이 아닙니다. 바로 이런 시스템들을 수십 년간 만들어 온 경험에서, 이 도전의 무게를 깊이 이해하는 사람들이 빚어낸 결과물입니다. 우리에게는 이미 검증된 엔지니어링과 운영 철학이 있고, 버전 관리의 지형이 변해 가는 만큼 그 철학을 계속 발전시키겠다는 강한 의지가 있습니다.
여러분이 우리와 우리 플랫폼에 믿음을 보내 주시기를 바랍니다.