[CS300 #194] Git 기초 — 스냅숏과 해시로 이루어진 역사
컴퓨터공학 300 주제 시리즈의 194번째 글이다. 전체 지도는 여기.
한 줄 요약
Git 은 프로젝트 전체의 스냅숏을 커밋으로 저장하는 분산 버전 관리 시스템이다. 모든 내용은 그 내용의 해시를 이름으로 삼아 저장되고(내용 주소 저장), 커밋은 부모 커밋의 해시를 품어 하나의 사슬이 된다. 이 두 가지를 이해하면 Git 명령 대부분이 왜 그렇게 동작하는지 보인다.
왜 필요한가
보고서_최종.docx, 보고서_최종_진짜.docx, 보고서_최종_진짜_수정.docx. 버전 관리 없이 파일을 고쳐 본 사람은 이 목록을 안다. 혼자서도 헷갈리는데, 다섯 명이 같은 코드를 동시에 고치면 누가 언제 무엇을 왜 바꿨는지 추적할 방법이 없다.
버전 관리 시스템은 세 가지를 준다.
- 역사: 모든 변경이 누가, 언제, 왜(커밋 메시지) 했는지와 함께 남는다.
- 되돌리기: 어느 시점으로든 돌아갈 수 있다. 실험이 실패해도 안전하다.
- 협업: 여러 사람의 변경을 합치고, 충돌이 나면 알려 준다.
Pro Git 은 Git 이 2005년 리눅스 커널 개발 공동체에서, Linus Torvalds 를 중심으로 만들어졌다고 소개한다. 커널처럼 큰 프로젝트를 빠르게, 완전히 분산된 방식으로 다루는 것이 목표였다.
핵심 개념
차이(delta)가 아니라 스냅숏
예전 버전 관리 시스템들은 파일별 변경분을 쌓았다. Git 은 커밋할 때마다 프로젝트 전체가 어떻게 생겼는지를 기록한다. 바뀌지 않은 파일은 새로 저장하지 않고 이전에 저장한 것을 가리킨다. 그래서 어떤 커밋으로 이동하든 변경분을 처음부터 다시 적용할 필요 없이 그 스냅숏을 바로 꺼낸다.
세 영역과 파일의 상태
작업 트리 스테이징 영역(인덱스) 저장소(.git)
(실제 파일) (다음 커밋에 들어갈 것) (커밋된 역사)
│ git add ─────────> │ git commit ─────────> │
│ <──────────────────────────────────── git switch/restore │
| 상태 | 뜻 |
|---|---|
| untracked | Git 이 아직 모르는 새 파일 |
| modified | 마지막 커밋 이후 고쳤지만 아직 스테이징하지 않음 |
| staged | 다음 커밋에 넣기로 표시함 |
| committed | 저장소에 안전하게 기록됨 |
스테이징 영역이 따로 있는 이유는 커밋을 고르기 위해서다. 파일 다섯 개를 고쳤어도 그중 버그 수정에 해당하는 두 개만 골라 한 커밋으로, 나머지를 다른 커밋으로 나눌 수 있다. git add -p는 한 파일 안에서도 변경 덩어리(hunk) 단위로 골라 담게 해 준다.
기본 명령 흐름
git init # 현재 디렉터리를 저장소로
git status # 지금 각 파일이 어느 상태인지
git add hello.txt # 스테이징
git commit -m "인사말 추가" # 스테이징된 것을 커밋
git log --oneline --graph # 역사 보기
git diff # 작업 트리 vs 스테이징 영역
git diff --staged # 스테이징 영역 vs 마지막 커밋
git restore hello.txt # 작업 트리 변경 버리기
git restore --staged hello.txt # 스테이징 취소(작업 트리는 그대로)
git diff 와 git diff --staged 가 비교하는 대상이 다르다는 점이 초보자를 가장 많이 헷갈리게 한다. 위 세 영역 그림에서 어느 두 칸을 비교하는지 떠올리면 된다.
객체 모델: blob, tree, commit
Git 내부 구조를 보면 .git/objects 에는 세 종류의 핵심 객체가 들어 있다.
commit d179243...
├─ tree 68aba62... ← 루트 디렉터리
│ └─ blob 3b18e51... hello.txt
├─ parent (이전 커밋 ID)
├─ author / committer
└─ message
| 객체 | 담는 것 |
|---|---|
| blob | 파일 내용(이름 없음) |
| tree | 디렉터리: 이름, 모드, 그리고 blob·하위 tree 의 ID |
| commit | 루트 tree ID, 부모 커밋 ID, 작성자, 메시지 |
각 객체의 ID 는 “종류 + 길이 + 내용” 의 해시다. 기본 형식은 SHA-1(40자리 16진수)이고, 최근 Git 은 SHA-256 저장소 형식도 지원한다. 이 구조에서 세 가지 성질이 나온다.
- 같은 내용은 한 번만 저장된다. 파일 이름이 달라도 내용이 같으면 같은 blob 이다.
- 내용이 바뀌면 ID 가 바뀐다. 한 글자만 고쳐도 blob ID 가 바뀌고, 그것을 가리키는 tree ID, 그 tree 를 가리키는 commit ID 가 연쇄적으로 바뀐다.
- 역사를 몰래 고칠 수 없다. 커밋은 부모 ID 를 품고 있으므로, 과거 커밋 하나를 고치면 그 뒤 모든 커밋의 ID 가 바뀐다. 이미 공유한 커밋을 고쳐 쓰는 일(
rebase,commit --amend후 강제 푸시)을 조심해야 하는 이유다.
브랜치는 포인터일 뿐
브랜치는 특정 커밋 ID 를 적어 둔 이름표다. main 이 커밋 C3 를 가리키다가, 새 커밋 C4 를 만들면 main 이 C4 로 옮겨 간다. HEAD 는 “지금 내가 서 있는 브랜치” 를 가리키는 포인터다. 브랜치를 만드는 비용이 거의 없는 이유가 이것이다. 파일을 복사하는 것이 아니라 41바이트짜리 이름표 하나를 만드는 것이다. 브랜치 전략은 다음 글에서 다룬다.
분산: 모두가 전체 역사를 갖는다
git clone 은 서버의 최신 파일만이 아니라 전체 역사를 복사한다. 그래서 네트워크 없이 커밋·로그·diff 가 모두 된다. git fetch 는 원격의 새 커밋을 가져오기만 하고, git pull 은 가져온 뒤 현재 브랜치에 합친다(fetch + merge 또는 rebase). git push 는 내 커밋을 원격에 보낸다.
직접 해 보기
Git 을 실행하지 않고, 파이썬 hashlib 으로 Git 이 객체 ID 를 만드는 방식을 그대로 재현해 본다.
import hashlib, zlib
def git_object(kind: str, body: bytes):
"""git 이 객체를 저장하는 방식: '<종류> <길이>\\0' + 내용 → SHA-1"""
store = f"{kind} {len(body)}".encode() + b"\0" + body
return hashlib.sha1(store).hexdigest(), zlib.compress(store)
# 1) 파일 내용 → blob
oid, packed = git_object("blob", b"hello world\n")
print("blob ", oid)
print("저장 위치 .git/objects/" + oid[:2] + "/" + oid[2:], f"({len(packed)} 바이트, zlib 압축)")
# 2) 내용이 한 글자만 달라도 완전히 다른 ID
print("blob ", git_object("blob", b"hello world!\n")[0], "<- 느낌표 하나 추가")
# 3) 내용이 같으면 파일 이름이 달라도 같은 blob (중복 저장 없음)
a = git_object("blob", b"same\n")[0]
b = git_object("blob", b"same\n")[0]
print("같은 내용의 두 파일 ->", "같은 blob" if a == b else "다른 blob")
# 4) tree: '<모드> <이름>\0<20바이트 이진 ID>' 를 이름순으로 이어 붙인다
def tree(entries):
body = b"".join(f"{mode} {name}".encode() + b"\0" + bytes.fromhex(h)
for mode, name, h in sorted(entries, key=lambda e: e[1]))
return git_object("tree", body)[0]
t = tree([("100644", "hello.txt", oid)])
print("tree ", t)
# 5) commit: tree ID + 부모 + 작성자 + 메시지. 부모 ID 가 들어가므로 역사가 사슬이 된다
commit_body = (f"tree {t}\n"
"author Kim <kim@example.com> 1760000000 +0900\n"
"committer Kim <kim@example.com> 1760000000 +0900\n"
"\n첫 커밋\n").encode()
print("commit", git_object("commit", commit_body)[0])
실행 결과:
blob 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
저장 위치 .git/objects/3b/18e512dba79e4c8300dd08aeb37f8e728b8dad (28 바이트, zlib 압축)
blob a0423896973644771497bdc03eb99d5281615b51 <- 느낌표 하나 추가
같은 내용의 두 파일 -> 같은 blob
tree 68aba62e560c0ebc3396e8ae9335232cd93a3f60
commit d1792435104d60012d77dbc154f353c1de56393b
Git 이 설치된 곳에서 echo 'hello world' | git hash-object --stdin 을 실행하면 첫 줄과 같은 3b18e51... 이 나온다. 해시 함수는 결정적이므로, 같은 내용이면 세계 어느 컴퓨터에서든 같은 ID 가 나온다. 분산 저장소들이 중앙 서버 없이도 “같은 커밋” 을 알아보는 원리다.
커밋 ID 는 작성 시각과 작성자까지 포함한 내용의 해시이므로, 같은 파일을 커밋하더라도 시각이 1초만 달라도 다른 커밋 ID 가 된다. 위 d179243... 은 예시로 넣은 이름·시각 기준의 값이다.
현업에서는
- 커밋은 작고 의미 있게. “버그 수정 + 리팩터링 + 포매팅” 을 한 커밋에 넣으면 리뷰도, 되돌리기도 어렵다. 스테이징 영역으로 나눠 담는다.
- 커밋 메시지는 “왜” 를 쓴다. 무엇을 바꿨는지는 diff 가 말한다. 몇 년 뒤
git blame으로 이 줄을 찾아온 사람에게 필요한 것은 이유다. - 비밀값은 한 번 커밋되면 역사에 남는다. 다음 커밋에서 지워도 이전 커밋의 blob 은 그대로 있다. 토큰을 커밋했다면 역사 정리보다 토큰 폐기와 재발급이 먼저다.
.gitignore와 커밋 전 비밀값 검사 훅이 예방책이다. - 인프라 설정도 Git 에. 홈랩 k3s 클러스터의 매니페스트와 Helm 값 파일을 Git 으로 관리하면, “지난주에 잘 되던 설정” 이 정확히 무엇이었는지
git log와git diff로 바로 확인할 수 있다.
확인 문제
- Git 의 세 영역을 쓰고,
git add와git commit이 각각 어느 영역에서 어느 영역으로 옮기는지 설명하라. git diff와git diff --staged는 각각 무엇과 무엇을 비교하는가?- 이름이 다르고 내용이 같은 두 파일은 저장소에 몇 개의 blob 으로 저장되는가? 이유는?
- 과거 커밋 하나의 내용을 고치면 그 뒤 커밋들의 ID 가 모두 바뀌는 이유는?
- 브랜치를 만드는 비용이 거의 들지 않는 이유는?
풀이
- 작업 트리, 스테이징 영역(인덱스), 저장소.
git add는 작업 트리의 변경을 스테이징 영역으로,git commit은 스테이징 영역의 내용을 저장소의 새 커밋으로 옮긴다. git diff는 작업 트리와 스테이징 영역을,git diff --staged는 스테이징 영역과 마지막 커밋을 비교한다.- 하나. blob ID 는 내용(종류·길이 포함)의 해시이고 파일 이름은 tree 에 기록되므로, 같은 내용은 같은 blob 이 된다.
- 커밋은 부모 커밋의 ID 를 내용에 포함하고, ID 는 내용의 해시다. 과거 커밋의 ID 가 바뀌면 그것을 부모로 품은 다음 커밋의 내용이 바뀌어 ID 가 바뀌고, 이 변화가 끝까지 전파된다.
- 브랜치는 커밋 ID 하나를 적어 둔 포인터(이름표)일 뿐이고, 파일을 복사하지 않는다.
더 읽을거리 (References)
- Scott Chacon, Ben Straub, Pro Git, 2nd ed.
- Pro Git, 10.2 Git Internals — Git Objects
- Git 공식 문서, git-add
- Python 문서, hashlib — Secure hashes and message digests