Git 고급 심화 — 객체 저장소·세 트리·머지 전략·리베이스 메커니즘·프로덕션 운영

이 글의 핵심

Git이 객체를 어떻게 직렬화하는지, 인덱스가 충돌 시 왜 “스테이지”를 갖는지, ort 머지가 무엇을 바꿨는지, 리베이스가 어떤 백엔드로 커밋을 재적용하는지, 그리고 번들·워크트리·부분 클론으로 저장소를 어떻게 운영하는지 정리합니다.

들어가며: 명령어 너머의 Git

이 글은 Git 기초 입문 [#1] — 설치·커밋·브랜치·원격 저장소 한 번에에서 다룬 뼈대를 구현과 운영 쪽으로 더 밀어 넣습니다. 객체 저장소의 직렬화 형식에서 시작해, 머지·리베이스 도중 HEAD와 인덱스가 어떤 상태가 되는지, 머지 엔진과 리베이스 백엔드가 실제로 무엇을 하는지, 그리고 번들·워크트리·부분 클론 같은 운영 기능을 차례로 살펴봅니다.


객체 저장소: blob, tree, commit, tag (그리고 pack)

콘텐츠 주소와 직렬화

Git 객체는 헤더와 본문을 이어 붙인 뒤 zlib으로 압축해 저장합니다. loose 객체의 원시 형태는 개념적으로 다음과 같습니다.

"<type> <size>\0<content>"

blob의 <content>는 파일의 바이트열 그 자체이며, 파일 이름이나 경로는 blob에 들어 있지 않습니다. 이름과 경로는 tree가 담당합니다. tree의 <content>는 엔트리의 연속이고, 각 엔트리는 모드 이름\0 뒤에 이진 해시(SHA-1이면 20바이트)가 붙는 형태입니다. 하위 디렉터리는 다른 tree를 가리키는 엔트리로 표현되므로 디렉터리 구조 전체가 재귀적인 tree로 나타납니다.

commit의 <content>는 텍스트 필드로, tree <sha>, 0개 이상의 parent <sha>, author/committer, 선택적 인코딩과 서명, 그리고 메시지를 포함합니다. annotated tag 객체는 object <sha>, type commit(대상 객체 타입), tag 이름, tagger, 메시지와 서명 블록을 담습니다.

객체 ID는 위 바이트열의 해시(기본 SHA-1, --object-format=sha256으로 만든 저장소는 SHA-256)입니다. 내용이 같으면 ID도 같으므로 같은 파일 내용은 한 객체로 공유됩니다. 다만 loose 객체와 여러 pack에 같은 객체가 일시적으로 중복 존재하는 것은 가능하며, gc/repack이 이를 정리합니다.

lightweight tag와 annotated tag

lightweight 태그는 refs/tags/name이 커밋 OID를 직접 가리키는 참조일 뿐이며 tag 객체가 따로 없습니다. annotated 태그는 tag 객체가 존재하고, 참조는 그 tag 객체를 가리키며 tag 객체가 다시 대상 커밋을 가리킵니다. GPG/SSH 서명 태그는 annotated 태그의 객체 안에 서명을 넣는 방식입니다.

그래서 annotated 태그는 “태그 객체 OID”와 “벗겨낸(peeled) 커밋 OID” 두 값을 가집니다. git show-ref -d를 실행하면 refs/tags/v1.0(태그 객체)과 refs/tags/v1.0^{}(peeled 커밋)가 함께 출력되어 이 차이를 확인할 수 있습니다.

Packfile과 델타

loose 객체가 많아지면 파일 수와 I/O 부담이 커지므로, Git은 git gc, git repack, 그리고 fetch/push 전송 과정에서 객체를 pack으로 묶습니다. pack 안에서는 비슷한 객체끼리 델타로 저장해 중복을 줄입니다. 그래서 같은 파일이 조금씩만 바뀌는 히스토리는 공간 효율이 좋지만, 이미 압축된 대용량 바이너리(이미지, 압축 파일, 빌드 산출물)는 델타 효과가 거의 없어 버전마다 용량이 그대로 쌓입니다.

이런 바이너리는 Git LFS나 별도 아티팩트 저장소로 빼는 쪽이 일반적입니다.

직접 객체 만들어 보기

아래는 객체가 어떻게 생겼는지 감을 잡기 위한 예입니다.

echo 'hello' | git hash-object -w --stdin   # blob 생성 후 해시 출력
git cat-file -t <blob-sha>                  # 타입 확인: blob
git cat-file -p <blob-sha>                  # 내용 확인
git cat-file -p HEAD^{tree}                 # 현재 커밋의 tree 엔트리 보기

세 그루 나무: working tree, index, HEAD와 진행 중 상태

기본 세 층

층역할
Working tree편집 중인 실제 파일
Index (staging)다음 커밋에 들어갈 스냅샷(경로별 blob OID와 메타데이터)
HEAD현재 브랜치(또는 detached 상태의 커밋)가 가리키는 커밋. 그 커밋의 tree가 마지막으로 확정된 스냅샷

git diff(워킹 vs 인덱스), git diff --cached(인덱스 vs HEAD), git diff HEAD(워킹 vs HEAD)의 구분은 Git 기초 입문 [#1]에서 설명한 것과 같은 논리입니다.

머지·리베이스·체리픽 중 HEAD는 무엇을 가리키나

일반 커밋이 아닌 작업을 진행하는 동안에는 HEAD 외에 추가 참조와 상태 파일이 생깁니다. 머지 중에는 .git/MERGE_HEAD에 머지 상대의 팁이 기록되고, 충돌을 해결하고 git add하기 전까지 커밋이 막힙니다. 리베이스 중에는 .git/rebase-merge(merge 백엔드) 또는 .git/rebase-apply(apply 백엔드) 디렉터리에 진행 상태가 있고, HEAD는 새 베이스 위에 지금까지 재적용된 마지막 커밋을 가리키는 detached 상태가 됩니다. 체리픽이나 revert 중에는 CHERRY_PICK_HEAD나 REVERT_HEAD가 생깁니다.

즉 HEAD만 봐서는 지금 어떤 작업 도중인지 알 수 없고, git status가 이 상태 파일들을 읽어 “rebase in progress” 같은 안내를 보여 줍니다. 이 상태를 모른 채 git diff만 보고 판단하면 엉뚱한 기준과 비교하게 될 수 있습니다.

인덱스의 스테이지: 충돌 파일이 세 번 보이는 이유

unmerged 경로는 인덱스에 최대 세 개의 스테이지를 동시에 가집니다. 스테이지 1은 공통 조상(merge base) 쪽 blob, 스테이지 2는 “ours”(현재 HEAD 쪽), 스테이지 3은 “theirs”(머지해 들어오는 쪽)입니다. 한쪽에서 파일이 삭제됐다면 해당 스테이지가 빠질 수 있습니다. 충돌을 해결하고 git add하면 그 경로는 다시 스테이지 0 하나로 합쳐집니다.

git ls-files -u

출력에 같은 경로가 여러 번 나오면 아직 충돌이 해결되지 않았다는 뜻입니다. git checkout --ours/--theirs <path>나 git restore --ours/--theirs <path>는 스테이지 2 또는 3의 내용을 워킹 트리에 꺼내 쓰는 명령입니다.

리베이스 중에는 ours/theirs가 직관과 반대가 된다는 점에 주의해야 합니다. 리베이스는 새 베이스(upstream)에 체크아웃한 상태에서 내 커밋을 하나씩 다시 적용하므로, “ours”는 upstream 쪽(지금까지 재적용된 결과)이고 “theirs”는 지금 적용 중인 내 커밋입니다.


머지 전략 내부: 베이스, ort, 이름 변경, 옥토퍼스

삼방향 병합의 전제

표준 머지는 merge base O와 양 끝 A, B의 tree를 비교합니다. 한쪽에서만 바뀐 내용은 자동으로 반영하고, 같은 영역을 양쪽이 바꿨으면 충돌로 남깁니다. 이 단순한 비교가 깨지는 대표적인 경우가 파일 이름 변경입니다. Git은 이름 변경을 기록하지 않고 내용 유사도로 추정하므로, 병합 전략이 rename detection을 직접 수행합니다.

recursive와 ort

오래된 문서는 recursive 전략을 기본으로 설명합니다. Git 2.34부터는 ort(“Ostensibly Recursive’s Twin”)가 기본 병합 전략이 되었고, Git 2.50에서는 recursive를 지정해도 ort로 처리되도록 바뀌었습니다. ort는 결과를 메모리에서 계산한 뒤 워킹 트리에 반영하는 구조로 다시 작성되어, 이름 변경이 많은 대규모 저장소에서 훨씬 빠르고 일부 엣지 케이스의 충돌 처리도 개선되었습니다. 사용자 입장에서 같은 입력에 대한 결과는 대부분 같지만, 이름 변경이 얽힌 경우에는 결과가 달라질 수 있습니다.

ours 전략과 ours 옵션

이름이 겹쳐 혼동하기 쉬운 세 가지가 있습니다. git merge -s ours other는 상대 브랜치의 변경을 전부 버리고 현재 tree를 그대로 유지한 머지 커밋을 만드는 전략입니다. “이 브랜치는 이미 반영된 것으로 기록만 남긴다”는 용도로 씁니다. git merge -X ours other는 기본 ort 전략을 쓰되, 충돌한 덩어리(hunk)만 현재 쪽으로 자동 해결하는 전략 옵션이며 충돌하지 않은 상대의 변경은 그대로 들어옵니다. 마지막으로 git checkout --ours <path>는 이미 발생한 충돌에서 인덱스 스테이지 2를 워킹 트리에 꺼내는 명령입니다. -s ours와 -X ours를 혼동하면 상대 브랜치의 변경이 통째로 사라지므로 팀 문서에 구분해서 적어 두는 것이 좋습니다.

옥토퍼스 머지와 subtree

git merge a b c처럼 둘 이상의 브랜치를 한 번에 합치면 octopus 전략이 자동으로 쓰입니다. 결과는 부모가 셋 이상인 머지 커밋 하나이며, 수동 해결이 필요한 충돌이 생기면 octopus는 머지를 거부합니다. 그래서 서로 독립적인 토픽 브랜치를 묶을 때만 적합합니다.

subtree 전략은 다른 프로젝트의 tree를 하위 디렉터리에 맞춰 병합할 때 쓰입니다. 서브모듈이나 패키지 매니저로 의존성을 관리하는 방식과 비교해, 히스토리가 한 저장소에 섞이고 상위 프로젝트로 변경을 되돌려 보내기가 번거롭다는 트레이드오프가 있습니다.

git merge-tree로 미리보기

Git 2.38부터 git merge-tree --write-tree A B는 워킹 트리와 인덱스를 건드리지 않고 머지 결과 tree를 계산해, 충돌 여부와 충돌 파일 목록을 출력합니다. 서버 쪽에서 PR의 머지 가능 여부를 판단하는 용도로 쓰기 좋습니다. 그래도 큰 충돌을 피하는 가장 확실한 방법은 브랜치 수명을 짧게 유지하고 자주 통합하는 것입니다.


리베이스 vs 머지: 메커니즘과 협업 리스크

DAG 관점

머지는 두 히스토리를 머지 커밋 하나(또는 fast-forward)로 접합하며, 이미 존재하는 커밋 OID를 바꾸지 않습니다. 리베이스는 커밋을 새 베이스 위에 다시 만들어 적용하므로 내용이 같아도 부모가 바뀌어 OID가 전부 달라집니다.

리베이스는 커밋 단위로 순서대로 재적용하며, 충돌이 나면 그 커밋에서 멈춥니다. 해결 후 git add와 git rebase --continue로 다음 커밋으로 넘어가고, --abort로 시작 전 상태로 되돌릴 수 있습니다.

백엔드: merge와 apply

git rebase에는 두 백엔드가 있습니다. Git 2.26부터 기본인 merge 백엔드는 시퀀서를 통해 커밋마다 cherry-pick과 같은 방식으로 삼방향 병합을 수행하므로, 머지 전략의 rename detection이 그대로 적용되고 --rebase-merges, --exec, 인터랙티브 모드를 지원합니다. apply 백엔드는 커밋을 패치로 만들어 git am으로 적용하는 예전 방식으로, --apply를 지정하거나 --whitespace=fix 같은 apply 전용 옵션을 쓸 때 선택됩니다. 두 백엔드는 빈 커밋 처리나 이름 변경 처리 등에서 동작이 다르며, 자세한 차이는 git rebase 문서의 “BEHAVIORAL DIFFERENCES” 절에 정리되어 있습니다.

패치가 잘 쪼개진 선형 히스토리는 리뷰와 git bisect에 유리하다는 점이 리베이스의 장점입니다. 반면 이미 원격에 푸시된 브랜치를 리베이스하면 동료의 로컬 브랜치와 갈라지므로 강제 푸시가 필요합니다. 이때는 --force 대신 --force-with-lease를 써서, 내가 마지막으로 본 이후 원격에 다른 사람의 커밋이 올라왔다면 푸시가 거부되도록 해야 합니다.

인터랙티브 리베이스와 히스토리 편집

rebase -i는 편집기에 작업 목록(todo)을 열어 커밋 순서 변경, 합치기(squash/fixup), 메시지 수정, 중간에 셸 명령 실행(exec)을 지시하게 해 줍니다. 구체적인 사용법은 실전 인터랙티브 리베이스와 reset·revert를 참고하세요.

머지 커밋을 남길지, 리베이스로 줄일지

목표흔한 선택
공개 브랜치의 안정성머지 또는 squash 머지(호스팅 설정)
로컬 정리·리뷰 가독성리베이스 후 PR
이미 공유된 브랜치재작성 금지, 또는 합의 후 --force-with-lease

프로덕션 Git 패턴

번들(bundle): 오프라인·백업·에어갭

git bundle은 pack 데이터와 참조 목록을 하나의 파일로 묶어, 네트워크 없이 저장소를 옮기게 해 줍니다. 번들은 일반 원격처럼 clone/fetch의 대상이 될 수 있습니다.

git bundle create repo.bundle --all
git bundle verify repo.bundle          # 번들이 유효하고 필요한 선행 커밋이 있는지 확인
git clone repo.bundle restored-copy

워크트리(worktree): 한 저장소에 여러 작업 디렉터리

git worktree add ../hotfix hotfix-branch는 같은 객체 저장소를 공유하는 추가 체크아웃을 만듭니다. 메인 작업 디렉터리를 그대로 둔 채 긴급 핫픽스를 다른 디렉터리에서 진행할 수 있습니다. 같은 브랜치를 두 워크트리에서 동시에 체크아웃하는 것은 기본적으로 막혀 있습니다.

부분 클론과 sparse checkout

부분 클론은 git clone --filter=blob:none <url>처럼 blob을 클론 시점에 받지 않고 체크아웃이나 diff에 필요해질 때 받아 오는 방식으로, 클론 시간과 디스크를 줄입니다. 서버가 필터를 지원해야 하며(GitHub, GitLab은 지원), 오프라인에서 과거 커밋을 체크아웃하면 blob을 받아 올 수 없다는 제약이 있습니다. sparse checkout(git sparse-checkout set <dir>...)은 필요한 경로만 워킹 트리에 펼칩니다.

두 기능은 monorepo나 대용량 자산 저장소에서 개발자 머신 부담을 줄이는 대표적인 조합입니다. 다만 빌드가 체크아웃되지 않은 경로에 의존하면 깨지므로, sparse 환경에서도 빌드가 되는지 CI에서 확인해 두는 편이 안전합니다.

유지보수와 무결성

git maintenance start           # 저장소를 등록하고 OS 스케줄러(cron/launchd/작업 스케줄러)로 주기 작업 예약
git fsck --full                 # 끊긴 객체·손상 탐지
git reflog                      # HEAD와 브랜치 이동 기록, 잃어버린 커밋 복구 단서

gc는 도달할 수 없는 객체를 정리하고 pack을 재구성합니다. reflog가 참조하는 커밋은 기본 만료 기간(도달 불가능 항목 30일, 그 외 90일) 동안 남아 있으므로, 실수로 리셋한 커밋도 그 사이에는 복구할 수 있습니다. 공유 서버에서는 gc가 실행되는 시간과 동시 fetch/push에 대한 정책을 정해 두는 것이 좋습니다.

보안과 거버넌스

호스팅 서비스에서는 보호 브랜치, 필수 리뷰, CI 상태 검사, 서명 커밋/태그 요구를 조합해 메인 브랜치에 들어가는 변경을 통제합니다. 자체 서버라면 pre-receive 훅에서 비밀 토큰 패턴이나 브랜치 이름 규칙을 검사할 수 있습니다. 외부 기여는 fork 기반 PR로 받고 CODEOWNERS로 경로별 리뷰어를 지정하면 권한을 분리할 수 있습니다.

워크플로 전반은 Git 워크플로우 가이드와 함께 읽으면 좋습니다.


같이 보면 좋은 글