Search

Git 내부 구조와 Content-addressable 파일시스템

Git을 단순한 버전 관리 도구로 인식하는 것은 그 본질을 완전히 이해하기 어렵습니다. Git은 근본적으로 Content-addressable 파일시스템이며, 그 위에 버전 관리 사용자 인터페이스가 구축된 구조입니다. 이러한 내부 구조를 파악하면 Git의 동작 원리를 체계적으로 이해할 수 있고, 복잡한 상황에서도 적절한 해결책을 도출할 수 있습니다.
Git 명령어는 크게 두 범주로 구분됩니다. 일반 사용자가 주로 사용하는 checkout, branch, commit과 같은 명령어는 "Porcelain" 명령어라고 하며, Git의 저수준 작업을 수행하는 hash-object, cat-file, update-index와 같은 명령어는 "Plumbing" 명령어라고 합니다. 이번 글에서는 주로 Plumbing 명령어를 통해 Git의 내부 동작 원리를 살펴보겠습니다.

Content-addressable 파일시스템

Git의 핵심은 Content-addressable 파일시스템입니다. 이는 데이터를 그 내용에 기반한 주소로 저장하는 시스템을 의미합니다. Git은 모든 데이터를 SHA-1 해시값으로 식별하며, 이 해시값이 해당 데이터에 접근하기 위한 유일한 키 역할을 합니다.
git hash-object 명령어가 이 개념을 가장 잘 보여줍니다. 데이터를 입력하면 Git은 그 내용과 헤더 정보를 조합하여 SHA-1 해시를 계산하고, 해당 데이터를 .git/objects 디렉토리에 저장합니다.
echo 'test content' | git hash-object -w --stdin # 출력: d670460b4b4aece5915caf5c68d12f560a9fe3e4
Shell
복사
실제로 Git을 초기화해보면 책과 달리 이미 몇 개 객체가 있는 경우가 있는데, 이는 GitHub Desktop 같은 도구에서 기본 파일들을 추가하기 때문입니다. CLI로 직접 초기화하면 objects 폴더가 비어 있는 것을 확인할 수 있습니다. 또한 윈도우 PowerShell에서는 master^{tree} 대신 "master^{tree}"처럼 따옴표를 사용해야 하며, 최근에는 기본 브랜치명이 main으로 변경된 경우가 많습니다.
여기서 중요한 점은 동일한 내용을 가진 데이터는 항상 같은 해시값을 생성한다는 것입니다. 이를 통해 Git은 자동으로 중복 데이터를 제거하고 저장 공간을 효율적으로 사용할 수 있습니다.
저장 과정은 다음과 같습니다. Git은 먼저 객체 타입과 크기 정보가 포함된 헤더를 생성하고, 이를 원본 내용과 결합하여 SHA-1 해시를 계산합니다. 그 후 전체 데이터를 zlib으로 압축하여 해시값의 첫 두 자리를 디렉토리명으로, 나머지 38자리를 파일명으로 사용하여 저장합니다.

객체 모델의 계층 구조

Git의 객체 모델은 세 가지 핵심 객체 타입으로 구성됩니다. 이 세 객체의 단순한 조합으로 복잡한 버전 관리가 가능하다는 점이 인상적입니다.

Blob 객체

Blob 객체는 Git에서 가장 단순한 객체 타입입니다. 파일의 내용만을 저장하며, 파일명이나 디렉토리 정보, 권한 등의 메타데이터는 포함하지 않습니다. 이러한 설계는 Git의 "내용 기반 주소 지정" 철학을 구현하는 핵심 요소입니다.
Blob 객체가 생성되는 과정을 단계별로 살펴보면 Git의 저장 메커니즘을 이해할 수 있습니다. 먼저 Git은 파일 내용 앞에 헤더를 추가합니다. 이 헤더는 "blob", 공백, 파일 크기, 널 문자로 구성됩니다. 예를 들어 "what is up, doc?"라는 내용의 경우 "blob 16\0what is up, doc?" 형태가 됩니다.
# 객체 타입 확인 git cat-file -t d670460b4b4aece5915caf5c68d12f560a9fe3e4 # 출력: blob # 객체 내용 확인 git cat-file -p d670460b4b4aece5915caf5c68d12f560a9fe3e4 # 출력: test content
Shell
복사
이때 같은 내용을 가진 파일들은 파일명이나 위치와 상관없이 항상 동일한 SHA-1 해시를 생성할 것입니다. 이는 Git이 자동으로 중복을 제거할 수 있게 해주는 핵심 메커니즘입니다. 프로젝트 내 여러 위치에 동일한 내용의 파일이 있어도 Git은 하나의 Blob 객체만 저장합니다.

Tree 객체

Tree 객체는 Git에서 가장 복잡하면서도 핵심적인 역할을 담당합니다. 디렉토리 구조를 표현하는 객체로, 각 Tree 객체는 해당 디렉토리에 포함된 파일과 하위 디렉토리에 대한 정보를 저장합니다. 이 정보에는 파일 모드(권한), 객체 타입, SHA-1 해시, 이름이 포함됩니다.
Tree 객체를 이해하는 핵심은 그것이 특정 시점의 디렉토리 상태를 나타내는 스냅샷이라는 점입니다. 예를 들어, 다음과 같은 프로젝트 구조가 있다고 가정해보겠습니다:
프로젝트/ ├── README.md ├── src/ │ ├── main.py │ └── utils/ │ ├── helper.py │ └── config.py └── docs/ └── guide.md
Plain Text
복사
git write-tree 명령어는 현재 인덱스(Staging Area)의 상태를 기반으로 Tree 객체를 생성합니다. 인덱스는 본질적으로 다음 커밋될 내용의 준비 영역이며, 이미 Tree 객체의 임시 버전이라고 볼 수 있습니다. git write-tree는 이 인덱스 정보를 읽어서 실제 Tree 객체를 생성합니다.

재귀적 Tree 생성의 동작 원리

Tree 객체 생성 과정에서 가장 흥미로운 부분은 재귀적 처리입니다. Git은 가장 깊은 디렉토리부터 시작하여 상위 디렉토리로 올라가며 각각을 독립적인 Tree 객체로 생성합니다. 위 예제에서는 다음 순서로 진행됩니다:
1단계: 가장 깊은 디렉토리 (utils/)
# utils/ 디렉토리의 Tree 객체 생성 (SHA-1: abc123...) 100644 blob def456... helper.py 100644 blob ghi789... config.py
Shell
복사
2단계: 중간 레벨 디렉토리들
# src/ 디렉토리의 Tree 객체 생성 (SHA-1: jkl012...) 100644 blob mno345... main.py 040000 tree abc123... utils/ # docs/ 디렉토리의 Tree 객체 생성 (SHA-1: pqr678...) 100644 blob stu901... guide.md
Shell
복사
3단계: 루트 디렉토리
# 최상위 Tree 객체 생성 (SHA-1: vwx234...) 100644 blob yza567... README.md 040000 tree jkl012... src/ 040000 tree pqr678... docs/
Shell
복사
이 구조에서 중요한 점은 각 디렉토리가 독립적인 Tree 객체가 되고, 상위 Tree는 하위 Tree 객체들을 SHA-1 해시로 참조한다는 것입니다. 이는 Git이 변경사항을 효율적으로 추적할 수 있게 해주는 핵심 메커니즘입니다.
Git 저장소 내의 모든 개체
예를 들어, src/utils/helper.py 파일만 수정된다면, Git은 다음 객체들만 새로 생성합니다:
helper.py의 새로운 Blob 객체
utils/ 디렉토리의 새로운 Tree 객체
src/ 디렉토리의 새로운 Tree 객체
루트 디렉토리의 새로운 Tree 객체
반면 docs/ 디렉토리의 Tree 객체는 변경되지 않았으므로 기존 객체를 재사용합니다. 이러한 구조적 공유(structural sharing)는 Git의 효율성을 크게 높여줍니다.

Tree 객체의 구조적 유연성

특히 git read-tree --prefix 옵션을 접하면서 Tree 객체의 구조적 유연성에 대해 더 깊이 탐구하게 되었습니다. 이 명령어는 기존 Tree 객체를 가상의 하위 디렉토리로 배치할 수 있게 해줍니다.
git read-tree --prefix=backup d8329fc1cc938780ffdd9f94e0d364e0ea74f579
Shell
복사
이 명령어를 통해 실제 파일 시스템과 무관하게 논리적인 디렉토리 구조를 생성할 수 있습니다. 처음에는 이런 "가상" 디렉토리가 어떤 의미인지 혼란스러웠지만, 핵심은 Git에게 "가상"과 "실제"의 구분이 없다는 점입니다.
중요한 점은 이렇게 생성된 구조를 체크아웃하면 실제 파일 시스템에 물리적으로 생성된다는 것입니다. Git은 Tree 객체가 기술하는 최종적인 디렉토리 구조만을 보며, 그 구조가 어떤 방식으로 만들어졌는지는 전혀 고려하지 않습니다. 이 과정을 통해 Git의 "구조와 내용의 분리" 철학을 더 명확히 이해할 수 있었습니다.
예를 들어, 다음과 같은 과정을 거치면:
# 현재 상태를 Tree로 저장 ORIGINAL_TREE=$(git write-tree) # 새로운 파일 추가 echo 'New feature' > feature.txt git add feature.txt # 원래 Tree를 backup 디렉토리로 배치 git read-tree --prefix=backup $ORIGINAL_TREE # 최종 Tree 생성 FINAL_TREE=$(git write-tree)
Shell
복사
결과적으로 다음과 같은 구조가 만들어집니다:
워킹 디렉토리/ ├── backup/ # --prefix로 생성된 실제 디렉토리 │ ├── file1.txt │ └── file2.txt └── feature.txt
Plain Text
복사
이런 유연성은 프로젝트 리팩토링이나 멀티 프로젝트 병합 등 다양한 시나리오에서 활용할 수 있습니다.

Commit 객체

Commit 객체는 Git의 객체 모델에서 시간의 흐름과 변화의 맥락을 기록하는 핵심 요소입니다. 특정 시점의 프로젝트 스냅샷을 나타내는 Tree 객체를 가리키며, 작성자 정보, 커밋 시간, 커밋 메시지, 그리고 부모 커밋에 대한 참조를 포함합니다.
git cat-file -p fdf4fc3 # tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 # author Scott Chacon <schacon@gmail.com> 1243040974 -0700 # committer Scott Chacon <schacon@gmail.com> 1243040974 -0700 # # first commit
Shell
복사

커밋 체인의 형성과 히스토리 구조

Commit 객체의 진정한 힘은 부모 커밋 참조를 통해 형성되는 연결 관계에서 나옵니다. 각 커밋은 자신의 직전 상태를 나타내는 부모 커밋을 참조하며, 이를 통해 프로젝트의 전체 히스토리가 연결된 그래프 구조를 형성합니다.
첫 번째 커밋을 제외한 모든 커밋은 적어도 하나의 부모 커밋을 가집니다. 일반적인 선형 개발에서는 하나의 부모만 가지지만, 브랜치를 병합할 때는 두 개 이상의 부모를 가지는 병합 커밋이 생성됩니다:
# 일반 커밋 (부모 1개) git cat-file -p cac0cab # tree 0155eb4229851634a0f03eb265b69f5a2d56f341 # parent fdf4fc3344e67ab068f836878b6c4951e3b15f3d # author Scott Chacon <schacon@gmail.com> 1243040974 -0700 # committer Scott Chacon <schacon@gmail.com> 1243040974 -0700 # # second commit # 병합 커밋 (부모 2개) # parent sha1-of-main-branch # parent sha1-of-feature-branch
Shell
복사
이러한 구조를 통해 Git은 복잡한 개발 히스토리를 정확히 재현할 수 있습니다. 각 커밋은 그 시점에서의 완전한 프로젝트 상태(Tree 객체)를 보존하면서, 동시에 그 변화가 어떤 맥락에서 발생했는지(부모 커밋 참조)를 기록합니다.

커밋 간의 관계와 히스토리 탐색

커밋 객체들이 형성하는 방향성 있는 그래프 구조는 Git의 강력한 히스토리 탐색 기능의 기반이 됩니다. 이제까지 자주 사용했던 git log 명령어는 현재 커밋부터 시작하여 부모 커밋 참조를 따라가며 히스토리를 순회합니다:
git log --oneline # 1a410ef third commit # cac0cab second commit # fdf4fc3 first commit
Shell
복사
각 커밋은 완전한 프로젝트 스냅샷을 담고 있지만, 개발자가 인식하는 "변경사항"은 연속된 두 커밋의 Tree 객체를 비교함으로써 계산됩니다. 이는 Git이 스냅샷 기반 시스템이면서도 효율적으로 diff를 제공할 수 있는 이유입니다.

참조 시스템

SHA-1 해시값을 직접 사용하는 것은 비실용적이므로, Git은 참조(References) 시스템을 제공합니다. 참조는 .git/refs 디렉토리에 저장되는 파일들로, 각 파일은 특정 커밋의 SHA-1 값을 포함합니다.
echo 1a410efbd13591db07496601ebc7a059dd55cfe9 > .git/refs/heads/master
Shell
복사
브랜치는 본질적으로 특정 커밋을 가리키는 이동 가능한 포인터입니다. 새로운 커밋이 생성될 때마다 현재 브랜치의 참조가 새 커밋을 가리키도록 업데이트됩니다.
HEAD 파일은 특별한 종류의 참조로, 현재 체크아웃된 브랜치를 가리키는 간접 참조(symbolic reference)입니다.
cat .git/HEAD # ref: refs/heads/master
Shell
복사
태그는 두 종류로 구분됩니다. Lightweight 태그는 단순히 특정 커밋을 가리키는 참조이며, Annotated 태그는 별도의 태그 객체를 생성하여 추가 메타데이터를 저장합니다.

저장 최적화

Git은 초기에 각 객체를 개별 파일로 저장하는 "Loose 객체" 형식을 사용합니다. 이 방식은 단순하고 직관적이지만, 객체 수가 증가하면 저장 공간과 성능 문제가 발생할 수 있습니다. 특히 대용량 파일을 여러 번 수정하는 경우, 비슷한 내용의 객체들이 각각 별도 파일로 저장되어 디스크 공간을 많이 차지하게 됩니다. 이는 책 초반에 의문을 가지고 조사했던 내용인데, 이제야 그 정확한 원리를 알게 되었습니다.

Loose 객체에서 Packfile로의 전환

이를 해결하기 위해 Git은 Packfile 형식을 도입했습니다. Packfile은 여러 객체를 하나의 파일로 묶고, 유사한 객체들 사이에 Delta 압축을 적용하여 저장 공간을 극적으로 줄입니다.
Git이 자동으로 압축을 수행하는 시점은 다음과 같습니다:
Loose 객체가 약 7,000개를 넘어설 때
git gc 명령을 명시적으로 실행할 때
리모트 저장소로 Push할 때
git repack 명령을 실행할 때
git gc # Counting objects: 18, done. # Delta compression using up to 8 threads. # Compressing objects: 100% (14/14), done.
Shell
복사

Delta 압축의 동작 원리

Delta 압축은 Git 저장 최적화의 핵심 메커니즘입니다. Git은 이름이나 크기가 비슷한 파일들을 찾아 서로 비교한 후, 한 파일은 완전히 저장하고 다른 파일은 차이점만 저장합니다.
Git은 직관과 다르게 최신 버전을 완전히 저장하고 이전 버전을 Delta로 저장합니다. 이는 최신 버전에 더 자주 접근한다는 실용적 고려에서 나온 설계입니다. 예를 들어, 22KB 크기의 파일을 수정했을 때:
git verify-pack -v .git/objects/pack/pack-xxx.idx # b042a60... blob 22054 5799 1463 # 최신 버전 (완전 저장) # 033b446... blob 9 20 7262 1 b042a60... # 이전 버전 (Delta로 9바이트만)
Shell
복사
이 예시에서 보듯이 22KB 파일의 이전 버전이 단 9바이트의 Delta로 압축되었습니다.

Packfile과 인덱스 파일

Packfile과 함께 생성되는 인덱스 파일(.idx)도 중요한 역할을 합니다. 인덱스 파일에는 각 객체의 Packfile 내 오프셋 정보가 저장되어 있어, Git이 특정 객체를 빠르게 찾을 수 있게 해줍니다. Packfile에서 객체를 순차 검색하지 않고도 직접 접근할 수 있는 것은 이 인덱스 파일 덕분입니다.
압축 결과는 상당히 인상적입니다. 앞서 예제에서 약 15KB였던 객체들이 압축 후 7KB 정도로 줄어들었는데, 이는 거의 절반 수준입니다. 실제 프로젝트에서는 압축률이 훨씬 높아질 수 있으며, 특히 텍스트 파일이 많은 소스 코드 저장소에서는 90% 이상의 압축률을 보이는 경우도 흔합니다.

결론

Git의 내부 구조를 이해하면 복잡한 병합 상황이나 히스토리 수정 작업에서 무엇이 일어나고 있는지 정확히 파악할 수 있습니다. 각 커밋이 Tree 객체를 가리키고, Tree 객체가 재귀적으로 구성된다는 것을 알면 병합 충돌의 본질을 이해할 수 있습니다.
하지만 무엇보다 놀라운 것은 Blob, Tree, Commit이라는 단순한 세 개의 객체 타입으로 이렇게 견고하고 유연한 분산 버전 관리 시스템을 구축했다는 점입니다. Content-addressable 파일시스템이라는 간단한 개념 위에 구축된 이 아키텍처는 수많은 개발자들이 복잡한 프로젝트를 협업할 수 있게 해주는 강력한 기반이 됩니다. 단순함 속에서 나오는 이런 강력함이야말로 Git이 현대 소프트웨어 개발의 필수 도구가 된 이유가 아닐까 합니다.

참고