Branch와 HEAD로 보는 Git 히스토리 모델(DAG) 이해하기

Tech/기타 2025. 11. 20. 17:33
728x90
728x90

 

 

 

 

 

[이전글] Git의 데이터 저장 방식과 commit 이해하기

[다음글] Git merge / rebase / cherry-pick으로 히스토리 다루기

 

 

 

 

서론

이전 글에서는 Git을 내용 기반 주소를 사용하는 Key-Value 저장소 관점에서 바라보면서

  • .git/objects에 쌓이는 Blob / Tree / Commit / Tag 객체
  • git cat-file로 실제 해시를 따라가며 commit → tree → blob 구조
  • 두 커밋 사이에서 어떤 객체들이 재사용/새로 생성되는지
  • git diff가 Tree / Blob 단위로 어떤 식으로 변경 파일을 찾아내는지

까지 정리해봤습니다. 이전 편의 포커스는 Git 안에 무엇이 저장되는가에 대해서였습니다.

 

이번 포스팅에서는 기본적인 저장 방식 위에 객체들을 어떻게 이어붙여 히스토리를 만들고, 히스토리 위에서 branch와 HEAD, tag가 어떻게 움직이는지를 정리해보려고 합니다.

 

1편에서 이미 first, second 커밋이 있는 간단한 레포를 만들어 두었습니다. 이번 글에서는 이전 레포 위에서 branch와 HEAD를 얹어서 보는 느낌으로 진행하려합니다.

 

(ref/branch/HEAD 개념을 더 자세히 알아보기 위해 Pro Git의 Git Internals - References을 참조했습니다.)

 

 

 

커밋 그래프(DAG)

이전 포스팅에서 커밋 객체를 직접 까보면서 아래 정보들을 확인했었습니다.

# first commit
commit 7fc68d4...
tree 3354a0b...
author ...
committer ...

first

# second commit
commit fc912aa...
tree be51317...
parent 7fc68d4...
author ...
committer ...

second

 

여기서 parent 필드를 주목해야합니다. first 커밋은 최초 커밋으로 부모 커밋이 없으며, second 커밋은 first를 부모로 가리키고 있습니다.

 

7fc68d4 (first)  ->  fc912aa (second)

 

이렇게만 보면 커밋들이 단방향 LinkedList처럼 보일 수 있습니다.

 

 

하지만, Git에서는 merge 커밋이 부모를 2개 이상 가질 수 있고, 여러 branch가 갈라졌다가 다시 합쳐질 수 있습니다. 그래서 커밋들은 LinkedList가 아닌 DAG(Directed Acyclic Graph, 방향 비순환 그래프) 구조를 형성합니다.

  • 방향(Directed): commit들은 부모–자식 관계로 이어지고, 보통 부모(과거) → 자식(현재) 방향으로 그래프를 그림
  • 비순환(Acyclic): 과거 커밋이 다시 미래 커밋을 가리키는 식으로의 순환이 생기지 않음

단순하게 정리하자면, 커밋들이 parent 링크로 이어진 그래프 위에서 branch와 HEAD가 움직인다고 할 수 있습니다.

(이 DAG에 대해서는 merge를 다음 포스팅에서 다룬 뒤 더 자세하게 정리해보도록 하겠습니다)

 

 

 

 

ref & branch & HEAD

ref

ref는 특정 커밋을 가리키는 이름(참조) 입니다. branch, tag, remote branch 등은 모두 ref의 한 종류입니다.

  • branch: .git/refs/heads/*
  • tag: .git/refs/tags/*
  • remote branch: .git/refs/remotes/*
$ cat .git/refs/heads/master
fc912aa419552b61e97fb086dae0cefdc20cd58a

 

각 ref 파일 내에는 커밋 해시 한 줄만 들어있습니다. 이 구조 덕분에 어떤 해시를 가리키느냐만 바꿔서 특정 스냅샷을 찾아갈 수 있습니다.

  • 새 커밋을 만들 때: 해당 브랜치를 나타내는 ref 파일 안의 해시가 이전 커밋 → 새 커밋 해시로 바뀜
  • 다른 브랜치로 이동할 때: 각 ref의 해시는 그대로 둔 채 .git/HEAD가 가리키는 ref만 바뀜

 

 

 

branch

우리는 branch를 보통 기능 하나를 개발하는 작업 단위 정도로 쓰지만, 내부적으로 branch는 마지막 커밋을 가리키는 포인터(ref)일 뿐입니다.

 

예를 들어, 1편의 first/second 커밋이 있는 레포에서는 master branch는 second 커밋을 가리키고 있습니다.

$ git log --oneline
fc912aa second
7fc68d4 first

$ cat .git/refs/heads/master
fc912aa419552b61e97fb086dae0cefdc20cd58a

 

 

 

branch를 새로 만들어도 이는 똑같습니다. 최초 생성한 브랜치로 바로 이동하거나(switch -c / checkout -b), 단순히 생성(git branch)할 때 최신 분기가 기준이 되기 때문에 모두 같은 커밋을 가리키는 상태가 됩니다.

$ git branch feature/login
$ git checkout -b feature/logout

$ cat .git/refs/heads/feature/login .git/refs/heads/feature/logout
fc912aa419552b61e97fb086dae0cefdc20cd58a
fc912aa419552b61e97fb086dae0cefdc20cd58a

 

 

 

 

HEAD

여러 branch를 만들고, 우리가 어느 branch에서 작업하고 있는지를 HEAD를 통해 알 수 있습니다.

$ cat .git/HEAD
ref: refs/heads/master

 

.git/HEAD에 저장되어 있는 값은 현재 이 레포에서 어떤 브랜치를 보고 있는지를 나타냅니다. 일반 ref와 달리 브랜치를 직접 가리키는 심볼릭 ref입니다.

 

이해를 돕기 위해 HEAD를 직접 움직여보겠습니다.

$ git switch -c feature/signup

 

위 명령어를 실행하면 .git/refs/heads/feature/signup 파일이 만들어지고, 그 안에 master와 같은 해시가 생성됩니다.

그리고 .git/HEAD의 내용이 변경됩니다.

$ cat .git/refs/heads/feature/signup
fc912aa419552b61e97fb086dae0cefdc20cd58a

$ cat .git/HEAD
ref: refs/heads/feature/signup

 

 

 

 

여기서 새로운 커밋을 만들면, 새 커밋이 하나 생기고 feature/signup 브랜치 ref가 그 커밋을 가리키도록 업데이트됩니다.

HEAD는 변경사항이 없으니 여전히 feature/signup branch의 참조를 유지하게 됩니다.

# feature/signup 브랜치에서
echo "console.log('feature');" >> src/app/main.ts
git add src/app/main.ts
git commit -m "add feature log"

 

 

 

 

 

새 커밋이 생길 때마다 “HEAD가 가리키는 **브랜치 ref**”가 한 칸씩 앞으로 이동하는 모습입니다. HEAD가 직접 해시를 들고 움직이는 게 아니라 HEAD → 브랜치 → 커밋 구조에서 브랜치 → 커밋 관계만 새 커밋으로 바뀌는 셈입니다.

 

 

 

 

tag

branch가 커밋 그래프 위에서 앞으로 움직이는 포인터라면, tag는 한 커밋에 고정된 이름표처럼 쓰입니다. 보통의 오픈소스 릴리즈 등의 버전 관리 등에 자주 쓰이는 그 tag입니다.

 

위에서 언급했다시피 tag는 .git/refs/tags 경로에 생성됩니다. tag 또한 ref의 한 종류이기 때문에, 브랜치와 마찬가지로 태그 파일 안에 커밋 해시가 한 줄 저장됩니다.

 

 

 

branch와의 차이점은, 직접 삭제하지 않으면 특정 시점에 고정해서 쓰는 이름표처럼 쓰입니다. 반면 branch는 새 커밋이 생길 때마다 앞으로 이동하는 포인터입니다. 결국 둘의 차이는 계속 이동시킬 것이냐, 특정 시점에 고정해서 사용할 것이냐의 차이입니다.

 

 

 

reflog

1편에서 Git의 객체가 불변이고, branch/tag 같은 ref만 옮겨 다닌다고 얘기했습니다. 그렇기 때문에 우리는 만약 하드 리셋으로 잘못된 시점으로 리셋하여 작업을 전부 날려먹더라도 복구할 수 있습니다. 커밋 오브젝트는 .git/objects 안에 그대로 있고, 단지 하드 리셋으로 branch ref가 더이상 그 커밋을 가리키지 않을 뿐입니다. 

 

git reset --hard HEAD~1

 

reflog는 Git이 HEAD와 각 branch ref가 이전에 어떤 커밋들을 가리켰었는지를 기록해 두는 로그입니다.

$ git reflog
fc912aa HEAD@{0}: reset: moving to HEAD~1
4741022 HEAD@{1}: commit: add feature log
fc912aa HEAD@{2}: checkout: moving from master to feature/signup
...

 

HEAD가 움직인 기록을 추적할 수 있기 때문에 이 해시들로 새 branch를 만들거나 다시 reset하여 복구할 수 있습니다.

 

정리하자면, 이전 포스팅에서 다뤘던 Git의 객체(Blob / Tree / Commit)는 Insert/Select만 되는 불변 객체들이라 남아있고, reflog는 HEAD/branch가 어디를 가리켰는지에 대한 로그를 확인할 수 있습니다. 그래서 잘못된 reset 이후에도, 로컬 저장소 기준에서는 꽤 많은 경우 커밋을 되살릴 수 있는 수단이 됩니다. (더 자세한 예시는 Pro Git의 Maintenance & Data Recovery에서 확인할 수 있습니다.)

 

 

 

정리

이전 포스팅에서는 Git을 내용 기반 해시를 사용하는 K-V 저장소 위에 객체들을 쌓는다고 정리했습니다.

이번 포스팅에서는 추가로, ref와 branch, HEAD를 같이 정리하여 우리가 전반적으로 사용하는 커밋, 브랜치 생성 및 이동 등의 상황에서 내부적으로 Git이 어떻게 동작하는지 살펴보았습니다. 이 모든 것이 결국 효율적인 Git 저장 객체들을 활용하면서, 커밋 DAG 위에서 움직이는 이름표들의 조합이라고 볼 수 있을 것 같습니다.

 

Git은 commit graph 위에 branch/HEAD 같은 ref를 띄워 둔 구조이고, 우리는 평소에 이 ref들을 옮기면서 작업하고 있다는 관점으로 이해하니까, Git 명령어들이 머리 속에서 조금 더 일관되게 정리되는 느낌입니다.

 

다음 편에서는, merge, rebase를 통해 조금 더 커밋들을 다루고 히스토리에 어떤 차이를 만드는지에 대해 정리해보겠습니다.

 

 

 

 

 

 

References.

https://git-scm.com/book/en/v2/Git-Internals-Git-References

https://www.atlassian.com/git/tutorials/refs-and-the-reflog

https://git-scm.com/docs/git-symbolic-ref

https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery

 

 

 

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록