Git 2.55's reftable backend creates 10,000 refs in 40ms instead of 650ms
Git 2.55 reftable 백엔드, 참조 1만 개 생성 시간 650ms에서 40ms로 단축
Git 2.55의 reftable은 참조를 대량으로 만들 때 기존 files 백엔드보다 10~60배 빠르고 디스크도 덜 씁니다. 하지만 여러 프로세스가 동시에 참조를 만들면 기본 잠금 대기 시간 탓에 실패가 잦아져, CI처럼 쓰기가 몰리는 저장소는 마이그레이션 전에 동시성 테스트가 필요합니다.
- 주제
AI 요약
Git 2.55.0의 reftable 백엔드는 참조를 개별 파일로 저장하는 기존 files 백엔드와 달리, 정렬된 테이블 파일 몇 개에 참조 데이터베이스를 담습니다. 글쓴이는 동일한 Git 빌드와 저장소 조건에서 두 형식의 쓰기·읽기 속도, 디스크 사용량, 동시 쓰기 동작을 비교했습니다. 특히 대량 생성 성능만 보면 reftable이 크게 앞서지만, 여러 프로세스가 한 저장소에 동시에 쓰는 상황에서는 결과가 달라집니다.
대량 쓰기와 저장 공간
새로 만든 bare 저장소에 같은 참조 생성 작업을 실행했습니다. 참조 1만 개를 만들 때 files 백엔드는 436~650ms, reftable은 39~42ms가 걸렸습니다. 5만 개에서는 files가 2.1~12.4초, reftable이 199~213ms였습니다. files 백엔드의 5만 개 결과는 같은 조건으로 세 번 실행해도 편차가 컸습니다. 한 디렉터리에 느슨한 참조 파일 수만 개를 만들 때 평균 속도뿐 아니라 지연 시간도 불안정하게 나타났습니다.
디스크 사용량 차이도 큽니다. 참조 1만 개에서 .git/refs/는 1만 개 파일에 40MB를 사용했고, .git/reftable/는 테이블 하나와 목록 파일을 합쳐 272KB를 차지했습니다. 참조 5만 개에서는 각각 198MB와 1.4MB였습니다. files 쪽의 작은 파일은 실제 참조 내용보다 파일 시스템 블록 오버헤드가 용량을 키웠습니다.
읽기와 네트워크 작업
읽기 성능 차이는 쓰기만큼 크지 않았습니다. git for-each-ref는 참조 1만 개에서 files 백엔드가 180~183ms, reftable이 132~134ms로 약 1.4배 빨랐습니다. 5만 개에서는 각각 897~943ms와 636~714ms로 약 1.3배 차이가 났습니다.
Git 2.51 릴리스 노트가 제시한 git fetch 22배, git push 18배 향상도 재현해 보려 했습니다. 글쓴이는 --mirror 복제와 --no-local 복제를 따로 실행하고 git ls-remote도 측정했습니다. 가장 큰 차이는 참조 5만 개에서 나타났습니다. ls-remote가 files의 342~376ms에서 reftable의 73~189ms로 줄어 2~4배 빨라졌습니다. 1만 개에서는 files가 174~178ms, reftable이 119~169ms로 차이가 작았습니다. 단일 머신의 file:// 측정으로 릴리스 노트의 배수를 재현하지 못했으며, 글쓴이는 네트워크 전송 조건에 따라 고정 왕복 지연과 참조 광고 비용의 비중이 달라질 수 있다고 봤습니다. 따라서 해당 수치는 특정 측정 환경의 결과로 다뤄야 한다고 설명합니다.
동시 쓰기에서 드러난 잠금 병목
가장 큰 주의점은 여러 프로세스가 서로 다른 참조를 동시에 만드는 경우입니다. files 백엔드는 참조마다 별도 파일을 잠그므로 다른 참조를 쓰는 프로세스끼리 작업을 진행할 수 있습니다. 반면 reftable은 테이블 스택에 쓰고 tables.list 파일 잠금을 공유합니다. 테이블을 합치는 기하급수적 압축(compaction)도 같은 잠금을 필요로 합니다.
동시 작업자 50명에서는 두 백엔드 모두 50건을 성공시켰습니다. 이때 files는 37~43ms, reftable은 74~108ms가 걸렸습니다. reftable에 작업자 100명을 투입하면 두 차례 모두 56건만 성공했습니다. 150명에서는 다섯 차례 실행에서 54~70건이 성공했고, 나머지는 cannot lock references 오류로 실패했습니다. files 백엔드는 같은 조건에서 150건 전부 성공했으며 106~119ms가 걸렸습니다. 실패한 업데이트는 서로 다른 참조를 대상으로 했기 때문에 실제 참조 충돌은 없었습니다.
reftable.lockTimeout 기본값은 100입니다. 이를 5000으로 높이자 150개 작업이 세 차례 모두 성공했습니다. 다만 실행 시간은 1.05~1.15초로 늘었습니다. 잠금 실패는 사라졌지만 직렬화 병목은 남았고, 프로세스가 차례를 기다리면서 작업이 files 백엔드보다 약 10배 느려졌습니다. 한 프로세스가 저장소에 쓰는 일반적인 작업에서는 문제가 드러나지 않을 수 있지만, CI 작업마다 브랜치를 만들거나 여러 사용자가 짧은 시간에 브랜치를 푸시하는 환경에서는 실제 동시성에 맞춘 테스트가 필요합니다.
트랜잭션과 마이그레이션
글쓴이는 한 참조의 예상 이전 값이 틀린 네 건의 업데이트 묶음을 git update-ref --stdin으로 실행했습니다. 두 백엔드 모두 전체 묶음을 거부하고 아무것도 적용하지 않았습니다. 이런 원자적 처리 방식은 files 백엔드에서도 오래전부터 제공했으므로 reftable만의 차이는 아닙니다.
git refs migrate --ref-format=reftable은 참조 1만 개가 있는 저장소를 168ms 만에 변환했고 모든 참조를 보존했습니다. 다만 다른 worktree가 연결된 저장소는 아직 지원하지 않아 마이그레이션을 거부했습니다. 변환 뒤 .git/HEAD 파일은 실제 브랜치 이름 대신 ref: refs/heads/.invalid를 담습니다. Git 명령으로 현재 브랜치를 확인하거나 저장소를 복제하는 동작은 정상적이지만, .git/HEAD를 직접 읽는 도구는 이 값을 보게 됩니다. 이는 reftable의 내부 기록 방식과 호환성을 위해 문서화된 동작입니다.
적용할 때 확인할 점
Git 2.55.0은 2026년 6월 29일 출시됐으며, 글에 따르면 Git 2.51 릴리스 노트는 reftable이 충분히 성숙해 Git 3.0에서 새 저장소의 기본 형식이 될 예정이라고 설명합니다. 글쓴이는 전체적으로 쓰기 작업이 한 번에 몰리지 않는 저장소라면 reftable의 대량 쓰기 속도와 작은 저장 공간이 유리하다고 정리합니다. 반대로 같은 저장소에 여러 프로세스가 동시에 참조를 만드는 환경은 전환 전에 실제 부하로 동시 쓰기를 시험하고 reftable.lockTimeout 설정을 확인해야 합니다.
dev.to 반응
- @kanunilabs — 여기서 동시성 결과가 아마 가장 흥미롭습니다. 대량 쓰기 수치만 보면 reftable이 당연한 업그레이드처럼 보이지만, 동시 작업자 150명에서 30~63%가 실패한다면 이야기가 꽤 달라집니다.
- @alexgeorgiev17 — 맞습니다. 이 차이가 얼마나 큰지 분명히 말할 필요가 있습니다. 동시 작업자 150명에서 files 백엔드는 106~119ms 만에 150건을 모두 성공시켰지만, 기본
lockTimeout을 적용한 reftable은 여러 차례 시험에서 30~63%가 실패했습니다. 대량 쓰기 측정은 reftable이 잘하는 작업, 즉 한 프로세스가 많은 일을 처리하는 상황을 시험합니다. 동시성 측정은 많은 프로세스가tables.list잠금과 그에 따른 기하급수적 압축 작업을 놓고 경쟁하는 상황을 시험합니다. 기본 설계가 비용을 치르는 지점입니다.reftable.lockTimeout을 5000으로 올리면 오류 없이 150건이 성공하지만, files 백엔드에서 약 110ms 걸리던 작업이 1.05~1.15초로 늘어납니다. 실패 대신 지연을 택하는 셈이며, 직렬화는 그대로 남습니다. CI나 봇이 같은 저장소에 동시에 푸시하는지, 보통 사람 수준의 사용인지에 따라 마이그레이션 판단이 달라집니다. 기본값을 믿기 전에 실제 저장소가 작업자 50명 수준인지, 100~150명 수준인지 확인할 만합니다.
- @alexgeorgiev17 — 맞습니다. 이 차이가 얼마나 큰지 분명히 말할 필요가 있습니다. 동시 작업자 150명에서 files 백엔드는 106~119ms 만에 150건을 모두 성공시켰지만, 기본
- @mrsaynothing — 40ms라는 수치는 형식 설계와 맞아 보입니다. reftable은 테이블 하나를 메모리 매핑해 조회를 묶어 처리하지만, 느슨한 참조는 파일마다
lstat을 해야 합니다. 서버의 bare 저장소를 변환할 때 참고할 점도 있습니다. 참조 저장 형식은 원자적으로 바뀌지만, 이후 모든 복제 요청은 그 저장소와 통신하므로 작업 시간을 조율해야 합니다.for-each-ref나branch -a같은 조회 중심 작업도 측정했나요, 아니면 쓰기만 측정했나요?- @alexgeorgiev17 — 좋은 질문입니다. 조회에서도 묶음 처리 효과가 나타나는지 보려고
for-each-ref를 시험했습니다. 차이는 훨씬 작았습니다. 참조 1만 개에서 180~183ms 대 132~134ms, 5만 개에서 897~943ms 대 636~714ms로 reftable이 약 1.3~1.4배 빨랐습니다. 읽기는 수천 개의 느슨한 파일을 하나씩 쓰는 작업과 접근 방식이 다르므로 쓰기 속도 차이와 비슷할 이유가 없습니다.branch -a는 별도로 측정하지 않았습니다.for-each-ref와 비슷하게 전체 참조를 열거하므로 비슷한 결과를 예상하지만, 직접 측정한 결과는 아닙니다. 마이그레이션과 관련해서 지적한 부분은 시험하지 않았습니다. 변환은 동시 트래픽이 없는 저장소에서만 실행했습니다. 참조 형식은 로컬 저장 방식만 바꾸고 와이어 프로토콜은 건드리지 않으므로 복제 협상 자체는 달라지지 않을 것 같습니다. 다만 읽기 작업이 디스크에서 불완전하게 변환된 상태를 만나는 구간이 있는지는 확인하지 않았습니다. 실제 재현이 필요한 부분입니다.
- @alexgeorgiev17 — 좋은 질문입니다. 조회에서도 묶음 처리 효과가 나타나는지 보려고
- @naveen_alavilli — 동시성 문제는 CI에서 실제로 사람을 곤란하게 만들 수 있습니다. 작업을 여러 갈래로 나눈 빌드가 작업별 참조를 쓰거나, 빌드마다 태그를 만들거나, Gerrit 방식의 변경 참조를 만들거나, 매트릭스 작업의 각 칸에서 봇이 결과 참조를 푸시하는 상황입니다. 모두 서로 다른 참조를 쓰므로 실제 충돌은 없습니다. 바로 이런 작업을 files 백엔드는 참조별 잠금으로 처리했습니다. 자동화 도구들이 참조별 독립성에 기대고 있다는 점은 릴리스 노트에 나오지 않습니다. 원자성 시험과 함께 보면 대응 방법도 생각할 수 있습니다.
cannot lock references는 업데이트 거부가 아니라tables.list경합입니다.update-ref --stdin은 두 백엔드 모두 전부 적용하거나 전혀 적용하지 않으므로, 지수형 대기 시간을 둔 재시도는 안전합니다. 다만 이제update-ref를 실행하는 파이프라인 도구마다 재시도 정책이 필요할 수 있습니다. 22배 성능을 재현하지 못한 부분은 실행 사이에 페이지 캐시를 비우고 시험해 볼 만합니다. reftable의 읽기 이점은 파일 수와 메타데이터 시스템 호출을 줄이는 데서 나오므로, 느슨한 참조 파일이 페이지 캐시에 올라가면 files 백엔드가 비용을 덜 냅니다. 페이지 캐시가 따뜻한 측정에서는 바로 그 차이가 줄어듭니다. 1.3~1.4배가 정상 상태의 수치이고, 다른 측정은 콜드 스타트 결과일 수도 있습니다. 디스크 수치는 네트워크 파일 시스템에서 더 큰 차이를 만들 수 있습니다. NFS나 SMB에 있는 참조 5만 개의 작은 파일은 파일마다 왕복이 필요할 수 있어 로컬 ext4와 상황이 다릅니다. 네트워크 저장소에서는 reftable의 이점이 더 커지는 한편 동시성 한계도 더 빨리 느낄 수 있습니다. - @mihai_leanzero — 자세한 글입니다. 동시성 결과의 반전이 기억에 남습니다. 기본값이 2인
geometricFactor가 테이블을 자주 합치고, 그 때문에tables.list경합이 생긴다고 했습니다.lockTimeout을 올리는 대신, 또는 함께geometricFactor를 높여 압축 횟수를 줄이는 시험도 했나요? 동시성에서 큰 병합 작업이 새로운 병목이 될까요?lockTimeout을 올려 지연 시간을 약 10배 치르기 전에 시험해 볼 만한 설정처럼 보입니다.
원문: dev.to / 번역·요약: Trawling