깃 저장소는 파일 시스템을 전제로 설계됐다. 수많은 작은 파일(느슨한 객체)과 몇 개의 큰 묶음 파일(packfile), 그리고 그 안을 빠르게 찾기 위한 인덱스로 구성된다. 이 구조를 S3 같은 객체 스토리지 위로 옮기려면, "임의 위치 읽기"와 "부분 갱신"이라는 두 전제를 다시 설계해야 한다.
객체 스토리지는 파일 중간을 고쳐 쓸 수 없고(전체 교체만 가능), 임의 위치 읽기는 범위 요청(Range GET)으로만 가능하며 요청마다 지연이 붙는다. 그래서 packfile을 그대로 올리는 것만으로는 실용적인 성능이 안 나온다.
이 주제를 처음 파게 된 계기는 단순했다. 저장소 하나가 수십 GB로 불어났는데, 대부분은 오래된 히스토리였다. 자주 쓰는 건 최근 커밋뿐인데 클론할 때마다 전부 받아야 했다. "옛날 것은 싼 스토리지에 두고 필요할 때만 가져오면 안 되나?"라는 생각이 출발점이었다.
깃의 저장 구조 복습
| 구성 요소 | 역할 | 객체 스토리지에서의 문제 |
|---|---|---|
| 느슨한 객체(loose object) | 개별 객체를 zlib 압축해 파일 하나로 저장 | 객체 수만큼 요청 발생 — 치명적 |
| packfile | 여러 객체를 델타 압축해 묶은 큰 파일 | 통째로 받으면 낭비, 부분 읽기 필요 |
| pack index(.idx) | packfile 내 객체 위치를 찾는 색인 | 먼저 받아야 무엇을 읽을지 결정 가능 |
| refs | 브랜치·태그가 가리키는 커밋 | 잦은 갱신 — 일관성 보장이 필요 |
| reverse index / bitmap | 도달 가능성 계산 가속 | 있으면 전송량이 크게 줄어든다 |
packfile을 다시 만든다는 아이디어
깃이 만든 packfile을 그대로 쓰는 대신, 객체 스토리지 접근 패턴에 맞게 재구성하는 접근이 있다. 핵심은 세 가지다.
# 범위 요청으로 pack 안의 특정 객체만 읽는 개념
# 1) 인덱스를 받아 객체 X 의 오프셋과 길이를 알아낸다 (캐시 가능)
GET /repo/pack-abc.idx -> 오프셋 테이블
# 2) 해당 구간만 범위 요청
GET /repo/pack-abc.pack
Range: bytes=10485760-10490879 -> 필요한 5KB만 전송
# 주의: 델타 체인이 있으면 기반 객체도 읽어야 한다.
# 체인이 길수록 요청 횟수가 늘어난다 → 체인 길이 제한이 중요한 이유
refs 갱신과 일관성
객체는 불변이라 다루기 쉽지만 refs는 다르다. 브랜치 포인터는 계속 바뀌고, 두 클라이언트가 동시에 푸시하면 경쟁이 생긴다. 객체 스토리지는 전통적으로 강한 일관성 보장이 제한적이었고, 조건부 쓰기 지원도 구현마다 다르다.
- 조건부 쓰기(If-Match/ETag)로 낙관적 갱신
- refs만 별도의 일관성 있는 저장소(DB·KV)에 보관
- 단일 쓰기자(writer) 구조로 경쟁 자체를 제거
- append-only 로그로 refs 이력을 기록하고 최신만 읽기
- 읽고 쓰기 사이에 검사 없이 덮어쓰기
- 여러 프로세스가 동시에 같은 ref 파일을 갱신
- 최종 일관성만 보장되는 경로에 ref 저장
- 캐시된 ref 값을 검증 없이 신뢰
실무적으로 가장 안전한 건 객체는 스토리지에, refs는 트랜잭션이 되는 저장소에 두는 분리 구조다. 객체는 내용 주소 지정(해시가 곧 이름)이라 중복 쓰기가 무해하고, refs만 엄격히 관리하면 된다.
실무에서 더 현실적인 선택지들
직접 구현하기 전에, 이미 있는 수단으로 문제가 풀리는지 확인하는 게 순서다. 대부분의 "저장소가 너무 크다" 문제는 아래로 해결된다.
| 수단 | 해결하는 문제 | 한계 |
|---|---|---|
--filter=blob:none 부분 클론 | 히스토리 전체 blob을 안 받아도 됨 | 작업 중 필요한 blob을 온디맨드로 받음(네트워크 의존) |
--depth 얕은 클론 | 오래된 커밋 제외 | 히스토리 탐색·blame 제약 |
| sparse-checkout | 모노레포에서 일부 경로만 체크아웃 | 트리 범위 관리 필요 |
| Git LFS | 대용량 바이너리 분리 | 별도 서버·스토리지 운영, 마이그레이션 비용 |
| 커밋 그래프·비트맵 | 조회·도달성 계산 가속 | 정기 재생성 필요 |
# 대용량 저장소를 다루는 실전 조합
git clone --filter=blob:none --sparse https://example.com/mono.git
cd mono
git sparse-checkout set apps/web packages/ui
# 필요한 시점에 blob 을 가져온다 (자동)
# 히스토리 탐색이 필요하면 그때 받아온다
# 저장소 유지보수 (서버·클라이언트 모두)
git maintenance start # 백그라운드 최적화 예약
git commit-graph write --reachable
git repack -adb # 비트맵 포함 재패킹
언제 객체 스토리지 백엔드를 고려하나
반대로 팀 하나가 저장소 몇 개를 쓰는 상황이라면, 객체 스토리지 백엔드를 직접 만드는 건 과한 투자다. 부분 클론과 sparse-checkout, 정기 재패킹으로 대부분 해결된다.
설계 시 챙길 실무 항목
- 캐싱 계층 — 자주 읽는 pack과 인덱스는 로컬 디스크나 엣지에 캐시한다. 원격 왕복을 줄이는 게 전부다.
- 가비지 컬렉션 — 도달 불가 객체를 어떻게 정리할지 미리 정한다. 객체 스토리지에서는 삭제도 비용이다.
- 동시 쓰기 제어 — 푸시가 동시에 들어올 때의 직렬화 방식(락, 큐, 단일 쓰기자)을 명확히 한다.
- 무결성 검증 — 내용 주소 지정 덕분에 해시 검증이 자연스럽다. 다운로드 후 반드시 검증한다.
- 비용 모델 — 저장 단가보다 요청 수와 데이터 전송료가 지배적일 수 있다. 예상 접근 패턴으로 먼저 계산한다.
자주 묻는 질문
깃 저장소를 S3에 그냥 올려도 되나요?
파일을 복사해 두는 백업 용도라면 가능하지만, 그 상태로 클론·페치를 서비스하기에는 성능이 나오지 않습니다. 느슨한 객체가 많으면 요청 수가 폭증하고, packfile을 통째로 받으면 전송량이 낭비됩니다. 접근 패턴에 맞춘 재구성이 필요합니다.
packfile을 재구성한다는 게 무슨 뜻인가요?
함께 읽히는 객체를 가까이 배치해 한 번의 범위 요청으로 여러 객체를 가져올 수 있게 하고, 델타 체인 길이를 제한해 복원에 필요한 추가 읽기를 줄이는 것을 말합니다. 인덱스를 작고 캐시 가능하게 유지하는 것도 함께 중요합니다.
refs는 왜 따로 다뤄야 하나요?
객체는 내용 주소 지정이라 불변이지만 refs는 계속 바뀌고 동시 갱신 경쟁이 발생합니다. 객체 스토리지의 일관성 모델과 조건부 쓰기 지원은 구현마다 다르므로, refs만 트랜잭션이 보장되는 저장소에 두는 분리 구조가 안전합니다.
저장소가 너무 커서 클론이 느립니다. 먼저 뭘 해 볼까요?
부분 클론(--filter=blob:none)과 sparse-checkout 조합을 먼저 시도하세요. 대부분의 경우 이것만으로 초기 클론 시간과 디스크 사용량이 크게 줄어듭니다. 서버 측에서는 커밋 그래프와 비트맵을 생성해 두면 페치 성능이 개선됩니다.
Git LFS와는 어떻게 다른가요?
LFS는 대용량 바이너리를 별도 저장소로 분리해 깃 히스토리를 가볍게 유지하는 방식입니다. 객체 스토리지 백엔드는 깃 객체 저장 자체를 객체 스토리지로 옮기는 접근으로, 목적과 계층이 다릅니다. 바이너리 자산이 문제라면 LFS가 먼저 검토할 선택지입니다.
비용은 어떻게 예상하나요?
저장 단가보다 요청 횟수와 데이터 전송료가 지배적인 경우가 많습니다. 예상 클론·페치 빈도와 캐시 적중률을 기준으로 요청 수를 추정하고, 자주 읽히는 pack과 인덱스를 캐시해 원격 요청을 줄이는 설계를 먼저 넣으세요.

댓글 0