- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 매일 쓰지만 아무도 안을 안 본다
- 핵심 통찰 — Git은 콘텐츠 주소 저장소다
- 네 가지 객체 — blob, tree, commit, tag
- SHA 해시 — 왜 그 긴 문자열인가
- 커밋 DAG — 역사는 그래프다
- 브랜치는 그냥 포인터다 — Git의 가장 해방적인 사실
- .git 디렉터리 안을 들여다보자
- 같은 내용은 한 번만 저장된다 — 중복 제거의 우아함
- 팩파일 — 저장을 한 번 더 압축하다
- 전체 그림 — 하나로 이어 보기
- 이 모델을 알면 무엇이 달라지나
- 마치며
- 참고 자료
들어가며 — 매일 쓰지만 아무도 안을 안 본다
Git은 개발자가 매일 쓰는 도구지만, 그 내부가 실제로 어떻게 생겼는지 아는 사람은 의외로 적습니다. 대부분은 git add, git commit, git push를 주문처럼 외워 쓰고, 뭔가 꼬이면 스택오버플로에서 복사한 명령으로 겨우 빠져나옵니다.
그런데 Git 내부는 놀랍도록 단순하고 우아합니다. 사실 Git의 핵심 데이터 모델은 몇 시간이면 완전히 이해할 수 있을 만큼 작습니다. 그리고 한번 그 모델을 이해하고 나면, 그동안 마법처럼 보였던 Git의 동작들이 전부 논리적으로 설명됩니다. 브랜치를 만드는 게 왜 그렇게 빠른지, git checkout이 실제로 무엇을 하는지, 왜 커밋 해시가 그런 모양인지가 전부 하나의 그림 안에서 이해됩니다.
이 글은 Git을 "명령어 모음"이 아니라 "데이터 저장 시스템"으로 봅니다. Git이 여러분의 파일을 실제로 어떤 구조로 저장하는지, 그 구조가 왜 그렇게 설계되었는지를 바닥부터 파헤칩니다. 다 읽고 나면 Git이 훨씬 덜 무섭게 느껴질 것입니다.
핵심 통찰 — Git은 콘텐츠 주소 저장소다
Git을 이해하는 단 하나의 열쇠가 있다면 이것입니다. Git은 근본적으로 콘텐츠 주소 저장소(content-addressable store)다.
콘텐츠 주소 저장이란, 데이터를 "이름"이나 "위치"가 아니라 "내용 그 자체"로 주소를 매기는 방식입니다. 구체적으로 Git은 어떤 내용을 저장할 때, 그 내용에 대해 SHA 해시를 계산하고, 그 해시값을 그 내용의 주소(=이름)로 씁니다.
이 아이디어의 결과는 강력합니다.
- 같은 내용은 항상 같은 주소를 갖는다. 두 파일의 내용이 완전히 같으면, 해시도 같고, 따라서 Git 저장소 안에서 딱 한 번만 저장됩니다.
- 내용이 조금이라도 바뀌면 주소가 완전히 달라진다. 해시 함수의 성질상, 바이트 하나만 달라도 전혀 다른 해시가 나옵니다.
- 무결성이 공짜로 따라온다. 주소가 곧 내용의 해시이므로, 데이터가 손상되면 해시가 맞지 않아 즉시 감지됩니다.
이 콘텐츠 주소 방식 위에, Git은 네 종류의 객체(object)를 쌓아 올립니다. 이 네 객체가 Git 데이터 모델의 전부입니다.
네 가지 객체 — blob, tree, commit, tag
Git이 저장하는 모든 것은 결국 네 종류의 객체 중 하나입니다. 각각을 살펴봅시다.
blob — 파일의 내용
blob은 파일 하나의 내용을 담습니다. 여기서 중요한 것은 blob이 오직 내용만 담는다는 점입니다. 파일 이름도, 경로도, 권한도 blob 안에 없습니다. 그저 바이트 덩어리입니다. 그래서 이름이 다르지만 내용이 같은 두 파일은 동일한 blob 하나를 가리킵니다.
tree — 디렉터리 구조
tree는 디렉터리에 해당합니다. tree는 이름과 객체 참조의 목록을 담습니다. 각 항목은 "이 이름의 항목은 저 blob(파일)이다" 혹은 "이 이름의 항목은 저 tree(하위 디렉터리)이다"를 가리킵니다. 즉 tree가 blob들에게 이름과 구조를 부여합니다. blob이 파일 내용이라면, tree는 그 내용을 파일 시스템처럼 조직합니다.
commit — 스냅샷과 역사
commit은 우리가 흔히 "커밋"이라 부르는 것입니다. 하나의 commit 객체는 다음을 담습니다.
- 최상위 tree 하나에 대한 참조 — 이 커밋 시점의 프로젝트 전체 스냅샷
- 하나 이상의 부모 커밋에 대한 참조 (최초 커밋은 부모가 없고, 병합 커밋은 부모가 둘 이상)
- 작성자와 커미터 정보, 타임스탬프
- 커밋 메시지
여기서 핵심은 commit이 스냅샷을 가리킨다는 것입니다. 흔한 오해가 "Git은 변경분(diff)을 저장한다"는 것인데, 사실 각 커밋은 그 시점의 전체 트리를 통째로 가리킵니다. diff는 필요할 때 두 스냅샷을 비교해 계산하는 것이지, 저장의 기본 단위가 아닙니다. (뒤에서 볼 팩파일이 저장 효율을 위해 델타를 쓰지만, 그것은 논리 모델이 아니라 저장 최적화입니다.)
tag — 이름표
tag 객체는 특정 객체(대개 커밋)에 영구적인 이름을 붙이는 데 씁니다. v1.0.0 같은 릴리스 태그가 대표적입니다. 태그에는 태그를 단 사람, 날짜, 메시지, 그리고 서명이 들어갈 수 있습니다.
이 네 객체의 관계를 그림으로 보면 이렇습니다.
commit ──parent──▶ commit ──parent──▶ commit
│ │ │
▼ (tree) ▼ ▼
tree ──────────────▶ blob (README.md 내용)
│
├──────────────▶ blob (main.py 내용)
│
└──────────────▶ tree ──────▶ blob (src/util.py 내용)
(하위 디렉터리)
commit은 tree를 가리키고, tree는 blob과 다른 tree를 가리키고, commit은 부모 commit을 가리킵니다. 이 단순한 참조 구조가 Git의 전부입니다.
SHA 해시 — 왜 그 긴 문자열인가
Git을 쓰다 보면 a1b2c3d4... 같은 40자리(혹은 요약된 7자리) 16진수 문자열을 계속 보게 됩니다. 이것이 바로 객체의 주소, 즉 SHA 해시입니다.
Git은 각 객체를 저장할 때, 객체의 타입과 내용을 합쳐 SHA 해시를 계산합니다. 그 해시가 객체의 유일한 이름이 됩니다. 커밋 해시가 그렇게 생긴 이유가 여기 있습니다. 그것은 임의로 부여한 번호가 아니라, 그 커밋의 내용(가리키는 tree, 부모, 메시지, 작성자 등) 전체로부터 계산된 지문입니다.
여기서 아름다운 성질이 나옵니다. 커밋 해시는 그 커밋이 가리키는 tree를 포함하고, tree 해시는 그것이 가리키는 blob들을 포함하며, 커밋은 부모 커밋의 해시를 포함합니다. 즉 각 객체의 해시는 그것이 도달할 수 있는 모든 것에 의존합니다.
그 결과, 역사 어딘가의 오래된 커밋에서 파일 한 바이트만 바뀌어도, 그 커밋의 해시가 바뀌고, 그것을 부모로 삼는 모든 후손 커밋의 해시가 연쇄적으로 바뀝니다. 이것이 Git이 무결성을 보장하는 방식입니다. 역사를 몰래 조작하는 것이 불가능합니다. 해시가 전부 어긋나기 때문입니다. 이런 구조를 머클 트리(Merkle tree) 또는 해시 DAG라고 부릅니다.
(역사적으로 Git은 SHA-1을 써 왔고, 충돌 우려 때문에 SHA-256으로의 전환이 진행되어 왔습니다. 하지만 데이터 모델의 원리는 어떤 해시 함수를 쓰든 동일합니다.)
커밋 DAG — 역사는 그래프다
많은 사람이 Git 역사를 "일렬로 늘어선 커밋들"로 상상합니다. 하지만 정확히는 **DAG(방향성 비순환 그래프, Directed Acyclic Graph)**입니다.
- 방향성(Directed): 각 커밋은 부모를 가리킵니다. 화살표는 과거를 향합니다.
- 비순환(Acyclic): 커밋은 자기 조상을 부모로 가질 수 없습니다. 순환이 없습니다.
브랜치가 갈라지고 합쳐지면 이 그래프는 일렬이 아니라 진짜 그래프가 됩니다.
o---o---o (feature 브랜치)
/ \
o---o---o---o-------------o---o (main 브랜치)
│ │ │
과거 ────────────────────▶ 현재
병합 커밋(위 그림에서 두 선이 만나는 지점)은 부모가 둘입니다. 하나는 main의 이전 커밋, 다른 하나는 feature의 마지막 커밋입니다. 이렇게 커밋이 여러 부모를 가질 수 있기 때문에 역사가 그래프가 됩니다.
이 DAG 관점은 Git의 많은 동작을 명쾌하게 설명합니다. git log는 이 그래프를 현재 지점에서 부모 방향으로 거슬러 올라가며 순회하는 것이고, git merge는 두 지점의 공통 조상을 찾아 그로부터의 변경을 합치는 것이며, rebase는 커밋들을 떼어 다른 지점 위에 다시 붙이는 것입니다. 전부 그래프 위의 조작입니다.
브랜치는 그냥 포인터다 — Git의 가장 해방적인 사실
Git을 배울 때 가장 큰 깨달음의 순간은 대개 이것입니다. 브랜치는 무겁고 복잡한 무엇이 아니라, 그냥 커밋 하나를 가리키는 포인터일 뿐이다.
정확히 말하면, 브랜치는 커밋 해시 하나를 담고 있는 작은 텍스트 파일입니다. main 브랜치는 "main이 가리키는 최신 커밋의 해시는 이거야"라고 적힌 41바이트짜리 파일에 불과합니다. 새 브랜치를 만든다는 것은, 그저 같은 커밋을 가리키는 새 포인터 파일을 하나 더 만드는 것입니다.
이 사실이 여러 가지를 설명합니다.
- 브랜치 생성이 왜 즉각적인가. 파일 하나를 쓰는 것뿐이니, 저장소가 아무리 커도 브랜치 생성은 순식간입니다. 무거운 복사가 일어나지 않습니다.
- 커밋하면 무슨 일이 일어나는가. 현재 브랜치에서 커밋하면, 새 커밋 객체가 만들어지고, 브랜치 포인터가 그 새 커밋을 가리키도록 갱신됩니다. 포인터가 한 칸 앞으로 이동하는 것입니다.
- HEAD란 무엇인가.
HEAD는 "지금 내가 어느 브랜치에 있는가"를 가리키는 또 하나의 포인터입니다. 보통 브랜치를 가리키고, 그 브랜치가 다시 커밋을 가리킵니다.
이 관계를 그림으로 보면 이렇습니다.
HEAD ──▶ main ──▶ commit(f9a2...) ──▶ commit(3c1d...) ──▶ ...
(최신) (그 이전)
git commit 하면:
HEAD ──▶ main ──▶ commit(새것!) ──▶ commit(f9a2...) ──▶ ...
포인터가 앞으로 이동
"브랜치는 포인터"라는 사실을 내면화하면 Git이 갑자기 두렵지 않아집니다. 브랜치를 지워도 커밋 객체 자체는 (다른 곳에서 참조되는 한) 사라지지 않고, 실수로 옮긴 브랜치도 포인터를 원래 커밋으로 되돌리면 그만입니다. 태그도 브랜치와 비슷한 포인터지만, 브랜치가 커밋할 때마다 앞으로 움직이는 반면 태그는 한 커밋에 고정되어 움직이지 않는다는 차이가 있습니다.
.git 디렉터리 안을 들여다보자
지금까지의 개념은 전부 .git 디렉터리 안에 실제로 존재합니다. 프로젝트 루트의 .git 폴더를 열어 보면, 추상적으로만 이야기하던 것들의 실물이 보입니다. 대략 이런 구조입니다.
.git/
├── HEAD # 지금 체크아웃된 브랜치를 가리킴 (예: ref: refs/heads/main)
├── config # 저장소 설정
├── objects/ # 모든 객체(blob, tree, commit, tag)가 여기 저장됨
│ ├── 3c/
│ │ └── 1d8f... # 해시 앞 2자리가 폴더, 나머지가 파일명
│ ├── f9/
│ │ └── a2b7...
│ └── pack/ # 팩파일 (뒤에서 설명)
├── refs/
│ ├── heads/ # 로컬 브랜치들 — 각 파일이 커밋 해시 하나를 담음
│ │ ├── main
│ │ └── feature
│ └── tags/ # 태그들
└── logs/ # reflog — 참조가 어떻게 움직였는지 기록
핵심을 짚어봅시다.
objects/디렉터리가 실제 데이터 저장소입니다. 모든 blob, tree, commit, tag가 각자의 해시를 파일명으로 삼아 여기 저장됩니다. 해시 앞 2자리를 폴더 이름으로 떼어내는 것은, 한 폴더에 파일이 너무 많아지지 않게 하는 실용적 장치입니다.refs/heads/안의 각 파일이 바로 브랜치입니다.main파일을 열면 커밋 해시 한 줄이 들어 있습니다. 앞서 말한 "브랜치는 포인터"가 여기서 문자 그대로 확인됩니다.HEAD파일은 보통ref: refs/heads/main같은 내용을 담아, 지금 어느 브랜치에 있는지를 가리킵니다.logs/의 reflog는 브랜치와 HEAD가 시간에 따라 어떻게 이동했는지를 기록합니다. 실수로 커밋을 잃어버렸을 때 reflog로 되찾을 수 있는 이유가 이것입니다.
Git 내부를 실제로 손으로 만져보며 익히고 싶다면 Git 실습장에서 커밋을 쌓고 브랜치를 만들며 이 구조가 어떻게 변하는지 눈으로 확인할 수 있습니다. 개념을 글로만 읽는 것과, 그래프가 실제로 자라나는 것을 보는 것은 이해의 깊이가 다릅니다.
같은 내용은 한 번만 저장된다 — 중복 제거의 우아함
콘텐츠 주소 방식의 가장 실용적인 결과 중 하나는 **자동 중복 제거(deduplication)**입니다.
앞서 말했듯, blob의 이름은 그 내용의 해시입니다. 그러므로 저장소 안에 내용이 완전히 같은 파일이 여럿 있어도, 그것들은 전부 동일한 blob 하나를 가리킵니다. 물리적으로는 한 번만 저장됩니다.
이 원리는 커밋 사이에서도 강력하게 작동합니다. 커밋을 100번 했는데 그중 어떤 파일이 한 번도 바뀌지 않았다고 합시다. 그러면 그 파일의 blob은 100개의 커밋에 걸쳐 딱 하나만 존재합니다. 각 커밋의 tree들이 모두 같은 blob 해시를 가리킬 뿐입니다. 마찬가지로, 한 커밋에서 파일 하나만 바꿨다면, 바뀐 파일의 새 blob과 그 파일을 담은 tree들만 새로 생기고, 나머지 바뀌지 않은 blob과 tree는 이전 커밋의 것을 그대로 재사용합니다.
이것이 Git이 방대한 역사를 담고도 저장소가 생각만큼 크게 부풀지 않는 이유입니다. 각 커밋이 전체 스냅샷을 "가리키지만", 바뀌지 않은 부분은 물리적으로 공유하기 때문입니다. 스냅샷 모델의 개념적 단순함과, 중복 제거를 통한 저장 효율을 동시에 얻는 것입니다.
팩파일 — 저장을 한 번 더 압축하다
지금까지는 각 객체가 objects/ 아래에 개별 파일로 저장된다고 설명했습니다(이를 느슨한 객체, loose object라 부릅니다). 이것만으로도 중복 제거 덕분에 꽤 효율적이지만, Git은 여기서 한 걸음 더 나아갑니다. **팩파일(packfile)**입니다.
저장소가 커지면 개별 객체 파일이 수십만 개로 늘어날 수 있습니다. 파일이 너무 많으면 파일 시스템 부담도 크고, 각 객체를 따로 압축하는 것보다 함께 압축하는 것이 효율적입니다. 그래서 Git은 주기적으로(또는 git gc 실행 시) 많은 객체를 하나의 팩파일로 모읍니다.
팩파일의 두 가지 핵심 최적화는 이렇습니다.
- 함께 압축. 여러 객체를 하나의 파일에 모아 통째로 압축하면, 개별 압축보다 압축률이 좋습니다.
- 델타 인코딩. 여기서 흥미로운 반전이 있습니다. 앞서 "Git은 스냅샷을 저장한다"고 했는데, 팩파일 안에서는 비슷한 객체들끼리 차이(델타)만 저장할 수 있습니다. 예를 들어 어떤 큰 파일의 여러 버전이 있으면, 하나를 통째로 저장하고 나머지는 그것과의 차이로만 표현합니다.
여기서 중요한 것은 계층의 구분입니다. 논리 모델에서 Git은 여전히 스냅샷 기반입니다. 각 커밋은 개념적으로 완전한 트리를 가리킵니다. 하지만 물리 저장에서는 팩파일이 델타 인코딩으로 공간을 아낍니다. 이 둘은 모순이 아닙니다. 사용자와 명령어가 보는 것은 스냅샷 모델이고, 델타는 그 아래 저장 계층의 최적화일 뿐입니다. Git은 델타로 저장된 객체를 읽을 때 자동으로 재구성해서 완전한 객체로 돌려줍니다.
이 설계는 앞서 본 SQLite나 ripgrep의 교훈과도 통합니다. 깨끗하고 단순한 논리 모델을 사용자에게 보여주되, 그 아래에서는 실용적인 최적화를 숨겨서 한다는 것입니다.
전체 그림 — 하나로 이어 보기
지금까지의 조각을 하나의 그림으로 합치면 Git의 데이터 모델 전체가 보입니다.
refs/heads/main ─┐
│ (포인터)
HEAD ─────────────┘
│
▼
commit ──parent──▶ commit ──▶ ...
│
▼ (스냅샷: tree 가리킴)
tree ──────▶ blob (파일 내용, 해시로 주소화)
│
└────────▶ tree ──▶ blob
(하위 디렉터리)
* 모든 객체는 내용의 SHA 해시로 주소화됨 (콘텐츠 주소)
* 같은 내용 = 같은 해시 = 한 번만 저장 (중복 제거)
* objects/에 느슨한 객체로, 나중에 팩파일로 압축
이 그림 하나에 이 글의 모든 개념이 들어 있습니다. 포인터(브랜치·HEAD)가 커밋을 가리키고, 커밋이 스냅샷(tree)을 가리키고, tree가 파일 내용(blob)을 가리키며, 모든 것이 내용의 해시로 주소화되어 중복 없이 저장됩니다.
이 모델을 알면 무엇이 달라지나
Git의 데이터 모델을 이해하면 실무에서 여러 가지가 바뀝니다.
- 명령어가 논리적으로 보인다.
checkout은 작업 디렉터리를 특정 tree의 내용으로 바꾸고 HEAD를 옮기는 것,merge는 공통 조상을 찾아 병합 커밋을 만드는 것,reset은 브랜치 포인터를 옮기는 것. 전부 객체와 포인터의 조작으로 설명됩니다. - 실수가 덜 무섭다. 커밋 객체는 참조되는 한 사라지지 않으므로, 브랜치를 잘못 옮겼어도 reflog로 원래 커밋 해시를 찾아 포인터를 되돌리면 됩니다. "잃어버린 것처럼 보이는" 커밋도 대개 그대로 있습니다.
- 성능이 이해된다. 브랜치가 왜 가볍고, 큰 저장소에서 왜 특정 작업이 빠른지가 데이터 구조로 설명됩니다.
- 협업이 명확해진다.
push와fetch는 결국 객체들과 참조를 저장소 사이에 주고받는 것입니다. 무엇이 오가는지 그림이 그려지면 충돌과 동기화 문제도 덜 혼란스럽습니다.
마치며
Git이 어렵게 느껴지는 이유는 대개 그 내부 모델을 모른 채 명령어만 외우기 때문입니다. 하지만 그 모델 자체는 놀랍도록 작고 우아합니다. 네 종류의 객체(blob, tree, commit, tag), 내용으로 주소를 매기는 SHA 해시, 커밋들이 이루는 DAG, 그리고 그 위를 가리키는 가벼운 포인터인 브랜치. 이것이 전부입니다.
이 그림을 한번 머릿속에 넣고 나면, 그동안 마법처럼 보였거나 두렵게 느껴졌던 Git의 동작들이 전부 같은 논리 위에서 이해됩니다. 브랜치를 만드는 것도, 커밋을 되돌리는 것도, 역사를 다시 쓰는 것도, 결국 객체를 만들고 포인터를 옮기는 일입니다.
다음에 Git이 예상 밖으로 동작해 당황하거든, 명령어 대신 데이터 모델을 떠올려 보세요. "지금 어떤 객체가 만들어졌고, 어떤 포인터가 어디를 가리키는가?" 이 질문 하나면 대부분의 혼란이 풀립니다. 그리고 Git 실습장에서 직접 커밋과 브랜치를 만들어 보며 그래프가 자라나는 것을 눈으로 확인하면, 이 모델이 완전히 여러분의 것이 됩니다.
참고 자료
- Pro Git 책 — Git 내부 (Git Internals): https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain
- Git 객체 (Git Objects): https://git-scm.com/book/en/v2/Git-Internals-Git-Objects
- Git 참조 (Git References): https://git-scm.com/book/en/v2/Git-Internals-Git-References
- 팩파일 (Packfiles): https://git-scm.com/book/en/v2/Git-Internals-Packfiles
- Git from the Bottom Up (John Wiegley): https://jwiegley.github.io/git-from-the-bottom-up/