Git 저장소를 Postgres의 두 개 테이블로 모델링하고, libgit2 백엔드로 일반 Git 클라이언트가 데이터베이스를 원격으로 푸시/클론할 수 있게 하는 실험과 Forgejo 같은 포지에 적용했을 때의 가능성을 살펴본다.
2025년 12월에는 git을 데이터베이스로 쓰는 패키지 매니저에 대해 썼고, Cargo의 인덱스, Homebrew의 탭(tap), Go의 모듈 프록시, CocoaPods의 Specs 저장소가 모두 접근 패턴이 git 저장소가 설계된 범위를 넘어서는 순간 같은 벽에 부딪힌다는 이야기를 했다.
homebrew-core는 패키지 포뮬러(formula)마다 Ruby 파일 하나를 두고 있으며, 예전에는 brew update를 할 때마다 전체 저장소를 클론하거나 페치했다. 그러다 저장소가 충분히 커지자 GitHub가 명시적으로 그만해 달라고 요청할 정도가 됐다. Homebrew 4.0은 사용자가 커밋 히스토리보다는 “현재 시점의 패키지 상태”를 원한다는 점에 맞춰 HTTP로 JSON 파일을 내려받는 방식으로 바꿨다. 하지만 포뮬러를 업데이트하려면 여전히 homebrew-core에 풀 리퀘스트를 열어야 하는데, 협업 도구가 사는 곳이 git이기 때문이다. git을 데이터베이스로 쓰는 대신, 데이터베이스를 git으로 쓰면 어떨까?
git 저장소는 내용 기반 주소 지정(content-addressable) 오브젝트 스토어로, 오브젝트는 그 내용의 SHA1으로 인덱싱되어 들어가고, 해시로 특정 오브젝트를 가리키는 이름 붙은 레퍼런스(ref) 집합이 추가로 존재한다. 디스크 상의 포맷(개별 파일로 저장되는 loose object, 별도의 인덱스와 함께 델타 압축된 아카이브인 packfile, 파일 디렉터리와 packed-refs 플랫 파일로 나뉘며 NFS에서 깨질 수 있는 락킹 프로토콜을 가진 ref 스토어 등)은 구현 세부사항이다. 실제로 중요한 것은 저장소들 사이에서 오브젝트와 ref를 동기화하는 프로토콜이며, git-프로그램은 그 구현체 중 하나일 뿐이므로 클라이언트가 눈치채지 못하게 스토리지 백엔드를 바꿀 수 있다.
전체 데이터 모델은 두 개 테이블로 들어맞는다:
sqlCREATE TABLE objects ( repo_id integer NOT NULL, oid bytea NOT NULL, type smallint NOT NULL, size integer NOT NULL, content bytea NOT NULL, PRIMARY KEY (repo_id, oid) ); CREATE TABLE refs ( repo_id integer NOT NULL, name text NOT NULL, oid bytea, symbolic text, PRIMARY KEY (repo_id, name) );
오브젝트의 OID는 git이 하는 것과 같은 방식으로, pgcrypto의 digest() 함수를 이용해 SHA1("<type> <size>\0<content>")로 계산한다. ref는 SELECT FOR UPDATE를 통한 compare-and-swap 업데이트를 한다. libgit2 백엔드는 이 테이블들을 스토리지 레이어로 등록하고, 정말로 프로토콜이 포맷과 분리 가능하다면, 일반 git 클라이언트는 차이를 모른 채 Postgres 데이터베이스로 푸시하고 거기서 클론할 수 있어야 한다.
이를 테스트하기 위해 gitgres를 만들었다. 약 2,000줄의 C 코드로 libpq를 통해 Postgres에 붙는 libgit2의 git_odb_backend와 git_refdb_backend 인터페이스를 구현했고, 스토리지 함수용으로 약 200줄의 PL/pgSQL도 작성했다. libgit2는 pack 협상(negotiation), 델타 해소(resolution), ref 광고(advertisement), 전송(transport) 프로토콜을 처리하고, 백엔드는 두 테이블에 대해 읽고 쓰기만 한다. 또한 git 원격 헬퍼(git-remote-gitgres) 덕분에 어떤 저장소에도 Postgres-backed 원격을 추가할 수 있으며, 데이터베이스와 통신하고 있다는 사실을 전혀 모르는 일반 git 클라이언트로 푸시/클론을 할 수 있다. 저장소에는 Dockerfile도 들어 있어, libgit2와 libpq를 소스에서 빌드하지 않고도 시험해 볼 수 있다.
objects 테이블에는 git이 디스크에 저장했을 바이트가 그대로 들어가며, 일련의 SQL 함수가 이를 파싱해 트리 엔트리, 커밋 메타데이터, 부모 링크로 풀어낸 다음 다른 테이블처럼 조인할 수 있게 해준다.
sqlSELECT r.name AS repo, c.author_name, c.authored_at, i.title AS issue FROM commits c JOIN repositories r ON r.id = c.repo_id JOIN issues i ON i.repo_id = c.repo_id AND c.message ILIKE '%#' || i.index || '%' WHERE c.authored_at > now() - interval '30 days';
이 쿼리는 git 커밋 데이터와 Forgejo의 이슈 트래커를 조인한다. 현재라면 git log로 커밋을 가져오고, 애플리케이션 코드에서 이슈 참조를 패턴 매칭한 뒤, 해당 이슈를 찾기 위해 데이터베이스를 질의해야 한다. 양쪽이 모두 Postgres에 있으면 쿼리 하나로 끝난다.
자체 호스팅 Forgejo나 Gitea 인스턴스는 사실 두 시스템을 억지로 붙여 놓은 형태다. Postgres가 받치는 웹 애플리케이션과, 파일시스템에 있는 bare git 저장소 모음이다. 웹 UI에서 git 데이터를 보여줘야 하는 기능은 무엇이든 바이너리를 실행(shell out)하고 텍스트를 파싱해야 한다. 그래서 blame 뷰처럼 단순한 기능도 쿼리를 실행하는 대신 서브프로세스를 띄워야 한다. git 데이터가 다른 모든 것과 같은 Postgres 인스턴스에 살고 있다면, 그 경계가 사라진다.
Forgejo는 이슈, 풀 리퀘스트, 사용자, 권한, 웹훅, 브랜치 보호 규칙, CI 상태를 이미 Postgres에 저장한다. 파일시스템에 남아 있는 것은 git 저장소뿐이며, 이 때문에 모든 배포에서 둘 사이의 백업을 조율해야 하고, 두 시스템은 스케일링과 장애 양상이 서로 다르다. 코드베이스에서도 부담이 드러난다. Forgejo는 git에 있는 브랜치 메타데이터를 자체 데이터베이스 테이블로 미러링(models/git/branch.go)해 두는데, 매번 git에 셸아웃하지 않고 브랜치를 쿼리하기 위해서다.
모든 git 상호작용은 modules/git을 통해 이뤄지는데, 약 15,000줄의 Go 코드가 git 바이너리에 셸아웃하고 텍스트 출력을 파싱한다. git 데이터가 Postgres에 있다면 오브젝트 읽기는 Forgejo가 이미 가지고 있는 DB 커넥션에서 SELECT content FROM objects WHERE oid = $1로 바뀌고, 커밋 히스토리 워킹은 git log를 띄우는 대신 머티리얼라이즈드 뷰(materialized view)에 대한 쿼리가 된다.
배포는 단일 Postgres 인스턴스로 수렴한다. pg_dump가 포지 메타데이터, git 오브젝트, 사용자 데이터를 함께 백업하고, 레플리카가 NFS 마운트나 Gitaly 스타일 RPC 레이어 없이도 웹 UI의 읽기 스케일링을 처리한다. 그 경로는 modules/git을 Postgres를 질의하는 패키지로 교체하는 Forgejo 포크다. 여기서 Repository는 파일시스템 경로 대신 데이터베이스 커넥션과 repo_id를 보유하고, Commit, Tree, Blob은 쿼리 결과를 감싸는 얇은 래퍼가 된다.
Postgres에는 포지들이 현재 별도 인프라로 직접 구축하는 것과 겹치는 기본 프리미티브가 있다. refs 테이블에 트리거를 걸어 NOTIFY를 쏘면, 연결된 어떤 클라이언트든 푸시가 발생한 즉시 알 수 있는데, 이는 포지들이 보통 커스텀 웹훅 폴링 레이어를 만들게 되는 이유이기도 하다. 멀티테넌트 저장소 격리는 objects와 refs 테이블의 로우 레벨 보안(RLS)으로 데이터베이스의 관심사가 되고, 논리적 복제(logical replication)를 쓰면 Postgres 인스턴스 사이로 특정 저장소만 선택적으로 스트리밍할 수 있는데, 파일시스템 기반 git으로는 할 수 없는 부분 미러링(partial mirroring) 같은 것이다. 조상(ancestry) 쿼리와 merge-base 계산을 위한 커밋 그래프 탐색은 재귀 CTE로 처리할 수 있고, blob 내용에 pg_trgm 인덱스를 두면 별도의 검색 인덱스를 세우지 않고도 모든 저장소에 대해 부분 문자열 검색을 제공할 수 있다.
콘텐츠 레벨 diff, 3-way 머지, blame은 SQL로 재구현하기보다 libgit2에 남겨 둔다. libgit2는 이미 해당 기능을 지원하고, cgo 바인딩을 통해 Postgres 백엔드에서 그대로 동작하기 때문이다. 따라서 Forgejo 포크는 “modules/git을 raw SQL로 대체”가 아니라 “Postgres를 백엔드로 하는 libgit2로 modules/git을 대체”가 된다. 읽기 측 쿼리는 단순한 경우만 다루고, 콘텐츠 비교나 그래프 알고리즘이 들어가는 작업은 여전히 libgit2가 Postgres를 스토리지 레이어로 삼아 수행해야 한다. 이는 분명 의미 있는 의존성이지만, libgit2는 잘 유지보수되고 있으며 Rust 생태계와 여러 GUI 클라이언트에서 이미 프로덕션으로 사용 중이다. 재귀 CTE로 일부를 SQL로 구현해 보는 것은 언젠가 흥미롭겠지만, 동작하는 포지를 만들기 위해 필수는 아니다. 남은 핵심 조각은 서버 측 pack 프로토콜이다. 원격 헬퍼는 클라이언트 측을 커버하지만, Forgejo 통합에는 Postgres를 대상으로 upload-pack과 receive-pack을 말하는 서버가 추가로 필요하다. 이는 libgit2의 트랜스포트 레이어를 통해서든, objects 테이블을 직접 질의하는 Go 구현을 통해서든 가능하다.
Git packfile은 델타 압축을 사용해 10MB 파일이 한 줄 바뀔 때마다 전체가 아니라 diff만 저장한다. 반면 objects 테이블은 각 버전을 통째로 저장한다. 같은 파일을 100번 수정하면 Postgres에서는 약 1GB를 차지하지만, packfile이라면 아마 50MB 정도일 수 있다. Postgres도 TOAST로 큰 값을 저장하고 압축하긴 하지만, 이는 개별 오브젝트를 고립된 상태에서 압축하는 것이지 packfile처럼 버전 간 델타 압축을 하는 것은 아니므로 스토리지 오버헤드는 현실적인 문제다. Postgres 내부에서 주기적으로 오브젝트를 리패킹(repack)하는 델타 압축 레이어를 추가하거나, LFS처럼 큰 blob을 S3로 오프로딩하는 것이 자연스러운 다음 단계다. 대부분의 저장소에서는 중앙값(median) 저장소가 작고 디스크가 싸기 때문에 여전히 큰 문제가 아닐 수 있다. 또한 GitHub의 Spokes 시스템도 수년 전 비슷한 트레이드오프를 했는데, 수백 엑사바이트 규모에서도 저장 효율보다 중복성과 운영 단순성이 이겨서 데이터센터 전반에 걸쳐 각 저장소를 압축하지 않은 완전한 복사본 3개를 저장했다.
gitgres는 지금은 멋진 해킹에 가깝지만, 오픈 소스 호스팅이 ForgeFed, Forgejo의 페더레이션 작업, 그리고 커뮤니티를 위해 작은 인스턴스를 운영하는 사람들의 증가로 연합(federation)과 탈중앙화 쪽으로 계속 움직인다면, 단일 Postgres 배포의 운영 단순성은 순수한 스토리지 효율보다 더 중요해진다. 소수의 거대 포지에서 다수의 소형 포지로 가려면, docker compose up으로 띄우고 pg_dump로 백업할 수 있는 포지가 필요할 가능성이 크다. 그리고 데이터베이스 옆에서 관리해야 하는 bare 저장소 파일시스템이 없다면, 그건 훨씬 쉬워진다.