Git 내부 구조 실측: 객체·packfile·gc·bisect
blob 해시를 손으로 계산하고 cat-file 로 트리·커밋을 읽고 plumbing 으로 커밋을 만든다. gc 와 대용량 파일을 실측하고 bisect 로 300개 중 8번 만에 범인을 찾는다.
시리즈: Git 강의 6/6 (고급). 매일 Git 을 쓰고 있고(1~5강), .git 안에 무엇이 있는지, 왜 저장소가 느리고 커지는지, 무언가를 망가뜨린 커밋을 어떻게 찾는지 알고 싶은 분을 위한 글입니다. 객체 해시를 손으로 계산하고, git commit 없이 커밋을 만들고, gc·대용량 파일·bisect 를 실측합니다.
"영상 파일은 커밋하지 마"라는 말을 숫자로 납득하고 싶은 분, rebase 하면 해시가 왜 바뀌는지 궁금한 분, 커밋 300 개 중 어느 것이 금액 계산을 망가뜨렸는지 찾아야 하는 분을 위한 글입니다. 객체 모델을 알면 Git 의 이상한 동작(rebase 후 ID 변경, "지운" 커밋 복구, 거대한 클론)이 예측 가능한 결과로 바뀝니다. 아래 내용은 공식 문서를 옮긴 것이 아니라 전부 직접 실행하고 잰 결과입니다.
환경: Git 2.54.0 (Apple Git-157), macOS shasum, 고정 테스트 데이터 생성에 Python 3.9.6(시스템 기본), Apple Silicon Mac(M1 Pro). 연습용 저장소에서 데모용 이름과 고정 시각을 써서 아래 해시는 재현됩니다.
blob 은 "blob <크기>\0" 더하기 내용
$ printf 'hello\n' > hello.txt
$ git hash-object hello.txt
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | shasum -a 1
ce013625030ba8dba906f756967f9e9ca394464a -
비밀은 이게 전부입니다. 객체 ID 는 짧은 헤더(blob, 공백, 바이트 수, NUL 바이트) 뒤에 내용을 붙인 것의 SHA-1 입니다. 파일 이름은 들어가지 않습니다. git add 하면 해시를 이름으로 한 파일이 디스크에 생깁니다(앞 두 자리가 디렉터리).
$ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
$ git cat-file -t ce01362 -> blob
$ git cat-file -s ce01362 -> 6
$ git cat-file -p ce01362 -> hello
디스크의 파일은 21 바이트이고 zlib 로 압축돼 있습니다. Python 으로 풀면 해시된 바이트열 그대로가 보입니다.
$ python3 -c "import zlib;print(zlib.decompress(open('.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a','rb').read()))"
b'blob 6\x00hello\n'
한 글자만 바꿔도 완전히 다른 객체가 됩니다(hello → v2 는 8c1384d…). Git 은 "변경분"을 저장하지 않습니다. 버전마다 통째로 저장하고, 나중에 packfile 이 압축합니다.
트리와 커밋: 스냅숏을 저장하는 방식
src/copy.txt(내용은 똑같이 hello)와 src/main.py 를 추가해 커밋하고 객체를 세어 봤습니다.
.git/objects/3d/f2b26e… tree (src/)
.git/objects/68/b30a75… tree (루트)
.git/objects/87/2bfebe… commit
.git/objects/b9/17a726… blob (main.py)
.git/objects/ce/013625… blob (hello.txt 와 src/copy.txt 둘 다)
파일은 셋인데 객체는 다섯. hello 가 든 두 파일이 blob 하나를 같이 씁니다. 내용을 읽어 봅니다.
$ git cat-file -p HEAD
tree 68b30a75a76fffa7bf25023339501ec8cd7104d5
author demo <demo@example.com> 1794445200 +0000
committer demo <demo@example.com> 1794445200 +0000
First commit
$ git cat-file -p HEAD^{tree}
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
040000 tree 3df2b26ecb8bcdda43db72b3f32af69ffa7736b3 src
$ git cat-file -p HEAD:src
100644 blob ce013625030ba8dba906f756967f9e9ca394464a copy.txt
100644 blob b917a726c93f902e43291d9009d6488385133b67 main.py
트리는 디렉터리 목록(모드, 종류, 해시, 이름)이고, 커밋은 작은 텍스트(루트 트리, 부모 커밋, 작성자, 커미터, 메시지)입니다. 커밋도 blob 과 똑같이 해시됩니다.
$ git cat-file commit HEAD > c.raw
$ printf 'commit %s\0' $(wc -c < c.raw | tr -d ' ') | cat - c.raw | shasum -a 1
872bfebe6661489bdb33dce1ebf41088eb94f21b -
$ git rev-parse HEAD
872bfebe6661489bdb33dce1ebf41088eb94f21b
부모의 해시가 커밋 본문에 들어 있으므로, 커밋 하나를 바꾸면 그 뒤 모든 커밋의 해시가 바뀝니다. rebase 가 "이력을 다시 쓰는" 이유(4강)도, 커밋 ID 하나가 그 뒤의 이력 전체를 보증하는 이유도 여기에 있습니다.
plumbing 명령만으로 커밋 만들기
git commit 은 "plumbing(배관)" 명령 세 개를 감싼 편의 명령입니다.
$ echo "v2" > hello.txt
$ git add hello.txt
$ git write-tree
9bc1402c06ef1cef8f6d0e539a5333f3797b22de
$ echo "Commit made by hand" | git commit-tree 9bc1402 -p HEAD
6b582d907ea233b6037ec127b2e4405c052d999b
$ git update-ref refs/heads/main 6b582d9
$ git log --oneline
6b582d9 Commit made by hand
872bfeb First commit
$ git status --short
<- 출력 없음: 인덱스가 이미 새 트리와 같음
write-tree 는 인덱스로 트리 객체를 만들고, commit-tree 는 트리를 커밋으로 감싸며, update-ref 는 브랜치 이름을 옮깁니다. 브랜치의 정체는 해시 한 줄이 적힌 파일입니다. 객체를 만지기 시작하면 만나는 에러:
$ git cat-file -p 0000000
fatal: Not a valid object name 0000000
SHA-256 저장소도 있습니다(git init --object-format=sha256). 거기서는 git hash-object 가 2cf8d83d…ebb4 를 출력했고, printf 'blob 6\0hello\n' | shasum -a 256 과 같았습니다. 방식은 같고 해시만 깁니다.
loose 객체와 packfile: gc 실측
git add, git commit 마다 loose 객체(객체 하나당 zlib 파일 하나)가 쓰입니다. git gc 는 이것을 packfile 로 묶고, 비슷한 객체는 서로의 델타(차이)로 저장합니다. 효과를 보려고 Python random.seed(7) 로 "1,000 줄 CSV(30,691 바이트)의 한 줄만 바꾸는 커밋"을 1,000 개 만들었습니다. 쇼핑몰 상품 가격표를 매일 조금씩 고치는 상황입니다. git fast-import 로 넣은 뒤 git unpack-objects 로 loose 객체로 풀어서, 매번 같은 loose 객체 3,000 개에서 시작했습니다.
## gc 전
count: 3000
size: 19.53 MiB
## gc 후
count: 0
in-pack: 3000
packs: 1
size-pack: 484.38 KiB
| 회차 (Apple Silicon Mac, M1 Pro) | gc 전 .git/objects |
gc 후 | git gc 시간 |
|---|---|---|---|
| 1 | 20,000 KB | 564 KB | 0.80초 |
| 2 | 20,000 KB | 576 KB | 0.92초 |
| 3 | 20,000 KB | 568 KB | 0.92초 |
pack 안을 종류별로(git cat-file --batch-check='%(objectsize) %(objectsize:disk)'):
| 종류 | 개수 | 원래 크기 | pack 안 크기 |
|---|---|---|---|
| blob | 1,000 | 30,661,605 B | 214,805 B |
| tree | 1,000 | 39,000 B | 49,846 B |
| commit | 1,000 | 209,837 B | 146,248 B |
git verify-pack -v 에 따르면 990 개가 델타로 저장됐고 체인 깊이는 최대 49 였습니다. 30 MB 의 CSV 이력이 210 KB 의 blob 이 됐습니다. 트리를 보세요. 작은 객체는 zlib 의 고정 오버헤드 때문에 압축하면 오히려 커집니다.
보통은 gc 를 직접 돌릴 필요가 없습니다. loose 객체가 일정 수를 넘으면 Git 이 여러 명령 뒤에 gc --auto 를 실행합니다. 4강에서 벤치마크를 망가뜨린 것도 이 백그라운드 자동 실행이었습니다.
대용량 바이너리: 고통을 실측하다
이제 다들 경고하는 경우입니다. 20 MiB 랜덤 바이트(버전 v 마다 Python random.seed(v) 라 재현 가능)를 10 버전 커밋했습니다. 영상이나 게임 에셋을 다시 내보낼 때마다 커밋하는 상황입니다.
## 커밋 10 개 후 loose
count: 40
size: 201.51 MiB
## git gc (1회): 24.49초
size-pack: 200.07 MiB
gc 로 줄어든 건 0.7%, 시간은 텍스트 이력의 0.8~0.9초에 비해 24.49초. 20,971,520 바이트짜리 각 버전이 pack 안에서 20,977,930 바이트를 차지합니다. 랜덤(또는 이미 압축된) 데이터는 줄지 않고 쓸 만한 델타도 없어서, Git 은 모든 버전을 통째로 영원히 저장하고 클론할 때마다 전부 내려받습니다. 세 가지 방법으로 클론했습니다(로컬 file:// 전송이라 네트워크 시간 제외). 각 3회:
| 클론 | 1회 | 2회 | 3회 | .git 크기 |
|---|---|---|---|---|
전체 (--no-local) |
1.46초 | 1.66초 | 2.99초 | 207~213 MB |
--depth 1 |
0.23초 | 0.35초 | 0.28초 | 20 MB |
--filter=blob:none (blobless) |
0.27초 | 0.55초 | 0.39초 | 20 MB |
실제 네트워크에서는 차이가 더 벌어집니다. 전체 클론은 얕은 클론보다 약 10배의 바이트를 전송하기 때문입니다. 부분 클론에도 대가가 있습니다. 얕은 클론에서는 git switch --detach HEAD~5 가 fatal: invalid reference: HEAD~5 로 실패했고(그 이력이 없음), blobless 클론에서는 같은 명령이 성공하는 대신 빠진 버전을 그 자리에서 받아 왔습니다(여기서 0.43초, .git 은 20 MB 에서 40 MB 로 증가). 꼭 버전 관리를 해야 하는 큰 파일이라면 Git 에는 포인터만 두는 Git LFS 가 있지만, 이 머신에는 설치돼 있지 않아 시험하지 않았습니다.
| 상황 | 선택 |
|---|---|
생성물(빌드 결과, .apk, .ipa, 내보낸 파일) |
커밋하지 않는다. .gitignore 에 넣고 릴리스 산출물로 따로 보관 |
| 코드와 함께 버전 관리가 필요한 큰 에셋 | Git LFS (여기서는 실행 안 함) |
| 최신 코드만 필요(CI, 잠깐 보기) | git clone --depth 1 |
| 이력은 필요하지만 옛 파일 내용은 불필요 | git clone --filter=blob:none |
| 큰 파일을 실수로 커밋함 | 이력에 계속 남는다. 지우려면 이력 재작성(git filter-repo, 여기서는 실행 안 함)과 전원 재클론이 필요 |
git bisect: 커밋 300 개에서 8번 만에 범인 찾기
세금 포함 금액 계산이 처음엔 맞았는데 지금은 틀립니다. 그 사이에 "Refactor step N" 이라는 커밋이 300 개. 버그(10% 가 1% 로 바뀜)는 커밋 211 에 있지만 모르는 척합니다. bisect 는 이력을 이진 탐색하고, git bisect run 을 쓰면 각 커밋의 판정을 스크립트에 맡길 수 있습니다. 스크립트는 정상이면 0, 비정상이면 1 로 끝나야 합니다.
#!/bin/sh
# test_tax.sh: 0 = 정상, 1 = 비정상
out=$(python3 -c 'from tax import total; print(total(1000))')
[ "$out" = "1100" ]
$ git bisect start HEAD HEAD~299
Bisecting: 149 revisions left to test after this (roughly 7 steps)
[95892e7b…] Refactor step 150
$ git bisect run ~/demo/test_tax.sh
running '~/demo/test_tax.sh'
Bisecting: 74 revisions left to test after this (roughly 6 steps)
[d0546bdf…] Refactor step 225
running '~/demo/test_tax.sh'
Bisecting: 37 revisions left to test after this (roughly 5 steps)
[e787361d…] Refactor step 187
...
[72f52537…] Refactor step 211
running '~/demo/test_tax.sh'
72f52537d6519b73cf52dd4aa0392555090184c1 is the first bad commit
commit 72f52537d6519b73cf52dd4aa0392555090184c1
Refactor step 211
tax.py | 4 ++--
bisect found first bad commit
$ git bisect reset
오래된 순으로 모든 커밋을 체크아웃해 처음 실패할 때까지 테스트하는 단순한 방법과 비교했습니다. 각 3회(Apple Silicon Mac, M1 Pro).
| 이력 | bisect 테스트 횟수 | bisect 시간 | 선형 테스트 횟수 | 선형 시간 |
|---|---|---|---|---|
| 커밋 300, 버그는 211번째 | 8 | 0.44 / 0.37 / 0.38초 | 211 | 11.69 / 13.73 / 12.89초 |
| 커밋 3,000, 버그는 2,111번째 | 11 | 0.52 / 0.50 / 0.49초 | 2,111 | 122.29 / 135.56 / 133.10초 |
이력이 10배가 돼도 bisect 테스트는 3번 늘었을 뿐입니다. 테스트에 1분 걸린다면(빌드와 테스트 스위트) 11분과 하루 반의 차이입니다.
흔한 실수
망가진 bisect 스크립트가 엉뚱한 커밋을 지목. 테스트 스크립트에 오타(pyhton3)를 넣어 봤습니다. 매번 pyhton3: command not found 가 찍히고 비교가 실패해 모든 커밋에서 스크립트가 1("비정상")로 끝났고, bisect 는 자신 있게 이렇게 보고했습니다.
2d4b99385a37b3ebbb3cd09658e6d8af549f2ddd is the first bad commit
Refactor step 2
bisect found first bad commit
해결: bisect run 전에 정상인 걸 아는 커밋에서 스크립트를 직접 돌려 echo $? 가 0 인지 확인한다. 종료 코드 125 는 "테스트할 수 없으니 건너뛰기", 127 을 넘는 코드는 bisect 를 중단시킵니다.
start 전에 git bisect good. Git 은 You need to start by "git bisect start" 라고 답합니다. 끝나면 git bisect reset 도 잊지 마세요. 안 하면 이력 중간 커밋에 detached 상태로 남습니다.
.git/objects 를 ls -l 해서 저장소 크기를 판단. 보이는 건 loose 객체뿐이고, gc 후엔 거의 비어 있습니다. loose 와 pack 을 나눠 보여 주는 git count-objects -vH 를 쓰세요.
솔직한 한계·버전 메모
- Git 2.54.0 (Apple Git-157) 에서만 확인했습니다. SHA-256 형식은 Git 2.29 부터 있었고 오랫동안 실험적 기능이었습니다. SHA-1 과 SHA-256 저장소 간 상호 운용은 시험하지 않았습니다.
- 부분 클론(
--filter)은 서버 지원이 필요합니다. 이번 로컬 "서버"는uploadpack.allowFilter=true로 설정했습니다. 호스팅 서비스마다 다릅니다. - gc 시간은 CPU 코어 수(델타 탐색은 멀티스레드)와 디스크에 따라 다르고, 회차별 편차는 ±15% 였습니다. 대용량 파일 gc 는 1회만 쟀습니다.
du와count-objects값은 조금 다릅니다(파일 시스템 블록과 파일 바이트 수의 차이). 표마다 무엇을 썼는지 적었습니다.- Git LFS 와
git filter-repo는 이 환경에 없어 설명만 했습니다.
연습 문제
- Git 없이,
hi와 줄바꿈뿐인(3 바이트) 파일의 객체 ID 를 출력하는 명령줄을 쓰세요. 그다음git hash-object로 확인해 보세요. - bisect 테스트에 빌드가 필요한데, 옛 커밋에서는 의존성이 없어 상관없는 이유로 실패하기도 합니다. 그럴 때 스크립트는 무엇으로 끝나야 하고, 왜 1 이면 안 될까요?
정답
printf 'blob 3\0hi\n' | shasum -a 1.printf 'hi\n' | git hash-object --stdin과 같아야 합니다(45b983be…).- 125 로 끝냅니다. bisect 에 "이 커밋은 테스트할 수 없으니 건너뛰라"고 알리는 코드입니다. 1 로 끝내면 그 커밋이 비정상으로 판정되어, 위의 망가진 스크립트처럼 탐색이 엉뚱한 곳으로 갑니다.
이전: Git 강의 5: reset·revert·reflog 로 되돌리기 · 시리즈 처음: Git 강의 1: init·add·commit · 함께 보기: rebase 와 cherry-pick · 신입 학습 로드맵으로 돌아가기
자주 묻는 질문
Git 은 파일과 커밋의 해시를 어떻게 계산하나요?
헤더와 내용을 합쳐 해시합니다. 파일이라면 'blob <크기>' 와 NUL 바이트 뒤에 내용이 이어지는 바이트열입니다. printf 'blob 6\0hello\n' | shasum 의 결과는 ce013625… 로 git hash-object 출력과 같았습니다. 커밋도 본문(tree, parent, author, committer, 메시지)을 같은 방식으로 해시하므로, 부모가 바뀌면 이후 해시가 전부 바뀝니다.
git gc 를 하면 저장소가 작아지나요?
텍스트 이력이라면 극적으로 작아집니다. 30 KB CSV 의 1,000 버전이 loose 객체로 19.53 MiB, gc 후 484 KiB 였습니다. 1,000 버전 중 990 개가 델타로 저장됐기 때문입니다. 랜덤 바이너리에는 전혀 효과가 없어 20 MiB 파일 10 버전이 loose 로 201.51 MiB, pack 후 200.07 MiB 였고 gc 에 24.5초가 걸렸습니다.
git bisect 는 몇 번 테스트하면 끝나나요?
대략 커밋 수의 log2 번입니다. git bisect run 과 테스트 스크립트로 커밋 300 개는 8회(0.4초), 3,000 개는 11회(0.5초)였습니다. 오래된 순으로 전부 테스트하면 211회와 2,111회(약 12초와 약 2분). 스크립트는 정상이면 0, 비정상이면 1~127(125 제외)을 돌려줘야 하며, 그렇지 않으면 bisect 가 엉뚱한 커밋을 범인으로 지목합니다.