Git— 스냅샷으로 이해하는 버전 관리
1장

스냅샷과 커밋

Git은 파일의 변경 내역이 아니라 프로젝트 전체의 순간 사진—스냅샷—을 저장한다.

왜 "스냅샷"인가

Git을 처음 배울 때 가장 큰 개념적 장벽은 "Git이 파일 차이(diff)를 저장한다"는 오해다. 실제로 Git은 커밋할 때마다 프로젝트 파일 트리 전체를 하나의 스냅샷으로 기록한다.

전통적인 델타 기반 VCS(CVS, Subversion 등)는 최초 버전을 저장한 뒤 이후 변경분만 누적해 나간다. 특정 리비전을 얻으려면 모든 델타를 순서대로 재생해야 하므로 히스토리가 길수록 느려진다. Git은 이 문제를 근본적으로 다른 방식으로 해결했다.

커밋 시 Git은 각 파일의 현재 내용 전체를 blob 오브젝트로 저장하고, 디렉터리 구조를 tree 오브젝트로 저장한다. 파일 내용이 이전 커밋과 동일하다면 새 blob을 만들지 않고 기존 blob을 가리키는 포인터만 재사용한다. 덕분에 스냅샷 모델임에도 실제 디스크 사용량은 델타 방식과 비슷하게 유지된다.

직관: Git 저장소를 "사진 앨범"이라고 상상하자. 매 커밋은 새 사진 한 장이다. 필름 현상처럼 이전 사진을 참조하는 것이 아니라, 각 사진은 그 순간의 전체 장면을 담고 있다. 변하지 않은 배경은 포토샵의 레이어처럼 공유된다.

세 가지 공간 — 작업 디렉터리, 스테이징, 저장소

Git에서 파일은 항상 세 공간 중 하나(또는 여러 곳)에 존재한다. 이 세 공간의 관계를 이해하는 것이 Git 조작 전반의 핵심이다.

작업 디렉터리 (Working Directory) — 우리가 실제로 편집하는 파일들이 있는 공간. Git은 이 영역을 감시하지만 아직 아무것도 기록하지 않는다.
스테이징 영역 (Staging Area / Index) — 다음 커밋에 포함될 변경 사항을 미리 모아두는 임시 공간. git add 명령으로 파일을 여기로 올린다. .git/index 파일에 저장된다.
저장소 (Repository)git commit으로 확정된 스냅샷들이 영구히 보존되는 공간. .git/objects/ 디렉터리 안에 불변 오브젝트로 기록된다.

스테이징 영역이 존재하는 이유는 커밋의 원자성을 사용자가 직접 제어할 수 있도록 하기 위해서다. 작업 디렉터리에서 여러 파일을 동시에 수정했더라도, 논리적으로 연관된 변경 사항만 골라 스테이징한 뒤 커밋하면 히스토리가 훨씬 읽기 좋아진다.

예를 들어 버그 수정과 리팩터링을 동시에 작업했다면, git add로 버그 수정 파일만 스테이징해 첫 번째 커밋을 만들고, 이어서 리팩터링 파일을 스테이징해 두 번째 커밋을 만들 수 있다.

세 공간 사이의 이동
작업 디렉터리  ──git add──▶  스테이징 영역  ──git commit──▶  저장소(.git)
               ◀──git restore──              ◀──git reset HEAD──
               ◀──────────────git checkout <commit>──────────────
핵심: git status는 항상 세 공간의 차이를 두 단계로 보여준다. "Changes to be committed"는 스테이징 vs 저장소의 차이, "Changes not staged"는 작업 디렉터리 vs 스테이징의 차이다.

커밋 오브젝트의 구조

커밋은 단순한 저장 단위가 아니다. Git의 커밋 오브젝트는 네 가지 핵심 정보를 담은 구조화된 텍스트 레코드다.

커밋 오브젝트가 저장하는 정보는 다음과 같다.

tree — 이 커밋 시점의 프로젝트 루트 디렉터리를 가리키는 tree 오브젝트의 SHA. 파일 전체 구조가 여기서 시작된다.
parent — 이전 커밋의 SHA. 히스토리 연결 고리다. 저장소의 첫 번째 커밋에는 parent가 없으며, 병합 커밋에는 parent가 두 개다.
author / committer — 이름, 이메일, 유닉스 타임스탬프, 타임존. author는 변경을 실제 작성한 사람, committer는 저장소에 기록한 사람이다(패치를 적용하는 메인테이너 워크플로에서 둘이 다를 수 있다).
message — 커밋 메시지 전문. 첫 줄이 제목, 빈 줄 이후가 본문이다.
커밋 오브젝트 원문 (git cat-file -p HEAD 출력 예시)
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가 수거하기 전까지).

함정: "git commit --amend로 커밋을 수정했다"는 표현은 기술적으로 부정확하다. 실제로는 새 커밋이 생성되었고 브랜치 레이블이 옮겨간 것이다. 이미 원격에 push한 커밋을 --amend하면 SHA가 달라지므로 force push가 필요해지고 협업자들의 히스토리와 충돌한다.

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

커밋 만들기 — 실제 흐름

이론을 정리했으니 이제 실제 커밋이 어떻게 만들어지는지 단계별로 추적해 보자.

세 커밋이 만들어지는 과정 1 / 6
SPACE 재생 · ← → 이동 · R 리셋

커밋이 생성될 때마다 main 브랜치 포인터(실제로는 .git/refs/heads/main 파일에 적힌 SHA)가 새 커밋으로 갱신된다. HEAD는 현재 "main을 추적하겠다"고 선언한 심볼릭 레퍼런스이므로 main이 앞으로 나아갈 때 HEAD도 따라간다.

각 커밋의 parent 포인터가 이전 커밋을 가리키므로, 커밋 체인은 방향성 비순환 그래프(DAG)를 형성한다. git log는 HEAD에서 시작해 parent를 따라 거슬러 올라가며 히스토리를 출력한다.

핵심: 브랜치는 특정 커밋을 가리키는 가벼운 포인터다. 커밋 그래프 자체는 브랜치와 독립적으로 존재하며, 브랜치는 그 그래프 위를 걷는 책갈피에 불과하다.
← 목차브랜치와 HEAD →