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> SP <name> NUL <20-byte-sha> <mode> SP <name> NUL <20-byte-sha> ...

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

mode의미
100644일반 파일
100755실행 파일
120000symlink
160000gitlink (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 세 버전을 비교한다:

파일BDF결과
a.txt"foo""foo""bar""bar" (F가 바꿈)
b.txt"x""y""x""y" (D가 바꿈)
c.txt"1""2""3"conflict (둘 다 바꿈)
d.txtexistsdeletedchangedconflict (삭제 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 addgit 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 clonegit 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-commitcommit 직전 (lint, test)
prepare-commit-msg에디터 열기 직전 (자동 message 삽입)
commit-msgmessage 입력 후 (형식 검증)
post-commitcommit 직후 (알림)
pre-pushpush 직전 (전체 테스트)
pre-rebaserebase 직전

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

11.2 서버 사이드

Hook시점
pre-receivepush 수신 시작, 전체 검증
update각 ref 업데이트 직전
post-receivepush 완료 후 (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>(<scope>): <subject>

<body>

<footer>
  • 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.442.46 최신 기능 (20242025)

  • 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/389)

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

작성 글자: 0원문 글자: 17,583작성 단락: 0/389