git이 어떻게 내부적으로 git의 기능들을 구현하였는지, 그 내부 구조를 살펴본다.
먼저 아래 설명을 하기 위해 해싱에 대해 먼저 설명을 해볼 것이다.
해싱은 어떠한 데이터를 고정된 길이의 해시 값으로 변경하는 것으로, git에서는 해시 값을 통해 git에서 관리하는 오브젝트들을 식별하고 관리한다.
해싱 알고리즘에는 대표적으로 MD5, SHA1, SHA2, SHA3 등이 있다.
그중 git에서는 SHA-1 해싱 알고리즘을 사용하는데, 이 알고리즘은 데이터를 20byte의 해시값으로 변환시킨다. SHA-256 등에 비해서는 collision에 취약하지만, git object들의 경우 그 숫자가 아주 많지 않으므로 SHA-1 알고리즘을 사용하더라도 collision이 일어날 확률은 매우 낮다.
그럼에도 git을 사용하며 collision이 일어날까 두려운, 혹은 불편한 사람들은 아래 git 공식 웹사이트에서 발췌한 글을 읽어보자.
SHA-1 해시 값에 대한 단상
Git을 쓰는 사람들은 가능성이 작긴 하지만 언젠가 SHA-1 값이 중복될까 봐 걱정한다. 정말 그렇게 되면 어떤 일이 벌어질까?
이미 있는 SHA-1 값이 Git 데이터베이스에 커밋되면 새로운 개체라고 해도 이미 커밋된 것으로 생각하고 이전의 커밋을 재사용한다. 그래서 해당 SHA-1 값의 커밋을 Checkout 하면 항상 처음 저장한 커밋만 Checkout 된다.
그러나 해시 값이 중복되는 일은 일어나기 어렵다. SHA-1 값의 크기는 20 바이트(160비트)이다. 해시 값이 중복될 확률이 50%가 되는 데 필요한 개체의 수는 280이다. 이 수는 1자 2,000해 ('자’는 '경’의 '억’배 - 1024, 충돌 확률을 구하는 공식은
p = (n(n-1)/2) * (1/2^160)
)이다. 즉, 지구에 존재하는 모래알의 수에 1,200을 곱한 수와 맞먹는다.아직도 SHA-1 해시 값이 중복될까 봐 걱정하는 사람들을 위해 좀 더 덧붙이겠다. 지구에서 약 6억 5천만 명의 인구가 개발하고 각자 매초 Linux 커널 히스토리 전체와(650만 개) 맞먹는 개체를 쏟아 내고 바로 Push 한다고 가정하자. 이런 상황에서 해시 값의 충돌 날 확률이 50%가 되기까지는 약 2년이 걸린다. 그냥 어느 날 동료가 한 순간에 모두 늑대에게 물려 죽을 확률이 훨씬 더 높다.
git object는 blob, tree, commit으로 나뉘어지며, 이들은 앞서 설명한 SHA-1 해싱 알고리즘으로 만들어진 해시 값으로 식별된다.
이러한 object들은 git에서 key:value 형태로 관리된다. 즉, 해시 값
: 실제 파일
의 형태로, .git/objects/
디렉토리 내부에 저장된다.
또한, git에서 object들은 snapshot으로 저장된다. 이전과 비교했을 때의 변경점을 저장하는 것이 아니라, 파일을 통채로 저장한다는 말이다.
왜 git이 이렇게 동작하는지 불편한 사람들도 있을 텐데, 그에 대한 내용은 아래에서 추가적으로 설명해보겠다.
blob(binary large object)은 git에서 파일을 저장하는 단위이다.
예를 들어, 비어 있던 프로젝트 디렉토리에 “hello”라는 내용이 쓰여진 README.md를 추가했다고 해보자.
이때 README.md에 쓰여 있는 내용을 압축한 객체가 blob이고, 이 압축된 객체의 해시 값으로 이 객체가 관리된다. (zlib으로 압축됨)
blob이 파일에 대응된다면, tree는 디렉토리에 대응된다. 즉, tree는 그 안에 또다른 tree 혹은 blob을 포함할 수 있다.
git에 저장되는 모든 파일들은 blob과 tree의 구조로 관리되며, tree가 저장하는 값은 다음과 같다.
커밋 객체에는 tree와 blob으로 이뤄진 스냅샷을 누가, 언제, 왜 저장했는지 정보를 저장한다.
commit이 저장하는 값은 다음과 같다.
커밋 객체 또한 위 저장하는 값들을 해싱한 해시 값으로 관리된다.
어제 프로젝트 디렉토리에 a
, b
, c
파일을 커밋했었는데, 오늘 a
파일을 수정하고, b
파일을 삭제하고, d
파일을 추가해 현재 디렉토리에 a’
, b
, d
파일이 있다고 해보자.
어제 커밋 상태는 아래와 같다. (해시 값은 편의상 6자리로 나타냈고, 임의의 값이다)
1aa96b commit "yesterday"
3d743c tree
aaf4c6 blob a
62cdb7 blob b
0beec7 blob c
그런데, a
파일을 수정해 a’
이 되었다면, git은 revert, reset 등이 가능해야 하므로 a
파일의 상태와 a’
파일의 상태를 모두 저장해야 한다. 따라서 a’
파일의 내용을 a
와 별도로 저장한다.
b
파일은 삭제하였지만, 이또한 이전의 상태도 기억해야 하므로 b
파일은 남겨둔다.
c
파일은 그대로 둔다.
d
파일은 새로 생성되었으므로, 새로운 커밋 객체의 트리에 더한다.
위 내용을 커밋한 내용은 아래와 같다.
2dd2be commit "today"
c755ee tree
080c56 blob a'
0beec7 blob c
3c3638 blob d
1aa96b commit "yesterday"
3d743c tree
aaf4c6 blob a
62cdb7 blob b
0beec7 blob c
여기까지 읽었다면 git의 내부가 생각보다 상당히 간단히 구현되어 있다는 생각이 들 것이다.
git의 내부가 생각보다 너무 간단해 실망한 사람들이 있을 것 같은데, 실제로 git은 이보다 더 복잡하게 파일을 저장한다.
git은 최적화를 위해 Packfile과 그 index 파일을 만든다. (.git/objects/pack/
에 저장됨)
git은 pack되지 않은 개체(Loose object)가 너무 많을 때, git gc
명령을 실행했을 때, 리모트 서버로 Push할 때 pack을 한다.
git은 먼저 이름이나 크기가 비슷한 파일을 찾고, 두 파일을 비교해서 한 파일은 다른 부분만 저장하는 식으로 pack을 진행한다.
주의할 점은, git은 모든 object를 pack하지는 않는다는 것이다. 일부 object만 pack을 진행한다. 나중에 git object를 통해 실제 파일을 복원하고자 할 때는 loose object 중에 파일이 있는지 확인하고, loose object 중에서 파일이 없다면 packfile의 index 파일을 살펴보며 packfle에 해당 파일이 있는지 확인한다.
Git 공식 문서 - Git의 내부
Git 내부 구조를 알아보자 (1) — 기본 오브젝트
Hash Algorithm Comparison: MD5, SHA-1, SHA-2 & SHA-3