본문 바로가기
Backend2026년 9월 17일9분 읽기

깃을 객체 스토리지 위에 올리기 — packfile 구조와 S3 기반 저장소 설계

YS
김영삼
조회 167
깃을 객체 스토리지 위에 올리기 — packfile 구조와 S3 기반 저장소 설계

깃 저장소는 파일 시스템을 전제로 설계됐다. 수많은 작은 파일(느슨한 객체)과 몇 개의 큰 묶음 파일(packfile), 그리고 그 안을 빠르게 찾기 위한 인덱스로 구성된다. 이 구조를 S3 같은 객체 스토리지 위로 옮기려면, "임의 위치 읽기"와 "부분 갱신"이라는 두 전제를 다시 설계해야 한다.

객체 스토리지는 파일 중간을 고쳐 쓸 수 없고(전체 교체만 가능), 임의 위치 읽기는 범위 요청(Range GET)으로만 가능하며 요청마다 지연이 붙는다. 그래서 packfile을 그대로 올리는 것만으로는 실용적인 성능이 안 나온다.

이 주제를 처음 파게 된 계기는 단순했다. 저장소 하나가 수십 GB로 불어났는데, 대부분은 오래된 히스토리였다. 자주 쓰는 건 최근 커밋뿐인데 클론할 때마다 전부 받아야 했다. "옛날 것은 싼 스토리지에 두고 필요할 때만 가져오면 안 되나?"라는 생각이 출발점이었다.

깃의 저장 구조 복습

구성 요소역할객체 스토리지에서의 문제
느슨한 객체(loose object)개별 객체를 zlib 압축해 파일 하나로 저장객체 수만큼 요청 발생 — 치명적
packfile여러 객체를 델타 압축해 묶은 큰 파일통째로 받으면 낭비, 부분 읽기 필요
pack index(.idx)packfile 내 객체 위치를 찾는 색인먼저 받아야 무엇을 읽을지 결정 가능
refs브랜치·태그가 가리키는 커밋잦은 갱신 — 일관성 보장이 필요
reverse index / bitmap도달 가능성 계산 가속있으면 전송량이 크게 줄어든다
핵심 통찰 객체 스토리지에서 비용과 지연을 결정하는 건 전송량보다 요청 횟수인 경우가 많다. 그래서 설계의 목표는 "작은 요청 수천 번"을 "큰 요청 몇 번"으로 바꾸는 것이다. 깃의 packfile은 이미 그 방향으로 만들어진 구조라, 잘 활용하면 궁합이 나쁘지 않다.

packfile을 다시 만든다는 아이디어

깃이 만든 packfile을 그대로 쓰는 대신, 객체 스토리지 접근 패턴에 맞게 재구성하는 접근이 있다. 핵심은 세 가지다.

1
접근 지역성에 맞춘 재배치
함께 읽히는 객체를 pack 안에서 가까이 둔다. 그러면 한 번의 범위 요청으로 여러 객체를 가져올 수 있다.
2
인덱스를 먼저, 작게
어떤 범위를 읽을지 결정하는 인덱스는 작고 캐시 가능해야 한다. 인덱스를 매번 전부 받으면 이득이 사라진다.
3
델타 체인 길이 제한
델타 압축은 크기를 줄이지만, 체인이 길면 복원에 여러 객체가 필요하다. 원격 읽기에서는 체인이 짧을수록 유리하다.
4
불변 객체로 다루기
pack은 한 번 쓰면 바꾸지 않고 새 pack을 추가한다. 객체 스토리지의 불변성 모델과 맞아떨어진다.
# 범위 요청으로 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                # 비트맵 포함 재패킹

언제 객체 스토리지 백엔드를 고려하나

검토할 만한 조건
저장소 수가 아주 많고 대부분이 거의 접근되지 않는다 (콜드 데이터 비중이 높다)
디스크 기반 저장소 서버의 용량 확장이 운영 부담이 되고 있다
지역별로 분산된 팀이 읽기 위주로 접근한다 (CDN·엣지 캐시 활용 가능)
아카이브 성격의 저장소를 저렴하게 장기 보관해야 한다
멀티 테넌트 호스팅을 직접 운영한다

반대로 팀 하나가 저장소 몇 개를 쓰는 상황이라면, 객체 스토리지 백엔드를 직접 만드는 건 과한 투자다. 부분 클론과 sparse-checkout, 정기 재패킹으로 대부분 해결된다.

설계 시 챙길 실무 항목

  • 캐싱 계층 — 자주 읽는 pack과 인덱스는 로컬 디스크나 엣지에 캐시한다. 원격 왕복을 줄이는 게 전부다.
  • 가비지 컬렉션 — 도달 불가 객체를 어떻게 정리할지 미리 정한다. 객체 스토리지에서는 삭제도 비용이다.
  • 동시 쓰기 제어 — 푸시가 동시에 들어올 때의 직렬화 방식(락, 큐, 단일 쓰기자)을 명확히 한다.
  • 무결성 검증 — 내용 주소 지정 덕분에 해시 검증이 자연스럽다. 다운로드 후 반드시 검증한다.
  • 비용 모델 — 저장 단가보다 요청 수와 데이터 전송료가 지배적일 수 있다. 예상 접근 패턴으로 먼저 계산한다.

자주 묻는 질문

깃 저장소를 S3에 그냥 올려도 되나요?

파일을 복사해 두는 백업 용도라면 가능하지만, 그 상태로 클론·페치를 서비스하기에는 성능이 나오지 않습니다. 느슨한 객체가 많으면 요청 수가 폭증하고, packfile을 통째로 받으면 전송량이 낭비됩니다. 접근 패턴에 맞춘 재구성이 필요합니다.

packfile을 재구성한다는 게 무슨 뜻인가요?

함께 읽히는 객체를 가까이 배치해 한 번의 범위 요청으로 여러 객체를 가져올 수 있게 하고, 델타 체인 길이를 제한해 복원에 필요한 추가 읽기를 줄이는 것을 말합니다. 인덱스를 작고 캐시 가능하게 유지하는 것도 함께 중요합니다.

refs는 왜 따로 다뤄야 하나요?

객체는 내용 주소 지정이라 불변이지만 refs는 계속 바뀌고 동시 갱신 경쟁이 발생합니다. 객체 스토리지의 일관성 모델과 조건부 쓰기 지원은 구현마다 다르므로, refs만 트랜잭션이 보장되는 저장소에 두는 분리 구조가 안전합니다.

저장소가 너무 커서 클론이 느립니다. 먼저 뭘 해 볼까요?

부분 클론(--filter=blob:none)과 sparse-checkout 조합을 먼저 시도하세요. 대부분의 경우 이것만으로 초기 클론 시간과 디스크 사용량이 크게 줄어듭니다. 서버 측에서는 커밋 그래프와 비트맵을 생성해 두면 페치 성능이 개선됩니다.

Git LFS와는 어떻게 다른가요?

LFS는 대용량 바이너리를 별도 저장소로 분리해 깃 히스토리를 가볍게 유지하는 방식입니다. 객체 스토리지 백엔드는 깃 객체 저장 자체를 객체 스토리지로 옮기는 접근으로, 목적과 계층이 다릅니다. 바이너리 자산이 문제라면 LFS가 먼저 검토할 선택지입니다.

비용은 어떻게 예상하나요?

저장 단가보다 요청 횟수와 데이터 전송료가 지배적인 경우가 많습니다. 예상 클론·페치 빈도와 캐시 적중률을 기준으로 요청 수를 추정하고, 자주 읽히는 pack과 인덱스를 캐시해 원격 요청을 줄이는 설계를 먼저 넣으세요.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록