← all posts
DEV 2026.05.05 · 11 min read Intermediate

Git은 파일 변경을 어떻게 추적하는가

index 바이너리 포맷의 stat 캐시부터 3 Tree 모델, git add의 blob 생성, skip-worktree 플래그, .gitignore 매칭 알고리즘까지 — Git staging area의 설계 철학을 추적한다.


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 --cachedgit diff가 서로 다른 결과를 보여주는 것이 버그가 아니라 이 설계의 정상 동작이다.

git add -p(patch mode)는 이 원리 위에서 동작한다. Working ↔ Index diff를 hunk 단위로 나눠 사용자가 yes/no로 선택하면, 선택된 hunk만 적용한 새 blob을 만들어 index를 갱신한다. 결과적으로 Index는 Working도 HEAD도 아닌 “사용자가 골라낸 중간 버전”이 된다. 한 파일 안에서도 의미 단위로 commit을 나눌 수 있는 이유다.

Index 플래그의 두 얼굴

.git/index entry에는 두 개의 특수 비트가 있다. assume-unchangedskip-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의 함정

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는 단순한 와일드카드 매칭이 아니다. 여러 파일이 누적되고, 우선순위가 있고, 같은 파일 안에서도 순서가 중요하다.

적용 우선순위(위가 우선):

  1. CLI --exclude 옵션
  2. .git/info/exclude (개인용, 공유 안 됨)
  3. 가장 가까운 디렉토리의 .gitignore부터 상위로
  4. 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-worktreeassume-unchanged보다 안전하다. tracked 파일을 로컬에서만 관리해야 할 때 정답이다.
  • .gitignore는 untracked 파일에만 효과가 있고, 같은 파일 안에서 마지막 매칭 줄이 우선한다.

다음 글에서는 이 index와 tree에서 commit 객체가 어떻게 만들어지는지, 그리고 Git의 DAG가 어떻게 history를 표현하는지 추적한다.