Git의 안을 들여다보고 Git이 어떻게 작동하는지 알려드리겠습니다. 너무 디테일한 것들은 알아서 빼놓고 설명해드리겠습니다. 그래도 자세히 알고 싶은 분들은: the user manual로 가시길 바랍니다.
Git은 어떻게 눈에 띄지않게 강력한 툴일 수 있을까요? 습관적으로 하게되는 commit과 병합을 제외하고, VCS자체가 컴퓨터에 설치되지 않은 것 같아보일 때가 있습니다.
다른 VCS들은 사용할때 쓸때없이 많은 절차와 검열 등으로 고생할 수 있습니다. 파일의 보안상태가 '읽기전용’으로 세팅되어 작업을 하려고 할 때마다 중앙서버에 승인을 요청해야 할 경우도 빈번합니다. 그리고 VCS의 유저들이 많아질수록 업무처리 속도가 현저히 떨어질수도 있습니다. 그리고 중앙서버나 네트워크가 다운 될 경우에는 아무런 작업을 할 수 없죠.
반면에, Git은 당신의 로컬컴퓨터 디렉토리에 '.git’디렉토리를 형성하여 그곳에 작업기록을 하게됩니다. 그 기록은 온전히 당신만의 것이죠. 그렇기에 오프라인 상태에서도 작업을 끊기지 않고 진행할 수 있습니다. 그리고 작업중인 파일에 대해 모든 권한을 가지고 있게 해주죠.
대부분의 사람들은 크립토그래피를 어떤 정보를 비밀스럽게 숨기는 정도로 생각합니다. 그러나 크립토그래피의 진정한 목적은 정보를 보안하는 것이죠. 크립토그래피로 보호되는 hash는 데이터가 공격받거나 지워질 위험으로부터 보호해줍니다.
SHA1 hash는 고유의 160-비트 ID 번호로 생각하시면 됩니다. 그리고 이 번호는 당신이 평생, 또는 열번의 삶을 살 정도의 시간에, 쓸 byte에 부여되는 번호이기에 보안이 철저합니다.
SHA1 hash 자체 역시 byte로 구성되어 있기에 SHA1 hash의 hash를 만드는 것도 가능합니다. 이건 생각보다 유용한 기능입니다: 'hash chains’를 한번 살펴보세요. 우리는 나중에 Git이 이 기능을 어떻게 사용해서 효율적으로 데이터를 보호하는지 살펴보겠습니다.
짧게 말하자면, Git은 당신의 모든 데이터를 `.git/objects`섭디렉토리에 저장합니다. 그리고 보통의 파일이름대신 각 파일에 지정된 ID를 통해서 이 파일들을 찾을 수 있습니다. 그렇기에 Git은 보통의 파일시스템을 뭔가 굉장히 효율적인 데이터베이스로 변화시켜줍니다.
어떻게 Git이 당신이 파일의 이름을 변경할 때 Git에게 이름을 바꾼다 말한적도 없는데 알수 있을까요? *git mv*를 실행할수도 있겠지만 그것은 *git rm*을 사용하고 *git add*를 사용하는 것과 같습니다.
Git은 단순화된 지침으로 재설정된 파일명을 찾아내고 버전사이의 카피들을 만들어냅니다. Git은 코드들이 옮겨지고 카피되고 지워질 경우를 다 알아낼수 있죠. 모든 경우의 수를 다 알수는 없겠지만, Git은 대부분을 알아채고 있고 이 자동화된 기능은 날이 갈수록 발전하고 있습니다.
Git은 Git이 트랙킹하는 모든 파일들의 크기, 생성된 시간, 마지막 편집시간을 색인 (index)를 이용해서 기록하여 둡니다. 만약에 어떤 파일에 작업하여 변화가 생겼다면 Git은 현 파일상태와 인덱스에 저장되어있는 상태를 비교하여 파일의 변화를 감지합니다. 만약에 서로간의 차이가 없다면 Git은 그 파일이 가장 최신버전으로 업데이트되었다고 생각하고 더 이상 읽지 않습니다.
인덱스 정보를 읽는 작업은 파일을 읽는 것보다는 훨씬 빠르게 진행되니 당신이 한 작업들은 Git이 아주 빠르게 업데이트 해줄 것입니다.
인덱스는 마치 중간 대기 구역과 같다고 말씀드린 적이 있습니다. 왜 그렇게 얘기 했을까요? Add 명령어는 파일들을 순전히 업데이트 시켜 데이터베이스에 업데이트 하지만 *commit*은 (별도의 옵션을 사용하지 않는다는 전제하에) 자체적인 로컬데이터베이스에 있는 파일들에 대한 commit만 진행하지요.
Linux Kernel Mailing List post 에서 Git의 역사를 나열하여 설명해줍니다. Git의 역사학자들에게 정말 흥미있는 웹사이트지요.
데이터의 모든 버전은 '오브젝트 데이터베이스’에 보관되며, 이 데이터베이스는 `.git/objects`의 섭디렉토리에 상주하고 있습니다. `.git/`에 있는 다른 파일들은 더 적은 정보를 담고 있지요 (인덱스, branch 이름, 태그, 설정 정보, 로그, head commit의 위치 등). 오브젝트 데이터베이스는 Git의 기본이지만 우아하고 또 Git의 힘의 원천입니다.
`.git/objects’에 있는 파일들은 각각 오브젝트입니다. 그리고 크게 세가지 오브젝트로 나눌수 있습니다: blob 오브젝트, tree 오브젝트, and commit 오브젝트.
첫째, 마술을 보여드리겠습니다. 우선 아무 파일 이름을 선택하십시오. 빈 디렉토리에서 :
$ echo sweet > YOUR_FILENAME $ git init $ git add . $ find .git/objects -type f
.git/objects/aa/823728ea7d592acc69b36875a482cdf3fd5c8d
을 보게 될 것입니다.
저는 파일이름을 알지도 못하는데 이걸 제가 어떻게 알까요? 왜냐하면
"blob" SP "6" NUL "sweet" LF
의 SHA1 hash 는 aa823728ea7d592acc69b36875a482cdf3fd5c8d 이고, SP 는 공간이며, NUL 는 0 바이트이고, LF는 라인피드이기 때문입니다. 직접 확인해보시려면 다음 명령어를 입력하세요:
$ printf "blob 6\000sweet\n" | sha1sum
Git은 콘텐츠 주소를 지정하는 것이 가능합니다: 파일은 파일 이름에 따라 저장되지 않습니다. 대신에 hash로 저장이 되며 이런 정보는 blob 오브젝트라고 불리우는 곳에 저장되어 있지요. 우리는 hash를 파일 내용에 대한 고유 ID로 생각할 수 있습니다. 그래서 어떤 의미에서 우리는 파일의 내용에 따라 주소를 지정하는 것으로 생각이 가능합니다. 초기`blob 6`는 오브젝트 타입과 크기를 저장해 놓은 header나 다름없습니다; 단지 내부 부기를 단순화합니다.
따라서 저는 당신이 보게 될 것을 쉽게 예측할 수 있었습니다. 파일 이름은 관련이 없습니다. 내부 데이터만을 이용해 blob 오브젝트를 구성하는게 가능합니다.
그러면 당신은 동일한 파일이 어떻게되는지 궁금 할 수 있습니다. 우선 아무런 파일 이름을 사용해 복사본을 추가해보십시오. + .git / objects +의 내용은 몇개의 복사본을 추가해도 동일합니다. Git은 데이터를 한 번만 저장합니다.
별개로, + .git / objects + 내의 파일은 zlib로 압축되어 있으므로 그들을 직접 보지 마십시오. zpipe -d 를 통해 필터링을 해서 보시던지, 아니면 다음 명령어를 실행해 보시면 됩니다.:
$ git cat-file -p aa823728ea7d592acc69b36875a482cdf3fd5c8d
주어진 오브젝트를 예쁘게 인쇄해줍니다.
그러나 파일 이름은 어디에 간거죠? 그들은 어떤 단계에서 어딘가에 저장되어야합니다. Git은 커밋 중에 파일 이름을 찾습니다.
$ git commit # Type some message. $ find .git/objects -type f
이제 3 개의 개체를 보게 될 것입니다. 이번에는 당신이 선택한 파일 이름에 부분적으로 의존하기 때문에 두 개의 새 파일이 무엇인지 제가 말씀드릴 수 없습니다. ‘`rose’'를 파일이름을 지정한 것으로 가정하여 진행하겠습니다. 그렇게 설정하시지 않은 경우 기록을 다시 작성하여 그렇게 지정한 것처럼 보이게 할 수 있습니다.
You should now see 3 objects. This time I cannot tell you what the 2 new files are, as it partly depends on the filename you picked. We’ll proceed assuming you chose “rose”. If you didn’t, you can rewrite history to make it look like you did:
$ git filter-branch --tree-filter 'mv YOUR_FILENAME rose' $ find .git/objects -type f
그럼 이제 +.git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9+를 보실 수 있을겁니다. 왜냐하면 이것이 SHA1 hash이기 때문입니다:
"tree" SP "32" NUL "100644 rose" NUL 0xaa823728ea7d592acc69b36875a482cdf3fd5c8d
다음을 입력하여 이 파일에 실제로 위 내용이 포함되어 있는지 확인하십시오.
$ echo 05b217bb859794d08bb9e4f7f04cbda4b207fbe9 | git cat-file --batch
zpipe으로 이 hash를 확인하는 것이 아마 쉬운 방법일 겁니다:
$ zpipe -d < .git/objects/05/b217bb859794d08bb9e4f7f04cbda4b207fbe9 | sha1sum
Hash 확인은 cat-file로는 좀 어려울 수도 있습니다. 왜냐하면 압축되지 않은 원래의 파일보다 더 많은 파일을 포함하고 있을 수 있기 때문입니다.
This file is a tree object: a list of tuples consisting of a file type, a filename, and a hash. In our example, the file type is 100644, which means ‘rose` is a normal file, and the hash is the blob object that contains the contents of `rose’. Other possible file types are executables, symlinks or directories. In the last case, the hash points to a tree object.
If you ran filter-branch, you’ll have old objects you no longer need. Although they will be jettisoned automatically once the grace period expires, we’ll delete them now to make our toy example easier to follow:
$ rm -r .git/refs/original $ git reflog expire --expire=now --all $ git prune
실제 프로젝트의 경우 일반적으로 이와 같은 명령을 피해야합니다. 이 명령어들은 백업들을 지울 수 있기 때문이죠. 깨끗한 repository를 원한다면 일반적으로 새로운 클론을 새롭게 만드는 것이 좋습니다. 그리고 +.git+을 조작할때는 주의하십시오: 만약에 여러가지 커맨드들이 동시에 실행 중이거나 갑작스러운 정전이 발생하면 안되잖아요. 일반적으로 refs는 *git update-ref -d*로 삭제해야합니다. 그래도 +refs / original+를 수동으로 제거하는 것이 안전하긴 하지만요.
우리는 3 개의 오브젝트들 중 2 개를 설명했습니다. 세 번째는 'commit’오브젝트입니다. 이 오브젝트의 내용은 commit 메시지와 날짜 및 시간에 따라 다릅니다. 여기에있는 것과 일치 시키려면 약간의 조정이 필요합니다.
$ git commit --amend -m Shakespeare # Commit 메시지를 바꿉니다. $ git filter-branch --env-filter 'export GIT_AUTHOR_DATE="Fri 13 Feb 2009 15:31:30 -0800" GIT_AUTHOR_NAME="Alice" GIT_AUTHOR_EMAIL="alice@example.com" GIT_COMMITTER_DATE="Fri, 13 Feb 2009 15:31:30 -0800" GIT_COMMITTER_NAME="Bob" GIT_COMMITTER_EMAIL="bob@example.com"' # 타임스탬프와 저자를 바꿉니다. $ find .git/objects -type f
그럼 이제 .git/objects/49/993fe130c4b3bf24857a15d7969c396b7bc187
를 보게될것 입니다.
"commit 158" NUL "tree 05b217bb859794d08bb9e4f7f04cbda4b207fbe9" LF "author Alice <alice@example.com> 1234567890 -0800" LF "committer Bob <bob@example.com> 1234567890 -0800" LF LF "Shakespeare" LF
이전과 마찬가지로 zpipe 또는 cat-file을 실행하여 직접 확인할 수 있습니다. 이것은 첫 번째 commit이므로 부모 commit이 없지만 이제 나중에 commit을 할때마다 항상 부모 commit을 식별하는 한 줄이 포함될것 입니다.
Git의 비밀은 너무 단순 해 보입니다. 몇 시간 안에 몇 개의 셸 스크립트를 혼합하고 C 코드를 추가하여 몇 시간 만에 만들 수있는 것 같아 보입니다. 결국에 Git은 견고성을 위해 잠금 파일과 fsync로 장식 된 기본 파일 시스템 작업과 SHA1 해싱의 혼합으로 구성된 프로그램입니다. 실제로 이것은 Git의 초기 버전을 정확하게 설명합니다. 그럼에도 불구하고 공간을 절약하기위한 독창적인 패킹 트릭과 시간을 절약하기위한 독창적 인 인덱싱 트릭을 포함해 이제 우리는 Git이 버전 컨트롤을 위한 완벽한 데이터베이스로 거듭나게 되는지 알게되었습니다.
예를 들어, 오브젝트 데이터베이스 내의 파일이 디스크에 의해 손상된 경우 오류가 발생하면 hash가 더 이상 일치하지 않아 문제를 알려줍니다. 기존 hash에 다른 hash를 지정해 주면서, 우리는 모든 수준에서 무결성을 유지합니다. Commit은 부분적으로 파일을 관리하지않고 전체적으로 프로젝트를 관리하여줍니다. Commit의 hash를 계산하고서 모든 tree, blob 과 부모 commit들을 저장한 후에 데이터베이스에 저장합니다. 오브젝트 데이터베이스는 정전과 같은 예상치 못한 방해에 영향을받지 않습니다.
우리는 Git으로 가장 사악한 적도 물리칠수 있습니다. 만약에 누군가가 아주 예전 버전의 프로젝트에서 파일 내용을 은밀하게 수정한다고 생각해봅시다. 오브젝트 데이터베이스를 정상 상태처럼 보이게 유지하려면 해당 데이터베이스의 해시도 변경해야 할겁니다. 이 말은 해당 파일을 참조하는 tree 오브젝트의 hash도 변경해야한다는 의미입니다. 그리고 차례로 그러한 tree를 포함하는 모든 commit 오브젝트의 hash도 변경해야 한다는 거지요. 이러면 해당 파일 이후의 commit모두 변경을 해야한다는 말이됩니다. 이것은 공식 헤드의 hash는 나쁜 repository의 hash와 달라질 수 밖에 없다는 것입니다. 그러니 일치하지 않는 hash의 흔적을 따라 나쁜의도로 변경된 파일을 정확히 찾아 낼 수 있습니다. 처음에 손상된 commit도 마찬가지로 찾을 수 있습니다.
짧게 말하자면, 마지막 commit을 나타내는 20 바이트가 안전하다면 Git repository를 임의로 아무에게도 들키지 않게 조작하는 것은 불가능합니다.
Git의 유명한 기능은 또 어떻습니까? branch 만들기? 병합하기? 태깅하기? 세부 사항. 현재 head는 + .git / HEAD + 파일에 보관됩니다. commit 오브젝트의 hash를 포함합니다. Commit 중에 hash가 업데이트됩니다. Branch는 거의 동일합니다: 그것들은 +.git/refs/heads+에 저장된 파일들 입니다. 태그도 +.git/refs/tags +에 있지만 다른 명령어들로 업데이트됩니다. == 부록 A: Git의 약점들 ==
Git을 소개하면서 저는 Git의 약점들을 몇 개 숨기긴 했습니다. 몇가지 약점들은 script나 hook을 통해 해결할수 있고, 몇가지는 프로젝트를 수정하면서 해결할수 있고, 그 외의 약점들은 현 시점에선 그냥 앉아서 기다리고 있을 수 밖에 없습니다. 그러기 싫으시다면 직접 도와줘보십쇼!
시간이 지나면 해커들은 SHA1의 약점들을 더 많이 발견하게 될겁니다. 이미 hash에서의 충돌을 찾아내는 건 가능한 일이지요. 몇 년 안에는 Git repository를 위해할 수 있는 연산능력을 가진 일반컴퓨터도 있을 수 있습니다.
Git이 그런 일이 일어나기전에 hash관련 기능들을 발전할 수 있었으면 좋겠어요.
Git을 Microsoft Windows에서 사용하는 건 성가실 수 있습니다:
- Cygwin, 리눅스와 비슷한 윈도우체제에선 a Windows port of Git 가 있습니다.
- Git for Windows 는 아직 몇몇 허점이 있지만 Windows에서 Git을 효율적으로 쓸수 있게 해줍니다.
만약에 당신의 프로젝트가 굉장히 크고, 쓸때없는 파일들이 많이 들어있는 상태이고, 상시로 바뀌는 상태라면, Git은 하나의 파일을 트랙킹하지 않기에 다른 VCS보다 유용하지 않을 수 있습니다. Git은 프로젝트 단위로 트랙킹을 하기 때문입니다. 이건 Git의 장점입니다.
그래도 만약 하나의 파일만을 트랙킹하기 원하다면 프로젝트를 여러개의 파트로 분리해두는 겁니다. 여러개의 파트로 분리해도 git submodule 명령어를 이용하면 하나의 repository를 유지할 수 있을겁니다.
몇몇의 VCS는 유저들로 하여금 작업하기전에 파일들을 강제로 마킹 시킵니다. 이러한 강제성은 중앙서버와 연결하는데 귀찮은 절차이지만 두개의 장점이 있습니다:
- 버전의 차이 (Diff)를 체크하는데 매우 빠릅니다. 마킹 된 파일만 검사하면 되니까요.
- 유저는 어떤 사람이 어떤 작업을 하는지 중앙서버를 조회하면 간단히 알아낼 수 있습니다.
Git으로도 이렇게 하는게 가능합니다. 그러나 그렇게 하기위해선 코딩이 좀 필요하니 프로그래머의 도움이 좀 필요할 수 있겠군요.
Git은 프로젝트 전체를 트랙킹하기 때문에 어떤 한 파일의 히스토리를 재건설하는데 다른 (하나의 파일만 트랙킹하는) VCS들보다 느릴 수 있습니다.
그렇게 심하게 느려진다는 것은 아니고 오히려 Git의 장점들이 이 하나의 단점을 상쇄하고도 남습니다. 예를 들어 'git checkout’은 'cp -a’보다 빠르고 프로젝트 전체의 변화를 압축화하는 것이 파일 하나하나씩 압축하는 것보다 효율적입니다.
만약에 어떠한 프로젝트의 히스토리가 길 경우, 클론을 만드는 것은 다른 VCS들의 'checking out’보다 컴퓨터의 용량을 더 차지할 수 있습니다.
그러나 길게보면 클론이 checking out보다 나을 것입니다. 클로닝 이후 다른 명령어들은 매우 빠르고 오프라인으로도 진행이 가능하니까요. 그러나 어떠한 경우에는 좀 더 히스토리가 얕은 클론을 --depth 명령어를 통해 만드는 것이 더 나은 선택일 수 있습니다. 이렇게 만들어진 클론은 작업실행 속도가 빠르겠지만 몇몇 기능들이 제외되어 있을 수 있습니다.
Git은 파일에 작업을 더 많이할 수록 그 작업량에 대비해 빠르게 Version Control을 할 수 있도록 하기위해 쓰여진 프로그램입니다. 인간은 하나의 버전에서 다음 버전으로 작업을 할때 소량의 작업만 진행할 수 있죠. 예를들어, 한줄짜리 코드에 있는 버그를 고친다던가, 새로운 기능을 넣는다던가, 코멘트를 코드에 단다거나 말이죠. 그런데 만약 commit과 commit 사이에 작업량이 방대하게 클 경우 그 파일의 히스토리는 비례해서 커질 수 밖에 없겠죠.
VCS는 이것에 대해 아무것도 할 수 없습니다. 일반 Git 유저들은 그 부풀어진 파일들을 곧대로 받아들일 수 밖에 없겠죠.
그러나 왜 방대한 작업량이 필요했는지에 대해 알아볼 필요는 있습니다. 파일 포맷이 바뀌어서 그랬을수도 있죠. 소량의 작업은 소량의 변화를 주기마련입니다.
아니면 데이터베이스나 백업자료실를 구축해놓는 것이 이런 방대한 프로젝트들을 진행하는 데에 있어 VCS보다 적합할수도 있습니다. 예를 들어 VCS는 어떤 웹캠에서 주기적으로 찍은 이미지를 관리하는 데에는 적합하지 않습니다.
만약에 파일들이 매번 변화하고 있고 각각의 변화에 무조건 버젼번호를 매겨야겠다 한다면 Git을 중앙서버처럼 쓰는 방법밖에 없습니다. 개발자들은 상대적으로 가벼운 클론을 만들면 되죠. 이렇게 일을 진해하면 물론 단점도 있을겁니다. 픽스들을 패치로 배포해야하고 Git tool들이 들어먹지 않을 수도 있어요. 근데 이렇게라도 일을 진행해야하는게 맞는 방법일 수 있습니다. 아무도 히스토리가 매우 긴 프로젝트들을 곧대로 받긴 싫어하거든요.
다른 예시로는 큰 바이너리 파일들을 수행하는 펌웨어들에 기반한 프로젝트를 진행할 경우입니다. 펌웨어의 히스토리는 유저들에게 별로 흥미로운 소재는 아니고, 업데이트들은 압축률이 매우 좋지 않습니다. 그래서 펌웨어들을 재구성할떄는 repository의 크기가 매우 커지는 경우가 있죠.
이럴때에는 모든 소스코드들이 Git repository에 저장되어 있는 편이 좋고, 바이너리 파일들은 따로 보관되어야 할 것 입니다. 이 일을 좀 더 쉽게 진행하기 위해서 Git 유저가 어떤 파일에 대해 클론을 만들수있고 *rsync*를 할 수 있으며, 가벼운 클론을 만들수있는 코드를 배포하는 것이 좋을 수 있습니다.
몇몇 중앙관리식 VCS들은 새로운 commit이 받아들여질때마다 임의의 양의정수를 보존합니다. Git은 양의정수보다 나은 hash를 써서 commit을 관리합니다.
그러나 어느 사람들은 아직도 양의정수로 commit관리를 하길 추구합니다. 다행히도 Git에 추가프로그래밍을하여 Git repository에서 양의정수를 1씩 더하는 방식으로 commit을 관리할수도 있습니다.
어느 클론 파일이나 양의정수를 사용하여 commit을 관리할 수 있습니다. 그러나 이건 아무짝에도 쓸모가 없죠. 중앙 repository만 이 숫자를 쓸꺼니까요.
빈 섭디렉토리는 트랙킹되지 않습니다. 그러나 더미 파일을 만들어서 트랙킹하게 편법을 쓸 수 있죠.
현 버전의 Git으로써 이 문제점은 Git의 약점입니다. Git이 다시 수면위로 올라가고 더 많은 사람들이 사용하게 될수록 이런 약점도 메꿔나가 지겠죠.
보통의 컴퓨터공학자들은 숫자를 셀 때 0부터 세지 1부터 세지 않습니다. 하지만 안타깝게도 commit의 횟수를 셀때 git은 컴퓨터공학자들처럼 숫자를 세지 않습니다. Git의 그 많은 명령어들은 commit이 태초적으로 한번 이루어지기 전까지는 실행되지 않을겁니다. Branch들을 rebasing 할때나 이럴 경우에는 예외일 수도 있습니다.
애초에 Git은 태초의 commit으로부터 많은 혜택을 받습니다: repostiory가 생성되자마자 HEAD는 20 zero bytes의 스트링으로 자동설정됩니다. 이 특별한 commit은 빈 나무로 표현합니다. 빈 나무는 부모님 commit도 없습니다. 한마디로 근본이 없는 친구를 태초의 commit으로 부릅니다.
그리고 태초의 commit후, git log를 로드했을때 Git이 오류를 내지 않고 단순히 commit이 하나도 안 되었다고 알려줄 것입니다.
태초의 commit은 한마디로 zero commit의 양자같은 컨셉트입니다.
그러나 이런 구성은 가끔 문제를 야기합니다. 여러개의 branch가 모두 태초의 commit을 하고 이제 branch를 병합시켜야 할때, rebasing은 아마 유저 본인이 수동으로 버전청소를 하라고 할수도 있습니다.