외계어 같은 git 메시지들을 이해해 보자

    깃을 CLI로 쓰다 보면 명령어를 칠 때마다 뜨는 메시지들이 있다. 사실 원하는 작업이 성공할 때는 이 메시지를 제대로 읽을 생각도 잘 안 하는데, 그러다가 예전에 detached HEAD로 갔다가 삽질했던 적이 있기도 하고, 개인적으로 깃을 쓰면 쓸수록 진짜 신기하고 좋은 기술이라는 생각이 들어서 어떻게 작동하는 건지 궁금하기도 했다. 그래서 아주 대표적인 명령어라고 할 수 있는 commit/push, pull, rebase/merge 메시지를 뜯어보면서 이 메시지들이 어떤 의미를 가지고 있는지, 그게 git의 동작 원리랑은 어떤 관계가 있는지 탐구해 보려고 한다.

     

     

    commit을 해보자

    자, test1.txt라는 파일에 “This is test!”라는 내용을 적어 커밋한다고 생각해 보자. git add 후에 커밋하면 아래와 같은 메시지를 보게 될 것이다.

    자, 처음에 있는 [main b472191]은 브랜치명과 커밋 해시값이라는 것은 쉽게 알 수 있다. 그 다음 줄에 있는 1 file changed, 2 insertions(+)는 이전 커밋에 비해 몇 개 파일이 변경됐는지, 추가된 라인 수와 줄어든 라인 수가 나온다.

    그리고 다음에 나오는 부분이 create mode 100644 파일명 이다. 여기서 말하는 mode란 무엇일까? 100644의 마지막 3자리인 ‘644’를 보자. 이건 파일 접근 권한이다. 앞에 있는 세 자리(100)은 파일 타입이다. 100644는 실행 권한은 없는 일반 파일을 의미한다. 더 자세한 정보는 이 링크를 참조하면 된다. create mode라는 말이 어떤 의미인지 관련 자료를 찾아봐도 확실히 하기 좀 어려웠는데, git 내부적으로 mode가 100644로 설정된 새로운 파일 정보가 추가되었다는 것 정도로 이해하면 될 듯하다.

     

     

    원격 브랜치에 push해 보자

    이와 같이 push하면 이런 메시지들을 보게 된다. 가장 먼저 눈에 띄는 건 objects 라는 단어다. object면 객체고, 이게 아무래도 우리가 커밋한 파일과 관련된 데이터일 것 같다. 근데 뭔가 이상하다. 우리가 커밋하고 푸시한 건 분명 test.txt라는 파일 1개 뿐인데, 푸시할 때 번호를 매기고(enumerate), 개수를 세고(count), 쓰기(write)한 object의 개수는 3개라는 것이다. 그렇다면 단순히 파일 내용 이외에 다른 정보들을 담은 object가 더 있다는 뜻일 수밖에 없다. 그게 뭘까?

     

    이제 다시 우리가 습관적으로 해 오던 git add부터 돌아보면서, git이 어떻게 작동하는지 조금 더 깊게 파 보자.

     

     

    git에선 무슨 일이 일어나고 있을까?

    먼저 우리가 test.txt라는 파일을 작성해서 저장하고, 아직 git add를 하지 않았다고 가정해 보자. 이 상태에서 git commit을 하면 커밋이 될까? 안 된다. 우리가 작성한 test.txt 파일은 git의 관리대상이 아니기 때문이다. git status 명령어를 쳐 보면, untracked files 목록에 test.txt 파일이 있는 걸 볼 수 있을 것이다. 이 상태에서 git add 명령어를 사용하면 test.txt 파일이 working area에서 staging area로 올라간다.

     

    앞에서 stageing area로 올라간다는 표현을 썼다. git에 어느정도 익숙한 사람이라면 당연하게 받아들여지는 개념일 것이다. 그런데 이 stage 된다는 건 어떻게 구현되는 걸까? 진짜 컴퓨터 내부 공간 어딘가에 working directory와 staged directory라는 게 있어서, 그 사이에서 파일이 왔다갔다 하는 걸까?

     

    이 질문에 대답하기 위해, 로컬 깃 저장소의 정보가 담긴 .git 폴더의 변화를 살펴보자. watchtree를 사용해 (macOS에서는 brew를 통해 설치해주어야 한다) watch -n 1 tree .git 명령어를 사용하면 .git 디렉토리의 내용을 트리로 보여주면서, 1초 단위로 변경 내용을 갱신해준다.

     

    먼저 우리가 test.txt 파일을 작성하고 add하지 않았을 때의 tree 상태다.

    그 다음 test.txt를 add했을 때의 tree다. 차이가 보이는가?

    objects라는 디렉토리 밑에 뭔가가 생겼다! a6/fe988… 로 시작하는 파일이다. 디렉토리명 2자(a6)와 나머지 파일명은 해시값인데, 이 파일을 그냥 열면 git 전용 이진 파일이기 때문에 내용을 읽을 수 없고, 대신 git show 커맨드를 사용하면 내용을 볼 수 있다. git show a6fe988 커맨드를 통해 파일 내용을 읽어 보자. 대부분의 깃 관련 해시값이 그렇듯 앞 글자 일부만 쳐도 작동한다.

    놀랍게도 우리가 작성한 test.txt의 내용 그대로다. 이걸 blob 파일이라고 하는데, 이처럼 git add 처리를 하면 git이 해당 파일의 내용을 담은 blob 오브젝트를 만든다. 결국 git add를 통해 staging area로 올린다는 말은 개념적 설명이고, add 처리를 하면 git이 해당 파일을 트래킹하기 시작하고, 원본 파일 내용을 담은 blob 파일을 만들어서 저장한다.

     

    그런데 위에서 본 이미지에서는 object가 3개였다. 아직 2개가 더 남았다는 소린데, 이건 이 add된 파일을 commit해 보면 알 수 있다. git add test.txt 이후에 git commit 키워드로 이 파일을 커밋하면, .git 내부의 objects 디렉토리에는 아래와 같이 2개의 파일이 더 생긴다.

    이제 이 파일들도 git show 키워드를 통해 읽어 보자.

    먼저 566142 파일을 읽어보면 아래와 같은 내용을 볼 수 있다. 맨 위에 써 있는 tree 566142라는 표시에서 볼 수 있듯이 이 파일은 tree라는 object다. 그리고 그 안에는 test.txt가 적혀 있다. 조금 더 자세히 보기 위해 같은 파일을 git cat-file -t 명령어를 통해서 읽어 보겠다.

    blob abfe988이라는 글자가 보이는가? 위에서 살펴본 test.txt의 내용을 담은 blob 파일의 해시값이다. 즉 566142라는 tree 오브젝트가 test.txt의 blob 파일을 가리키고 있다는 뜻이다.

     

    그 다음에는 남은 b47219도 읽어 보자. 우선 git show 명령어로 읽어 보겠다.

    뭔가 보자마자 마음이 편해지면서 익숙한 느낌이 들 것이다. 그렇다. 이 object는 우리가 그동안 늘 봐왔던 commit이라는 오브젝트다. 이 오브젝트의 해시값 b47219… 가 우리가 git을 쓰면서 익숙하게 여기는 커밋의 해시값이다. 여기에는 작성자, 작성 시간 등의 정보가 있다. 그리고 git cat-file -p 명령어로 보면, commit 오브젝트가 tree에 대한 정보를 갖고 있다는 걸 확인할 수 있다.

    tree 566142라는 문구가 보인다. 그리고 이 566142는… 예상하다시피 위에서 살펴 본 바로 그 tree의 해시값이다.

    여기까지 오면 blob ← tree ← commit의 관계를 어느정도 눈치챘을 것 같다. 정리해 보면 아래와 같다.

    우리가 ‘커밋' 단위로 생각하는 것을 조금 더 깊게 들어가 보면, commit이라는 오브젝트는 여러 정보와 함께 특정한 tree의 해시값을 가리키고 있는데, 그 tree 속에는 각 파일의 내용이 담긴 blob이라는 오브젝트의 목록이 있는 구조다. n개의 blob ← tree ← commit이라고 보면 된다.

     

    이러면 위에서 우리가 ‘내가 작성하고 커밋한 파일은 test.txt 하나인데 왜 object는 3개나 되지?’라고 제기했던 의문에 대한 답이 풀렸을 것이다. 우리는 깃을 사용할 때 commit 단위로만 생각하지만, 이 commit이라는 건 결국 blob ← tree ← commit의 3단계 중 가장 추상화된 최종 포인터일 뿐이다. 실제로 정보를 온전히 전달하기 위해서는 이 오브젝트가 모두 필요하다.

     

    변경된 blob만 새롭게 추가하면 된다

    자, 여기에 더해서 test2.txt라는 파일을 새로 만들었다고 생각해 보자. 이 파일에는 “this is second test!”라는 내용을 적어 넣겠다. 그리고 git add로 이 파일을 stage에 올리면 objects 디렉토리의 내용은 다음과 같이 된다. b9/fac595 파일이 생성된 걸 볼 수 있다. 이제 우린 이게 blob 파일임을 안다. 이 파일의 내용은 test2.txt의 내용을 담고 있다.

    자, 이제 다시 커밋을 해 보자. d12704와 1a747 두 오브젝트가 새로 생겼다. 각각 commit과 tree다.

    먼저 commit 오브젝트인 d12704를 읽어 보면, 예상한 것처럼 tree 1a747을 가리키고 있다. 여기서 앞의 첫 번째 commit과는 다르게 parent라는 값이 있음을 볼 수 있는데, parent의 값은 바로 이전 커밋인 b4721의 해시값이다. 커밋들 관의 관계는 나중 커밋이 이전 커밋을 참조하고 있는 구조다. 이걸 보면 왜 많은 자료들에서 커밋을 도식화할 때 커밋1 ← 커밋2 ← 커밋3 과 같은 형태로 쓰는지 알 수 있다.

     

    그 다음에는 tree 오브젝트인 1a747을 읽어 보자. 이 오브젝트가 가리키고 있는 blob은 이제 두 개가 된다. 첫 번째는 맨 처음 생성된 test.txt의 blob 파일인 a6fe988이다. 그리고 새롭게 생성된 test2.txt의 blob인 b9fac5도 추가되어 있다.

    이 blob - tree - commit 구조를 그림으로 그려 본다면 아래와 같다.

    2번째 커밋 d12704의 tree를 따라가 보면 test1과 test2의 blob을 가리키고 있는데, 이 중 test1.txt은 부모 커밋(이전 커밋) 에서 가리킨 blob을 그대로 가리키고 있음을 볼 수 있다. 깃은 일종의 ‘스냅샷'이라는 말을 들어본 적이 있을 것이다. 바로 이런 과정을 통해 2번째 커밋인 d12704는 test1.txt와 test2.txt로 이루어진 저장소의 상태를 스냅샷처럼 찍어놓은 형태로 작동할 수 있는 것이다. 만약 세 번째 커밋에서 기존 파일이 수정되거나 새로운 파일이 추가되면 또 새로운 blob이 추가될 것이고, 3번째 커밋의 tree는 이 blob들의 목록을 가지게 될 것이다.

     

    커밋을 할 때마다 이 오브젝트 파일들은 새로 추가되어서 누적될 뿐 삭제되지 않는다. 그래서 웬만한 작업은 git reflog 같은 명령어를 통해 복구할 수 있다. 이렇게 로컬에서 계속해서 매 커밋의 오브젝트들이 누적되며 관리되기 때문에 네트워크 연결 없이도, 분산식으로 작업할 수 있는 git의 특성이 생기게 된다.

     

    깃을 쓸 때마다 마법처럼 작동한다는 생각을 했는데… (너무 마법 같아서 가끔은 이해가 잘 안 될 때도 많다) 이렇게 동작 구조를 조금 더 면밀히 살펴 보니 아! 하고 깨닫는 부분이 많았다. merge / rebase / cherry-pick 등으로 브랜치의 커밋들이 합쳐지는 경우도 좀 더 자세히 공부해 보고 싶다.


    참고자료

    댓글