Skip to main content

Command Palette

Search for a command to run...

Git 내부 구조 — Object Database, Refs, 그리고 Packfile

Updated
15 min read

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는 두 가지 일을 합니다.

  1. 입력 바이트 앞에 blob <크기>\0 헤더를 붙인 뒤 SHA-1을 계산해 객체 ID를 정합니다.
  2. -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 mainHEADrefs/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>.packpack-<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가 하는 일은 단순화하면 셋입니다.

  1. pack-refs — loose ref들을 packed-refs에 모음.
  2. repack — loose object와 기존 packfile들을 새 packfile로 묶음. 도달 불가 객체는 빼거나 별도 unreachable pack으로 분리.
  3. 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를 옮기는지 보이기 시작하면, "왜 이 명령이 이렇게 동작하는가"가 거의 언제나 같은 데이터 구조 두 개로 설명됩니다.

참고자료

More from this blog

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

Git merge 내부 동작 — 3-way merge, merge base, 그리고 recursive에서 ort로

git merge를 매일 쓰지만, 그 한 줄이 안에서 무슨 일을 하는지 들여다본 적은 드물어요. 이 글은 merge가 두 갈래의 변경을 어떻게 합치는지, merge base가 왜 필요한지, 그리고 Git이 기본 전략을 recursive에서 ort로 갈아치운 이유를 따라가요. Git을 쓰는 백엔드 개발자를 대상으로 해요. 브랜치 두 개를 합치는 일은 겉보기

May 15, 202612 min read

Java NIO ByteBuffer 내부 구조 — Direct vs Heap, Cleaner, 그리고 off-heap 메모리가 GC를 우회하는 방법

Netty가 빠른 이유, Kafka 클라이언트가 직렬화에 신경 쓰는 이유, MappedByteBuffer로 수 GB짜리 파일을 다루는 이유. 그 한가운데에는 ByteBuffer가 있어요. 이번 글에서는 ByteBuffer의 두 얼굴 — heap과 direct — 가 어떻게 다른지, off-heap 메모리는 어떻게 잡고 어떻게 풀리는지, JVM과 운영체제 사

May 15, 202612 min read

Java Flight Recorder 내부 구조 — Thread-Local Buffer부터 Disk Repository까지

JFR을 켜면 1% 미만 오버헤드로 JVM 내부가 그대로 기록돼요. 어떻게 이렇게 가벼울 수 있는지, 그리고 그 데이터가 어떤 경로를 거쳐 디스크에 쌓이는지 한 번 따라가 봐요. 이 글은 JFR을 "그냥 잘 쓰는 도구"에서 "내부 동작을 아는 도구"로 끌어올리고 싶은 분을 위한 글이에요. 운영 중인 서버에서 갑자기 응답 시간이 튀어요. 메트릭 그래프는 분

May 15, 202614 min read

끄적끄적 테크 블로그

162 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ