Git 내부 구조 — Object Database, Refs, 그리고 Packfile
git commit이 무슨 일을 하는지는 알지만,.git/디렉토리 안에서 정확히 무엇이 저장되는지를 한 번이라도 들여다본 적이 있는 분을 대상으로 합니다. blob/tree/commit/tag 네 가지 객체와 refs, index, packfile이 같은 그래프 위에서 어떻게 협력하는지를 plumbing 명령으로 직접 확인하면서 정리합니다.
글의 의도
git은 명령 100여 개로 구성된 거대한 도구처럼 보이지만, 그 밑에는 의외로 단순한 두 개의 데이터 구조가 있습니다.
- 변하지 않는 객체들(immutable objects)을 SHA로 키 잡아 보관하는 object database
- 그 객체들을 가리키는 사람이 읽을 수 있는 이름들의 집합인 refs
git add, git commit, git checkout이 하는 일을 한 줄로 줄이면 "object database에 새 객체를 쓰고, ref를 옮긴다"입니다. 이 글은 그 두 데이터 구조의 모양과 그 위에서 평소 명령들이 어떤 순서로 동작하는지를 plumbing 명령으로 한 단계씩 확인합니다.
1. Git은 content-addressable filesystem
Git을 이해하는 가장 단순한 모델은 "파일시스템에 SHA를 키로 객체를 쓰고 읽는 KV 스토어"입니다. 이 KV 스토어가 곧 object database이고, .git/objects/ 디렉토리가 그 실체입니다.
키는 객체 본문의 SHA-1 해시입니다. 같은 내용을 두 번 저장해도 같은 키를 갖기 때문에 자연히 중복이 제거되고, 객체를 다른 객체가 참조하는 순간 위변조를 감지하는 Merkle 트리가 만들어집니다. SHA-1의 약한 충돌 저항성을 보완하기 위해 Git 2.29(2020-10) 이후 SHA-256 저장소를 만들 수 있고, Git 2.51(2025-08)이 내부 plumbing의 SHA-256 지원을 정리하면서, 장기적으로는 SHA-256을 신규 저장소 기본값으로 가져가는 방향이 메일링 리스트에서 논의·검토되고 있습니다(공식 릴리스 약속은 아님). 다만 GitHub를 비롯한 주요 호스팅이 아직 SHA-256을 지원하지 않아 실제 전환은 진행 중입니다.
flowchart LR
WT[Working Tree<br/>your files]
IDX[(Index<br/>.git/index)]
ODB[(Object Database<br/>.git/objects/)]
REFS[(Refs<br/>.git/refs/, .git/HEAD)]
WT -- "git add" --> IDX
IDX -- "git commit" --> ODB
ODB -- "tree/commit references" --> ODB
REFS -- "points to" --> ODB
ODB -- "git checkout" --> WT
이 그림에서 화살표가 가리키는 방향을 한 번 더 봐 주세요. Object database 자기 자신을 향한 화살표가 핵심입니다. commit이 tree를 가리키고, tree가 다시 blob과 sub-tree를 가리키고, commit이 부모 commit을 가리킵니다. 객체끼리 만든 이 그래프가 곧 저장소의 모든 이력입니다. Refs는 그 그래프 위의 한 노드를 가리키는 이름표일 뿐입니다.
2. 네 가지 객체 타입
Object database가 보관하는 객체는 정확히 네 종류입니다. 다른 모든 추상화는 이 넷의 조합으로 만들어집니다.
| 타입 | 가리키는 것 | 저장하는 것 |
|---|---|---|
| blob | (없음) | 파일 한 개의 바이트 |
| tree | blob, tree | 디렉토리 한 단계의 엔트리 목록 |
| commit | tree, parent commit | 한 시점의 스냅샷 + 메타데이터 |
| tag | 객체 하나 | annotated 태그(서명·메시지) |
각각 직접 만들어 보겠습니다. 이후 예제는 모두 git init으로 만든 빈 저장소 위에서 동작합니다.
2.1 blob — 파일의 바이트
blob은 파일 이름이나 권한을 모릅니다. 단지 바이트만 저장합니다.
$ git init demo && cd demo
$ echo "hello" | git hash-object -w --stdin
ce013625030ba8dba906f756967f9e9ca394464a
git hash-object는 두 가지 일을 합니다.
- 입력 바이트 앞에
blob <크기>\0헤더를 붙인 뒤 SHA-1을 계산해 객체 ID를 정합니다. -w플래그가 있으면 헤더 + 본문을 zlib으로 압축해.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a에 씁니다.
객체 ID 앞 두 글자가 디렉토리, 나머지 38글자가 파일명입니다. 디렉토리를 갈라 두는 이유는 단일 디렉토리에 파일이 너무 많을 때 일부 파일시스템이 느려지는 문제를 피하기 위함입니다. 파일 하나에 zlib으로 압축된 blob 6\0hello\n 같은 바이트가 들어 있습니다.
$ git cat-file -t ce013625
blob
$ git cat-file -p ce013625
hello
cat-file은 정확히 반대 작업을 합니다. 파일을 읽어 zlib 해제, 헤더 분리, 본문 출력입니다. 본문의 SHA-1이 곧 파일 이름이어야 하므로 객체가 손상되면 즉시 검증 실패가 납니다.
2.2 tree — 디렉토리 한 단계
tree는 한 디렉토리의 엔트리(파일 권한, 파일 이름, blob 또는 sub-tree의 SHA)를 모은 객체입니다.
$ git update-index --add --cacheinfo 100644,ce013625030ba8dba906f756967f9e9ca394464a,greeting.txt
$ git write-tree
68aba62e560c0ebc3396e8ae9335232cd93a3f60
$ git cat-file -p 68aba62e
100644 blob ce013625030ba8dba906f756967f9e9ca394464a greeting.txt
엔트리 모드는 파일시스템의 mode와 닮았지만 Git이 인정하는 값은 정해져 있습니다.
100644— 일반 파일(읽기·쓰기)100755— 실행 가능 파일120000— 심볼릭 링크040000— 디렉토리(sub-tree)160000— gitlink(submodule)
write-tree는 현재 인덱스 상태로부터 tree 객체를 한 개 만듭니다. sub-디렉토리가 있으면 자동으로 sub-tree를 먼저 쓰고 그 SHA를 부모 tree의 엔트리에 적습니다. tree는 디렉토리 한 단계만을 표현하지만, 자기 자신을 다시 가리킬 수 있어 임의 깊이의 디렉토리 트리를 표현합니다.
2.3 commit — 시점의 스냅샷
commit 객체는 tree 한 개를 가리키고, 0개 이상의 부모 commit을 가리키며, 작성자/커미터와 메시지를 담습니다.
$ echo "first commit" | git commit-tree 68aba62e
3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62
$ git cat-file -p 3c4e9cd7
tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60
author Hyunjun Kim <me@example.com> 1715308800 +0900
committer Hyunjun Kim <me@example.com> 1715308800 +0900
first commit
여기서 두 가지를 짚어 둡니다. 첫째, commit은 diff가 아니라 tree를 가리킵니다. Git은 매 commit마다 그 시점의 전체 스냅샷을 저장합니다. diff는 단지 두 commit 사이에서 사후적으로 계산하는 view일 뿐입니다. 둘째, 부모 commit의 SHA가 본문에 그대로 들어가기 때문에, 부모를 한 비트라도 바꾸면 자식의 SHA도 바뀝니다. 이 연쇄가 git history의 무결성을 만듭니다.
merge commit은 부모를 두 개 갖고, 빈 저장소의 첫 commit은 부모를 0개 갖습니다. octopus merge는 부모 3개 이상을 갖습니다.
2.4 tag — 영구 보관용 이름
tag 객체는 commit을 가리키는 영구 이름이고, 작성자·메시지·서명을 포함합니다. refs/tags/v1.0이라는 ref가 commit을 직접 가리키는 lightweight tag와 달리, annotated tag는 별도의 tag 객체가 만들어집니다.
$ git tag -a v1.0 -m "first release" 3c4e9cd7
\( git cat-file -p \)(git rev-parse v1.0)
object 3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62
type commit
tag v1.0
tagger Hyunjun Kim <me@example.com> 1715308800 +0900
first release
릴리스에 GPG/SSH 서명을 붙이고 싶거나, 메시지로 변경 내역을 남기고 싶다면 annotated tag를 씁니다.
2.5 객체끼리의 그래프
객체 4종이 어떻게 한 그래프로 모이는지는 git log --graph로 보는 ref·commit 그래프와는 다른 시야입니다.
flowchart LR
C2[commit C2] --> T2[tree T2]
T2 --> Bsrc[blob src/main.go bytes]
T2 --> SubT2[tree src/]
SubT2 --> Bsrc
C2 --> C1[commit C1]
C1 --> T1[tree T1]
T1 --> BsrcOld[blob src/main.go old bytes]
TAG[tag v1.0] --> C1
같은 파일 이름의 두 commit이 있어도 내용이 같으면 같은 blob을 공유합니다. 다음 절에서 보겠지만, 같은 SHA를 갖는 파일이 두 번 저장되지 않는 이 성질이 Git의 큰 저장소 성능 비결 중 하나입니다.
3. Loose object와 .git/objects 디렉토리
hash-object로 저장된 객체는 모두 loose object 형식으로 한 객체 = 한 파일에 들어갑니다. 디스크에는 다음과 같이 보입니다.
.git/objects/
├── 68/
│ └── aba62e560c0ebc3396e8ae9335232cd93a3f60 (tree)
├── 3c/
│ └── 4e9cd789d4519d6033c8c4c1cb0488d09a8b62 (commit)
├── ce/
│ └── 013625030ba8dba906f756967f9e9ca394464a (blob)
├── info/
└── pack/
각 파일은 zlib으로 압축된 <type> <size>\0<content> 형식입니다. 객체 ID는 압축 전 본문의 SHA로 계산되므로, 디스크에 저장된 파일을 그대로 zlib 해제하면 본문이 복구됩니다.
info/는 alternate object database 같은 보조 메타데이터용이고, pack/이 곧이어 다룰 packfile들의 자리입니다.
4. Refs — 객체 그래프 위의 이름표
Object database만으로는 "현재 어디에 있는지", "main 브랜치가 어디를 가리키는지"를 알 수 없습니다. 그것을 알려 주는 것이 ref입니다. ref는 commit(또는 tag) 객체의 ID를 적어 둔 짧은 텍스트 파일이고, 위치는 .git/refs/ 아래 약속된 자리입니다.
.git/
├── HEAD -> ref: refs/heads/main
├── packed-refs (모인 ref들)
└── refs/
├── heads/
│ ├── main -> 3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62
│ └── feature/x -> ...
├── tags/
│ └── v1.0 -> tag 객체의 ID
└── remotes/
└── origin/
└── main -> ...
ref 한 개는 보통 객체 ID 40자 + 줄바꿈만 들어 있는 평범한 텍스트 파일입니다. git update-ref가 그 파일에 안전하게 SHA를 씁니다.
$ git update-ref refs/heads/main 3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62
$ cat .git/refs/heads/main
3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62
git update-ref가 단순히 파일을 덮어쓰는 대신 별도 명령으로 분리된 이유는 두 가지입니다. 첫째, 동시 실행 시 잠금을 잡습니다. 둘째, 옵션에 따라 reflog에 기록을 남깁니다.
4.1 HEAD와 symbolic ref
HEAD는 보통 ref: refs/heads/main 같은 한 줄짜리 symbolic ref입니다. ref가 객체 ID를 직접 가리키는 대신 다른 ref를 가리킵니다. git checkout main은 HEAD를 refs/heads/main을 가리키도록 바꾸고, 거기서부터 객체 그래프를 따라가 working tree를 만듭니다.
git checkout 3c4e9c처럼 commit SHA로 직접 체크아웃하면 HEAD에 SHA가 직접 들어갑니다. 이 상태가 detached HEAD입니다. 이 상태에서 만든 새 commit은 어떤 ref도 가리키지 않으므로, 다른 곳으로 옮겨 가는 순간 reachability를 잃고 GC 대상이 됩니다.
4.2 packed-refs
ref가 수십만 개 쌓이면 작은 파일이 그만큼 디스크에 흩뿌려집니다. git pack-refs --all은 그 파일들을 모아 .git/packed-refs 한 파일에 합칩니다.
# pack-refs with: peeled fully-peeled sorted
3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62 refs/heads/main
^7e9b1e2... (peeled commit ID — annotated tag일 때)
이후 ref를 갱신하면 그것은 다시 .git/refs/에 loose 파일로 만들어지고, packed-refs에는 그대로 남되 loose 파일이 우선합니다. Symbolic ref와 깨진 ref는 packed-refs에 들어가지 않습니다.
4.3 reflog
git update-ref로 ref가 갱신될 때, 만약 그 ref가 refs/heads/, refs/remotes/, refs/notes/ 아래거나 HEAD/ORIG_HEAD 같은 pseudoref이면, 변경 이력이 .git/logs/<ref> 파일에 한 줄씩 추가됩니다. 이게 reflog입니다.
0000000000... 3c4e9cd789... Hyunjun Kim <me@example.com> 1715308800 +0900 commit (initial): first commit
3c4e9cd789... 7e9b1e2c41... Hyunjun Kim <me@example.com> 1715308900 +0900 commit: add greeting
reflog는 ref가 어디를 가리켰는지의 로컬 이력입니다. git reset --hard HEAD~로 commit을 잃었다고 생각해도, reflog가 직전 SHA를 알고 있으므로 보통 90일 안에는 복구됩니다. 단, push 되지 않은 reflog는 다른 클론에 존재하지 않는다는 점이 중요합니다.
5. Index — staging area의 진짜 정체
git status가 보여 주는 staged/unstaged의 두 영역은 사실 세 영역의 비교입니다.
flowchart LR
WT[Working Tree<br/>files on disk]
IDX[(Index<br/>.git/index, binary)]
HEAD[HEAD commit<br/>tree object]
WT -. "compare" .- IDX
IDX -. "compare" .- HEAD
WT -- "git add path" --> IDX
IDX -- "git restore --staged" --> HEAD
IDX -- "git commit" --> HEAD
git add는 working tree와 index를 비교해서 변경 사항을 index 쪽으로 가져오고, git status는 working tree↔index, index↔HEAD 두 차이를 함께 보여 줍니다.
Index는 텍스트가 아니라 .git/index 한 개의 바이너리 파일입니다. 헤더는 12바이트로, 4바이트 시그니처 DIRC(0x44495243), 4바이트 버전(현재 흔한 것은 2/3/4), 4바이트 엔트리 수입니다. 그 뒤로 각 엔트리가 ctime/mtime/dev/inode/mode/uid/gid/size/object ID/flags/path 순으로 정렬되어 들어갑니다. 엔트리는 path를 unsigned 바이트로 비교한 사전순으로 정렬됩니다.
이 구조가 만드는 두 가지 결과를 기억해 두면 평소 명령이 더 잘 보입니다.
- 인덱스는 working tree의 현재 모습이 아니라, 다음 commit이 만들 tree 객체의 재료 목록입니다. 그래서 같은 파일을 수정·add·다시 수정한 뒤
git diff만 치면 두 번째 수정만 보입니다(working tree↔index만 비교). 첫 번째 수정은git diff --staged로 봐야 보입니다. git commit이 만드는 tree 객체는 인덱스에서 곧장 만들어집니다. 그래서 working tree의 변경 사항이 commit에 빠질 수 있고, 그게 의도한 동작입니다.
5.1 stat 정보가 들어가는 이유
엔트리에 ctime/mtime/inode가 들어가는 것은 성능 때문입니다. git status는 working tree의 모든 파일을 내용 비교해서 다른지 확인하지 않고, 먼저 stat 정보를 비교해 다른 파일 후보만 추립니다(racy git 처리 포함). 큰 저장소에서 git status가 1초 안에 끝나는 비결입니다.
6. Plumbing 명령으로 commit 한 번 만들기
객체 4종, refs, index를 합쳐 commit을 하나 만드는 과정을 plumbing으로만 재현해 봅니다. 평소 쓰는 git add, git commit이 내부에서 하는 일과 정확히 같습니다.
# 1. 빈 저장소
$ git init demo && cd demo
# 2. blob 만들기
$ echo "hello" > greeting.txt
\( BLOB=\)(git hash-object -w greeting.txt)
\( echo \)BLOB
ce013625030ba8dba906f756967f9e9ca394464a
# 3. index에 등록
\( git update-index --add --cacheinfo 100644,\)BLOB,greeting.txt
# 4. index → tree
\( TREE=\)(git write-tree)
\( echo \)TREE
68aba62e560c0ebc3396e8ae9335232cd93a3f60
# 5. tree → commit
\( COMMIT=\)(echo "first commit" | git commit-tree $TREE)
\( echo \)COMMIT
3c4e9cd789d4519d6033c8c4c1cb0488d09a8b62
# 6. ref 갱신
\( git update-ref refs/heads/main \)COMMIT
# 7. HEAD를 main을 가리키도록
$ git symbolic-ref HEAD refs/heads/main
$ git log --oneline
3c4e9cd (HEAD -> main) first commit
이 7단계가 곧 git add greeting.txt && git commit -m "first commit"이 내부적으로 하는 일의 전부입니다. 커밋이 세 단계(blob → tree → commit)로 나뉘어 있고, ref 갱신은 별개의 마지막 단계라는 사실이 직관과 잘 맞습니다.
7. Packfile — 객체 수십만 개를 압축해 한 파일로
큰 저장소를 클론해서 .git/objects/를 들여다 보면 loose object가 거의 없고, 대신 .git/objects/pack/pack-<sha>.pack과 pack-<sha>.idx 파일 한 쌍을 보게 됩니다. Packfile은 객체를 모아 delta 압축까지 적용한 효율적인 저장 포맷입니다. Loose 형태가 객체당 한 파일이라면, packfile은 수만~수백만 객체를 한 파일에 묶습니다.
7.1 언제 만들어지는가
Packfile은 두 시점에 만들어집니다.
git push/git fetch— 네트워크로 객체를 보내거나 받을 때 자동으로 생성. fetch 받은 packfile은 그대로.git/objects/pack/에 저장됩니다.git gc/git repack— 로컬에서 loose object가 쌓였거나 packfile이 너무 많아질 때.git gc --auto는 loose object 6,700개 또는 packfile 50개 같은 임계치(gc.auto/gc.autoPackLimit기본값)를 넘으면 자동으로 실행됩니다.
7.2 파일 포맷
.pack 파일은 본문, .idx 파일은 본문 안의 객체 위치를 빠르게 찾기 위한 인덱스입니다.
.pack의 헤더는 12바이트로, 4바이트 시그니처 PACK, 4바이트 버전(보통 2), 4바이트 객체 수입니다. 그 뒤로 객체들이 차례로 옵니다. 각 객체 헤더는 가변 길이로, 첫 바이트의 상위 3비트로 타입을 나타내고 나머지 비트와 그 뒤 바이트로 압축 전 길이를 표현합니다.
타입 인코딩 슬롯은 7개 중 6개를 실제 사용합니다. type 1-4는 앞서 본 객체 타입(commit/tree/blob/tag), type 6은 OBJ_OFS_DELTA, type 7은 OBJ_REF_DELTA이고, type 5는 reserved로 비워져 있습니다. 즉 delta 타입은 3개가 아니라 2개입니다.
- OBJ_OFS_DELTA (type 6) — 같은 packfile 안의 어떤 객체로부터의 delta. 베이스를 packfile 내부의 음수 오프셋으로 가리키므로 packfile 안에서 자기완결적입니다.
- OBJ_REF_DELTA (type 7) — 베이스 객체의 ID(20바이트 SHA-1)로 가리키는 delta. 베이스가 다른 packfile에 있어도 됩니다.
- type 5 — reserved. 현재 어떤 객체 타입도 할당되지 않았습니다.
.idx(현재 v2)는 다음 항목들을 갖습니다.
- 4바이트 매직
\377tOc+ 4바이트 버전(=2) - 256개 fan-out 테이블(첫 바이트가 N 이하인 객체 누적 수)
- 정렬된 SHA-1 목록
- 4바이트 CRC32 목록(packfile에서 packfile로 데이터를 복사할 때 무손실 검증)
- 4바이트 오프셋 목록(2GB 이하 packfile)
- 8바이트 오프셋 목록(2GB 초과 packfile, MSB 플래그로 indirection)
CRC32가 v2부터 들어간 이유가 흥미로운데, repacking 중 한 packfile에서 다른 packfile로 압축된 바이트를 그대로 옮길 때 손상 여부를 검증할 수 있게 하기 위함입니다.
7.3 Delta 압축
같은 파일의 여러 버전, 비슷한 파일들 사이에서 packfile은 한 객체를 베이스로 두고 나머지를 "베이스 + 변경 instruction" 형태로 저장합니다. 알고리즘은 xdelta 계열로, 단순화하면 "베이스의 어느 구간을 그대로 복사하라(copy)" + "이 바이트들을 추가하라(insert)"의 두 명령을 엮은 것입니다. 데이터를 추가하는 비용이 제거하는 비용보다 크기 때문에, 큰 베이스에서 작은 변경분을 적용하는 형태가 가장 압축률이 좋습니다.
git pack-objects는 객체를 타입과 크기로 정렬한 뒤 --window(기본 10) 안의 후보들과 delta 비교를 하고, --depth(기본 50, 최대 4095)까지 delta chain을 늘립니다. depth가 깊을수록 압축률은 좋지만, 객체 하나를 복원할 때 그만큼 베이스를 따라가며 적용해야 하므로 unpack 성능이 나빠집니다.
flowchart LR
Base[blob v3 base 1MB]
D1[OBJ_OFS_DELTA v2 200B]
D2[OBJ_OFS_DELTA v1 150B]
D1 -- "base offset" --> Base
D2 -- "base offset" --> D1
이 chain에서 v1을 복원하려면 base + D1 + D2 순으로 변경 instruction을 적용해야 합니다. depth=2짜리 예시이고, 실제 packfile은 50까지 늘어나는 chain을 흔하게 갖습니다.
7.4 .keep 파일
.git/objects/pack/pack-<sha>.keep 파일이 있으면 git gc --auto가 그 packfile을 다른 pack과 합치지 않습니다. 변하지 않는 베이스 packfile을 안전하게 고정하는 용도입니다. GitHub Spokes·GitLab 같은 호스팅에서 알트 오브젝트 디렉토리와 함께 자주 활용됩니다.
8. Reachability와 GC
같은 그래프 위의 객체라도 어떤 ref에서도 도달할 수 없으면 unreachable입니다. Unreachable 객체는 일정 기간이 지나면 GC로 사라집니다.
git gc가 하는 일은 단순화하면 셋입니다.
- pack-refs — loose ref들을 packed-refs에 모음.
- repack — loose object와 기존 packfile들을 새 packfile로 묶음. 도달 불가 객체는 빼거나 별도 unreachable pack으로 분리.
- prune —
--prune=<date>보다 오래된 unreachable 객체를 실제 삭제. 기본은 2주.
Reflog가 살아 있는 동안에는 그 reflog가 가리킨 commit도 reachable로 간주되므로, "reset --hard로 잃은 commit"이 reflog 만료 전까지(기본 90일) 보호됩니다. 그래서 사고가 나면 가장 먼저 보는 명령이 git reflog입니다.
9. 한 그림으로 다시 보기
지금까지의 모든 조각이 한 commit이 만들어지는 흐름 안에서 어떻게 모이는지 다시 보겠습니다.
flowchart LR
WT[Working Tree<br/>greeting.txt 'hello']
IDX[(Index DIRC<br/>greeting.txt -> ce0136..)]
BLOB[blob ce0136..<br/>'hello']
TREE[tree 68aba6..<br/>greeting.txt -> ce0136..]
COMMIT[commit 3c4e9c..<br/>tree 68aba6.., parent none]
MAIN[refs/heads/main<br/>-> 3c4e9c..]
HEAD[HEAD<br/>ref: refs/heads/main]
PACK[(packfile after gc<br/>delta-encoded)]
WT -- "git add (hash-object + update-index)" --> IDX
WT -- "hash-object -w" --> BLOB
IDX -. "uses" .- BLOB
IDX -- "git write-tree" --> TREE
TREE -- "git commit-tree" --> COMMIT
COMMIT -- "git update-ref" --> MAIN
HEAD -- "symbolic-ref" --> MAIN
BLOB -- "git gc / push" --> PACK
TREE --> PACK
COMMIT --> PACK
정리하면 다음과 같습니다.
- 모든 데이터(blob/tree/commit/tag)는 SHA로 키 잡힌 immutable한 객체로 object database에 들어갑니다.
- ref는 그 객체 그래프 위의 한 노드를 가리키는 가변 이름표이고, 갱신은
git update-ref가 잠금과 reflog를 책임집니다. - index는 다음 commit이 만들 tree의 재료 목록을 담는 바이너리 파일이고, working tree와 HEAD 사이의 staging 단계입니다.
- packfile은 같은 객체들을 모아 delta 압축한 효율적 보관 포맷이고, push/fetch와 gc 시점에 만들어집니다.
10. 자주 부딪히는 다섯 가지 함정
10.1 detached HEAD에서 만든 commit이 사라진다
브랜치가 가리키지 않는 detached HEAD에서 commit을 만들고 다른 브랜치로 옮겨 가면, 그 commit은 어떤 ref에서도 도달할 수 없습니다. 즉시 사라지지는 않지만 reflog 만료(기본 90일) 후 GC가 가져갑니다. detached 상태에서 작업했다면 옮기기 전에 git switch -c new-branch로 ref를 만들어 두면 됩니다.
10.2 force push로 누군가의 commit을 잃게 만든다
git push --force는 원격 ref를 임의 SHA로 덮어씁니다. 옛 SHA가 어떤 ref에서도 도달할 수 없게 되면 원격에서 unreachable이 되고, 다른 사람이 받지 않은 상태였다면 영영 잃습니다. 협업 브랜치라면 --force-with-lease로 "내가 안 본 갱신이 있으면 거부"를 명시하는 편이 안전합니다.
10.3 .git을 통째로 옮기면 큰 공유 저장소가 부풀어 오른다
monorepo 사용자가 동일한 packfile을 여러 워크트리에서 공유하기 위해 alternate object database(info/alternates)를 쓰는 경우가 있습니다. 이때 alternate 쪽 packfile을 GC로 prune하면 참조하던 워크트리의 객체가 사라집니다. alternate를 쓸 때는 alternate 저장소의 GC 정책을 별도로 관리해야 합니다.
10.4 큰 바이너리를 commit하면 영원히 남는다
git rm으로 파일을 지워도 그 파일을 가리키는 commit이 어딘가에 남아 있으면 blob은 reachable이고, packfile에서 사라지지 않습니다. 큰 바이너리가 한 번 들어가면 모든 클론이 그 바이트를 영구히 끌고 다닙니다. git filter-repo로 history를 다시 쓰거나, 처음부터 Git LFS로 바이너리를 외부에 두는 편이 옳습니다.
10.5 git reset 세 가지 모드의 차이를 잊는다
git reset은 항상 현재 브랜치 ref가 가리키는 commit을 옮기는 명령입니다. 차이는 그 다음에 index/working tree를 어디까지 같이 옮기느냐입니다.
--soft— ref만 옮긴다. index와 working tree는 그대로.--mixed(기본) — ref + index를 새 commit으로 맞춘다. working tree는 그대로.--hard— ref + index + working tree를 모두 새 commit으로 맞춘다. 로컬 변경 사항이 사라진다.
--hard는 working tree 변경을 잃기 때문에 위험하지만, 잃은 commit은 reflog로 복구 가능하다는 점은 기억해 두면 좋습니다.
정리
이 글에서 끝까지 가져갈 핵심을 줄이면 이렇게 됩니다.
- Git은 SHA로 키 잡힌 immutable object의 KV 스토어와, 그 위의 가변 이름표 ref로 이루어진 단순한 모델입니다.
- 객체는 4종이고 commit은 diff가 아니라 tree 스냅샷을 가리킵니다.
- index는 working tree의 모습이 아니라 다음 commit이 만들 tree의 재료입니다.
- packfile과 delta는 같은 그래프를 더 압축해서 저장하는 방법일 뿐, 데이터 모델은 그대로입니다.
- 사고가 나면 reflog와 reachability를 떠올리세요. unreachable이 되기 전까지는 객체는 거기에 있습니다.
평소 쓰는 git add, git commit, git push가 이 모델 위에서 어떤 객체를 만들고 어떤 ref를 옮기는지 보이기 시작하면, "왜 이 명령이 이렇게 동작하는가"가 거의 언제나 같은 데이터 구조 두 개로 설명됩니다.
참고자료
- Pro Git book — https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain
- Pro Git book, Git Objects — https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
- Pro Git book, Git References — https://git-scm.com/book/en/v2/Git-Internals-Git-References
- Pro Git book, Packfiles — https://git-scm.com/book/en/v2/Git-Internals-Packfiles
- gitformat-pack(5) 공식 명세 — https://git-scm.com/docs/gitformat-pack
- gitformat-loose(5) — https://git-scm.com/docs/gitformat-loose
- git index-format — https://git-scm.com/docs/index-format
- git update-ref — https://git-scm.com/docs/git-update-ref
- git symbolic-ref — https://git-scm.com/docs/git-symbolic-ref
- git pack-refs — https://git-scm.com/docs/git-pack-refs
- git pack-objects — https://git-scm.com/docs/git-pack-objects
- git pack-heuristics — https://git-scm.com/docs/pack-heuristics
- git gc — https://git-scm.com/docs/git-gc
- hash-function-transition — https://git-scm.com/docs/hash-function-transition
- Git 2.51 SHA-256 진행 상황 — https://www.helpnetsecurity.com/2025/08/19/git-2-51-sha-256/
- LWN: Whatever happened to SHA-256 support in Git? — https://lwn.net/Articles/898522/

