컴퓨터공학 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 저장소 형식도 지원한다. 이 구조에서 세 가지 성질이 나온다.

  1. 같은 내용은 한 번만 저장된다. 파일 이름이 달라도 내용이 같으면 같은 blob 이다.
  2. 내용이 바뀌면 ID 가 바뀐다. 한 글자만 고쳐도 blob ID 가 바뀌고, 그것을 가리키는 tree ID, 그 tree 를 가리키는 commit ID 가 연쇄적으로 바뀐다.
  3. 역사를 몰래 고칠 수 없다. 커밋은 부모 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 로 바로 확인할 수 있다.

확인 문제

  1. Git 의 세 영역을 쓰고, git add 와 git commit 이 각각 어느 영역에서 어느 영역으로 옮기는지 설명하라.
  2. git diff 와 git diff --staged 는 각각 무엇과 무엇을 비교하는가?
  3. 이름이 다르고 내용이 같은 두 파일은 저장소에 몇 개의 blob 으로 저장되는가? 이유는?
  4. 과거 커밋 하나의 내용을 고치면 그 뒤 커밋들의 ID 가 모두 바뀌는 이유는?
  5. 브랜치를 만드는 비용이 거의 들지 않는 이유는?

풀이

  1. 작업 트리, 스테이징 영역(인덱스), 저장소. git add 는 작업 트리의 변경을 스테이징 영역으로, git commit 은 스테이징 영역의 내용을 저장소의 새 커밋으로 옮긴다.
  2. git diff 는 작업 트리와 스테이징 영역을, git diff --staged 는 스테이징 영역과 마지막 커밋을 비교한다.
  3. 하나. blob ID 는 내용(종류·길이 포함)의 해시이고 파일 이름은 tree 에 기록되므로, 같은 내용은 같은 blob 이 된다.
  4. 커밋은 부모 커밋의 ID 를 내용에 포함하고, ID 는 내용의 해시다. 과거 커밋의 ID 가 바뀌면 그것을 부모로 품은 다음 커밋의 내용이 바뀌어 ID 가 바뀌고, 이 변화가 끝까지 전파된다.
  5. 브랜치는 커밋 ID 하나를 적어 둔 포인터(이름표)일 뿐이고, 파일을 복사하지 않는다.

더 읽을거리 (References)