Git Isn't a Diff Tracker: How Blobs, Trees, DAG Commits, and the Index Actually Work Under the Hood
Git은 변경분 추적기가 아닙니다 — Blob, Tree, DAG 커밋과 인덱스의 내부 동작
Git은 커밋마다 변경분을 저장하는 대신 콘텐츠 해시로 식별하는 객체와 스냅샷 트리로 데이터를 구성합니다. 글은 인덱스, 브랜치 포인터, 커밋 그래프의 동작을 설명하고 reset·rebase·reflog 같은 명령을 내부 구조와 연결합니다.
- 주제
AI 요약
Git을 파일 변경분을 저장하는 도구로 이해하면 reset이나 rebase가 복잡하게 느껴집니다. 이 글은 Git을 불변 객체를 해시로 찾는 데이터베이스이자, 스냅샷을 DAG(Directed Acyclic Graph)로 연결하는 시스템으로 설명합니다. git diff가 변경분을 보여주더라도 Git의 논리적 저장 모델은 스냅샷입니다.
객체 데이터베이스
.git/objects에는 네 가지 객체가 저장됩니다. Blob은 파일 내용을 담고 파일명이나 경로는 담지 않습니다. Tree는 파일명과 권한을 Blob 또는 하위 Tree의 해시에 연결합니다. Commit은 루트 Tree, 부모 커밋, 작성자 정보와 메시지를 가리킵니다. Annotated Tag는 특정 커밋을 가리키며 태거 정보와 메시지를 담습니다.
객체는 내용과 유형, 길이로 구성한 헤더를 내용 앞에 붙인 뒤 해시를 계산해 식별합니다. 글의 예에서는 hello world\n을 Blob으로 기록하면 3b18e512dba79e4c8300dd08aeb37f8e728b8dad가 나옵니다. 파일명이 해시에 포함되지 않으므로 같은 내용의 파일은 경로가 달라도 동일한 Blob을 공유합니다. 객체 파일은 .git/objects 아래 해시 앞 두 글자를 디렉터리명으로 삼고 나머지를 파일명으로 저장하며, 디스크에서는 zlib으로 압축합니다. git hash-object, git cat-file로 객체를 만들고 내용을 확인할 수 있습니다.
스냅샷과 저장 공간
커밋할 때 Git은 변경분만 모은 패치 대신 전체 프로젝트 상태를 나타내는 Tree를 만듭니다. 다만 변경되지 않은 파일과 디렉터리의 해시를 재사용하므로 전체 내용을 매번 복사하지 않습니다. 예를 들어 1,000개 파일 가운데 docs/guide.md만 바꾸면 새 Blob과 변경된 디렉터리 Tree, 루트 Tree, Commit 객체를 만들고 나머지 파일과 폴더의 참조는 그대로 둡니다. 한 번도 바뀌지 않은 100MB 파일은 500개 커밋이 있어도 논리적으로 한 번만 저장됩니다.
Git은 일반적인 커밋 생성 시점이 아니라 git gc나 전송 과정에서 객체를 packfile로 묶을 때도 델타 압축을 사용합니다. 따라서 스냅샷이라는 논리 모델과 packfile 안의 실제 저장 방식은 구분해야 합니다. packfile에서는 비슷한 객체끼리 공통 부분을 찾아 델타로 저장할 수 있습니다.
작업 디렉터리, 인덱스, HEAD
글은 Git을 작업 디렉터리, 인덱스, 저장소의 세 영역으로 나눠 설명합니다. 작업 디렉터리는 편집 중인 파일이 있는 공간입니다. 인덱스는 .git/index에 있는 바이너리 파일이며 다음 커밋에 들어갈 상태를 준비합니다. git add는 작업 디렉터리의 파일을 읽어 Blob 객체를 기록하고, 파일 경로와 권한, Blob 해시를 인덱스에 반영합니다. 이 관점에서 인덱스는 커밋할 Tree의 초안입니다.
HEAD는 현재 위치를 가리키는 참조입니다. 보통 브랜치 이름을 가리키고, 브랜치 참조가 최신 커밋을 가리킵니다. 브랜치는 프로젝트 파일을 복사하는 대신 커밋 해시를 가리키는 작은 참조로 동작하므로 생성 비용이 프로젝트 크기에 좌우되지 않습니다. 특정 커밋을 직접 체크아웃하면 HEAD가 브랜치가 아닌 커밋을 가리키는 detached HEAD 상태가 됩니다. 이 상태에서 만든 커밋은 정상적으로 생성되지만 브랜치 참조가 따라오지 않습니다.
reset과 브랜치 통합
git reset --soft는 HEAD만 이전 커밋으로 옮겨 인덱스와 작업 디렉터리를 유지합니다. 기본값인 --mixed는 HEAD와 인덱스를 갱신하고 작업 디렉터리는 유지하므로 변경 사항이 스테이지에서 빠집니다. --hard는 HEAD와 인덱스, 작업 디렉터리를 모두 갱신해 커밋하지 않은 변경 사항을 버립니다.
브랜치를 병합할 때는 두 끝점이 갈라지지 않았으면 fast-forward로 참조만 옮깁니다. 서로 다른 커밋이 생겼다면 공통 조상과 각 브랜치의 끝점을 비교하는 3-way merge를 수행하고, 두 부모를 가진 Merge Commit을 만듭니다. 반면 rebase는 기존 커밋의 부모를 바꾸지 않습니다. 해당 커밋의 변경을 새 기준점 위에 다시 적용해 새 해시를 가진 커밋을 만들기 때문에 공유된 공개 브랜치의 커밋을 rebase하지 말라고 설명합니다.
복구와 충돌 대응
브랜치나 HEAD가 이동할 때 이전 위치는 reflog에 기록됩니다. git reset --hard HEAD~5 뒤에도 커밋이 즉시 사라지는 것은 아니므로 git reflog에서 해시를 찾아 복구 브랜치를 만들 수 있습니다. 다만 reflog와 도달 불가능한 객체의 보존 기간은 저장소 설정과 정리 작업에 따라 달라질 수 있습니다.
큰 파일이나 비밀 값을 커밋한 뒤 git rm으로 삭제해도 과거 커밋이 객체를 참조하므로 저장소 이력에는 남습니다. 글은 git-filter-repo나 BFG Repo-Cleaner로 이력을 다시 작성한 뒤 reflog 만료와 garbage collection을 수행하는 방법을 제시합니다. 충돌 상황에서는 git config --global merge.conflictstyle zdiff3를 설정하면 공통 조상의 내용도 충돌 표시에 포함돼 각 브랜치가 무엇을 바꿨는지 확인하기 쉽습니다. rebase 뒤 원격에 강제로 푸시할 때는 --force 대신 --force-with-lease를 권합니다. 원격 참조가 예상과 다르면 푸시를 중단해 다른 사람이 올린 커밋을 덮어쓰는 일을 막습니다.
dev.to 반응
- @dev_in_the_fog — 정말 사려 깊은 글입니다! 실제 엔지니어링 문제와 바로 적용할 수 있는 해결책을 기록하면 커뮤니티에 큰 가치가 생깁니다.
- @kyisaiah47 — Git은 논리 모델에서 스냅샷 트리를 사용하지만 packfile 안의 객체는 델타 압축할 수 있습니다. 다음 부분에서 객체 표현과 packfile 저장 방식을 구분해서 설명하나요?
원문: dev.to / 번역·요약: Trawling