- Published on
Git 내부 구조로 이해하는 명령어 — 객체, 참조, 인덱스로 다시 읽는 reset과 rebase
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 명령을 외우는 사람과 모델을 아는 사람
Git 사용법을 검색하면 대부분 명령어 목록이 나옵니다. 이런 상황에는 이 명령, 저런 상황에는 저 명령. 문제는 목록에 없는 상황이 반드시 온다는 것입니다. 그때 명령을 외운 사람은 다시 검색하고, 데이터 모델을 아는 사람은 추론합니다.
다행히 외울 모델은 아주 작습니다. 객체 네 종류, 참조, 인덱스. 이 셋이 전부입니다. 이 글은 그 셋을 직접 열어 보고, 그 다음 reset과 rebase와 cherry-pick과 분리된 HEAD가 왜 그렇게 동작하는지를 하나씩 되짚습니다. 데이터가 어떻게 저장되는지 자체가 궁금하다면 Git은 데이터를 어떻게 저장하나 편이 더 자세합니다. 이 글은 그 모델로 명령을 다시 읽는 쪽에 집중합니다.
네 가지 객체 — 그리고 같은 내용은 같은 해시
Git이 저장하는 객체는 네 종류뿐입니다.
블롭은 파일 내용만 담습니다. 파일 이름도, 권한도, 위치도 들어 있지 않습니다. 트리는 디렉터리 하나를 표현하며 모드와 타입과 해시와 이름의 목록을 담습니다. 커밋은 트리 하나와 부모 커밋들, 작성자와 커미터, 메시지를 담습니다. 태그 객체는 주석 달린 태그를 만들 때 생기며 대상 객체와 태그 이름과 서명을 담습니다.
그리고 이 모든 객체의 이름은 내용을 해싱한 값입니다. 그래서 내용이 같으면 이름도 같습니다. 확인하기 쉽습니다.
$ printf 'hello\n' > hello.txt
$ cp hello.txt copy.txt
$ git add hello.txt copy.txt
$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 copy.txt
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello.txt
파일 이름이 다른데 해시가 같습니다. 저장된 객체도 하나뿐입니다. 내용이 같으면 저장소 어디에서 몇 번을 다시 커밋하든 객체는 늘어나지 않습니다.
여기서 파생되는 사실이 하나 있는데, 실무에서 자주 사람을 혼란스럽게 합니다. 블롭이 이름을 모르기 때문에 Git은 파일 이름 변경을 기록하지 않습니다. 이름은 트리가 들고 있고, 이름만 바뀐 변경은 트리만 달라진 새 커밋일 뿐입니다. 로그와 diff에 보이는 이름 변경 표시는 저장된 사실이 아니라 Git이 그때그때 내용 유사도로 추정한 결과입니다. 이름 변경을 넘어 이력을 따라가는 옵션이 가끔 이상하게 동작하는 이유가 여기에 있습니다.
직접 열어 보기 — hash-object와 cat-file
해시가 어떻게 계산되는지는 명령 두 개로 확인됩니다.
$ printf 'hello\n' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a -
두 번째 줄이 Git이 실제로 하는 일입니다. 타입 이름과 바이트 수와 널 바이트로 이루어진 머리말을 내용 앞에 붙이고, 그 전체를 해싱합니다. 머리말이 있기 때문에 같은 바이트열이라도 블롭과 트리는 다른 해시를 갖습니다.
커밋도 특별할 것이 없는 텍스트입니다.
$ git commit -q -m "첫 커밋"
$ git cat-file -p HEAD
tree aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7
author Demo <demo@example.com> 1785081963 +0900
committer Demo <demo@example.com> 1785081963 +0900
첫 커밋
부모 줄이 없습니다. 최초 커밋이기 때문입니다. 여기서 트리를 따라 한 단계 더 들어갑니다.
$ git cat-file -t aaa96ce
tree
$ git cat-file -p aaa96ce
100644 blob ce013625030ba8dba906f756967f9e9ca394464a hello.txt
커밋이 트리를 가리키고, 트리가 이름과 블롭을 짝지어 들고 있습니다. 이 구조가 저장소 전체에 재귀적으로 반복될 뿐입니다. 커밋 객체 안에 파일 내용은 한 바이트도 없고, 파일 이름조차 없습니다.
객체가 몇 개나 있는지, 어떤 타입인지 한꺼번에 보고 싶다면 일괄 조회가 있습니다.
$ git cat-file --batch-check --batch-all-objects | head -4
ad50a5790e96b8f448d121aa76cee2cc3f05afce commit 178
aaa96ced2d9a1c8e72c56b253a0e2fe78393feb7 tree 37
ce013625030ba8dba906f756967f9e9ca394464a blob 6
참조 — 브랜치가 41바이트짜리 파일인 이유
브랜치는 자료 구조가 아닙니다. 커밋 해시 하나를 담은 텍스트 파일입니다.
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
951f4a64d6435393fdedb7eb6aadd53939826b4c
$ wc -c .git/refs/heads/main
41 .git/refs/heads/main
40자의 16진수와 줄바꿈 하나. 그래서 브랜치 생성은 파일 하나를 쓰는 작업이고, 저장소가 아무리 커도 비용이 같습니다. 다른 버전 관리 시스템에서 브랜치가 무거운 작업이었던 시절의 감각을 Git에 그대로 들고 오면 브랜치를 아끼게 되는데, 아낄 이유가 전혀 없습니다.
HEAD는 한 겹 더 있습니다. 커밋을 직접 가리키는 대신 브랜치 이름을 가리키는 상징 참조입니다. 그래서 커밋을 만들면 HEAD가 가리키는 브랜치의 파일이 갱신됩니다.
$ git symbolic-ref HEAD
refs/heads/main
$ git update-ref refs/heads/experiment 951f4a6
$ git rev-parse experiment
951f4a64d6435393fdedb7eb6aadd53939826b4c
한 가지 함정이 있습니다. 참조가 늘 파일로 존재하는 것은 아닙니다. 정리 작업이 돌고 나면 참조들이 한 파일로 묶여 개별 파일은 사라집니다.
$ git pack-refs --all
$ ls .git/refs/heads/
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
951f4a64d6435393fdedb7eb6aadd53939826b4c refs/heads/main
디렉터리가 비어 있다고 브랜치가 사라진 것이 아닙니다. 파일을 직접 읽는 대신 언제나 Git에게 물어야 합니다.
$ git rev-parse main
951f4a64d6435393fdedb7eb6aadd53939826b4c
$ git show-ref --heads
참조가 수만 개로 늘어나는 대형 저장소를 위해 새 저장 형식도 준비돼 있습니다. 아직 실험 단계이지만, 참조 조회 자체가 병목이 되는 환경에서는 검토할 만합니다.
$ git init --ref-format=reftable myrepo
인덱스 — 스테이징 영역의 실체
스테이징 영역은 개념이 아니라 파일입니다. 저장소 안의 이진 파일 하나이고, 경로와 블롭 해시와 파일 상태 정보의 목록을 담고 있습니다.
$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 hello.txt
100644 3b18e512dba79e4c8300dd08aeb37f8e728b8dad 0 note.txt
여기서 두 가지를 읽어야 합니다.
첫째, 파일을 스테이징하는 순간 그 내용은 이미 객체로 저장됩니다. 스테이징은 예약이 아니라 기록입니다. 그래서 아래 결과가 나옵니다.
$ echo v1 > note.txt
$ git add note.txt
$ echo v2 > note.txt
$ git commit -q -m "메모 추가"
$ git show HEAD:note.txt
v1
$ cat note.txt
v2
스테이징한 뒤에 파일을 더 고쳤다면 커밋되는 것은 스테이징 시점의 내용입니다. 이것은 버그가 아니라 인덱스가 무엇인지에 대한 정의 그대로의 동작입니다. 같은 이유로, 스테이징만 하고 커밋하지 않은 내용도 사고 후에 복구할 수 있습니다. 객체는 이미 저장돼 있기 때문입니다.
둘째, 목록의 숫자 0은 단계 번호입니다. 평상시에는 0 하나뿐이지만 충돌 중에는 같은 경로가 세 줄로 늘어납니다.
$ git ls-files --unmerged
100644 a1b2c3d4e5f60718293a4b5c6d7e8f9012345678 1 src/checkout/retry.ts
100644 b2c3d4e5f60718293a4b5c6d7e8f90123456789a 2 src/checkout/retry.ts
100644 c3d4e5f60718293a4b5c6d7e8f90123456789ab1 3 src/checkout/retry.ts
1번이 공통 조상, 2번이 현재 브랜치, 3번이 들어오는 쪽입니다. 충돌 상태라는 것은 인덱스에 한 경로에 대한 후보가 세 개 들어 있는 상태를 말합니다. 충돌을 해결하고 스테이징하면 세 줄이 0번 한 줄로 합쳐지고, 그때 비로소 커밋이 가능해집니다. 해결 도중에 각 버전을 직접 꺼내 볼 수도 있습니다.
$ git show :1:src/checkout/retry.ts > base.ts
$ git show :2:src/checkout/retry.ts > ours.ts
$ git show :3:src/checkout/retry.ts > theirs.ts
인덱스에는 파일 크기와 수정 시각 같은 상태 정보도 함께 들어 있습니다. 상태 확인이 파일을 열어 보지 않고도 변경 여부를 판단할 수 있는 이유이고, 파일이 수십만 개가 되면 그 확인 자체가 병목이 되는 이유이기도 합니다. 그 지점의 처방은 저장소가 느려질 때 편에 정리했습니다.
모델이 설명해 주는 명령들
이제 명령들을 다시 읽습니다. 판단 기준은 세 가지뿐입니다. 새 객체를 만드는가, 참조를 옮기는가, 워킹 트리를 덮어쓰는가.
| 명령 | 데이터 모델에서 하는 일 | 새 객체 | 워킹 트리를 덮어쓰나 |
|---|---|---|---|
git branch | 커밋 해시 한 줄을 담은 참조를 쓴다 | 없음 | 아니오 |
git switch | HEAD가 가리키는 대상을 바꾸고 인덱스와 워킹 트리를 맞춘다 | 없음 | 예 |
git commit | 트리와 커밋 객체를 만들고 현재 브랜치 참조를 옮긴다 | 만듦 | 아니오 |
git reset --soft | 브랜치 참조만 옮긴다 | 없음 | 아니오 |
git reset --hard | 참조를 옮기고 인덱스와 워킹 트리를 대상 커밋으로 맞춘다 | 없음 | 예 |
git rebase | 커밋마다 새 커밋 객체를 만들고 참조를 옮긴다 | 만듦 | 예 |
git cherry-pick | 대상 커밋의 부모를 기준으로 삼자 병합 후 새 커밋을 만든다 | 만듦 | 예 |
git tag -a | 태그 객체를 만들고 참조를 쓴다 | 만듦 | 아니오 |
표를 읽고 나면 자주 나오는 질문들이 저절로 풀립니다.
리셋이 위험한 명령으로 불리는 이유는 참조를 옮겨서가 아닙니다. 참조 이동은 41바이트짜리 파일을 다시 쓰는 일이고, 예전 값은 참조 로그에 남습니다. 위험한 것은 워킹 트리를 덮어쓰는 옵션뿐입니다. 표의 마지막 열이 곧 위험도입니다.
리베이스가 커밋을 옮기지 않고 새로 만드는 이유도 명확합니다. 커밋 객체 안에 부모 해시가 들어 있고 커밋의 이름은 그 내용 전체의 해시이므로, 부모가 달라지면 이름이 달라질 수밖에 없습니다. 재작성이라는 말은 은유가 아니라 문자 그대로의 설명입니다. 이 사실이 만드는 팀 규칙은 merge와 rebase 편에서 다뤘습니다.
체리픽이 충돌하는 이유도 여기서 나옵니다. 체리픽은 저장된 diff를 오려 붙이는 것이 아닙니다. Git은 애초에 diff를 저장하지 않고 스냅숏만 저장합니다. 그래서 체리픽은 대상 커밋의 부모 트리를 공통 조상으로 삼아 삼자 병합을 수행합니다. 가져오려는 커밋의 부모 상태와 지금 브랜치의 상태가 멀수록 충돌이 늘어납니다. 같은 변경을 여러 브랜치에 반복해서 옮기고 있다면 충돌 해결 재사용을 켜 두는 편이 낫습니다.
분리된 HEAD도 특별한 상태가 아닙니다. HEAD 파일에 브랜치 이름 대신 커밋 해시가 직접 들어 있을 뿐입니다.
$ git switch --detach 951f4a6
HEAD is now at 951f4a6 메모 추가
$ cat .git/HEAD
951f4a64d6435393fdedb7eb6aadd53939826b4c
이 상태에서 커밋을 만들면 HEAD 자체가 새 커밋을 가리키도록 갱신되는데, 갱신할 브랜치 참조가 없습니다. 그래서 다른 곳으로 이동하는 순간 그 커밋을 가리키는 것이 아무것도 남지 않습니다. Git이 친절하게 경고해 주는 이유입니다.
$ git switch -
Warning: you are leaving 1 commit behind, not connected to
any of your branches:
d66381f 임시 실험
If you want to keep it by creating a new branch, this may be a good time
to do so with:
git branch <new-branch-name> d66381f
Switched to branch 'main'
분리된 HEAD는 고장이 아니라 이름표가 없는 상태입니다. 커밋 객체는 멀쩡히 저장돼 있고, 이름만 붙이면 그대로 살아납니다.
SHA-1에서 SHA-256으로 — 충돌 공격과 Git의 대응
객체 이름이 해시라는 설계는 우아하지만 해시 함수의 수명에 종속됩니다. Git이 2005년에 고른 것은 SHA-1이었고, 그 뒤로 공격은 꾸준히 발전했습니다.
2017년 CWI 암스테르담과 구글 연구진이 같은 SHA-1 해시를 갖는 서로 다른 PDF 두 개를 공개했습니다. 2020년에는 르랑과 페랭이 선택 접두어 충돌을 실현하면서 비용을 수만 달러 규모까지 낮췄습니다. 선택 접두어 충돌은 공격자가 앞부분을 지정할 수 있다는 뜻이라 실제 문서 위조에 훨씬 가깝습니다.
Git의 해시는 일차적으로 무결성 검사와 식별을 위한 것이지 서명이 아닙니다. 하지만 서명된 커밋과 태그는 결국 이 해시에 서명하므로, 충돌이 가능하다면 서명의 의미가 흔들립니다.
대응은 두 갈래로 진행됐습니다. 첫째는 즉시 적용할 수 있는 방어입니다. Git 2.13부터 충돌 탐지 기능이 붙은 SHA-1 구현이 기본으로 들어갔습니다. 알려진 공격 기법의 흔적이 있는 데이터를 해싱하려 하면 계산을 마치는 대신 거부합니다.
$ git hash-object --stdin < shattered-1.pdf
fatal: SHA-1 collision detected on 38762cf7f55934b34d179ae6a4c80cadccbb7f0a
둘째는 근본 해결입니다. Git 2.29부터 SHA-256 객체 포맷을 실험적으로 쓸 수 있습니다.
$ git init --object-format=sha256 sha256demo
$ cd sha256demo
$ git rev-parse --show-object-format
sha256
$ printf 'hello\n' | git hash-object --stdin
2cf8d83d9ee29543b34a87727421fdecb7e3f3a183d337639025de576db9ebb4
같은 내용인데 이름이 완전히 다르고, 길이가 64자입니다. 저장소 안에서는 모든 명령이 평소대로 동작합니다. 다만 지금 실무에서 전환할 대상은 아닙니다. SHA-1 저장소와 SHA-256 저장소 사이의 상호 운용이 아직 완성되지 않았고, 주요 호스팅 서비스가 받아 주지 않기 때문입니다.
그래도 지금 해 둘 일은 있습니다. 해시 길이를 40으로 가정한 스크립트와 정규식은 언젠가 전부 깨집니다. 도구를 만든다면 길이를 하드코딩하는 대신 저장소에 물어보는 편이 안전합니다.
$ git rev-parse --show-object-format
sha1
마치며 — 명령표 대신 세 가지 질문
Git 명령을 전부 외울 필요는 없습니다. 낯선 명령을 만났을 때 세 가지만 물으면 됩니다. 이 명령이 새 객체를 만드는가, 참조를 옮기는가, 워킹 트리를 덮어쓰는가.
새 객체를 만든다면 해시가 바뀌므로 공유된 이력에서는 조심해야 합니다. 참조만 옮긴다면 참조 로그가 되돌려 줍니다. 워킹 트리를 덮어쓴다면 커밋되지 않은 변경이 사라지고, 그것만은 어떤 도구도 되살려 주지 못합니다. Git에서 진짜로 위험한 명령은 이력을 바꾸는 명령이 아니라, 아직 객체가 되지 않은 것을 지우는 명령입니다.
같은 모델로 되돌리기 명령들을 정리한 글은 상황별 되돌리기 편에 있습니다.