Git 내부 동작 이해하기

Git에 대해 어디까지 알고 있는가?
개발자로서 Git을 사용하면서 그저 형상관리의 도구로서만 접근하였지, 실제 내부적으로 어떻게 변경사항을 추적하고 브랜치를 따고 Merge 하는지에 대한 지식은 갖고 있지 않았다.
몇 년 전이라면, ‘그냥 그런갑다~’하고 넘어갔었겠지만, 최근에 실시간 서비스의 배포 프로세스에 대한 깊은 고민과 게임의 패치 방식에 대한 리서치를 진행하면서 당시에 Git은 어떻게 원하는 브랜치로 빠르게 체크아웃하고 형상관리를 할 수 있는지에 대한 의문이 들기도 했었다.
의문은 의문으로 영원히 남게 되었을 수도 있었겠지만, 다행히도 이번에 오픈소스 컨트리뷰션 아카데미를 진행하면서 Git의 내부 동작에 대해 공부해볼 수 있는 기회가 생겼다.
공부를 하면서 Git은 개발자의 니즈에 맞게 가장 효율적인 방식으로 내부 동작을 구현하고 있다고 느껴졌다.
Git의 내부 동작을 깊게 이해해보는 것이 개발자로서 사고를 확장하고, 현업에서도 도움을 받을 수 있을 것이라는 확신이 들었다.
자 시작~!
Git 기본 구조
Git이 효율적으로 형상관리의 도구로서 작동할 수 있는 핵심 원리는 다음과 같다.
Git은 스냅샷 기반으로 변경된 전체 파일 구조와 내용을 로컬 환경에 저장하며, 변경되지 않은 파일은 이전 스냅샷의 객체를 재사용하여 효율적으로 변경사항을 추적한다.
위 내용을 기반으로 Git의 구조를 이해해보자.
1. Snapshop 방식
일반적인 스냅샷의 의미는 순간적인 장면을 찍은 사진의 의미를 갖는다.
하지만, IT 분야에서의 스냅샷은 특정 시점의 시스템이나 저장소를 그대로 저장하여(사진을 찍듯이), 추후 스냅샷 시점으로 복원하기 위한 용도로 활용할 수 있도록 하는 정적인 복사본이다.
위에 작성했듯, 개발자가 프로젝트에서 a, b, c라는 파일을 수정했다면 a, b, c 파일에 대한 스냅샷을 저장한다.
즉, Git은 코드의 스냅샷을 저장하는 로컬 데이터베이스라고 할 수 있다.
TMI: Delta 방식 (SVN)
스냅샷 방식과 대비되는 방식으로는 Delta방식이 있다.
Delta 방식은 전체 파일을 저장하는 것이 아닌, 변경사항만 저장한다.
우리가 아는 SVN이 바로 Delta 방식을 채택한 형상관리 툴이다.
| 항목 | Git (스냅샷 기반) | SVN (델타 기반) |
| 저장 방식 | 파일 전체의 상태를 저장 (변경 없으면 참조) | 변경된 부분만 저장 (줄 단위로) |
| 저장 대상 | 모든 파일 + 폴더 구조 (Tree) | 파일 단위로만 버전 관리 |
| 속도 | 매우 빠름 (랜덤 접근) | 느림 (변경 추적 위해 누적 적용 필요) |
| 브랜치 생성 비용 | 거의 0 (단순한 포인터) | 무거움 (서버 작업 + 복사) |
| 커밋 이력 구조 | DAG 그래프 구조 | 선형 구조 |
| 로컬 커밋 | 가능 (분산형) | 불가능 (중앙 서버 필요) |
2. Git = 로컬 데이터베이스
Git은 로컬 데이터베이스다.
그럼 어디에 스냅샷이 저장될까?

바로, .git 폴더에 저장된다. 이 폴더는 숨긴 항목으로 관리된다.
이 .git 파일은 git init 명령어를 날렸을 때 생기게 된다.
git init 명령어는 저장소를 초기화하는 명령어다.
우리는 Git에게 해당 명령어를 실행시키고 이 폴더의 형상관리를 부탁하게 된다.
TMI: 로컬에 저장한다고? Github/Gitlab는 뭐임?
“어? Git에서 커밋하고 푸쉬하면 Github에 올라가잖아요? 그럼 Github에 저장되는 거 아님?” 라고 생각할 수도 있다.
이는 Git을 제대로 배우지 않고 GitHub를 먼저 사용하면서 생기는 대표적인 오해다.
Git은 내 로컬 컴퓨터 범위에 존재하는 형상관리 도구이고, Github는 Git의 데이터를 저장하고 협업할 수 있도록 지원하는 원격 저장소 서비스다.
즉, 정리하자면
- Git: 로컬에 커밋, 브랜치, 히스토리를 저장하는 도구
- GitHub: Git 저장소를 서버에 올리고 공유할 수 있게 해주는 서비스
커밋을 하면 로컬에만 저장되고, 푸시를 해야만 원격(GitHub, Gitlab 등)에 업로드된다.
3. .git의 내부 구조

git init 명령어를 처음 실행했을 때 생기는 .git 폴더의 내부 구조이다.
.. 너무 많다.
그러므로 현재 부분에서는 각 폴더와 파일이 어떤 역할을 하는지 간단하게 알아보고, 다음 챕터에서 실제 Git에서 개발자가 명령어를 실행할 때, 내부 구조에 어떤 변화가 발생하는지 알아보자.
| 항목 | 설명 | Git 개념 |
|---|---|---|
HEAD |
현재 체크아웃된 브랜치를 가리키는 포인터 (ex. ref: refs/heads/main) |
현재 작업 중인 브랜치 |
refs/ |
브랜치, 태그, 원격 브랜치들의 참조(ref) 정보를 저장 | 브랜치, 태그, 원격 참조 |
objects/ |
Git의 핵심! 커밋(commit), 트리(tree), 블롭(blob), 태그(tag) 등 모든 Git 객체 저장 | Git의 내부 데이터베이스 |
index |
스테이징 영역(staging area)의 상태를 저장하는 파일 | git add 후 상태 |
config |
해당 저장소의 로컬 설정 (user.name, remote.origin.url 등) |
로컬 저장소 설정 |
hooks/ |
커밋, 푸시 등 Git 이벤트에 자동 실행될 스크립트 저장소 (사용 안 되면 빈 디렉토리) | Git 자동화 (CI) |
info/ |
.gitignore에 추가적인 예외 설정을 포함할 수 있는 정보 저장소 |
무시할 파일 추가 지정 |
description |
Git 자체는 사용하지 않고, 일부 서버(GitWeb 등)에서 설명 용도로 사용 | - |
명령어 별 내부 동작
1. git init
.git/
├── config
├── description
├── HEAD
├── hooks/
│ └── ... (생략)
├── info/
│ └── exclude
├── objects/
│ ├── info/
│ └── pack/
└── refs/
├── heads/
└── tags/
git init 명령어는 작업 영역에서 .git 폴더를 생성하고 초기화 하는 명령어이다.
해당 동작에서 짚어볼 부분은 다음과 같다.
주요 내용
- HEAD 파일
- 현재 체크아웃(작업 중인)된 브랜치를 표기하는 역할을 하는 파일이다
- 따라서, HEAD 파일을 열어보면
ref: refs/heads/master로 기본적으로 설정되는 것을 확인할 수 있다 (설정에 따라main으로 기본 브랜치가 생성될 수 있음) - 현재 브랜치를 바꾸면 해당 파일 내용이 현재 브랜치에 맞게 수정된다
- objects 폴더
- 실질적인 로컬 데이터베이스의 역할을 하는 폴더이다
- Git에서 저장되는 모든 데이터는 object라고 한다
- objects의 종류에는 커밋(commit), 트리(tree), 블롭(blob), 태그(tag) 등이 있다
2. git add
.git/
├── config
├── description
├── FETCH_HEAD
├── HEAD
├── index
│
├── hooks/
│ └── ... (생략)
│
├── info/
│ └── exclude
│
├── objects/
│ ├── 30/
│ │ └── d74d258442c7c65512eafab474568dd706c430
│ ├── info/
│ └── pack/
│
└── refs/
├── heads/
└── tags/
git add 명령어는 작업 디렉토리 내 변경 사항을 stage 상태로 바꿔주는 명령어이다.
가장 크게 눈에 띄는 변화는 object/ 내부이다.
그리고 index 파일이 신규로 생성되었다.
주요 내용
- index 파일
- Git에 어떤 파일이 스테이징 되었는지, 그 파일의 어떤 버전이 커밋될 준비가 되었는지를 관리힌디/-
- 스테이징된 파일의 경로, 파일 상태, blob 해시(sha-1 / sha-256), 스테이지 번호, flag 등등을 저장한다
- 바이너리 형태로 저장되지만,
git ls-files --stage명령어를 사용한다면 그 내용을 확인할 수 있다 - ex)
100644 30d74d258442c7c65512eafab474568dd706c430 0 test.txt항목 설명 100644파일 모드 (퍼미션) 30d7...파일의 blob 해시 0스테이지 번호 test.txt경로
- objects 폴더
- 위 index 파일의 해시값을 기준으로 앞 자리 두 개 값으로 된 폴더와 나머지 부분으로 이루어진 파일이 생성된다
- 이때 SHA-1 해시값을 기준으로, 앞 두 자리는 디렉토리 이름, 나머지 38자는 해당 디렉토리 내 파일 이름으로 저장된다.
- 저장되는 객체는 실제로는 압축된 blob 데이터이며, Git은 이를 통해 동일한 내용의 파일을 중복 저장하지 않고 효율적으로 관리한다.
- 예를 들어
test.txt의 해시가30d74d258442c7c65512eafab474568dd706c430이라면,이 경로에 blob 객체가 저장된다. .git/objects/30/d74d258442c7c65512eafab474568dd706c430- 이 blob은 아직 커밋되지 않았고, 스테이징 상태(index에만 반영됨)이다.
Git에서 파일 상태
Git에서는 작업 디렉토리 내 모든 파일이 특정 상태를 가지며, 위 원문에 나온 stage는 변경 사항이 커밋될 준비가 된 상태를 의미한다.
| 상태 구분 | 설명 | Git 용어 |
|---|---|---|
| 기본 상태 | 변경되지 않은 상태 (커밋된 그대로) | unmodified |
| 수정 상태 | 작업 디렉토리에서 파일을 수정했지만 아직 add하지 않음 |
modified |
| 스테이징 상태 | 수정된 파일을 git add로 staging area에 올린 상태 |
staged |
| 커밋된 상태 | staging area의 변경을 git commit으로 로컬 저장소에 저장한 상태 |
committed → 커밋 후 |
unmodified |
||
| 추적되지 않음 | Git이 관리하지 않는 새 파일 (add도 안 됨) | untracked |
| 삭제됨 (스테이지 전) | 파일을 작업 디렉토리에서 삭제했지만 add하지 않음 |
deleted (unstaged) |
| 삭제됨 (스테이지 후) | 삭제한 파일을 add해서 삭제 사실을 커밋할 준비가 된 상태 |
deleted (staged) |
상태에 대한 라이프사이클을 살펴보면 다음과 같다. (a.txt의 상태 변동)
- 작업 디렉토리 내 a.txt 파일 신규 생성 → untracked 상태
- git add a.txt 진행 → staged 상태
- git commit 진행 → 스냅샷이 저장되고 a.txt는 unmodified 상태가 됨
- 이후 작업:
- a.txt 파일 수정 시 → modified 상태
- a.txt 파일 삭제 시 → deleted (unstaged)
- 삭제 사실을 git add로 반영하면 → deleted (staged)
헷갈릴 수 있는 내용
Q. gitignore에 a.txt 파일이 존재한다면 그게 untracked 아님?
A. 아님. gitignore에 존재하면 ‘의도적으로 이 파일을 무시해주세요’ 하는 것이기 때문에 아무 상태도 가지지 않고 없는 취급 함
3. git commit
.git/
├── COMMIT_EDITMSG
├── config
├── description
├── FETCH_HEAD
├── HEAD
├── index
│
├── hooks/
│ └── ... (생략)
│
├── info/
│ └── exclude
│
├── logs/
│ ├── HEAD
│ └── refs/
│ └── heads/
│ └── master
│
├── objects/
│ ├── 09/
│ │ └── 5a057d4a651ec412d06b59e32e9b02871592d5
│ ├── 30/
│ │ └── d74d258442c7c65512eafab474568dd706c430
│ ├── c1/
│ │ └── 2e839ba5ac481bf94376971d04a8c08dad9304
│ ├── info/
│ └── pack/
│
└── refs/
├── heads/
│ └── master
└── tags/
git commit 명령어는 staging(index 파일)에 올라간 변경사항을 기반으로 실제 로컬 데이터베이스에 스냅샷(commit 객체)을 생성하는 명령이다.
이 시점에서 Git은 내부적으로 여러 가지 object를 새로 생성하고, 로그 및 참조도 업데이트한다.
변화된 주요 파일/폴더들을 아래와 같이 짚어볼 수 있다.
주요 내용
- object 폴더
- 09/ 와 c1/가 추가로 생성되었다. 이는 tree 객체와 commit 객체이다
- 각 객체에 대한 확인은 해시값 자체으로는 어렵고
git cat-file -t {해시값}과 같은 명령어를 통해 확인이 가능하다
- 각 객체가 저장하는 정보는 다음과 같다
| 객체 타입 | 저장하는 정보 | 목적 |
| blob | 파일의 내용 | 실제 코드/텍스트 저장 |
| tree | 디렉토리 구조, 파일명, blob/tree 참조 | 파일/폴더 구성 저장 |
| commit | 커밋 메시지, 작성자, 시각, 참조 트리 등 | 프로젝트 이력/버전 정보 저장 |
- logs 폴더
.git/logs/HEAD와.git/logs/refs/heads/master에 커밋 로그가 추가된다- 이 로그에는 이전 커밋 → 새 커밋 해시, 작성자, 메시지 등의 이력 정보가 담긴다
- 이는
git log의 내부 데이터 기반이 되며, Git의 히스토리 추적과 되돌리기(reflog)의 핵심 역할을 한다
- refs/heads/master 파일
- 이 파일은 현재 브랜치가 가리키는 가장 최신 커밋의 해시를 담는다
- commit이 생성되면, 이 커밋의 해시가 이 경로에 저장된다
HEAD파일은 여전히ref: refs/heads/master를 가리키므로, HEAD도 자동으로 최신 커밋을 따라가게 된다.- 즉, 현재 담긴 파일 내용은
c12e839ba5ac481bf94376971d04a8c08dad9304이다
- COMMIT_EDITMSG 파일
- 마지막으로 입력한 커밋 메시지를 담는 파일이다
- 단순 기록용이며, 커밋 시 작성한 메시지를 저장한다


4. git checkout -b feature/test1
.git/
├── COMMIT_EDITMSG
├── config
├── description
├── FETCH_HEAD
├── HEAD
├── index
│
├── hooks/
│ └── ... (생략)
│
├── info/
│ └── exclude
│
├── logs/
│ ├── HEAD
│ └── refs/
│ └── heads/
│ ├── master
│ └── feature/
│ └── test1
│
├── objects/
│ ├── 09/
│ │ └── 5a057d4a651ec412d06b59e32e9b02871592d5
│ ├── 30/
│ │ └── d74d258442c7c65512eafab474568dd706c430
│ ├── c1/
│ │ └── 2e839ba5ac481bf94376971d04a8c08dad9304
│ ├── info/
│ └── pack/
│
└── refs/
├── heads/
│ ├── master
│ └── feature/
│ └── test1
└── tags/
git checkout -b {브랜치명} 명령어는 새로운 브랜치를 생성하고 동시에 해당 브랜치로 체크아웃(전환)하는 명령이다.
주요 내용
- /refs/heads/feature/test1 파일
- 해당 파일은 새로 만든 브랜치
feature/test1가 어떤 커밋을 가리키고 있는지 확인 가능하다 - 내용은 기존 브랜치(
master)와 동일한 커밋 해시값이 들어있다 - 즉, 브랜치만 새로 만든 것이고, 아직은 내용이 달라지지 않은 상태
- 해당 파일은 새로 만든 브랜치
- HEAD 파일
git init에서 언급했듯이, 현재 체크아웃(작업 중인)된 브랜치를 표기하는 역할을 하는 파일이다- 브랜치를 feature/test1로 체크아웃 했기 때문에,
ref: refs/heads/master에서ref: refs/heads/feature/test1로 수정된 것을 확인할 수 있다
- logs/refs/heads/feature/test1 파일
- feature/test1의 히스토리 로그를 관리하는 파일이 신규 생성 되었다
- logs/HEAD 파일
- 해당 파일에서 브랜치 변경 내역 또한 로그로 남는다
5. git merge
.git/
├── COMMIT_EDITMSG
├── config
├── description
├── FETCH_HEAD
├── HEAD
├── index
├── ORIG_HEAD
│
├── hooks/
│ └── ... (생략)
│
├── info/
│ └── exclude
│
├── logs/
│ ├── HEAD
│ └── refs/
│ └── heads/
│ ├── master
│ └── feature/
│ └── test1
│
├─objects/
│ ├─09/
│ │ 5a057d4a651ec412d06b59e32e9b02871592d5 (tree 객체)
│ ├─30/
│ │ d74d258442c7c65512eafab474568dd706c430 (blob 객체)
│ ├─39/
│ │ bd6882b26d6885bace37ea67850432317e54a2 (blob 객체)
│ ├─71/
│ │ 7009c1b31aa1ad0cc4a02b64cf9ce3914c7acc (commit 객체)
│ ├─9f/
│ │ 49d92d2ce8580d8cede57362391b9d1e2665e0 (tree 객체)
│ ├─c1/
│ │ 2e839ba5ac481bf94376971d04a8c08dad9304 (commit 객체)
│ ├── info/
│ └── pack/
│
└── refs/
├── heads/
│ ├── master
│ └── feature/
│ └── test1
└── tags/
git merge {브랜치명} 명령어는 현재 체크아웃된 브랜치에 다른 브랜치의 변경 내용을 통합하는 명령어이다.
ex)
- 체크아웃 브랜치:
master git merge feature/test1실행feature/test1커밋 내역이master에 병합됨
주요 내용
- ORIG_HEAD 파일
- 병합을 시작하기 전 HEAD의 위치(기존 커밋) 를 백업해두는 용도의 파일이다
- merge에 문제가 생겼을 경우,
ORIG_HEAD를 기준으로 되돌릴 수 있다
- objects 폴더
- 부모 커밋 객체 확인 →
git cat-file -p c12e839ba5ac481bf94376971d04a8c08dad9304명령어 결과
- 부모 커밋 객체 확인 →

- 자식 커밋 객체 확인 →
git cat-file -p 717009c1b31aa1ad0cc4a02b64cf9ce3914c7acc명령어 결과

- 즉,
c1..해시는71..해시보다 일찍 생성되었다.
그렇다면..
1. 왜 객체 종류는 3개일까?
Git은 단순히 파일의 변경만 저장하는 것이 아니라, 파일 내용(blob), 디렉토리 구조(tree), 커밋 이력(commit)을 분리하여 저장함으로써, 효율성과 추적성, 일관성을 동시에 확보할 수 있음.
2. object 폴더 에서 SHA-1 해시 값의 앞 2자리를 폴더이름으로 두는 이유
Git이 .git/objects/ 폴더에서 SHA-1 해시값의 앞 2자리를 디렉터리 이름으로, 나머지 38자를 그 디렉터리 안의 파일 이름으로 나누는 이유는, 수많은 객체 파일이 하나의 디렉터리에 몰리지 않도록 분산시켜 파일 시스템의 성능 저하를 방지하고 검색 속도를 향상시키기 위해서다.