생각을 정리하는 마인드맵, 풀맵 →
기술 딥다이브마지막 수정

[기술 딥다이브] Git은 내부적으로 어떻게 저장하나


한 줄 요약: Git은 변경 사항(diff)이 아니라 커밋마다 전체 스냅샷을 저장하고, blob·tree·commit 객체를 내용의 SHA로 키 지정해 보관하는 콘텐츠 주소 저장소다.

Git은 ’변경 사항(diff)’을 저장한다고 흔히 생각하지만, 실제로는 스냅샷 전체를 저장해요. 그리고 그것을 SHA로 키를 지정한 ’객체’로 보관해요. 내부 구조를 풀어 설명하고, 마지막에는 실제 저장소에서 객체를 직접 꺼내 확인해 보겠어요.

Git은 diff가 아니라 스냅샷을 저장한다

다른 버전 관리 시스템은 파일별 ’변경분(delta)’을 누적해요. Git은 커밋마다 해당 시점의 프로젝트 전체 스냅샷을 저장해요. 변경되지 않은 파일은 새로 저장하지 않고 이전 것을 가리키기만 하여(즉 같은 객체를 재사용하여) 효율을 확보해요.

네 가지 객체: blob, tree, commit, tag

Git의 모든 데이터는 .git/objects에 객체로 저장돼요.

  • blob: 파일의 내용(데이터). 파일명은 없고 내용만 존재해요.
  • tree: 디렉토리. 내부에 blob(파일)과 하위 tree(폴더)를 이름과 함께 가리켜요.
  • commit: 스냅샷 하나. 최상위 tree를 가리키고, 부모 커밋, 작성자, 메시지를 포함해요.
  • tag: (annotated tag) 특정 커밋을 가리키는 이름표.

이 구조를 도식으로 보면 다음과 같아요. commit이 tree를 가리키고, tree가 blob들을 가리키며, commit이 parent로 이전 커밋과 연결되어 히스토리가 만들어져요.

Git 객체 그래프 도식 commit 객체가 tree를 가리키고, tree가 blob과 하위 tree를 가리키는 구조. commit은 parent 화살표로 이전 커밋과 연결되어 히스토리를 형성. commit d01f463 tree ef48d44 parent d01f463의 이전 커밋 author, message tree ef48d44 .gitignore → blob 16d54bb AGENTS.md → blob aa3c2e6 src/ → tree 06f963b blob 16d54bb 파일 내용 그 자체 (이름 없음) 이전 commit 자기의 tree를 가리킴 tree .gitignore parent 박스 제목의 16진수가 곧 내용의 SHA 해시 — 내용이 바뀌면 해시도 바뀐다

키는 SHA: 내용이 같으면 해시도 같다

각 객체는 내용의 SHA 해시(SHA-1, 최근에는 SHA-256 옵션)를 키로 저장돼요. 내용이 같으면 해시가 같고, 한 글자라도 변경되면 해시가 완전히 달라져요. 그 이유는 다음과 같아요:

  • 같은 파일 내용은 같은 blob(자동 중복 제거)
  • 무결성 보장: 해시만 확인해도 변조 여부를 판단할 수 있음
  • 분산 환경에서 충돌 없이 동일한 객체를 공유 가능

실제로 확인해 보기: 객체 하나를 직접 꺼내기

git hash-object는 ‘내용 → 해시’ 계산만 해 보는 명령이에요. 문자열 hello와 줄바꿈을 넣어 실행하면:

$ printf "hello\n" | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

이 해시는 저장소와 무관하게 항상 같아요. 어느 컴퓨터에서, 어느 저장소에서 계산해도 ’hello\n’이라는 내용이면 이 값이 나와요. 내용이 해시를 결정한다는 것이 이 한 줄로 확인돼요. 이제 이 객체를 실제로 저장하고 다시 읽어 보겠어요:

$ printf "hello\n" | git hash-object --stdin -w   # -w: .git/objects에 실제 저장
$ git cat-file -t ce013625030ba8dba906f756967f9e9ca394464a
blob

$ git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
hello

cat-file -t는 객체의 타입을, -p는 내용을 출력해요. 저장 위치는 .git/objects/ce/013625...처럼 해시의 앞 두 글자가 디렉터리 이름이 돼요. zlib으로 압축되어 저장되므로 그냥 열어 보면 알아볼 수 없어요.

이번엔 실제 커밋을 해부해 보겠어요. 이 블로그 저장소에서 git cat-file -p HEAD를 실행한 실제 출력이에요(글 작성 시점 기준):

$ git cat-file -p HEAD
tree ef48d443c58dc099808e1f18d9feebc45981171c
parent d01f463b4c376474a5ced6c33eb2ebacc0feeeb0
author sng2c <sng2c@users.noreply.github.com> 1788472737 +0900
committer sng2c <sng2c@users.noreply.github.com> 1788472737 +0900

(커밋 메시지)

commit 객체의 실체는 이 텍스트 덩어리예요. tree를 가리키고, parent로 이전 커밋을 가리키고, 누가 언제 만들었는지가 전부 들어 있어요. 마지막으로 최상위 tree의 실제 모습이에요(git ls-tree HEAD):

100644 blob 16d54bb13c8a867268b45c22eb7794d2625a78f5  .gitignore
040000 tree 06f963b5b86d8901015e3d8e42a9745c81284bd0  src
100644 blob aa3c2e6b770bc9ac88b3d5fb37dd419d1643bca4  AGENTS.md

앞의 100644는 파일 모드, 두 번째가 객체 타입, 세 번째가 해시, 마지막이 파일명이에요. 도식에서 본 ’tree가 이름을 갖고 blob을 가리킨다’는 구조가 그대로 출력으로 나타나요. 이 명령들만 알면 .git 디렉터리는 더 이상 블랙박스가 아니에요.

콘텐츠 주소 저장소: 위치가 아니라 내용으로 찾는다

‘객체를 내용(해시)으로 찾는’ 구조예요. 사물함 번호로 찾는 대신 ’안에 든 물건의 고유 도장’으로 꺼내 보는 분류 방식이라, 위치로 찾는 일반 파일 시스템과 반대돼요. 그래서 Git에서는 ’어디에 저장했는가’가 아니라 ’내용이 무엇인가’가 중요해요. 이 구조 덕분에 브랜치, 태그, 스태시가 모두 ’해시를 가리키는 포인터’일 뿐이라는 사실도 이해가 돼요.

packfile: 느슨한 객체를 묶어 압축하다

이렇게 저장하면 작은 파일이 수천 개로 늘어나요(loose object, 느슨한 객체). 그래서 Git은 주기적으로 객체들을 packfile로 묶고 zlib 압축을 적용해요. 여기에 유사한 객체끼리 차이만 저장하는 델타 압축까지 더해 용량을 크게 줄이에요. ’스냅샷을 저장하는데 왜 용량이 안 터지지?’라는 의문의 답이 이 지점이에요. 스냅샷으로 저장하되, 저장 공간에서는 델타로 압축하는 것이에요.

어떻게 연결되는가

한 줄로 요약하면: Git은 커밋마다 전체 스냅샷을 blob, tree, commit 객체로 저장하고, 각 객체를 내용의 SHA로 키 지정해 보관하며, packfile로 압축하는 시스템이에요. ‘diff를 누적하는’ 것이 아니라 ‘스냅샷을 해시로 보존하는’ 구조가 Git의 무결성, 분산성, 효율성을 동시에 만들어내요. 내용이 같으면 해시가 같다는 원리 하나로 세 가지가 전부 설명돼요.

출처: Pro Git: Git Objects · Pro Git: Plumbing and Porcelain · 위키백과: Git

이미지: Jason Long, Wikimedia Commons (CC-BY 3.0) — 원본