On this page

이 글은 책 『팀 개발을 위한 Git, GitHub 시작하기』를 읽으면서 남긴 Git/GitHub 메모를 블로그용으로 다시 정리한 글입니다. 첫 번째 글에서는 Git의 내부 모델, 특히 커밋과 브랜치, HEAD 개념을 다룹니다.

Git을 조금 쓰다 보면 빠릿빠릿하다. 브랜치를 마구 생성해도 딱히 느려지지 않고 커밋도 금방 만들어진다.

그러다 보면 아래와 같은 찜찜함이 들기도 한다.

“브랜치를 만들면 파일이 한 벌 더 생기는 거 아닌가?"
"프로젝트가 커지면 커질수록 브랜치를 여러 개 만들면 디스크를 많이 먹는 거 아닌가?"
"브랜치를 옮기면 내 파일들은 정확히 어디로 가는 거지..?”

결론부터 말하면 Git 브랜치는 파일 묶음의 복사본이 아니라 커밋을 가리키는 포인터다. 그래서 브랜치를 만드는 일은 “새로운 꼬리표 하나를 특정 커밋에 붙이는 것”과 비슷하다.

생각보다 단순한 원리인데, 바로 그 단순함 때문에 Git 브랜치는 빠르고 가볍다고 할 수 있다.

Git은 변경분보다 스냅샷에 가깝게 저장한다

Git을 이해할 때 먼저 잡아야 하는 전제는 커밋이다.

SVN 같은 중앙 집중식 버전 관리 시스템은 “이전 버전과의 차이”를 저장한다. 그와 달리 Git에서 커밋은 프로젝트의 특정 시점 스냅샷에 가깝다. 즉, 변경된 파일을 통째로 저장한다. (Git도 내부적으로 압축과 델타 최적화를 한다고는 한다.)

commit
└── tree
    ├── README.md -> blob
    ├── src      -> tree
    │   └── app.ts -> blob
    └── package.json -> blob

여기서 대략적인 역할은 이렇다.

  • blob: 파일 내용
  • tree: 디렉터리 구조
  • commit: 어떤 tree를 가리키고, 부모 커밋과 작성자, 메시지 같은 메타데이터를 담는 객체

중요한 점은 Git이 모든 파일을 매번 새로 복사하는 식으로 커밋을 만들지 않는다는 것이다. 변경되지 않은 파일은 이전 파일의 링크를 재사용한다. 새 커밋은 “이 시점의 프로젝트는 이 tree를 보면 된다”는 기록에 가깝다.

Git의 4가지 파일 상태

브랜치와 HEAD를 보기 전에 Git의 네 가지 파일 상태를 알아둬야 한다. 왜냐하면 Git이 실제로는 working tree, 스테이지(index라고도 함), HEAD를 비교하면서 “이 파일이 지금 어떤 상태인지” 판단하기 때문이다.

  1. untracked: 한 번도 커밋하지 않은 파일. Git이 아직 버전으로 기록하지 않는 상태.
  2. staged: add 명령어를 통해 파일을 스테이지에 올린 상태.
  3. unmodified: 커밋 이후 파일이 바뀌지 않은 상태. 스테이지, working tree, HEAD가 같은 내용을 보고 있다.
  4. modified: 이미 추적 중인 파일이 수정된 상태. 다시 커밋하려면 add로 스테이지에 올려야 한다.

여기서 포인트는 modified가 그냥 “파일이 바뀜”이라는 뜻만은 아니라는 것이다. Git 입장에서는 working tree의 파일 내용이 스테이지나 HEAD에 기록된 내용과 달라졌다는 뜻이다.

git status
# working tree, index, HEAD를 비교해서 파일 상태를 보여준다.

git status는 Git이 세 공간을 어떻게 보고 있는지 알려준다. working tree clean이 보인다면 세 공간이 모두 같은 내용을 보고 있다는 뜻이다.

Git의 add와 commit

addcommit도 내부적으로는 어떻게 작동하는지 이해할 필요가 있다. 왜냐하면 브랜치와 HEAD가 커밋을 가리키는 포인터라는 사실을 이해하려면, 커밋이 어떻게 만들어지는지 알아야 하기 때문이다.

  • add: 앞으로 버전으로 기록할 파일 상태를 스테이지, 즉 index에 올리는 것. ☞ “이 파일의 이 상태를 다음 커밋 후보로 삼겠다”
  • commit: 그 시점에 index에 올라와 있는 파일들의 상태를 하나의 커밋 객체로 저장하는 것.

처음에는 add를 “파일을 추가한다” 정도로 외우기 쉽다. 그런데 이미 추적 중인 파일을 수정했을 때도 다시 add를 해야 한다. 이때의 add는 새 파일 등록이라기보다 수정된 파일의 현재 상태를 index에 다시 올리는 작업에 가깝다.

git add README.md
# README.md의 현재 내용을 index에 올린다.
# 새 파일이면 추적 대상으로 등록하고,
# 이미 추적 중인 파일이면 다음 커밋에 들어갈 상태를 갱신한다.

저수준 CLI 명령어로도 확인해볼 수 있다.

git hash-object README.md
# 파일 내용으로 체크섬을 계산한다.

git add README.md
# .git/index, 즉 스테이지에 README.md의 현재 상태가 기록된다.

git ls-files --stage
# 스테이지에 올라온 파일과 체크섬을 확인할 수 있다.

여기서 앞서 언급했지만 index는 스테이지의 다른 이름이다. 커밋 버튼 누르기 전 대기실 같은 곳이라고 생각하면 된다. 대기실이라고 해서 커밋 후에 텅 비는 것은 아니다. 커밋한 뒤에도 스테이지에는 커밋한 상태의 파일 정보가 남아 있다.

그래서 git commit 후에 working tree clean이 보인다는 것은 대략 이런 뜻이다.

HEAD         -> 방금 만든 커밋
index        -> 방금 커밋한 파일 상태
working tree -> 실제 작업 폴더의 파일 상태

조금 더 내부로 들어가면 commit은 index의 상태를 tree 객체로 만들고, 그 tree를 가리키는 commit 객체를 만든다.

git write-tree
# 현재 index의 상태를 tree 객체로 저장한다.

echo "커밋 메시지" | git commit-tree <tree-id> -p HEAD
# tree 객체와 부모 커밋 정보를 바탕으로 commit 객체를 만든다.

그런데 여기서 끝이 아니다. commit 객체를 만들었다고 해서 곧바로 브랜치가 움직이는 것은 아니다. git log에 표현되려면 HEAD 갱신이 필요하다.

git update-ref refs/heads/main <commit-id>
# main 브랜치가 새 commit 객체를 가리키도록 포인터를 업데이트한다.

그러면 이제 main 브랜치가 새 커밋을 가리키게 된어 git log에도 새 커밋이 보이게 된다.

브랜치는 길이 아니라 포인터다

브랜치라는 단어 때문에 머릿속에는 자연스럽게 나뭇가지 그림이 떠오르고, 실제로 그렇게 많이들 도식화하여 표현한다.

A---B---C main
     \
      D---E feature

이 그림이 이해는 잘 된다만 가끔 오해를 만든다. 브랜치가 정말로 물리적인 길이나 별도의 복사본처럼 존재한다고 느끼게 하기 때문이다.

Git 내부 모델에서 브랜치는 훨씬 단순하다.

브랜치는 특정 커밋 해시를 가리키는 이름이다. ☞ 마우스 커서처럼 “어딘가를 가리키고 있는 것”을 떠올리면 된다.

예를 들어 main 브랜치가 C 커밋을 가리키고 있다면 이런 상태라고 볼 수 있다.

main -> C

여기서 새 브랜치 feature/login을 만들면 Git은 파일 전체를 복사하지 않는다. 그냥 같은 커밋을 가리키는 꼬리표를 하나 더 만든다.

main          -> C
feature/login -> C

명령어로는 이런 느낌이다.

git checkout -b feature/login
# 현재 커밋을 가리키는 새 브랜치를 만든다.
# 프로젝트 파일 전체를 복사하는 작업이 아니다.

그래서 브랜치를 만드는 일은 빠르다. 파일 수가 많아도 본질적으로는 “새 ref를 만든다”는 작업에 가깝기 때문이다. ※ 프로젝트 폴더를 통째로 복사하는 일이 아니다.

실제로 Git 저장소 안에서는 브랜치가 .git/refs/heads/ 아래의 참조로 표현된다. 예를 들어 main 브랜치는 대략 이런 식의 정보를 가진다.

.git/refs/heads/main
└── 3f2a1c...  # main이 가리키는 커밋 해시

커밋하면 브랜치 포인터가 움직인다

브랜치를 만든 직후에는 mainfeature/login이 같은 커밋을 가리킨다.

A---B---C

        main
        feature/login

여기서 feature/login 브랜치에서 새 커밋 D를 만들면 어떻게 될까?

A---B---C---D
        ↑   ↑
        main
        feature/login

새 커밋이 생기고, 현재 브랜치 포인터만 앞으로 이동한다. 새로 커밋을 할 때마다 현재 브랜치는 최신 커밋을 “가리킨다.” main은 그대로 C에 남아 있다.

이 관점으로 보면 브랜치가 왜 실험에 적합한지 이해된다. 실험용 브랜치에서 커밋을 쌓아도, 기존 브랜치의 포인터는 움직이지 않는다. 실패하면 그 브랜치를 버리면 된다.

git checkout main
git branch -D feature/login
# feature/login이라는 이름표를 지운다.
# 이미 다른 곳에서 참조하지 않는 커밋은 나중에 Git의 정리 대상이 될 수 있다.

브랜치를 지운다고 당장 프로젝트 전체가 삭제되는 것이 아니다. 브랜치라는 참조가 사라지는 것이다. 이 차이를 알면 branch -D 같은 명령어를 볼 때 덜 무서울 수 있다.

HEAD는 “내가 지금 보고 있는 곳”이다

브랜치 다음으로 자주 나오는 개념이 HEAD다.

HEAD는 현재 작업 위치를 가리킨다. 보통은 특정 커밋을 직접 가리키기보다는 현재 체크아웃한 브랜치를 가리킨다. HEAD를 이용해서 브랜치 사이를 마치 타임머신처럼 넘나들 수 있다.

HEAD -> main -> C

이 상태에서 main 브랜치에 커밋을 하나 추가하면 main이 앞으로 움직이고, HEAD는 계속 main을 따라간다.

HEAD -> main -> D

그래서 일반적으로 “현재 브랜치에 커밋한다”라고 말할 수 있다. 정확히는 HEAD가 가리키는 브랜치가 새 커밋으로 이동하는 것이다.

그런데 HEAD가 브랜치가 아니라 특정 커밋을 직접 가리키는 경우도 있다. 이것이 detached HEAD 상태다.

git checkout 3f2a1c
# HEAD가 브랜치 이름이 아니라 특정 커밋을 직접 가리킨다.

detached HEAD가 무조건 나쁜 것은 아니다. 과거 커밋을 잠깐 확인할 일이 있을 수 있으니까. 하지만 이 상태에서 커밋을 만들면 브랜치 이름이 그 커밋을 잡아주지 않는다. 다른 브랜치로 체크아웃하는 순간 커밋은 다 사라진다. 물론 git reflog로 복구할 수 있지만 권장하지 않는 방법이라고 한다.

다시 말해 “임시로 과거 시점에 서 있는” 상태에 가깝다. 여기서 의미 있는 작업을 계속할 거라면 브랜치를 만들어주는 편이 낫다. 꼭 브랜치를 이용해 체크아웃 할 것!

git checkout -b debug/old-commit
# 현재 위치를 새 브랜치로 가리키게 한다.

checkout은 세 군데를 바꾼다

브랜치를 이동하는 명령어를 실행하면 단순히 HEAD만 바뀌는 것이 아니다. Git은 대체로 세 가지를 맞춘다.

  • HEAD: 현재 브랜치 또는 커밋
  • index: 다음 커밋에 들어갈 준비 영역(= 스테이지 영역)
  • working tree: 실제 파일이 보이는 작업 디렉터리

예를 들어 main에서 feature/login으로 이동하면 Git은 HEADfeature/login으로 바꾸고, 해당 커밋에 맞게 index와 working tree도 갱신한다.

git checkout -b feature/login
# HEAD가 feature/login을 가리키도록 바뀐다.
# index와 working tree도 해당 브랜치의 커밋 상태에 맞춰진다.

이때 커밋하지 않은 변경사항이 충돌을 일으킬 수 있으면 Git은 이동을 막는다. 현재 작업 디렉터리의 변경사항을 덮어쓸 위험이 있기 때문이다.

이 모델을 알고 있으면 checkout, reset, restore와 같은 명령어들이 어떻게 다른지 이해하기 쉬워진다. 결국 이 명령어들은 HEAD, index, working tree 중 어디를 움직이느냐의 문제이기 때문이다.

그래서 브랜치는 왜 가벼운가

다시 처음 질문으로 돌아오면 답은 단순하다.

브랜치는 커밋을 가리키는 포인터라서 가볍다.

조금 풀어쓰면 이렇다.

  • 새 브랜치를 만든다고 파일 전체를 복사하지 않는다.
  • 브랜치 이름은 특정 커밋 해시를 가리키는 참조다.
  • 새 커밋을 만들면 현재 브랜치 포인터가 앞으로 이동한다.
  • 브랜치를 삭제하는 것은 대체로 그 참조를 삭제하는 일이다.

그래서 Git에서는 브랜치를 아끼지 않아도 되며, 오히려 위험한 작업을 하기 전에 브랜치를 하나 만드는 습관이 권장된다.

Takeaway

  • Git 커밋은 프로젝트의 특정 시점 스냅샷에 가깝다.
  • 브랜치는 특정 커밋을 가리키는 포인터다.
  • HEAD는 현재 작업 위치를 가리킨다.
  • 보통 HEAD는 브랜치를 가리키고, 브랜치는 커밋을 가리킨다.
  • 새 커밋을 만들면 현재 브랜치 포인터가 앞으로 이동한다.
  • 브랜치 생성은 파일 복사가 아니라 참조 생성에 가깝기 때문에 가볍다.
  • 위험한 작업 전에는 브랜치를 하나 만들어두는 것이 좋다.

Git을 잘 쓰기 위해 명령어를 많이 외우는 것도 중요하겠지만, 지금 내가 어떤 커밋 위에 있고, 어떤 포인터를 움직이고 있는지, working tree가 어떤 상태인지 안다면 조금 더 두려움 없이 Git을 다룰 수 있을 것이다.

참고 자료