Skip to content

필사 모드: Git 내부 구조 완전 해부 — Object, Ref, Packfile, Reflog, Rebase/Merge 메커니즘 끝장 가이드 (2025)

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며 — Git은 파일시스템이다

`git commit -m "fix"`를 수천 번 쳤어도 **`.git/objects/` 안에 뭐가 들어있는지** 열어본 적 없는 개발자가 많다. 사실 Git은 버전 관리 도구라기보다는 **"내용 주소화 파일시스템(content-addressable filesystem) 위에 만든 DAG 데이터베이스"**에 가깝다. Linus Torvalds가 2005년 BitKeeper 라이선스 사태 이후 **2주 만에** 초기 버전을 만들었다는 유명한 일화도, 알고 보면 그가 "파일시스템을 만들고 싶었다"는 관점에서 출발했기에 가능했다.

이번 글에서는 `git init` 직후의 빈 디렉터리부터 시작해서, 하나의 파일을 commit 하면 내부적으로 어떤 객체 3개가 생기는지, `git rebase`가 왜 "이력 조작"인지, packfile이 수만 개의 작은 객체를 어떻게 수 MB로 줄이는지, reflog가 왜 구원자인지, merge 알고리즘이 어떻게 3-way에서 ort(Ostensibly Recursive's Twin)로 진화했는지, 그리고 SHA-1에서 SHA-256 전환이 왜 느리게 진행되고 있는지까지 **Git의 거의 모든 내부 구조**를 파헤친다.

이 글을 읽고 나면 `fatal: refusing to merge unrelated histories`, `detached HEAD`, `Your branch and 'origin/main' have diverged` 같은 메시지가 그냥 무서운 에러가 아니라 **"그래프가 이렇게 되어 있기 때문"**이라는 구체적 그림으로 보이게 될 것이다.

1. `.git` 디렉터리 해부 — 모든 것은 여기 있다

1.1 빈 저장소 초기화 후의 구조

$ mkdir demo && cd demo && git init

$ tree .git -L 1

.git

├── HEAD # 현재 브랜치를 가리키는 심볼릭 레퍼런스

├── config # 이 저장소의 설정

├── description # (gitweb용, 거의 안 쓰임)

├── hooks/ # pre-commit, pre-push 등 이벤트 훅 샘플

├── info/ # .git/info/exclude (로컬 gitignore)

├── objects/ # ★ 모든 객체가 저장되는 곳

│ ├── info/

│ └── pack/ # packfile 및 .idx 인덱스

└── refs/ # 브랜치, 태그, 리모트 레퍼런스

├── heads/ # 로컬 브랜치

├── tags/ # 태그

└── remotes/ # 리모트 추적 브랜치

**핵심은 `objects/`와 `refs/` 두 개**다. 나머지는 설정·보조 데이터다.

1.2 HEAD의 정체

$ cat .git/HEAD

ref: refs/heads/main

HEAD는 **포인터를 가리키는 포인터**다. `ref:` 한 줄뿐이다. 브랜치 main이 아직 없어도 HEAD는 `refs/heads/main`을 가리키고 있고, 첫 commit을 하는 순간 `.git/refs/heads/main` 파일이 생긴다.

`git checkout <commit-sha>`를 하면 HEAD가 브랜치 이름이 아니라 commit SHA 자체를 가리키는 상태로 바뀌는데, 이게 바로 **detached HEAD** 상태다. 브랜치라는 "손잡이"가 없으니 거기서 만든 commit은 어떤 브랜치에도 속하지 않고, checkout을 옮기는 순간 Garbage Collection 대상이 된다(단, reflog에는 남는다 — 후술).

2. 3가지 Object — Blob, Tree, Commit

Git의 세계에는 **딱 4종류**의 객체가 있다: blob, tree, commit, tag. 이 중 tag(annotated tag)는 선택적이므로 핵심은 **blob/tree/commit 세 가지**다.

2.1 Blob — 파일 내용

$ echo "hello git" > hello.txt

$ git hash-object -w hello.txt

8d0e41234f24b6da002d962a26c2495ea16a425f

`git hash-object`는 다음을 한다:

1. 내용 앞에 `blob <byte-size>\0` 헤더를 붙인다 → `"blob 10\0hello git\n"`

2. 전체를 **SHA-1** 해시 → `8d0e4123...`

3. zlib으로 compress

4. `.git/objects/8d/0e41234f...` 에 저장 (앞 2자리 디렉터리 + 나머지 38자 파일명)

$ ls .git/objects/8d/

0e41234f24b6da002d962a26c2495ea16a425f

$ git cat-file -p 8d0e4123

hello git

$ git cat-file -t 8d0e4123

blob

$ git cat-file -s 8d0e4123

10

**중요한 통찰**: blob은 **파일 이름을 저장하지 않는다**. "hello git\n" 내용만 저장한다. 동일한 내용의 파일이 100개 있어도 blob은 1개만 생긴다. 이게 **content-addressable**의 핵심이다.

2.2 Tree — 디렉터리

Tree는 파일 이름 → (mode, type, sha) 매핑이다. Unix `ls -l`의 결과를 직렬화한 것과 비슷하다.

$ git update-index --add --cacheinfo 100644 8d0e4123 hello.txt

$ git write-tree

4b825dc642cb6eb9a060e54bf8d69288fbee4904 # 예시 SHA

$ git cat-file -p 4b825dc6

100644 blob 8d0e41234f24b6da002d962a26c2495ea16a425f hello.txt

Tree 객체의 바이너리 포맷:

mode는 6자리 octal이지만 실제로는 5가지뿐:

| mode | 의미 |

| -------- | ----------------------- |

| `100644` | 일반 파일 |

| `100755` | 실행 파일 |

| `120000` | symlink |

| `160000` | gitlink (submodule) |

| `040000` | 디렉터리 (서브 tree) |

디렉터리가 중첩되면 tree가 tree를 가리키는 구조가 된다.

root-tree

├── (blob) README.md

├── (blob) package.json

└── (tree) src/

├── (blob) index.ts

└── (tree) utils/

└── (blob) helper.ts

2.3 Commit — 스냅샷에 붙은 메타데이터

$ git commit-tree 4b825dc6 -m "first commit"

f7d2a9... (commit sha)

$ git cat-file -p f7d2a9

tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904

parent 3a5e... # 첫 commit이면 이 줄 없음

author YoungJu Kim <you@example.com> 1760000000 +0900

committer YoungJu Kim <you@example.com> 1760000000 +0900

first commit

Commit 객체는 **텍스트 7~10줄**짜리다. 담고 있는 정보:

- `tree`: 이 commit이 찍은 전체 파일시스템 스냅샷(root tree의 SHA)

- `parent`: 이전 commit (merge commit이면 2개 이상)

- `author` / `committer`: 이름, 이메일, Unix timestamp, timezone

- 빈 줄 후 commit message

**Git은 스냅샷을 저장한다 — diff를 저장하지 않는다**는 말의 정확한 의미가 여기 있다. 각 commit은 파일시스템 전체의 root tree를 가리키고, 같은 내용의 파일은 같은 blob을 공유하기 때문에 실제 저장 공간은 폭발하지 않는다.

2.4 객체 그래프 — DAG의 탄생

commit C3 ─┐ tree T3 ─┬─ blob B(src/index.ts v3)

│ └─ tree T3a ─ blob B(README.md)

commit C2 ─┐ tree T2 ─┬─ blob B(src/index.ts v2) ← 재사용

│ └─ tree T3a ─ blob B(README.md) ← 재사용!

commit C1 ─┐ tree T1 ─┬─ blob B(src/index.ts v1)

│ └─ tree T1a ─ blob B(README.md.v1)

(parent=null)

C2에서 README.md를 수정하지 않았다면 T3a(README 하위 tree) 자체가 공유된다. 이게 Git이 "모든 commit이 스냅샷이지만 디스크 효율이 좋은" 이유다.

3. Refs — 이름표 시스템

3.1 단순한 텍스트 파일

$ cat .git/refs/heads/main

f7d2a9e8b3c4d1f0a5e6b7c8d9e0f1a2b3c4d5e6

**브랜치는 그냥 텍스트 파일 1개, 내용은 SHA 40자리**다. 그래서 Git이 브랜치 수천 개를 만들어도 부담이 없다. SVN/Perforce가 브랜치를 무거운 작업으로 취급한 것과 달리, Git에서 브랜치는 **포인터 생성**일 뿐이다.

$ git branch feature-x # 새 파일 하나 생성

$ cat .git/refs/heads/feature-x

f7d2a9e8b3c4d1f0a5e6b7c8d9e0f1a2b3c4d5e6 # 현재 HEAD와 동일

3.2 Packed refs

브랜치가 수만 개가 되면 파일이 많아져서 `git fetch`가 느려진다. 그래서 Git은 `.git/packed-refs`에 모아 저장할 수 있다.

$ cat .git/packed-refs

pack-refs with: peeled fully-peeled sorted

f7d2a9e8... refs/heads/main

3a5e1234... refs/heads/feature-x

8c9d0e1f... refs/tags/v1.0.0

^b2c4d6e8... (태그가 가리키는 객체 — peeled)

느슨한 refs(loose refs)와 packed refs 중 **loose가 우선**한다. `git pack-refs --all`로 정리할 수 있다.

3.3 Symbolic ref와 HEAD

$ git symbolic-ref HEAD

refs/heads/main

$ git symbolic-ref HEAD refs/heads/feature-x

HEAD 파일 내용이 "ref: refs/heads/feature-x"로 바뀜

`ORIG_HEAD`, `FETCH_HEAD`, `MERGE_HEAD` 같은 특수 ref도 모두 `.git/` 루트에 있는 파일들이다.

3.4 Remote tracking branch

$ cat .git/refs/remotes/origin/main

a1b2c3d4...

`origin/main`은 **"리모트에서 마지막으로 fetch한 시점의 main"**을 가리키는 로컬 스냅샷이다. 네트워크 요청을 하지 않고도 내 `main`보다 origin이 몇 커밋 앞선지 비교할 수 있는 이유다.

4. Index(Staging Area) — commit과 working tree 사이

4.1 `.git/index`의 정체

$ ls -l .git/index

-rw-r--r-- ... .git/index

바이너리 파일이다. 구조:

- 헤더: "DIRC" + 버전 + 엔트리 수

- 엔트리들: 각 파일당

- stat 정보 (ctime, mtime, dev, ino, mode, uid, gid, size)

- SHA-1 (blob sha)

- flags + 파일명

- 확장 정보 (TREE 캐시 등)

- 체크섬

`git ls-files --stage`로 확인:

$ git ls-files --stage

100644 8d0e41234f24b6da002d962a26c2495ea16a425f 0 hello.txt

100644 a2b3c4d5... 0 src/index.ts

**stat 정보를 저장하는 이유**는 성능이다. `git status`가 수만 개 파일을 매번 해시 계산하면 느려진다. stat이 같으면 "변경 없음"으로 판단하고 skip한다(lstat race 문제는 racy-git으로 처리).

4.2 3단계 모델

Working Tree ◀─ git checkout ─ Index(Stage) ◀─ git reset ─ HEAD (last commit)

│ │

└── git add ────────────────────▶│

└── git commit ────────────▶ new commit

**`git add`는 blob을 만들고 index를 업데이트하는 것**이고, **`git commit`은 index의 현재 상태로 tree를 만들고 commit을 만드는 것**이다.

4.3 Merge conflict과 stage 번호

Index는 실제로 `(path, stage)` 키를 가진다. stage 0은 정상, 1/2/3은 conflict 상태다.

| stage | 의미 |

| ----- | --------------------- |

| 0 | 정상 (resolved) |

| 1 | 공통 조상(base) |

| 2 | 현재 브랜치(ours) |

| 3 | 머지 대상(theirs) |

$ git ls-files --stage

100644 <base-sha> 1 conflict.txt

100644 <ours-sha> 2 conflict.txt

100644 <theirs-sha> 3 conflict.txt

`git add conflict.txt`를 하면 세 엔트리가 지워지고 stage 0으로 통합된다. 이게 "add로 conflict 해결을 완료했다고 알리는" 작동 원리다.

5. Packfile — 수만 개 객체를 수 MB로 압축

5.1 loose object의 한계

모든 객체를 `.git/objects/ab/cd...` 식 개별 파일로 두면:

- 파일 시스템 inode가 폭발

- zlib 압축이 파일 단위라 유사한 버전 간 중복 제거 불가

- fetch할 때 수만 요청이 필요 (git 프로토콜이 아니라 HTTPS dumb 프로토콜 가정 시)

5.2 Packfile 구조

`git gc` 또는 `git repack`을 하면 객체들이 packfile로 통합된다.

$ ls .git/objects/pack/

pack-a1b2c3d4...d5e6.idx # 인덱스 (어떤 SHA가 어느 offset에 있나)

pack-a1b2c3d4...d5e6.pack # 본체

pack-a1b2c3d4...d5e6.rev # 역인덱스 (offset → sha) ※ Git 2.31+

pack 파일 구조:

[12-byte header] "PACK" + version(4bytes=2) + object count(4bytes)

[object 1] [object 2] ... [object N]

[20-byte SHA-1 of whole packfile]

각 object entry:

- Type + size (가변 길이 인코딩)

- zlib-compressed content

- Type이 **OFS_DELTA** 또는 **REF_DELTA**이면: base object 참조 + 델타 명령어들

5.3 Delta compression — Git 성능의 비결

예: `Users.ts` 파일이 commit마다 한 줄씩 바뀌었다면, 10개 버전을 모두 저장하는 대신 **가장 큰 1개 + 9개 delta**로 저장한다.

Delta는 copy(base의 offset A부터 len B만큼 복사)와 insert(새 데이터 C 삽입) 명령어의 나열이다. rsync의 알고리즘과 유사하다.

Object A (base, full 1200 bytes)

Object B (delta vs A): copy[0..500] + insert["added line\n"] + copy[500..1200]

= 약 30~40 bytes

5.4 Delta 선택 — heuristic

어떤 객체를 base로 삼을지는 휴리스틱이다:

1. 같은 파일명의 다른 버전을 우선 탐색

2. 크기가 비슷한 객체 선택

3. window(기본 10)와 depth(기본 50) 제한 안에서 최소 delta 탐색

4. 결과를 pack 안에서 **인접하게 배치** (locality of reference)

큰 저장소에서 `git gc --aggressive`를 하면 window/depth를 크게 잡아서 더 공격적으로 최적화한다. Chromium 같은 거대 저장소에서 체크아웃 성능이 극단적으로 빨라지는 이유다.

5.5 Bitmap index

`pack-*.bitmap` 파일은 EWAH 비트맵으로 "어떤 commit에서 도달 가능한 객체 집합"을 미리 계산해 둔다. `git clone` / `git fetch` 시 reachable object 계산이 수십 배 빨라진다. GitHub/GitLab 같은 대형 호스팅은 이거 없이 못 돌아간다.

6. Reflog — "잃어버린 commit"을 되살리는 안전망

6.1 뭐가 저장되나

$ cat .git/logs/HEAD

0000...0000 f7d2a9... You <you@ex.com> 1760000000 +0900 commit (initial): first

f7d2a9... 3a5e12... You <you@ex.com> 1760000500 +0900 commit: add feature

3a5e12... b2c4d6... You <you@ex.com> 1760001000 +0900 rebase -i (pick): refactor

...

HEAD가 이동할 때마다 `before → after` + 명령어 설명이 기록된다. 브랜치마다 `.git/logs/refs/heads/<name>`에도 있다.

6.2 실전 복구

"실수로 reset --hard를 쳐서 작업이 날아갔다!"

$ git reflog

b2c4d6e HEAD@{0}: reset: moving to HEAD~3

8a9b0c1 HEAD@{1}: commit: WIP 중요한 작업

...

$ git reset --hard HEAD@{1} # 되살리기!

Reflog는 **기본 90일** 유지된다(`gc.reflogExpire`). 그 안에는 `git reset --hard`, `git checkout`, `git rebase` 중 날아간 commit이 모두 남아 있다. **Git으로 작업을 "진짜로" 잃어버리려면 의식적으로 reflog를 지우고 gc를 돌려야 한다**는 격언이 여기서 나온다.

6.3 reflog vs log

- `git log`: commit 그래프를 parent 체인 따라 탐색

- `git reflog`: HEAD/브랜치의 **이동 기록**을 시간순 탐색

rebase 도중 `--abort`를 못 했거나, detached HEAD에서 만든 commit이 어떤 브랜치에도 없어도 reflog에는 남아 있어서 SHA로 다시 찾을 수 있다.

7. Merge — 3-way에서 ort까지

7.1 Fast-forward

main: A ─ B

↑ main과 feature가 같은 commit

feature: A ─ B ─ C ─ D (현재 checkout)

$ git checkout main

$ git merge feature

Fast-forward

main이 feature의 직계 조상이라 **main 포인터를 C → D로 옮기기만** 하면 된다. commit이 새로 생기지 않는다.

7.2 3-way merge

두 브랜치가 갈라졌을 때:

┌─ E ─ F (feature)

A ─ B ─┤

└─ C ─ D (main)

Common ancestor(B)를 **merge base**라 한다. Git은 B, D, F 세 버전을 비교한다:

| 파일 | B | D | F | 결과 |

| ----------- | ------ | ------- | ------- | ------------------------ |

| `a.txt` | "foo" | "foo" | "bar" | "bar" (F가 바꿈) |

| `b.txt` | "x" | "y" | "x" | "y" (D가 바꿈) |

| `c.txt` | "1" | "2" | "3" | **conflict** (둘 다 바꿈) |

| `d.txt` | exists | deleted | changed | **conflict** (삭제 vs 수정) |

이게 3-way merge의 핵심이다 — "B와 비교해서 누가 바꿨는지"로 판단한다.

7.3 Recursive merge — 공통 조상이 여럿일 때

┌── X ──┐

A ─ B ─ ─ M1 (merge commit M1: X와 Y의 머지)

└── Y ──┘

└── C ─ D (main)

└── E ─ F (feature)

main과 feature의 공통 조상을 찾으려면 M1을 만나는데, M1 자체가 머지 commit이라 "ancestor" 후보가 여럿(X, Y)이다. Recursive 전략은 **X와 Y를 재귀적으로 머지해서 가상의 merge base를 만든 뒤** 거기를 기준으로 3-way를 수행한다. 이게 기본 전략이었다(2005~2021).

7.4 ort — Git 2.34의 기본 전략

**ort**는 "Ostensibly Recursive's Twin"의 약자로, recursive의 로직을 **디스크가 아닌 메모리에서 인덱스 조작으로** 수행하도록 재구현한 전략이다. 2021년 Linux kernel에서 `git merge`가 수 분 걸리던 게 수 초로 줄었다는 사례가 있다.

Git 2.34+부터 기본값이 `ort`. `merge.strategy=recursive`로 바꾸지 않는 이상 자동으로 빠른 전략을 쓴다.

7.5 conflict 해결 과정

$ git merge feature

Auto-merging c.txt

CONFLICT (content): Merge conflict in c.txt

Automatic merge failed; fix conflicts and then commit the result.

$ cat c.txt

<<<<<<< HEAD

2

=======

3

>>>>>>> feature

- `<<<<<<< HEAD` ~ `=======`: 현재 브랜치(ours)

- `=======` ~ `>>>>>>> feature`: 머지 대상(theirs)

`git checkout --conflict=diff3`로 설정하면 `|||||||` 구분자 아래에 공통 조상 버전까지 나타나서 "누가 뭘 바꿨는지" 보기 편하다.

해결 후 `git add` → `git commit`을 하면 **2개의 parent를 가진 merge commit**이 생긴다.

8. Rebase — 이력을 다시 쓰는 기술

8.1 rebase가 실제로 하는 일

Before:

┌─ E ─ F (feature)

A ─ B ─

└─ C ─ D (main)

$ git checkout feature

$ git rebase main

After:

A ─ B ─ C ─ D ─ E' ─ F' (feature는 E', F'를 가리킴)

Git은 E와 F를 **D 위에 "재적용"**한다. 구체적으로:

1. D부터 F까지의 공통 조상 B를 찾는다.

2. B 이후 feature에만 있는 commit들(E, F)을 **patch로 추출** (`git format-patch`와 비슷)

3. HEAD를 D로 옮긴다

4. E의 patch를 적용 → 새 commit E' 생성 (tree가 같을 수도 다를 수도 있음 — conflict 해결 결과에 따라)

5. F의 patch를 적용 → F' 생성

6. feature ref를 F'로 이동

**E와 E'는 SHA가 다르다**. parent와 timestamp(committer date)가 바뀌었기 때문이다. author date는 유지된다.

8.2 golden rule

**"Never rebase a branch that others have based work on"**

리모트에 푸시된 브랜치를 rebase하면 다른 팀원의 clone과 이력이 어긋나서 `git pull`이 지옥이 된다. `--force-with-lease`로 푸시하더라도 팀 합의가 없으면 재앙이다.

8.3 interactive rebase

$ git rebase -i HEAD~5

pick f7d2a9e add login form

pick 3a5e123 fix typo # ← squash로 이전과 합치기

pick b2c4d6e add password hash

pick 8c9d0e1 WIP

pick 1a2b3c4 add tests

명령어:

- `pick`: 그대로

- `reword`: commit message 수정

- `edit`: stop해서 파일 수정

- `squash`: 이전 commit과 합침 + message 합침

- `fixup`: squash인데 message 버림

- `drop`: 삭제

- `exec <cmd>`: 중간에 명령 실행(테스트 돌리기 등)

**주의**: interactive rebase는 이력을 자유롭게 조작하지만, 동시에 원래 commit들은 reflog에 남아 있다. 망치면 `git reflog`에서 되살릴 수 있다.

8.4 rerere — conflict 기억하기

$ git config --global rerere.enabled true

rerere("reuse recorded resolution")는 같은 conflict가 반복될 때(feature 브랜치를 main에 여러 번 rebase할 때 흔함) **이전에 해결했던 방식을 자동 적용**한다. 장수 feature 브랜치에선 반드시 켜자.

9. cherry-pick, revert, bisect

9.1 cherry-pick

$ git cherry-pick <sha>

특정 commit의 tree diff를 계산해 현재 HEAD에 적용 → 새 commit 생성. rebase의 단일 commit 버전이다. hotfix 백포팅에 자주 쓴다.

9.2 revert

$ git revert <sha>

해당 commit의 **역방향 patch**를 만들어 새 commit으로 적용한다. 이력을 되돌리지 않고 **앞으로 나아가며 취소**한다(공유 브랜치에서 reset 대신 써야 한다).

9.3 bisect — binary search로 버그 도입 commit 찾기

$ git bisect start

$ git bisect bad # 현재는 버그 있음

$ git bisect good v1.0.0 # v1.0.0에는 버그 없음

Git이 중간 commit을 checkout해 줌

$ ./run-tests.sh

$ git bisect bad # or good

로그 N개 → log2(N) 회 반복으로 범인 발견

$ git bisect reset

자동화도 된다: `git bisect run ./test.sh`. 대규모 저장소에서 회귀 버그 찾을 때 1시간을 5분으로 줄여준다.

10. Submodule vs Subtree vs Sparse checkout

10.1 Submodule — gitlink(mode 160000)

`.gitmodules`에 경로와 URL을 기록하고, superproject의 tree에는 **gitlink 엔트리(mode 160000)**로 특정 commit의 SHA를 박아둔다.

.gitmodules:

[submodule "libs/foo"]

path = libs/foo

url = https://github.com/org/foo.git

문제점:

- `git clone` 후 `git submodule update --init` 추가 필요

- 서브모듈 commit이 따로 관리돼서 "뭘 가리키는지" 헷갈림

- 브랜치 전환 시 서브모듈 상태가 꼬이기 쉬움

10.2 Subtree

`git subtree add --prefix=libs/foo <repo> <branch>`는 외부 저장소의 내용을 **현재 저장소의 tree 안에 그냥 복사**한다. 의존성이 저장소에 박혀 있어서 clone이 단순하지만, 업스트림 변경을 받으려면 별도 명령이 필요하다.

10.3 Sparse checkout + partial clone — monorepo 생존법

2020년 이후 Google/Microsoft monorepo 패턴:

partial clone: blob을 lazy fetch

$ git clone --filter=blob:none <url>

sparse checkout: 일부 경로만 working tree에 펼치기

$ git sparse-checkout init --cone

$ git sparse-checkout set libs/my-team apps/my-service

Chromium, Windows 같은 수십 GB 저장소도 이 조합으로 수십 MB만 받아서 작업할 수 있다.

11. Hook — 로컬/서버 이벤트 자동화

11.1 클라이언트 사이드

`.git/hooks/`의 실행 권한 있는 파일이 자동 실행된다.

| Hook | 시점 |

| --------------------- | ------------------------------------- |

| `pre-commit` | commit 직전 (lint, test) |

| `prepare-commit-msg` | 에디터 열기 직전 (자동 message 삽입) |

| `commit-msg` | message 입력 후 (형식 검증) |

| `post-commit` | commit 직후 (알림) |

| `pre-push` | push 직전 (전체 테스트) |

| `pre-rebase` | rebase 직전 |

Husky, lefthook, pre-commit(Python) 같은 도구가 이 훅 디렉터리를 활용한다.

11.2 서버 사이드

| Hook | 시점 |

| ---------------- | ----------------------------- |

| `pre-receive` | push 수신 시작, 전체 검증 |

| `update` | 각 ref 업데이트 직전 |

| `post-receive` | push 완료 후 (CI 트리거) |

GitHub/GitLab은 이 메커니즘 위에 자체 훅 시스템을 얹었다.

12. 프로토콜 — clone/fetch/push 아래서 벌어지는 일

12.1 v0 / v1: dumb HTTP

단순 static 파일 서버에서도 clone이 된다(옛날 방식). 클라이언트가 loose object를 하나씩 받아야 해서 느리다.

12.2 smart HTTP / SSH / git protocol

서버가 `git-upload-pack`(fetch용) 또는 `git-receive-pack`(push용) 프로세스를 실행한다.

Fetch 흐름:

Client: "want <sha1> <sha2> ..."

Client: "have <local_sha1> <local_sha2> ..."

Server: "ACK <sha>" (공통 조상 발견)

Server: pack 파일 스트리밍 전송 (필요한 객체만)

12.3 Protocol v2 (2018~)

- Capability 협상 분리로 초기 핸드셰이크 크기 축소

- Ref discovery가 **filter 가능** → 수만 개 브랜치가 있어도 필요한 것만 받음

- `ls-refs` 명령으로 특정 prefix만 요청 가능

Linux 커널, Chromium 같은 대형 저장소에서 fetch 속도가 극적으로 개선되었다. `git config --global protocol.version 2`로 강제할 수 있다.

13. Monorepo vs Polyrepo — 그리고 Git의 스케일 한계

13.1 Git이 버티기 힘든 지점

- **파일 수**: `git status`가 O(N), 수십만 파일에서 수 초 걸림 → fsmonitor, core.untrackedCache로 대응

- **커밋 수**: `git log`는 okay, 하지만 blame은 O(커밋×파일크기)

- **저장소 크기**: 수 GB 넘어가면 clone 수십 분

13.2 대응 기술

- **Scalar(= Git 2.38+ 옵션 기본화)**: Microsoft가 Windows(300GB 저장소) 관리용으로 개발, 현재는 Git 본체에 통합

- **partial clone + sparse checkout**: 상단 참조

- **commit-graph 파일**(`.git/objects/info/commit-graph`): commit 파싱 없이 ancestor 질의를 수행, `git log`/`merge-base`가 10배+ 빨라짐

- **fsmonitor**: macOS FSEvents, Linux inotify, Watchman을 통해 stat 요청 자체를 생략

13.3 JJ, Sapling — Git의 진화 후보

- **JJ (Jujutsu)**: Git 호환, 하지만 working copy 자체를 snapshot으로 관리 → index 개념 사라짐, `jj undo`가 모든 작업을 되돌릴 수 있음

- **Sapling (Meta)**: Mercurial 혈통, stack of diffs(Phabricator 스타일) 모델, Git 저장소에도 push 가능

Git이 사라질 일은 없지만, 위 도구들이 제시하는 UX는 Git의 다음 진화 방향을 가늠하게 한다.

14. SHA-1 → SHA-256 전환

14.1 왜 전환해야 하는가

2017년 Google **SHAttered** 공격으로 SHA-1 collision이 실제로 만들어졌다. Git의 content-addressable 시스템은 해시 충돌이 곧 데이터 무결성 붕괴다. 공격 비용이 낮아질수록 리스크가 커진다.

14.2 전환의 어려움

모든 commit/tree/blob이 서로를 SHA-1로 참조한다. 한 객체만 SHA-256으로 바꾸려고 해도 그걸 참조하는 모든 상위 객체가 바뀌어야 해서 **전체 저장소가 rewrite**된다. 팀 전체, CI/CD, 백업, 외부 도구가 모두 바뀌어야 해서 Big Bang 전환은 불가능하다.

14.3 Git의 접근: 다중 해시 저장소

- Git 2.29+부터 `git init --object-format=sha256` 지원

- 기존 SHA-1 저장소와 새로 만든 SHA-256 저장소 간 **양방향 매핑 테이블**을 유지

- push/fetch는 양쪽 해시를 번역

아직 GitHub/GitLab 같은 호스팅이 SHA-256을 완전히 지원하지 않고 있어서(2025년 기준 실험 단계), 실전 전환은 느리다. 단, **SHA-1DC**(SHA-1 with collision detection)가 기본 해시 함수로 쓰여서, 알려진 공격 패턴이 감지되면 거부된다.

15. 실전 트러블슈팅 10선

15.1 "detached HEAD"

원인: 브랜치가 아니라 특정 commit을 checkout한 상태.

해결: 지금 여기서 만든 commit을 유지하고 싶다면 `git branch <new-name>` 먼저.

15.2 "Your branch and 'origin/main' have diverged"

원인: 로컬 main과 리모트 main이 공통 조상 이후 각자 commit이 있음.

해결: `git pull --rebase` 또는 `git pull --no-rebase`(merge commit 생성). 팀 정책에 따라.

15.3 "refusing to merge unrelated histories"

원인: 두 브랜치가 완전히 다른 저장소에서 시작 → 공통 조상 없음.

해결: `git merge --allow-unrelated-histories` (의도가 맞는지 반드시 확인).

15.4 push 후 "force-pushed가 필요해요"

원인: rebase 후 푸시.

해결: `git push --force-with-lease`(남의 작업이 없을 때만 force). `--force`는 타인 작업을 덮어쓸 수 있어 위험.

15.5 "fatal: not a git repository"

원인: `.git` 디렉터리가 없거나 손상.

해결: 상위 디렉터리에서 `git rev-parse --show-toplevel`로 저장소 루트 확인.

15.6 "object file is empty"

원인: loose object 파일이 zero-byte (갑작스런 종료/디스크 풀).

해결: `find .git/objects -size 0 -delete`로 빈 파일 제거 후 `git fsck`. 최근 commit이면 reflog로 복구.

15.7 대용량 파일이 히스토리에 있어서 clone이 느림

해결:

1. `git filter-repo --path <big-file> --invert-paths`로 히스토리에서 제거

2. Git LFS로 이전

3. 팀 전체에 새 URL 공지 + reclone

15.8 `.DS_Store`, `node_modules`를 실수로 commit

해결:

$ git rm -r --cached node_modules

$ echo "node_modules/" >> .gitignore

$ git commit -m "chore: stop tracking node_modules"

이력에서 완전 삭제하려면 `git filter-repo`.

15.9 "Please tell me who you are"

원인: `user.name`/`user.email` 미설정.

해결: `git config --global user.name/email`.

15.10 cherry-pick 중 "empty commit"

원인: 이미 같은 변경이 현재 브랜치에 있어서 patch 적용 결과가 공집합.

해결: `git cherry-pick --skip` 또는 `--allow-empty`.

16. 팀 컨벤션 — 지속 가능한 이력을 위한 체크리스트

16.1 커밋 메시지

Conventional Commits:

- type: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `perf`, `ci`

- subject: 72자 이내, 명령문, 마침표 없음

- body: 왜 이 변경을 했는지 ("what"은 diff를 보면 됨, "why"가 핵심)

- footer: `BREAKING CHANGE: ...`, `Closes #123`

16.2 브랜치 전략

- **trunk-based**: 모두 main에 작은 PR로 머지. 장수 브랜치 없음. feature flag로 미완성 기능 제어

- **Git Flow**: main/develop/feature/release/hotfix. 릴리즈 주기가 긴 프로덕트용

- **GitHub Flow**: main + feature branch + PR. 웹 서비스 기본

16.3 PR 크기

- 300~500줄 이하 권장(리뷰 집중력 한계)

- "Add feature X" + "Add tests for feature X"는 같은 PR

- refactor와 feature는 분리 (혼합하면 리뷰어가 어떤 줄이 어느 변경인지 구분 못 함)

16.4 머지 방식

- **Merge commit**: 이력 보존 (작업 단위 그룹핑)

- **Squash**: 1 PR = 1 commit. main 이력이 깔끔하지만 세부 commit이 사라짐

- **Rebase merge**: fast-forward로 linear history. 개별 commit 유지, 하지만 CI가 각 commit에서 다 돌아가야 안전

팀마다 다르지만 "squash를 기본, 큰 feature는 merge commit"이 현대적 트렌드다.

17. Git 2.44~2.46 최신 기능 (2024~2025)

- **Reftable**: refs 저장 포맷 재설계. 수십만 개 ref가 있는 저장소에서 10배+ 속도 향상

- **partial clone 개선**: filter spec이 더 세밀해지고, blob lazy fetch의 네트워크 재시도/병렬성 개선

- **`git maintenance`**: `gc` 대체. background로 주기적 packfile 병합/사전 인덱스 계산

- **보안**: `safe.directory` 엄격화로 root 소유 경로의 임의 hook 실행 차단(CVE-2022-24765 이후)

- **SSH signing**: GPG 대신 SSH 키로 commit 서명 가능. Git 2.34+, GitHub는 2022년부터 지원

마무리 — Git을 "그래프 DB"로 보기

`.git/objects/` 안의 blob/tree/commit은 **불변(immutable) 객체**들이고, refs는 **mutable 포인터**들이다. 모든 Git 명령은 결국 다음 중 하나다:

1. 새 불변 객체를 만든다 (`add`, `commit`, `merge`)

2. 포인터를 옮긴다 (`reset`, `checkout`, `branch`, `rebase` 후반부)

3. 그래프를 질의한다 (`log`, `diff`, `blame`, `bisect`)

4. 원격과 동기화한다 (`fetch`, `push`)

5. 객체 저장소를 정리한다 (`gc`, `repack`)

이 모델로 보면 `rebase`는 "기존 commit을 복사해 새 commit을 만들고 포인터를 옮기는 것"이고, `reset --hard`는 "포인터를 옮기고 working tree를 거기 맞추는 것"이다. "이력이 날아갔다"는 건 포인터가 옮겨졌다는 뜻이지, 대부분의 경우 **객체 자체는 reflog의 참조로 인해 여전히 살아있다**.

다음 글에서는 Git 위에 쌓인 **GitHub/GitLab의 내부 구조 — PR 머지 큐, Actions 러너 격리, Code Search 인덱싱, Contribution graph 계산** 같은 "매일 보지만 어떻게 만들었는지 모르는" 주제를 다룬다. 그리고 GitHub Copilot처럼 Git diff를 학습 입력으로 쓰는 AI 시스템이 **patch 단위**로 학습할 때 어떤 신호를 얻는지까지 이어진다.

Git은 20년 된 도구지만, 그 내부 구조는 여전히 **현대 분산 시스템 설계의 교과서**다. 불변 객체 + 포인터 + 해시 = 더 복잡한 모든 것(Ethereum Merkle Trie부터 Docker image layer까지)의 조상이다.

현재 단락 (1/385)

`git commit -m "fix"`를 수천 번 쳤어도 **`.git/objects/` 안에 뭐가 들어있는지** 열어본 적 없는 개발자가 많다. 사실 Git은 버전 관리 도구라기...

작성 글자: 0원문 글자: 17,481작성 단락: 0/385