GitHub Rubicon, Git 객체와 ref 를 갈라 동시 푸시를 받는 구조와 한계
GitHub 은 저장소 하나에 들어오는 쓰기를 저장소 단위 잠금으로 한 줄에 세워 왔다. 새 Git 스토리지 Rubicon 은 Git 객체를 조율 없이 Azure Blob Storage 에 올리고, 브랜치 같은 ref 갱신만 Ref-CAS 라는 복제 로그에서 ref 단위로 비교·교체한다. 덕분에 서로 다른 브랜치로 가는 푸시는 서로를 기다리지 않고, GitHub 내부 시험에서 저장소 하나가 초당 7,200회 푸시를 받아 냈다. 다만 같은 브랜치를 노리는 푸시는 여전히 한 번에 하나만 들어간다. 지금은 제한적 비공개 프리뷰이고, 넓은 배포는 2027년 초로 잡혀 있다.
01 병목은 저장소 수가 아니라 저장소 하나에 몰리는 쓰기였다
GitHub 엔지니어링 블로그는 11월 5일 글에서 에이전트가 저장소 부하의 모양을 바꾼다고 봤다. 짧게 몰려 오던 쓰기가 꾸준한 흐름이 되고, 푸시 하나가 CI·배포·다른 에이전트의 컨텍스트 조회 같은 읽기를 줄줄이 끌고 온다는 것이다. 글이 예로 든 한 저장소는 2026년 3월에서 5월 사이에 하루 평균 요청이 약 4배로 늘었다. 평균 4,400만 건이 1억 8,100만 건이 됐다.
기존 구조로는 이 흐름을 받기 어려웠다. 10월 21일 공개된 Azure 이전기를 보면 저장소 대부분은 복제본 5개를 둔다. 쓰기는 과반만 기록하면 확정되지만, 실제로는 응답하는 정상 복제본을 시간 제한까지 모두 기다린다. 그래서 가장 느린 복제본이 쓰기 지연을 정하고, 쓰기마다 확정될 때까지 저장소 전체 잠금을 잡으니 처리량에도 천장이 있었다. 11월 글은 한 가지를 더 짚는다. 팩 병합 같은 압축 작업이 요청을 받는 복제본 위에서, 그것도 복제본마다 따로 돌았다는 점이다.
Azure 이전이 이 약점을 드러냈다. GitHub 은 저장소의 99.99999%를 기존 데이터센터와 Azure 두 곳에 두는 데까지 왔지만, 남은 것은 가장 바쁜 저장소들이었다. Azure 쪽 복제본은 왕복 약 25ms 거리에 있고 쓰기 하나에 왕복이 여러 번 든다. 이미 처리량 한계에 가까운 저장소는 그 비용을 쓰기마다 감당할 수 없다.
GitHub 은 그 사이 다리로 쿼럼 범위를 저장소에서 ref 로 좁히는 중이다. 실제와 비슷한 트래픽을 흘린 내부 시험에서 서로 다른 ref 쓰기의 처리량이 3~4배 올랐다고 밝혔다. 그래도 쿼럼 자체가 쓰기 상한으로 남는다는 것이 그 글의 결론이었고, Rubicon 이 그다음 단계다. 세 단계를 같은 기준으로 놓으면 차이가 보인다.
| 기준 | 기존 구조 | 이전 중 다리 | Rubicon |
|---|---|---|---|
| 쓰기 순서를 매기는 단위 | 저장소 전체 잠금 | ref 단위 쿼럼 | ref 단위 CAS |
| Git 객체 저장 | 복제본마다 전체 사본 | 쿼럼 + Blob 영속 후 확인 | 합의 없이 Blob 에 |
| 압축 | 요청 받는 모든 복제본 | 가상 쓰기 복제본 | 별도 압축 노드 |
| 공개 수치(내부 시험) | 없음 | 처리량 3~4배 | 초당 7,200 푸시 |
02 Git 객체는 합의 없이 쓰고, 순서는 ref 에만 매긴다
GitHub 이 꼽은 설계 원칙은 둘이다. 조율을 줄이고, 저장과 계산을 떼어 놓는다. Git 객체는 한 번 만들어지면 바뀌지 않고 내용의 해시가 곧 이름이라, 여러 쓰기 주체가 동시에 올려도 서로 맞출 것이 없다. 순서가 필요한 것은 "main 이 지금 어느 커밋을 가리키나" 같은 ref 갱신뿐이고, 그 데이터는 작다.
원칙대로 나눈 구성 요소는 다섯이다.
| 구성 요소 | 하는 일 | 성격 |
|---|---|---|
| Spokes | 라우팅·배치·부하 분산·격리 | 기존 계층 유지 |
| 가상 복제본 | 읽기·쓰기 요청 처리 | 일회용, 디스크는 캐시 |
| 압축 노드 | 팩 병합, 도달할 수 없는 객체 제거 | 백그라운드 전용 |
| Azure Blob Storage | 팩·인덱스·캐시된 fetch 응답 | 객체의 영속 저장소 |
| Ref-CAS | ref 와 팩 목록(manifest) | 포인터만 담는 복제 로그 |
영속 데이터는 Blob Storage 와 Ref-CAS 두 곳에만 있다. Blob 에 들어가는 데이터는 대부분 고유한 키에 한 번만 쓰이고, Ref-CAS 는 객체가 아니라 객체를 가리키는 값만 담는다. 가상 복제본은 정답을 들고 있지 않으니 언제든 버리고 다시 띄울 수 있다. Spokes 는 캐시가 데워진 복제본으로 같은 저장소 요청을 되도록 몰아 보낸다.
03 푸시 한 번은 팩 업로드와 ref 트랜잭션 한 번이다
쓰기 경로는 짧다. 순서가 정해져 있다는 점만 보면 된다.
에이전트 → 가상 복제본: git push
가상 복제본: 받은 팩 인덱싱
가상 복제본 → Blob: 팩 업로드
가상 복제본 → Ref-CAS: ref·팩 목록 갱신
Ref-CAS: 로그 복제본 과반 기록 → 확정
나중에, 압축 노드: 작은 팩 병합팩을 Blob 에 다 올린 뒤에야 ref 트랜잭션을 확정하므로, ref 는 늘 이미 영속된 객체만 가리킨다. Ref-CAS 트랜잭션은 자기가 고치는 ref 만 잠그고, 각 ref 가 쓰는 쪽이 알던 값 그대로인지 확인한다. 로그 복제본 과반이 기록하면 그때 보이게 된다. 지금 값이 기대한 값일 때만 바꾸는 compare-and-swap(CAS) 방식이다. 에이전트 푸시는 대개 자기 브랜치로 가니 대부분 부딪히지 않는다.
같은 ref 를 고치는 푸시 둘이 겹치면 로그에 붙는 것은 하나뿐이다. 진 쪽 클라이언트가 거절을 받는지, 서버가 다시 시도하는지는 공지에 없다.
읽기 쪽은 버전 번호 하나로 싸게 만든다. 확정마다 로그 순번(LSN)이 붙고, 마지막 확정의 LSN 이 곧 저장소 버전이다.
- 가상 복제본이 자기 버전을 Ref-CAS 에 보낸다.
- 최신이면 답에 ref 가 하나도 없다. 바뀌지 않은 저장소는 비교 한 번으로 끝난다.
- 뒤처졌으면 현재 ref 와 새 버전, 팩 위치를 받고, 필요한 팩만 Blob 에서 캐시로 당긴다.
- 캐시에서 읽기에 답한다.
팩이 쌓이면 읽기가 느려지므로 압축 노드가 뒤에서 합친다. 저장소의 팩을 샤드로 나눠 임대(lease)를 잡기 때문에 한 샤드는 한 노드만 만진다. 새 팩은 열린 구간(open segment)에 쌓이다가 개수·크기·나이 가운데 하나가 기준에 닿으면 봉인되고, 더 크고 적은 팩으로 합쳐진다. 노드는 결과가 입력과 똑같은 객체를 담는지 검증한 뒤 결과 팩을 목록에 올린다. 입력 팩은 별도 트랜잭션으로 목록에서 뺀 다음 나중에 지운다. GitHub 은 푸시가 늘어도 팩 수와 쓰기 지연 곡선이 거의 평평했다고 적었다.
04 ref 를 Blob 하나에 모으는 설계도, 쪼개는 설계도 버렸다
GitHub 이 처음 시도한 것은 저장소의 모든 ref 를 write-ahead log 하나에 두고 Blob 하나로 색인하는 방식이었다. 읽기는 쌌다. 하지만 모든 갱신이 그 Blob 하나에 대한 조건부 쓰기여서 왕복 한 번에 쓰기 하나만 들어갔고, 묶어 보내도 초당 수백 건 중반에서 멈췄다.
두 번째는 ref 를 해시로 나눠 여러 세그먼트 Blob 에 흩는 방식이었다. 다른 세그먼트끼리는 서로 기다리지 않게 됐지만, 이번엔 읽기가 모든 세그먼트를 확인해야 해서 가장 느린 세그먼트만큼 느려졌다. Azure 이전기에 따르면 저장소 대부분에서 읽기가 쓰기보다 100~1,000배 많다. 쓰기 지연을 줄이려고 읽기 지연을 내주는 거래는 맞지 않았다. 결국 ref 는 Blob 밖의 별도 로그 서비스로 나갔다.
객체 저장소 위에 자체 Git 서버를 올리려는 팀이라면 이 대목이 가장 쓸모 있다. ref 를 객체 저장소의 조건부 쓰기에만 맡기면 한곳에 모으든 쪼개든 쓰기와 읽기 가운데 한쪽이 손해를 본다는 것을 GitHub 이 먼저 겪어 보였다.
05 초당 7,200회는 내부 시험 값이고, 같은 브랜치 경합은 남는다
GitHub 이 내놓은 수치를 모으면 다음과 같다.
- 저장소 하나에 동시에 붙은 에이전트 5,000개, 초당 푸시 7,200회(내부 시험)
- Ref-CAS 의 ref 트랜잭션 처리량은 초당 수만 건
- 설계 목표는 현재 필요의 최소 100배
- 지금 가장 바쁜 저장소의 쓰기는 많아야 초당 수십 건이고, 대개 짧게 몰린다
이 수치는 낮춰 읽는 편이 맞다. 모두 GitHub 이 스스로 잰 값이고, 읽기 지연은 "계속 낮았다"·"곡선이 평평했다" 는 서술뿐 p50·p99 같은 값이 없다. 이번에 연 출처에는 같은 조건의 외부 측정도 없었다. GeekNews 가 옮겨 실은 Hacker News 반응에는 초당 수천 회 푸시가 정말 필요하냐는 반론이 많았다. 에이전트는 로컬에서 협업하고 다 된 작업만 PR 로 올리면 된다는 주장이다.
사용자 쪽에서 바뀌는 것은 없다고 GitHub 은 밝혔다. push·fetch·branch 를 같은 방식으로 하고, 보호 장치도 그대로 걸린다. 반면 공개 소프트웨어로 내놓는다는 말, 프리뷰 참여 방법과 대상, 어느 저장소부터 옮기는지는 글에 없다. 발표까지의 흐름은 이렇다.
| 시점 | 일어난 일 |
|---|---|
| 2025년 8월 | 저장소 첫 사본이 Azure Central US 리전에 들어감 |
| 2026년 5월 | Rubicon 설계 시작 |
| 2026년 10월 21일 | Azure 이전기 공개, ref 단위 쿼럼 소개 |
| 2026년 10월 마지막 주 | GitHub Universe 에서 새 스토리지 발표 |
| 2026년 11월 5일 | 구조 해설 공개, 제한적 비공개 프리뷰 진행 중 |
| 2027년 초(예정) | 더 넓은 배포 |
06 작은 팀은 기다리면 되고, 에이전트 브랜치 배치는 지금 정한다
GitHub.com 을 쓰는 팀은 코드도 CI 도 고칠 것이 없다. 사람 몇이 쓰는 저장소는 지금 가장 바쁜 저장소의 초당 수십 건에도 한참 못 미치니, Rubicon 이 푼 압력이 걸릴 일도 드물다. 프리뷰에 들어갈 길도 공개되지 않았으니 넓은 배포 공지를 기다리면 된다.
에이전트 여러 개를 한 저장소에 붙이는 팀은 사정이 다르다. Rubicon 이 늘린 것은 서로 다른 ref 로 가는 쓰기의 동시성이고, GitHub 이 그린 그림도 에이전트마다 자기 브랜치에 푸시하는 모습이다. 에이전트들이 main 같은 한 브랜치에 바로 푸시한다면 저장 계층이 아무리 빨라져도 한 번에 하나만 들어간다. 나머지는 Git 의 fast-forward 규칙에 걸려 다시 받아 합친 뒤 푸시해야 한다. 에이전트를 늘릴 계획이라면 에이전트별 브랜치에 푸시하고 PR 로 합치는 배치를 지금 정해 두는 편이 낫다. 브랜치 규칙만 바꾸면 되니 되돌리기 쉽고, Rubicon 이 겨냥한 형태와도 맞는다.
같은 압력에 Anthropic 은 반대 길을 골랐다. 사내 모노레포를 Git 에서 자체 시스템으로 옮겼고, 그 득실은 앤트로픽 사이언(Scion): 깃 대신 만든 이유와 장단점, 따라 할 것에 정리했다. GitHub 은 Git 프로토콜을 그대로 두고 저장 계층만 갈았으니, 사용자가 따로 옮길 것이 없다는 점이 다르다.
사내 GitLab·Gitea 운영자에게 Rubicon 은 설치할 수 있는 무엇이 아니다. 가져올 것은 원칙이다. 내용으로 이름 붙는 객체는 조율 없이 올리고, ref 만 기대값을 확인하며 바꾸고, 압축·GC 는 요청을 받는 노드 밖으로 뺀다. 합의 로그 서비스와 객체 저장소, 압축 전용 노드를 따로 두는 구성까지 따라 하면 지켜볼 부품이 셋 늘어난다. 작은 팀의 사내 Git 서버라면 저장소 하나에 초당 수십 건 쓰기가 실제로 들어오는지부터 재 보고, 그 전에는 구조를 바꾸지 않는 편이 낫다.
GitLab 의 고가용성 구성인 Gitaly Cluster(Praefect)를 쓰거나 검토 중이라면 한 가지를 더 본다. GitLab 문서는 Praefect 가 정상이고 최신인 복제본 모두에 동기로 써서 강한 일관성을 준다고 설명한다. 구조만 보면 정상 복제본을 모두 기다리던 GitHub 의 기존 구조와 닮았고, GitHub 은 그 구조에서 복제본이 멀어지자 쓰기가 밀리는 일을 겪었다. Praefect 에서 같은 한계를 잰 자료는 이번에 찾지 못했다. 또 같은 문서는 Kubernetes 위의 Gitaly Cluster(Praefect)가 베타이고 운영 환경에는 권하지 않으며, GitLab 이 이 Kubernetes 구성보다 새 Gitaly 스토리지 백엔드에 우선순위를 두었다고 적는다. 이 안내는 Kubernetes 배포에 대한 것이다.
상황별로 정리하면 이렇다.
| 상황 | 할 일 |
|---|---|
| GitHub.com, 사람 위주 저장소 | 없음. 넓은 배포 공지만 본다 |
| 에이전트가 한 브랜치에 바로 푸시 | 에이전트별 브랜치 + PR 로 바꾼다 |
| 에이전트별 브랜치를 이미 씀 | 없음. Rubicon 이 겨냥한 형태다 |
| Praefect 를 Kubernetes 에 올릴 계획 | 베타·우선순위 안내부터 읽는다 |
| 객체 저장소 위 자체 Git 서버 설계 | 객체·ref 분리, ref 는 CAS 로 |
07 할 일은 에이전트가 어느 ref 에 쓰는지 세는 것부터다
- 에이전트 설정과 스크립트에서 푸시 대상 브랜치를 모은다. 둘 이상이 같은 브랜치에 쓰면 에이전트별 브랜치로 나눈다.
- 에이전트가 브랜치를 덮어쓰는 곳은
--force대신--force-with-lease로 바꾼다. 원격 값이 마지막으로 받아 온 값과 같을 때만 덮어쓰는, 클라이언트 쪽 CAS 다. - 같은 작업 디렉터리에서 뒤로
git fetch를 도는 도구가 있으면--force-if-includes를 함께 주거나 기대값을 직접 적는다. - Gitaly Cluster(Praefect)를 Kubernetes 위에 새로 꾸릴 계획이면 GitLab 문서의 베타·우선순위 안내를 먼저 확인한다.
- 2027년 초 넓은 배포 공지와, GitHub 이 바로 뒤따를 작업으로 꼽은 원자적 커밋 API·클론 없는 작업·이벤트 스트림을 지켜본다.
3번이 필요한 까닭은 인자 없는 --force-with-lease 가 원격 추적 브랜치(origin/…)를 기대값으로 쓰기 때문이다. Git 문서는 cron 같은 것이 뒤에서 fetch 를 돌리면 그 기대값이 조용히 최신으로 바뀌어 보호가 무력해진다고 경고한다. --force-if-includes 는 그 원격 커밋을 로컬에 합친 적이 없으면 강제 푸시를 거절한다.
--force-with-lease 와 git update-ref 의 세 인자 형식은 Ref-CAS 가 서버에서 쓰는 "기대값을 확인한 뒤 바꾼다" 는 규칙을 Git 안에서 그대로 쓴다.
# 원격 브랜치가 마지막으로 받아 온 값일 때만 덮어쓴다
# 뒤에서 fetch 가 돌았어도, 그 원격 커밋을 로컬에 합친 적이 없으면 거절한다
git push --force-with-lease --force-if-includes origin agent/task-42
# 기대값을 직접 적는 방식: 원격 agent/task-42 가 OLD_OID 일 때만 덮어쓴다
git push --force-with-lease=agent/task-42:OLD_OID origin agent/task-42
# 로컬 ref: main 이 OLD_OID 일 때만 NEW_OID 로 바꾸고, 아니면 실패한다
git update-ref refs/heads/main NEW_OID OLD_OID팀 리드가 지금 돈이나 시간을 들일 일은 없다. 남는 결정은 에이전트를 어떤 브랜치 배치로 붙일지 하나이고, 그 결정은 Rubicon 배포를 기다리지 않고 내릴 수 있다.
08 자주 묻는 질문
같은 브랜치에서 진 에이전트 푸시는 어떻게 처리하나
GitHub 공지에는 서버 쪽 처리 방식이 없다. 지금 Git 규칙으로는 fast-forward 가 아닌 푸시가 거절되므로, 에이전트 스크립트에 다시 받아 합치고 다시 푸시하는 단계를 두면 된다.
초당 7,200회를 사내 Git 서버의 목표로 삼아도 되나
아니다. GitHub 에서 가장 바쁜 저장소도 지금은 초당 수십 건이고, 7,200회는 최소 100배 여유를 목표로 세운 설계를 내부 시험으로 보인 값이다. 사내 서버는 실제 쓰기 빈도부터 잰다.