왜 "스냅샷"인가
Git을 처음 배울 때 가장 큰 개념적 장벽은 "Git이 파일 차이(diff)를 저장한다"는 오해다. 실제로 Git은 커밋할 때마다 프로젝트 파일 트리 전체를 하나의 스냅샷으로 기록한다.
전통적인 델타 기반 VCS(CVS, Subversion 등)는 최초 버전을 저장한 뒤 이후 변경분만 누적해 나간다. 특정 리비전을 얻으려면 모든 델타를 순서대로 재생해야 하므로 히스토리가 길수록 느려진다. Git은 이 문제를 근본적으로 다른 방식으로 해결했다.
커밋 시 Git은 각 파일의 현재 내용 전체를 blob 오브젝트로 저장하고, 디렉터리 구조를 tree 오브젝트로 저장한다. 파일 내용이 이전 커밋과 동일하다면 새 blob을 만들지 않고 기존 blob을 가리키는 포인터만 재사용한다. 덕분에 스냅샷 모델임에도 실제 디스크 사용량은 델타 방식과 비슷하게 유지된다.
세 가지 공간 — 작업 디렉터리, 스테이징, 저장소
Git에서 파일은 항상 세 공간 중 하나(또는 여러 곳)에 존재한다. 이 세 공간의 관계를 이해하는 것이 Git 조작 전반의 핵심이다.
.git/index 파일에 저장된다..git/objects/ 디렉터리 안에 불변 오브젝트로 기록된다.스테이징 영역이 존재하는 이유는 커밋의 원자성을 사용자가 직접 제어할 수 있도록 하기 위해서다. 작업 디렉터리에서 여러 파일을 동시에 수정했더라도, 논리적으로 연관된 변경 사항만 골라 스테이징한 뒤 커밋하면 히스토리가 훨씬 읽기 좋아진다.
예를 들어 버그 수정과 리팩터링을 동시에 작업했다면, git add로 버그 수정 파일만 스테이징해 첫 번째 커밋을 만들고, 이어서 리팩터링 파일을 스테이징해 두 번째 커밋을 만들 수 있다.
작업 디렉터리 ──git add──▶ 스테이징 영역 ──git commit──▶ 저장소(.git)
◀──git restore── ◀──git reset HEAD──
◀──────────────git checkout <commit>──────────────커밋 오브젝트의 구조
커밋은 단순한 저장 단위가 아니다. Git의 커밋 오브젝트는 네 가지 핵심 정보를 담은 구조화된 텍스트 레코드다.
커밋 오브젝트가 저장하는 정보는 다음과 같다.
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 parent a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2 author 홍길동 <gildong@example.com> 1716000000 +0900 committer 홍길동 <gildong@example.com> 1716000000 +0900 feat: 로그인 폼 유효성 검사 추가 비밀번호 최소 길이(8자)와 이메일 형식 검사를 추가했다.
Git은 이 텍스트 앞에 commit <크기>\0을 붙인 뒤 SHA-1(또는 SHA-256) 해시를 계산해 오브젝트의 이름으로 쓴다. 내용이 단 한 글자라도 바뀌면 해시가 완전히 달라지므로, SHA는 내용의 지문이자 무결성 보증이다.
SHA 해시와 불변성
Git의 모든 오브젝트(blob, tree, commit, tag)는 그 내용을 해시한 40자리 16진수 문자열로 식별된다. 이 설계가 Git에 두 가지 강력한 속성을 부여한다.
무결성: 커밋 메시지를 단 한 글자 바꾸어도 SHA가 완전히 달라진다. 따라서 저장소 히스토리가 조용히 변조되는 상황을 원천적으로 감지할 수 있다. 네트워크 전송 중 손상된 데이터도 해시 불일치로 즉시 탐지된다.
불변성: 오브젝트는 생성 이후 절대 수정되지 않는다. git commit --amend는 기존 커밋을 고치는 것이 아니라 새 커밋 오브젝트를 만들고 브랜치 포인터를 새 커밋으로 이동시키는 동작이다. 기존 커밋은 여전히 저장소 어딘가에 남아 있다(나중에 GC가 수거하기 전까지).
SHA-1은 현재 보안 취약점이 알려져 있어 Git은 SHA-256 전환을 진행 중이다(git init --object-format=sha256). 그러나 원리—내용 해시로 오브젝트를 식별—는 동일하다.
오브젝트 저장소의 실제 구조
.git/objects/ 폴더를 직접 들여다보면 Git의 내부 저장 방식을 손으로 만져볼 수 있다.
오브젝트는 SHA의 처음 두 글자를 디렉터리명으로, 나머지 38글자를 파일명으로 써서 저장된다. 예를 들어 SHA가 a1b2c3d4...인 오브젝트는 .git/objects/a1/b2c3d4...에 zlib으로 압축되어 저장된다.
오브젝트가 누적되면 Git은 여러 오브젝트를 하나의 pack 파일으로 묶어 최적화한다. pack 파일 내부에서는 유사한 오브젝트끼리 델타 압축을 적용해 디스크를 절약한다. 이때 비로소 "원래 내용"과 "델타"가 함께 쓰이지만, 그것은 네트워크 전송·디스크 효율화를 위한 구현 세부사항이며 Git의 논리적 데이터 모델(스냅샷)과는 별개다.
# 오브젝트 타입 확인 git cat-file -t a1b2c3d4 # 오브젝트 내용 확인 git cat-file -p a1b2c3d4 # tree가 담고 있는 파일 목록 git ls-tree HEAD # 현재 스테이징 영역(index) 확인 git ls-files --stage
커밋 만들기 — 실제 흐름
이론을 정리했으니 이제 실제 커밋이 어떻게 만들어지는지 단계별로 추적해 보자.
커밋이 생성될 때마다 main 브랜치 포인터(실제로는 .git/refs/heads/main 파일에 적힌 SHA)가 새 커밋으로 갱신된다. HEAD는 현재 "main을 추적하겠다"고 선언한 심볼릭 레퍼런스이므로 main이 앞으로 나아갈 때 HEAD도 따라간다.
각 커밋의 parent 포인터가 이전 커밋을 가리키므로, 커밋 체인은 방향성 비순환 그래프(DAG)를 형성한다. git log는 HEAD에서 시작해 parent를 따라 거슬러 올라가며 히스토리를 출력한다.