Git은 파일 변경을 어떻게 추적하는가
index 바이너리 포맷의 stat 캐시부터 3 Tree 모델, git add의 blob 생성, skip-worktree 플래그, .gitignore 매칭 알고리즘까지 — Git staging area의 설계 철학을 추적한다.
- 01 Git이 파일이 아닌 SHA로 세상을 보는 이유
- 02 Git의 모든 포인터는 어디에 살고 있는가
- 03 Git은 파일 변경을 어떻게 추적하는가
- 04 Git history는 왜 DAG인가
- 05 Git Branch는 왜 이렇게 가벼운가
- 06 Git merge는 어떻게 충돌을 판단하는가
- 07 Rebase는 왜 새 commit을 만드는가
- 08 Git에서 변경을 되돌린다는 것의 의미
- 09 git stash는 어떻게 두 영역을 동시에 보존하는가
- 10 git push/fetch는 내부에서 무슨 일이 일어나는가
- 11 Git은 어떻게 실수를 복구하는가
- 12 Git Hook은 어디서 막고 어디서 통과시키는가
- 13 Submodule과 Subtree — 외부 레포 통합의 두 가지 철학
- 14 거대 레포는 어떻게 git을 견디는가
- 15 Git 워크플로우의 선택은 왜 팀 규모와 신뢰 수준에 달려 있는가
- 16 Git이 Push를 거부할 때 — 7가지 거부의 구조
git add를 실행할 때 실제로 무슨 일이 일어나는가? git status가 수만 개의 파일을 1초 안에 확인하는 이유는? 그리고 .gitignore에 추가했는데도 파일이 계속 commit에 들어가는 이유는? 이 질문들은 서로 다른 것처럼 보이지만, 전부 하나의 파일에서 시작한다 — .git/index.
Index란 무엇인가
.git/index는 staging area의 본체다. 단순한 파일 목록이 아니라 각 파일의 ctime, mtime, inode, size, blob SHA, mode를 저장하는 바이너리 파일이다.
Header (12 bytes)
"DIRC" magic + version + entry count
Entry (파일당)
ctime/mtime (8+8 bytes)
dev / inode / mode / uid / gid / size (각 4 bytes)
blob SHA (20 bytes)
flags + path (가변)
Extensions (선택)
TREE 캐시, REUC(충돌 해결 캐시)
Trailer
SHA-1 of all above (20 bytes)
xxd .git/index | head -1을 실행하면 4449 5243 0000 0002... — DIRC(DIR Cache의 약자)로 시작하는 헤더가 보인다. 파일 하나를 git add할 때마다 entry가 약 100바이트씩 늘어난다.
핵심은 stat 캐시다. git status가 빠른 이유가 여기 있다. 매번 모든 파일을 읽어 SHA를 계산하는 대신, ctime/mtime/inode가 index entry와 일치하면 변경 없음으로 판단한다. 다를 때만 실제 내용을 hash해 비교한다. 수만 파일 레포에서도 1초 이내가 가능한 이유다.
세 영역과 git diff
Git은 동시에 세 영역을 관리한다.
HEAD Tree ← 마지막 commit의 tree (objects/에 영구 저장)
Index ← .git/index (staging area)
Working Dir ← 실제 파일시스템
git status는 이 세 영역을 두 쌍으로 비교해 출력을 만든다.
| 비교 쌍 | 결과 |
|---|---|
| HEAD ↔ Index | ”Changes to be committed” |
| Index ↔ Working | ”Changes not staged” |
| Working에서 Index에 없는 파일 | ”Untracked” |
git diff의 변종도 같은 프레임으로 읽힌다. git diff는 Working ↔ Index, git diff --cached는 Index ↔ HEAD, git diff HEAD는 Working ↔ HEAD다. 명령어를 외우는 게 아니라 “어느 두 영역을 비교하는가”를 물으면 된다.
reset 모드도 마찬가지다. --soft는 HEAD ref만 이동하고, --mixed는 HEAD + Index를 맞추고, --hard는 세 영역 모두 통일한다. “어느 영역까지 갱신하느냐”가 전부다.
git add의 분해
git add foo.txt는 두 plumbing 명령으로 분해된다.
# 1. working 내용을 blob 객체로 영구 저장
blob_sha=$(git hash-object -w foo.txt)
# 2. index entry 갱신
git update-index --add --cacheinfo 100644 $blob_sha foo.txt
이 분해가 중요한 이유가 있다. git add 시점의 내용이 blob으로 영구 저장되므로, 그 후 working을 수정해도 staged 내용은 안전하게 보존된다. git add 후 파일을 수정하면 git diff --cached와 git diff가 서로 다른 결과를 보여주는 것이 버그가 아니라 이 설계의 정상 동작이다.
git add -p(patch mode)는 이 원리 위에서 동작한다. Working ↔ Index diff를 hunk 단위로 나눠 사용자가 yes/no로 선택하면, 선택된 hunk만 적용한 새 blob을 만들어 index를 갱신한다. 결과적으로 Index는 Working도 HEAD도 아닌 “사용자가 골라낸 중간 버전”이 된다. 한 파일 안에서도 의미 단위로 commit을 나눌 수 있는 이유다.
Index 플래그의 두 얼굴
.git/index entry에는 두 개의 특수 비트가 있다. assume-unchanged와 skip-worktree다.
git update-index --assume-unchanged config/local.yml # 성능 힌트
git update-index --skip-worktree config/local.yml # 의도적 제외
git ls-files -v # h=assume-unchanged, S=skip-worktree
둘 다 git status에서 그 파일을 숨기지만, 의도와 안전성이 다르다.
assume-unchanged는 “성능 힌트”다. git checkout이나 git pull이 해당 파일을 강제로 갱신할 수 있고, 이때 로컬 변경이 경고 없이 사라질 수 있다. 이미 tracked인 파일을 로컬에서만 바꿔 두려면 skip-worktree를 써야 한다. skip-worktree는 checkout/pull이 그 파일을 건드리지 않도록 보장한다.
sparse checkout이 내부적으로 skip-worktree를 대량 사용하는 이유가 여기 있다. 모노레포에서 team-b/ 수십만 파일에 skip-worktree를 걸면 index 크기는 수백 MB에서 수 MB로 줄어들고, git status 속도는 100배 가까이 빨라진다.
권장 우선순위는 명확하다: .gitignore(untracked) → 환경변수/config 시스템 → skip-worktree(이미 tracked) → assume-unchanged(마지막 수단).
.gitignore 매칭의 실제 규칙
.gitignore는 단순한 와일드카드 매칭이 아니다. 여러 파일이 누적되고, 우선순위가 있고, 같은 파일 안에서도 순서가 중요하다.
적용 우선순위(위가 우선):
- CLI
--exclude옵션 .git/info/exclude(개인용, 공유 안 됨)- 가장 가까운 디렉토리의
.gitignore부터 상위로 core.excludesFile(~/.gitignore_global)
같은 .gitignore 안에서는 마지막 매칭 줄이 이긴다. !pattern으로 negate할 수 있지만, 디렉토리 자체를 무시하면 그 안의 파일은 negate할 기회가 없다.
# 잘못된 패턴
build/
!build/keep.txt ← 작동 안 함 (build/ 자체가 통째로 무시됨)
# 올바른 패턴
build/* ← 디렉토리는 살리고 안만 무시
!build/keep.txt ← 이제 negate 가능
가장 흔한 함정: 이미 tracked인 파일은 .gitignore가 무력하다. git rm --cached <file>로 index에서 먼저 제거해야 한다. git check-ignore -v <path>로 어느 .gitignore의 어느 줄이 매칭했는지 추적할 수 있다.
정리
.git/index는 staging area의 바이너리 본체다. stat 캐시 덕분에git status가 수만 파일도 빠르게 처리한다.- Git의 모든 명령은 “HEAD Tree / Index / Working Directory 중 어느 영역에서 어느 영역으로 데이터를 옮기는가”로 설명된다.
git add는 blob 생성 + index 갱신 두 단계다. add 시점의 내용이 영구 저장되므로 이후 working 수정과 무관하게 staged 내용은 보존된다.skip-worktree는assume-unchanged보다 안전하다. tracked 파일을 로컬에서만 관리해야 할 때 정답이다..gitignore는 untracked 파일에만 효과가 있고, 같은 파일 안에서 마지막 매칭 줄이 우선한다.
다음 글에서는 이 index와 tree에서 commit 객체가 어떻게 만들어지는지, 그리고 Git의 DAG가 어떻게 history를 표현하는지 추적한다.