OpenAI에서 Git 확장하기
TMTGit 생태계에 기여하면서 에이전트가 만들어 내는 작업 규모를 감당할 소스 관리 시스템을 구축했습니다.
글: 기술진 Aaron Friel, Taylor Blau, Ted Nyman
지난 1년 동안 모노레포의 규모와 이를 뒷받침하는 시스템에 대한 요구가 놀라운 속도로 커졌습니다. Codex 덕분에 가능해진 동시 작업량을 감당하려면 소스 관리 시스템을 전면 개편해야 한다는 점이 분명했습니다.
우리는 엔지니어링 작업을 중단하거나 팀의 Git 사용 방식을 바꾸지 않고 Git 저장소를 위한 새로운 백엔드인 Pando를 구축했습니다. 지금은 OpenAI 모노레포의 모든 개발 작업을 Pando가 지원합니다. Pando를 설계하면서 두 가지 야심 찬 목표를 세웠습니다. 첫째는 여러 브랜치에 동시에 푸시하는 작업에 최적화된, 수평 확장이 가능한 쓰기 처리입니다. 둘째는 클라이언트가 가용 네트워크 대역폭을 모두 활용할 만큼 빠르게 데이터를 내려받을 수 있도록 읽기 처리량을 극대화하는 것입니다.
Git 프로토콜과 클라이언트의 호환성을 유지하면서 두 목표를 달성했기에, 엔지니어와 에이전트 모두 기존 워크플로에 Pando를 자연스럽게 도입할 수 있었습니다. 프로덕션에서 Pando는 푸시 지연 시간의 중앙값을 기존의 약 5분의 1로 줄였습니다. 현재는 하루에 거의 1페타바이트에 달하는 Git 데이터를 전송합니다.
이 글에서는 새로운 소스 관리 시스템을 만든 이유와 Git 호환성을 유지하기로 한 이유를 설명합니다. 일반적인 Git 확장 방식과 비교하면서 Pando가 어떻게 OpenAI의 확장 요구를 충족하는지도 살펴봅니다. 앞으로의 계획과 에이전트 규모의 작업을 지원하기 위해 Git에 계속 기여할 방법도 다룹니다.
새로운 소스 관리 시스템이 필요했던 이유
Codex는 OpenAI의 개발 규모와 속도를 바꾸었습니다. 엔지니어가 훨씬 많은 작업을 병렬로 처리할 수 있게 되면서 브랜치와 푸시, 머지가 늘어났고, 개발 환경과 연구 작업, CI에서 발생하는 페치와 클론도 증가했습니다.
이러한 부하는 서로 맞물려 커집니다. 이제 엔지니어 한 명이 여러 변경 사항을 동시에 CI에 보낼 수 있고, 푸시 한 번으로 여러 작업이 같은 커밋을 페치하기 시작할 수 있습니다.
지난해 말 무렵부터 모노레포를 뒷받침하는 시스템이 이 속도를 따라가지 못했습니다. 페치와 푸시가 느려지고 머지도 실패했으며, 때로는 성공률이 90% 아래로 떨어졌습니다. Git 자체의 한계에 도달한 것일까요, 아니면 모노레포를 제공하는 방식의 한계였을까요? Git은 이메일로 커널 패치를 주고받던 커뮤니티에서 시작되었습니다. 20년이 넘는 시간 동안 개발자들은 소프트웨어를 만드는 방식이 달라질 때마다 Git을 중심으로 도구를 발전시켜 왔습니다. 우리 모노레포에서도 불편이 나타나기 시작했을 때, 그동안 쌓아 온 성과를 계속 활용할 수 있는지가 중요했습니다.
익숙한 워크플로는 유지하면서, 서로 다른 브랜치로 들어오는 푸시를 병렬로 처리하고 같은 이력을 여러 클라이언트에 동시에 제공하도록 백엔드를 다시 만들 수 있겠다고 판단했습니다. 소스 관리 워크플로의 기반으로 Git을 유지하면 클라이언트를 개선한 결과가 OpenAI 밖에서도 유용할 수 있습니다. 우리는 자체 Git 버전에서 이루어진 개선을 업스트림에 기여해 더 넓은 커뮤니티와 나누고자 했습니다.
대규모 Git을 위한 엔지니어링
소스 관리 시스템을 확장할 때는 두 가지 핵심 과제를 해결해야 합니다. 여러 쓰기 클라이언트가 서로 다른 브랜치에 푸시할 수 있어야 하고, 여러 읽기 클라이언트가 같은 이력을 페치할 수 있어야 합니다. Pando는 두 작업 모두에 최적화되어 있습니다.
첫째, Pando는 각 브랜치의 업데이트를 독립적으로 조정합니다. 서로 관련 없는 브랜치의 업데이트는 병렬로 진행할 수 있고, 브랜치를 페치하는 클라이언트는 해당 리비전에 필요한 이력을 받습니다. 다만 여러 브랜치를 읽는 클라이언트가 각기 다른 시점의 상태를 볼 수 있다는 절충이 따릅니다.
둘째, Pando는 클라이언트마다 별도의 팩파일을 생성하는 대신 여러 페치에 걸쳐 팩파일을 재사용합니다. 팩파일은 여러 객체를 한데 압축하는 Git의 파일 형식입니다. 클라이언트가 이미 가지고 있는 객체를 일부 다시 내려받을 수는 있지만, 서버는 적은 연산만으로 같은 데이터를 여러 클라이언트에 제공할 수 있습니다.
이러한 선택 덕분에 브랜치별로 쓰기를 확장하고 캐시에서 읽기를 처리할 수 있습니다. 두 방식이 어떻게 맞물리는지 이해하기 위해, 먼저 Git이 이력을 저장하고 전송하는 방법부터 살펴보겠습니다.
Git 객체 저장 방식의 기초
Git은 저장소의 두 구성 요소인 객체와 참조를 서로 동기화할 수 있어야 합니다.
Git 저장소는 파일의 스냅샷과 이를 연결하는 이력을 객체 그래프로 기록합니다. 객체에는 블롭, 트리, 커밋, 태그가 있습니다. 블롭은 개별 파일의 내용을 담고, 트리는 이름을 블롭이나 다른 트리와 연결해 디렉터리 구조를 만듭니다. 커밋은 루트 트리와 0개 이상의 부모 커밋을 가리켜 저장소의 이력을 구성합니다.
객체는 유형과 내용을 해시한 값으로 식별합니다. 또한 객체는 불변입니다. 변경된 파일을 기록하면 새 블롭을 만들지만, 바뀌지 않은 파일과 디렉터리는 기존 객체를 재사용할 수 있습니다.
브랜치와 태그는 커밋을 가리키는 참조이며, 줄여서 ‘ref’라고 부릅니다. 브랜치에 커밋하면 새 커밋이 생성되고, 브랜치의 ref는 이 커밋을 가리키도록 바뀝니다.
Git 객체는 ID로 서로를 참조합니다. 커밋은 트리를 가리키고, 트리는 다른 트리와 블롭을 가리킵니다. 브랜치 refs/heads/main은 커밋 C2를 가리키고, C2의 부모는 C1입니다. C1은 루트 트리 T1을 가리키며, T1의 parser.py는 블롭 B1을 참조합니다. C2는 루트 트리 T2를 가리키며, T2의 parser.py는 블롭 B2를 참조합니다. 두 루트 트리의 docs/는 같은 트리 T3를 참조합니다. T3의 README.md 항목은 블롭 B3를, usage.md는 블롭 B4를 가리킵니다.
Git은 객체를 ‘개별(loose)’ 또는 ‘팩(packed)’ 형태로 저장합니다. 개별 객체는 객체 ID로 식별하는 파일에 하나씩 저장됩니다. 팩 형태의 객체는 하나의 팩파일에 함께 저장되므로, 비슷한 객체끼리 서로에 대한 델타, 즉 차이만 저장할 수 있습니다.
Git은 블롭을 별도 파일로 저장할 수도 있고, 여러 블롭을 묶으면서 한 블롭을 다른 블롭과의 델타로 인코딩할 수도 있습니다.
Git에서 어떤 커밋으로부터 부모 커밋, 트리, 블롭으로 이어지는 연결을 따라 객체를 찾을 수 있으면, 그 객체는 해당 커밋에서 도달 가능하다고 합니다. 페치할 때 클라이언트는 원하는 커밋과 이미 가지고 있는 커밋을 서버에 알려 줍니다. 서버는 원하는 커밋에서 도달 가능한 객체를 찾고, 클라이언트가 이미 가지고 있다고 확인된 객체를 제외한 다음, 나머지를 Git이 디스크에 저장할 때와 같은 형식의 팩파일로 보냅니다. 전체 클론은 객체가 하나도 없는 상태에서 시작하므로 복제할 이력에서 도달 가능한 모든 객체가 필요합니다.
푸시는 하나 이상의 ref를 업데이트하도록 서버에 요청하면서 반대 방향으로 객체를 전송합니다. 클라이언트는 서버가 알려 준 ref 목록을 이용해 서버에 이미 있는 객체를 보내지 않습니다. 따라서 전송하는 팩이 기존 객체에 의존할 수 있습니다. 팩만 받아서는 새 이력을 재구성하기에 충분하지 않을 수 있다는 뜻입니다.
Git 저장소를 여러 서버에 복제하려면 객체와 참조를 모두 제공할 수 있는 상태로 유지해야 합니다. 복제본이 참조의 새 값을 제공하려면, 먼저 그 값에서 도달 가능한 객체를 제공할 수 있어야 합니다. 또한 서비스는 서로 경합하는 참조 업데이트도 조정해야 합니다. 같은 브랜치의 동일한 기존 끝 커밋을 서로 다른 새 커밋으로 바꾸려는 푸시가 이에 해당합니다. 저장소의 모든 쓰기에 단일 순서를 부여하는 시스템에서는 서로 관련 없는 브랜치로의 푸시도 상대 작업이 끝나기를 기다릴 수 있습니다.
기존 분산 Git 서비스의 설계 방식
Pando를 설계하면서 Git 기반 소스 관리 시스템을 확장하는 기존 방식들을 검토했습니다. 하나는 3단계 커밋(3PC)으로 저장소 복제본 간 ref 업데이트를 조정하는 방식입니다. 3PC는 여러 노드에 걸친 커밋을 조정하는 프로토콜입니다. 다른 하나는 블롭 스토리지에 공유 선행 기록 로그(WAL)를 두어 업데이트에 순서를 부여하는 방식입니다. WAL은 변경 사항을 적용하기 전에 먼저 남기는 기록입니다.
3단계 커밋(3PC)
우리가 모델링한 3단계 커밋 설계에서 Git 서비스는 여러 복제본에 저장소 전체 사본을 유지합니다. 어느 서버든 푸시를 받으면 들어온 팩파일을 복사해 모든 복제본에 병렬로 전송합니다. 푸시를 반영하려면 과반수의 복제본이 ref 업데이트를 수락해야 합니다. 이 과정을 관리하는 서버를 코디네이터라고 하며, 코디네이터와 각 복제본은 여러 차례 통신해야 합니다.
참조 업데이트를 수락하려면 각 복제본은 자신이 가진 ref를 잠그고, 여전히 클라이언트가 예상하는 커밋을 가리키는지 확인합니다. 그러면 코디네이터는 수락한 복제본에 사전 커밋 메시지를 보냅니다. 과반수가 이 메시지에 응답하면 코디네이터가 업데이트를 실행하라고 지시합니다. 이후 최신 상태의 복제본은 로컬 저장소 사본에서 브랜치의 새 끝 커밋을 클라이언트에 알릴 수 있습니다.
전체 복제본을 유지하면 읽기를 로컬에서 처리할 수 있고, 클라이언트가 어느 복제본을 선택하든 모든 참조를 일관된 상태로 볼 수 있습니다. 그 결과 참조는 한 번에 하나씩 업데이트되며, 각 푸시마다 복제본 간 ref 업데이트를 조정하기 위한 여러 차례의 왕복 통신이 필요합니다.
정족수의 응답을 기다려야 하므로 복제본의 디스크 성능과 통신 지연이 푸시 처리 시간에 직접 영향을 줍니다. 쓰기량이 늘어나면 복제본은 읽기 트래픽뿐 아니라 계속 들어오는 팩도 감당해야 합니다.
선행 기록 로그(WAL)
우리가 모델링한 WAL 기반 설계에서 Git 서비스는 블롭 스토리지의 공유 매니페스트로 저장소 전체 업데이트에 순서를 부여합니다. 서버는 푸시를 받으면 들어온 팩파일을 블롭 스토어에 업로드하고, 로컬 저장소 사본에서 ref 업데이트를 준비합니다. 푸시를 반영할 때는 블롭 스토어의 공유 매니페스트에 항목을 추가합니다. 이 항목은 ref 업데이트를 기록하고 팩파일을 가리킵니다. 이어서 Git 서버는 비교 후 교환(compare-and-swap) 연산으로 매니페스트를 업데이트합니다. 이 연산은 매니페스트를 원자적으로 교체하거나 변경을 거부합니다. 그사이 다른 쓰기 클라이언트가 매니페스트를 업데이트했다면, 서버는 중간에 추가된 항목을 읽고 자신의 ref 업데이트가 여전히 유효한지 확인한 뒤 이미 업로드한 팩파일을 이용해 다시 시도합니다.
쓰기 클라이언트들은 팩파일을 병렬로 업로드할 수 있지만, 서로 관련 없는 브랜치에 푸시할 때도 같은 매니페스트를 업데이트해야 합니다. 비교 후 교환이 성공하기까지 걸리는 왕복 시간이 저장소에서 새 매니페스트를 반영할 수 있는 빈도를 제한합니다. 지연 시간이 짧은 블롭 스토어를 사용하면 이 단계가 빨라집니다. 여러 업데이트를 일괄 처리하면 매니페스트 한 번으로 여러 ref를 동시에 업데이트할 수 있지만, 대기열에 업데이트를 모아야 하므로 지연이 늘어납니다.
블롭 스토어는 팩파일을 병렬로 받을 수 있지만, 공유 매니페스트의 업데이트는 여전히 서로 경합합니다. 블롭 스토어의 용량을 늘리는 것만으로는 이 병목이 해결되지 않습니다. 각 복제본은 팩을 로컬에서 제공하기 전에 블롭 스토어에서 읽어 색인을 만들어야 합니다. 유입되는 쓰기가 이 작업보다 빠르면 복제본이 뒤처지고, 클라이언트는 방금 푸시한 커밋을 페치하기까지 더 오래 기다리게 됩니다.
Pando 아키텍처
이제 Pando를 살펴보겠습니다. Pando는 여러 클라이언트의 동시 푸시를 감당하도록 확장하는 것을 목표로 만든 새로운 Git 저장소 서비스 아키텍처입니다. 작동 방식은 다음과 같습니다.
제약 조건 다시 생각하기
Pando를 개발하기 전부터 우리는 Git을 포기하지 않고도 요구 사항을 충족할 수 있으리라고 짐작했습니다. 다만 대부분의 Git 시스템이 필수라고 여기는 몇 가지 조건에서는 타협해야 했습니다.
- 저장소 전체의 일관성: 모든 ref를 특정 시점의 일관된 스냅샷으로 제공하려면 저장소 전체에 걸친 조정이 필요합니다. 모든 ref 업데이트에 하나의 순서를 부여하는 시스템에서는 관련 없는 브랜치로의 푸시도 단일 조정 지점에서 경합합니다.
- 클라이언트마다 별도의 팩파일 생성: 로컬 이력이 서로 다른 두 클라이언트가 같은 브랜치를 페치하면, 이미 가진 객체를 보내지 않기 위해 서버가 각각 다른 팩을 생성해야 할 수 있습니다. 이런 응답을 만들려면 CPU와 디스크 I/O가 필요하므로, 읽기 클라이언트가 많아질수록 확장하기 어렵습니다.
대신 Pando는 쓰기를 확장하기 위해 일관성 보장 수준을 완화한 인과적 일관성 모델을 제공하고, 읽기를 확장하기 위해 팩파일을 재사용합니다.
인과적 일관성(CC)
클라이언트가 브랜치에 푸시하면 Pando는 들어온 팩파일을 블롭 스토어에 불변 블롭으로 저장합니다. 브랜치가 이동할 때마다 새 끝 커밋, 해당 커밋을 전달한 팩, 그 팩이 의존하는 이전 ref 버전들을 담은 ref 버전을 메타데이터 스토어에 기록합니다. 블롭 스토어와 메타데이터 스토어는 모두 수평 확장이 가능합니다.
Pando에서 커밋이나 참조를 페치하려면 일련의 ref 버전, 즉 팩파일을 클라이언트에 재생해 해당 ref 버전을 생성한 클라이언트의 상태를 재구성해야 합니다. 이 방식은 저장소 전체에는 더 약한 일관성을 제공하지만, 개별 ref의 업데이트에는 직렬 가능성을 보장합니다. 클라이언트들은 같은 ref에 동시에 푸시할 때만 충돌합니다. 여러 브랜치를 읽는 클라이언트는 그 결과가 저장소 전체의 동일한 한 시점을 나타낸다고 가정할 수 없습니다.
트렁크에 푸시할 때는 하나 이상의 커밋이 담긴 증분 팩이 추가됩니다. 별도 작업 브랜치에 푸시할 때는 그 작업의 이전 커밋도 매번 다시 포함하므로, 서버가 알려 준 트렁크 버전을 가진 클라이언트는 팩 하나로 해당 작업 브랜치를 페치할 수 있습니다.
이는 인과적 일관성의 한 형태입니다. 참조를 페치하는 읽기 클라이언트는 해당 참조를 만든 쓰기 클라이언트의 이력을 보게 됩니다. 푸시 중에 Pando는 클라이언트와 서버가 어떤 이력을 공통으로 가진 것으로 합의할지 제어해, 클라이언트가 다른 클라이언트와 공유하기 쉬운 팩파일을 생성하도록 합니다.
따라서 Pando는 Git 작업 중 모든 참조를 특정 시점의 상태로 제공하지 않으며, 클론이나 페치 안에서 ref 업데이트의 전체 순서도 보장하지 않습니다. 예를 들어 전체 클론을 위한 협상 중에 foo와 bar 브랜치가 모두 업데이트된다고 해 보겠습니다. 협상 중 한 클라이언트는 foo의 버전 1과 bar의 버전 2를 볼 수 있고, 다른 클라이언트는 foo의 버전 2와 bar의 버전 1을 볼 수 있습니다. 실제로 사용자와 에이전트는 ref 업데이트의 전역 순서에 의존하지 않으며, 개별 참조의 업데이트 순서에 의존합니다.
방식별 비교
세 가지 일관성 모델을 비교하는 이 시뮬레이션에서는 클라이언트 1,000개가 각자 서로 다른 브랜치를 선택하고, 고유한 커밋을 반복해서 푸시하도록 설정했습니다. 전체 경과 시간과 푸시부터 페치까지의 지연 시간을 측정합니다. 후자는 사용자와 에이전트가 협업하거나 버전 관리에 의존하는 다른 시스템을 이용할 때의 왕복 시간을 측정하므로 중요한 지표입니다. 시뮬레이션은 범용 가상 머신부터 고성능 가상 머신, 블롭 스토리지 시스템까지 다양한 하드웨어를 모델링합니다. 이 시뮬레이션에서는 3단계 커밋의 저장소 전체 일관성 조건을 완화했습니다.
Git 서버 구현 시뮬레이션
각 쓰기 클라이언트가 반복해서 푸시하는 과정을 추적하고, 별도의 읽기 클라이언트가 각 커밋을 체크아웃할 수 있게 되는 시점을 따로 측정합니다.
영상은 Fast 인프라에서 클라이언트 1,000개로 실행한 결과입니다. 재생 속도는 기본값인 30배이며, 머지 시뮬레이션은 비활성화했습니다.
이 시뮬레이션은 원리 설명을 위한 합성 모델이며, 프로덕션 벤치마크가 아닙니다. 푸시부터 페치까지의 평균 시간은 작업 브랜치의 푸시를 대상으로, 첫 시도부터 재시도를 거쳐 별도의 읽기 클라이언트가 해당 커밋을 확인할 때까지를 측정합니다. 읽기 작업을 수행할 자리를 기다리는 시간은 포함하지만, 빌드와 테스트 시간은 제외합니다. 머지를 활성화하면 쓰기 클라이언트의 작업 완료 조건에 필요한 페치와 트렁크 머지도 포함됩니다.
원문의 시뮬레이션을 실행해 Pando와 다른 두 방식을 비교해 보세요. 범용 하드웨어부터 고성능 하드웨어까지 인프라를 선택하고, 클라이언트 수를 비롯한 여러 설정을 바꿔 차이를 확인할 수 있습니다.
모델링한 3단계 커밋 시스템에서는 부하가 높아져 디스크가 포화되면 쓰기 지연 시간이 크게 늘어납니다. 선행 기록 로그 시스템도 같은 이유로 읽기 지연 시간이 크게 늘어납니다. 서버가 저널을 읽는 속도가 유입되는 작업을 따라가지 못하기 때문입니다. 모노레포를 대상으로 한 실제 테스트와 이 시뮬레이션 모두에서 Pando는 수천 개의 클라이언트가 동시에 푸시할 수 있도록 지원하며, 부하가 걸린 상태에서도 성능을 유지했습니다.
Pando는 푸시를 처리할 때 들어오는 팩파일을 로컬 저장소에 풀어 놓지 않고, 블롭 스토리지로 스트리밍하면서 검증합니다. 클라이언트가 페치를 시작하면 Pando는 읽기 클라이언트에 필요한 저장된 팩을 선택하고, 클라이언트는 이를 내려받습니다. 어떤 서버도 팩파일을 로컬 저장소에 적용하거나 비용이 큰 압축 해제와 패킹 작업을 할 필요가 없습니다. Pando 서버는 주로 협상과 팩파일 선택을 수행하므로 범용 하드웨어에서 실행할 수 있습니다.
덕분에 Pando는 클라이언트, 클라이언트와 Pando 사이의 네트워크, 블롭 스토리지 서비스가 허용하는 한계까지 확장할 수 있습니다. 용량만 충분하다면 서로 독립적인 브랜치에서 작업하는 클라이언트는 각자의 연결 속도가 허용하는 최대 속도로 팩을 전송할 수 있습니다. 같은 속도로 더 많은 클라이언트를 지원하려면 Pando 서버를 추가하고 메타데이터 스토어와 블롭 스토어를 함께 확장하면 됩니다.
Pando에서 브랜치 푸시와 페치하기
Pando는 쓰기 클라이언트의 팩파일을 블롭 스토리지에 저장하고 메타데이터 서비스에 ref 버전을 기록합니다. 페치할 때는 읽기 클라이언트에 필요한 팩을 Pando가 선택하고 Express가 제공합니다.

에이전트는 로컬에서 파일을 수정하고 커밋을 만든 뒤 git push를 실행합니다. 그러면 브랜치를 업데이트하기 위해 변경 사항을 담은 팩파일이 생성됩니다. Pando는 이 팩을 받아 블롭 스토리지로 스트리밍합니다. 들어온 푸시를 한 차례의 디코딩으로 검증한 다음, 여러 키를 다루는 트랜잭션을 이용해 메타데이터 스토어에 ref 버전을 기록하고 ref 포인터를 원자적으로 업데이트합니다. 이를 통해 Pando는 ‘자신이 쓴 내용을 이후 읽기에서 확인할 수 있는’ 일관성(read-your-own-write)을 제공합니다. 이 전체 쓰기 작업에 필요한 메타데이터 읽기와 쓰기 횟수는 *$O(1)$*입니다.
다른 클라이언트가 브랜치를 페치하면 Pando는 그 클라이언트가 이미 가진 이력을 바탕으로 저장된 팩들의 집합을 선택합니다. 이 작업에는 *$O(\log \Delta)$*번의 메타데이터 읽기가 필요합니다. 여기서 *$\Delta$*는 클라이언트가 가진 상태와 원하는 상태 사이를 ref 버전으로 측정한 최단 거리입니다. 서버는 팩파일 URI를 반환하고, 클라이언트는 지역별 캐시에 팩파일을 저장하는 Express 서비스를 통해 이를 내려받습니다.
이 설계는 Pando 아키텍처의 세 가지 핵심 서비스인 푸시, 페치, 컴팩션에 기반합니다.
푸시 확장하기
클라이언트가 푸시할 때 일반적인 Git 서버는 자신의 패를 전부 보여 주는 포커 플레이어처럼 보유한 모든 브랜치를 클라이언트에 알려 줍니다. 그러면 클라이언트는 커밋, 트리, 블롭을 모으되, 서버에 이미 있다고 확인한 객체는 제외한 팩파일을 만듭니다.
더 많은 ref를 알려 주면 푸시 크기를 줄일 수 있습니다. 하지만 쓰기 클라이언트가 제외한 객체를 다른 클라이언트는 가지고 있지 않을 수 있으므로, 해당 팩을 재사용하기가 어려워질 수도 있습니다.
Pando는 다른 방식을 택합니다. 모든 패를 보여 주는 대신 일부 브랜치만 골라 클라이언트에 알려 줍니다. 이유는 두 가지입니다.
- 모든 ref를 일관된 상태로 제공하는 데는 비용이 많이 듭니다. 브랜치가 수백만 개라면 ref 목록을 알리는 데이터만 100MiB를 넘을 수 있습니다. 이를 제공하려면 각 서버가 최신 매핑을 유지하면서 상당한 조정 비용을 감당하거나, ref 수가 늘어날수록 비용이 커지는 데이터베이스 쿼리를 실행해야 합니다.
- ref 일부만 알려 주면 클라이언트가 만드는 팩은 서버가 명시적으로 제외를 허용한 객체에만 의존하게 됩니다. 이 의존 관계를 추적하면 Pando는 한 번의 푸시에서 클라이언트가 만든 팩파일을 이후 페치의 응답으로 제공해 재사용할 수 있습니다.
Git 서버가 더 많은 ref를 알려 줄수록 푸시하는 클라이언트는 객체를 제외할 근거가 늘어나므로 더 작은 팩을 만들 수 있습니다. 하지만 이러한 팩파일을 재사용하려면 페치하는 클라이언트가 필요한 의존 객체를 모두 가지고 있음을 서버가 입증할 수 있어야 합니다. 그렇지 않으면 클라이언트에 필요한 객체가 팩파일에서 빠질 수 있습니다.
반대로 Git 서버가 ref를 하나도 알려 주지 않으면 클라이언트는 저장소 전체 이력이 담긴 팩파일을 생성합니다. 이 팩파일은 어떤 다른 클라이언트도 사용할 수 있지만, 어느 브랜치를 페치하든 사실상 전체 클론을 해야 한다는 큰 비용이 따릅니다.
더 많은 ref를 알려 주면 팩파일은 작아집니다. 단 하나의 ref만 알려 줘도 크기 감소 효과의 대부분을 얻을 수 있습니다.
기본적으로 클라이언트가 새 기능 브랜치를 푸시하면 Pando는 기준으로 지정한 트렁크 브랜치 하나만 알려 줍니다. 저장소의 브랜치가 늘어나도 알려 주는 데이터의 크기가 일정하게 유지되지만, 절충도 필요합니다. 엔지니어가 한 브랜치에 여러 번 푸시하거나 한 기능 브랜치 위에 다른 기능 브랜치를 쌓으면, 클라이언트는 트렁크에 없는 모든 내용을 다시 보내야 합니다.
그 결과 브랜치에 연이어 푸시할 때 팩이 커지고 중복 객체가 포함될 수 있으며, 클라이언트 대역폭을 더 사용합니다. 우리 모노레포에서는 이 방식으로 생성한 팩파일 크기의 중앙값이 20KiB 미만이었고, 99백분위수는 3MiB 미만이었습니다. 서버가 알려 준 트렁크 이력을 이미 가진 읽기 클라이언트가 팩파일 하나로 브랜치를 페치할 수 있으므로, 우리에게는 합리적인 절충이었습니다.
클라이언트에 알려 줄 ref 집합을 선택한 뒤 Pando는 푸시를 받아들이고 메타데이터 스토어를 업데이트합니다. 메타데이터 업데이트는 동기 부분과 비동기 부분으로 나눕니다. 동기 업데이트는 클라이언트가 방금 업데이트한 커밋을 즉시 페치할 수 있도록 보장합니다. 새 ref 버전을 기록하고 ref 포인터가 그 버전을 가리키도록 매핑을 업데이트하며, 새 끝 커밋과 팩파일 협상에 사용한 의존 관계, 푸시된 팩파일의 링크를 저장합니다.
동시에 같은 트랜잭션에 컴팩션 예약 같은 비동기 작업도 기록합니다. 매우 큰 푸시는 커밋 일부만 동기적으로 기록합니다. 덕분에 푸시 크기와 관계없이 메타데이터 스토어에 필요한 읽기와 쓰기 횟수를 상수로 유지할 수 있습니다.
특히 Pando 서버들은 서로 관련 없는 ref로의 푸시를 독립적으로 받아들일 수 있습니다. 경합하는 키는 ref 포인터뿐이며, 원자적인 비교 후 교환과 유사한 연산으로 보호합니다. Pando 서버나 메타데이터 스토어의 확장 단위, 블롭 스토어를 추가해 쓰기 처리량을 늘릴 수 있습니다.
회선 속도를 모두 활용하는 페치와 클론 성능
페치할 때 클라이언트는 서버에 두 개의 커밋 ID 집합을 제공합니다. 프로토콜에서는 이를 have와 want라고 합니다. 일반적인 Git 서버는 have에서 도달 가능한 객체와 want에서 도달 가능한 객체의 차집합을 구하고, 이를 압축해 클라이언트에 보냅니다. 오픈소스 Git 서버에는 이 작업을 빠르게 처리하기 위한 다양한 로컬 색인이 있지만, 팩파일을 계산하는 데 비용이 많이 들 수 있고 일반적으로 캐시하기도 어렵습니다.
대신 Pando는 푸시 때 기록한 커밋 매핑으로 클라이언트의 have와 want를 조회하고, 해당 커밋들을 포함하는 ref 버전 집합으로 변환합니다. 각 ref 버전은 Pando가 이전에 받은 팩파일에 연결됩니다. 서버는 이 정보를 바탕으로 클라이언트에 필요한 객체 집합을 모두 포함하는 팩파일들을 선택합니다.
Pando의 페치 계획 수립
Pando는 클라이언트가 가진 것과 원하는 것을 비교한 다음, 브랜치의 의존 데이터 중 빠진 부분까지 포함해 필요한 팩파일을 선택합니다.
Pando는 Git 프로토콜의
have와want를 일련의 ref 버전에 대응시킵니다. 별도 브랜치를 원한다는 것은 그 브랜치가 의존하는 트렁크 버전까지도 원한다는 뜻입니다.
Git 프로토콜은 클론이나 페치의 HTTP 응답에서 팩파일 하나만 보낼 수 있도록 합니다. 하지만 잘 알려지지 않은 기능인 packfile-uri를 사용하면 서버가 추가 팩을 따로 내려받을 수 있는 링크를 반환할 수 있습니다. Pando는 이 방식만 사용하며, 응답 본문에는 빈 팩파일을 전송합니다. OpenAI는 이 기능을 지원하기 위해 packfile-uri의 여러 개선 사항을 업스트림에 기여했습니다.
OpenAI의 시스템은 여러 지역에서 운영되므로 각 지역에 로컬 캐시를 갖춘 Pando 클러스터를 유지합니다. 팩 전송 서비스인 Express는 블롭 스토어를 캐시하고, 별도의 서비스는 메타데이터 스토어를 캐시합니다. 덕분에 Pando는 커밋 ID 요청이나 특정 형태의 ref 읽기를 해당 클러스터에 캐시된 상태만으로 처리할 수 있습니다. 팩파일 요청을 Express 서버로 라우팅할 때는 일관된 해싱(consistent hashing)을 사용하며, 캐시 적중률은 99%를 넘습니다.
이 캐시 시스템은 클라이언트와 Pando 사이의 네트워크 한도까지 활용할 수 있을 만큼 높은 처리량을 제공합니다. 팩파일 URI와 로컬 캐시를 사용하면 클라우드 업체의 일반 가상 머신만으로도 클론과 페치 작업이 10Gbps 네트워크 회선의 대역폭을 모두 사용하는 것을 확인했습니다.
컴팩션과 첫 바이트 수신까지의 시간
대부분의 Git 서버에서는 커지는 저장소의 성능을 유지하기 위해 리패킹과 가비지 컬렉션이 필요합니다. 둘 다 공간을 회수하지만, 연산과 I/O를 많이 사용합니다.
OpenAI 모노레포처럼 빠르게 변하는 저장소에서는 마지막 페치 이후 발생한 모든 푸시마다 개별 팩파일 URI를 보내는 것이 현실적이지 않습니다. 트렁크를 최신 상태로 따라잡으려면 클라이언트가 하루에도 수만 개의 팩파일을 내려받아야 하기 때문입니다.
대신 Pando는 로그 구조 병합 트리(LSM 트리)의 컴팩션과 비슷한 설계를 채택해 백그라운드에서 팩을 합치고 오버헤드를 줄입니다. 각 푸시는 Pando에서 ‘L0’라고 부르는 팩파일을 생성하며, 이는 Pando가 전송할 수 있는 가장 작은 데이터 단위입니다. 시간이 지나면 백그라운드 워커가 이 팩파일들을 모아 연속된 ref 버전 구간을 아우르는 커버(cover)를 만듭니다. 커버의 크기는 L0부터 단계별로 기하급수적으로 커집니다. 현재 우리 모노레포는 L4까지의 팩파일 커버를 사용합니다.
이 커버는 두 가지 역할을 합니다. 커버가 충분히 채워지면 컴팩션 워커가 해당 커버의 처리를 맡아 하나의 팩파일을 만듭니다. 팩파일 안의 객체를 재배치하므로 압축률이 좋아집니다. 동시에 ref 버전들에 대한 스킵 리스트 역할을 하여 페치 협상 성능도 높입니다. Pando는 각 레벨의 색인을 동시에 훑으면서 로그 시간에 클라이언트에 보낼 팩 집합을 선택할 수 있습니다. 이러한 팩파일 커버는 리패킹 여부와 관계없이 페치 계획을 더 효율적으로 세울 수 있게 하여, Pando에 요구되는 처리량과 지연 시간 양쪽의 부담을 줄입니다.
Pando의 컴팩션
작은 팩파일들을 여러 레벨에 걸쳐 더 큰 커버로 묶으면 압축률이 좋아지고 페치가 빨라집니다.
컴팩션은 여러 ref 버전의 팩파일을 백그라운드에서 합쳐, 더 적은 파일로 효율적으로 페치할 수 있게 합니다.
다른 많은 Git 서버와 달리 Pando는 모든 레벨의 팩파일을 보존하며, 폐기하거나 가비지 컬렉션으로 제거하지 않습니다. 페치 때 클라이언트가 요청하는 have와 want에 맞춰 모든 레벨의 팩을 계속 사용합니다. 또한 Pando를 API로 사용하는 클라이언트가 팩파일에서 임의의 객체를 읽을 때는, L4보다 L0에서 읽어야 하는 데이터가 적은 편입니다. L0의 객체는 압축되어 있을 가능성이 더 낮기 때문입니다.
Pando의 컴팩션은 클라이언트에 보내는 전체 데이터 크기를 크게 줄입니다. 또한 클라이언트가 뒤처진 정도보다 팩 계획 비용이 더 느리게 증가하도록 해 줍니다. 이는 규모가 크고 빠르게 변하는 저장소에서 특히 중요하며, 우리는 앞으로 이런 저장소가 소프트웨어 개발의 일반적인 모습이 되리라 예상합니다.
Pando 구축하기
우리는 Codex로 Pando를 구축했으며, 착수부터 프로덕션 배포까지 단 10주가 걸렸습니다. Pando의 핵심은 엔지니어 한 명이 설계했고, 여러 에이전트 팀이 구현했으며, 소수의 엔지니어가 배포했습니다. Pando는 올해 4월부터 프로덕션 트래픽을 처리하기 시작했으며, OpenAI에서 사용하는 더 큰 소스 관리 도구 체계의 일부입니다.
Pando의 설계를 검증할 때는 다양한 접근법을 사용했습니다. 더 안전한 소프트웨어 기반을 구축하기 위한 원칙을 적용했습니다. 처음부터 새로 시작하는 프로젝트였으므로 기존 의존성 없이 Rust로 구현하기로 했습니다. 에이전트가 자신의 변경 사항을 표준 Git 동작과 비교해 테스트할 수 있도록 적합성 테스트 스위트를 작성했습니다. 시스템의 불변 조건과 속성을 확인할 수 있는 결정론적 시뮬레이션 테스트 하네스도 개발했습니다. 이를 통해 협상과 팩 계획 과정에서 발생하는 미묘한 경쟁 상태와 회귀 문제를 찾아 완화할 수 있었습니다.
이 시스템에는 여러 모델이 기여했습니다. 새 모델을 사용할 수 있게 될 때마다 Pando의 성능과 안정성을 개선하는 데 활용했습니다. GPT‑6 Astra를 활용한 에이전트들은 Lean과 TLA+로 ‘Pando 이론’을 형식화했으며, 우리는 이를 이용해 구현의 버그를 찾아 수정하고 시스템의 속성을 증명했습니다. 여러 세션을 각각 수백 시간 동안 연속으로 실행하며 Pando의 예외적인 상황을 찾아 수정했습니다. 또한 Daybreak 기능을 갖춘 모델을 활용해 Git과 Pando를 점검하는 에이전트를 24시간 운영합니다.
Pando 배포하기
Pando는 처음에 기존 소스 관리 시스템 앞에서 읽기와 쓰기 요청을 전달하는 프록시로 배포했습니다. 이 방식으로 Pando와 기존 시스템의 동기화를 유지하면서, 중단 없이 안전하게 트래픽을 Pando로 옮길 수 있었습니다.
Pando는 캐싱 설계의 일부로, 프록시로 요청을 전달하기 전에 중복 요청을 제거하거나 여러 요청을 합칠 수도 있습니다. 덕분에 시스템을 하나씩 Pando로 옮기는 동안에도 기존 도구를 안정적으로 운영할 수 있었습니다. 더 많은 워크로드를 Pando로 이전할수록 각 워크로드의 푸시가 Pando 캐시를 채웠고, 기존 백엔드의 안정성도 개선되었습니다.
Pando를 사용할 때의 성능을 최적화하기 위해 개발자 컴퓨터와 ChatGPT 데스크톱 앱에 공개 Git 배포판을 배포하기 시작했습니다. 클라이언트를 Pando로 옮길 때는 Git CLI를 감싼 내부 래퍼를 이용해 단계적으로 이전했습니다. 변경 요청과 코드 리뷰를 처리하는 도구, 자동화용 API 등 여러 도구도 만들었습니다. Git 트래픽뿐 아니라 기존 API와 웹훅, 저장소 다운로드에 의존하던 수백 개의 연동도 함께 이전했습니다.
Codex 자체도 개발자와 팀의 이전을 도왔습니다. 기존 체크아웃을 준비하는 스킬과 에이전트에게 새 시스템의 사용법을 안내하는 스킬을 만들었습니다. 그 결과 개발자는 Codex에 개발 환경과 서비스를 이전해 달라고 요청하기만 하면 되었습니다.
Pando로 전환한 뒤 머지 안정성이 높아졌고, 푸시 지연 시간의 중앙값은 거의 80% 줄었습니다.
Pando는 CPU 코어 몇 개만으로 하루에 1페타바이트의 Git 데이터를 전송할 수 있습니다. 한 시간에 57테라바이트가 넘는 Git 팩을 제공한 적도 있으며, 그 데이터의 99% 이상을 로컬 디스크 캐시에서 처리했습니다.
팩을 재사용하면 이를 제공하는 노드에서 연산을 거의 하지 않고도 많은 클라이언트가 같은 저장소 이력을 내려받을 수 있습니다. 이 사례는 저장소 하나를 보여 주지만, Pando는 훨씬 더 많은 저장소를 지원하도록 설계했습니다. 수요가 증가하면 데이터 전송과 Git 협상을 서로 독립적으로 확장할 수 있습니다.
Git 프로젝트에 기여하기
Git 프로토콜과 클라이언트 위에 Pando를 구축하면서 Git 자체의 버그와 성능 개선 기회를 발견할 수 있었습니다.
예를 들어 Pando는 재사용 가능한 팩을 클라이언트에 직접 제공하므로, 이를 다운로드하고 색인하는 과정이 시스템의 속도와 안정성을 높이는 데 핵심이 되었습니다. 이 작업을 진행하면서 동시 다운로드, 전송 중단 후 정리, 이미 다운로드가 완료된 상태에서의 재개 처리에서 예외적인 상황을 발견했습니다. 해당 처리 경로를 수정하고, 페치에 기존 팩을 사용할 때 검증을 추가했으며, 이 작업을 업스트림에 기여했습니다.
어디에 시간과 대역폭이 쓰이는지도 파악해야 했습니다. 그래서 packfile-uri 다운로드 추적 기능과 팩 생성 시 바이트 수 측정을 추가했습니다. 덕분에 느린 페치가 팩 생성에 시간을 쓰는지, 전송에 시간을 쓰는지 구분하기 쉬워졌습니다.
OpenAI의 Git 빌드에서는 클라이언트가 서로 독립적인 팩을 병렬로 다운로드하고 색인할 수 있습니다. 또한 ‘명시적 have(explicit haves)’라는 개념을 추가해, 서버가 브랜치의 현재 끝 커밋과 푸시 클라이언트가 자신의 팩에서 제외해도 되는 이력을 따로 알려 줄 수 있도록 했습니다. Pando에서 클라이언트는 이를 이용해 --force-with-lease로 기능 브랜치가 이동하지 않았는지 확인하면서도, 트렁크만 기준으로 팩을 만들 수 있습니다. 그러면 해당 트렁크 이력을 가진 누구나 같은 팩으로 그 브랜치를 페치할 수 있습니다.
이러한 개선 중 일부는 이미 업스트림 Git에 반영되었고, 그 밖에도 많은 개선 사항을 공개 저장소에서 제공하고 있습니다. 우리는 이 개선 사항들이 Pando 밖에서도 유용하도록 Git 커뮤니티와 계속 협력하고 있습니다. 진행 상황은 공개 패치 트래커에서 확인할 수 있습니다.
앞으로의 계획
Git 저장소를 확장하는 기존의 통념을 다시 검토한 덕분에, Pando는 엔지니어와 에이전트가 이미 사용하는 Git 워크플로를 바꾸지 않고도 모노레포를 확장할 수 있게 했습니다. 우리 모노레포에서는 더 많은 작업을 병렬로 처리할 수 있게 되었고, 머지 안정성이 높아졌으며, 코드 변경 사항을 반영하는 데 걸리는 시간도 줄었습니다. 모노레포 외에도 많은 에이전트가 공유 Git 저장소에서 협업해야 하는 워크로드를 비롯해 OpenAI 내부의 다른 용도로 Pando를 활용하는 방안을 살펴보고 있습니다.
엔지니어와 에이전트가 더 많은 소프트웨어를 만들어 낼수록, OpenAI 밖에서도 더 많은 팀이 우리가 겪은 소스 관리의 한계에 부딪히게 될 것입니다. 현재 우리는 Pando를 가능하게 하는 기술을 사용자와 공유할 방법을 검토하고 있습니다.
Pando는 Git 저장소 사용자와 운영자들이 20년에 걸쳐 쌓아 온 경험을 바탕으로 합니다. 앞으로도 Pando를 개발하면서 오픈소스 Git 커뮤니티와 협력해, 더 많은 Git 사용자에게 도움이 되는 개선 사항을 공유하겠습니다.