들어가며 — 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은 버전 관리 도구라기...