Submodule과 Subtree — 외부 레포 통합의 두 가지 철학
gitlink 객체 구조부터 subtree merge strategy, filter-repo 모노레포 마이그레이션까지, 외부 레포를 통합하는 두 가지 근본적으로 다른 접근을 추적한다.
- 01 Git이 파일이 아닌 SHA로 세상을 보는 이유
- 02 Git의 모든 포인터는 어디에 살고 있는가
- 03 Git은 파일 변경을 어떻게 추적하는가
- 04 Git history는 왜 DAG인가
- 05 Git Branch는 왜 이렇게 가벼운가
- 06 Git merge는 어떻게 충돌을 판단하는가
- 07 Rebase는 왜 새 commit을 만드는가
- 08 Git에서 변경을 되돌린다는 것의 의미
- 09 git stash는 어떻게 두 영역을 동시에 보존하는가
- 10 git push/fetch는 내부에서 무슨 일이 일어나는가
- 11 Git은 어떻게 실수를 복구하는가
- 12 Git Hook은 어디서 막고 어디서 통과시키는가
- 13 Submodule과 Subtree — 외부 레포 통합의 두 가지 철학
- 14 거대 레포는 어떻게 git을 견디는가
- 15 Git 워크플로우의 선택은 왜 팀 규모와 신뢰 수준에 달려 있는가
- 16 Git이 Push를 거부할 때 — 7가지 거부의 구조
Git에서 외부 레포를 가져오는 방법은 두 가지다 — submodule과 subtree. 둘은 같은 문제를 풀지만, 데이터 모델부터 협업 흐름까지 근본적으로 다른 선택을 한다. 이 차이를 모르면 “왜 sub 변경이 parent에 안 보이지?”와 “왜 clone 후 vendor 디렉토리가 비어있지?” 사이에서 영원히 헤맨다.
Submodule의 본질 — gitlink
Submodule을 이해하는 열쇠는 tree entry의 mode 160000이다. Git의 tree entry는 파일(100644), 실행 파일(100755), 디렉토리(040000)를 구분하는데, 160000은 그 어느 것도 아닌 gitlink — 별도 레포의 특정 commit SHA를 가리키는 포인터다.
$ git ls-tree HEAD vendor/lib
160000 commit abc1234... vendor/lib
160000의 SHA를 parent 레포에서 조회하면 실패한다.
$ git cat-file -t abc1234
fatal: bad file # parent의 .git/objects에 없음
그 객체는 sub의 .git에 있다. parent는 SHA만 알고, 객체는 모른다. 이 분리가 submodule의 전부다.
vendor/sub/.git은 디렉토리가 아니라 텍스트 파일(gitfile)이다. gitdir: ../../.git/modules/vendor/sub 형태로 실제 .git 데이터 위치를 가리킨다. sub의 모든 객체는 parent의 .git/modules/<name>/에 보관된다.
이 구조의 직접적인 귀결: sub 안에서 commit 후 parent에서 git add vendor/sub && git commit을 하지 않으면 다른 협업자는 새 SHA를 알 수 없다. 두 단계가 항상 필요하다.
.gitmodules와 .git/config — 설정의 두 층위
Submodule의 메타데이터는 두 곳에 분리되어 저장된다.
.gitmodules — commit되어 공유된다. 모든 협업자가 보는 설정이다.
[submodule "vendor/sub"]
path = vendor/sub
url = https://github.com/foo/sub.git
branch = main
.git/config — 로컬 활성화 상태다. git submodule init이 .gitmodules를 읽어 이 파일에 복사한다.
[submodule "vendor/sub"]
url = https://github.com/foo/sub.git
active = true
URL을 변경하면 이 두 층위가 어긋난다. .gitmodules만 수정하고 commit/push한 뒤, 기존 협업자는 git submodule sync로 .git/config를 갱신해야 한다. 이 sync를 빠뜨리면 여전히 옛 URL로 fetch를 시도한다.
Subtree — history를 통합하는 방식
Subtree는 정반대 선택을 한다. 외부 레포의 tree를 prefix 디렉토리로 wrap해 parent에 직접 merge한다.
$ git subtree add --prefix=vendor/lib <url> main --squash
결과로 만들어지는 vendor/lib/은 gitlink가 아니라 mode 040000의 일반 tree다. 객체는 parent의 .git/objects에 있다. clone 한 번으로 sub도 함께 온다. init도 update도 없다.
--squash 플래그는 외부의 모든 commit을 단일 commit으로 압축한다. 없으면 외부의 commit chain 전체가 parent history에 들어온다.
--squash: A ── B ── M (Squashed lib)
└─ history 압축
--no-squash: A ── B ── lib-c1 ── lib-c2 ── M
└─ lib 모든 commit 통합
외부로 변경을 push back하려면 git subtree push가 필요한데, 내부적으로 git subtree split(vendor/lib 관련 commit만 추출)을 실행한 후 외부 레포로 push한다. submodule이 sub 안에서 자연스럽게 git push하는 것보다 훨씬 무겁다.
두 철학의 트레이드오프
같은 문제를 푸는 두 방식의 차이는 결국 **“분리를 유지할 것인가, 통합할 것인가”**다.
| 측면 | Submodule | Subtree |
|---|---|---|
| Parent 크기 | 작음 (SHA만) | 큼 (객체 포함) |
| Clone | 두 단계 | 한 번 |
| 외부 변경 push back | 자연스러움 | 복잡 (split) |
| 보안/라이센스 격리 | 강함 (별도 레포) | 약함 (통합) |
| 학습 곡선 | 높음 | 낮음 |
Submodule은 sub의 lifecycle이 parent와 다를 때, 보안 격리가 필요할 때, GPL 같은 라이센스를 분리해야 할 때 적합하다. Subtree는 단일 clone이 우선이고, sub이 자주 변하지 않는 vendored 라이브러리일 때 적합하다.
두 방식을 동시에 쓸 수 있다. 보안이 필요한 private sub은 submodule, 자주 안 변하는 작은 utility는 subtree — 같은 parent에서 혼용이 가능하다.
git filter-repo — history 자체를 재작성할 때
submodule/subtree와는 다른 차원의 도구가 git filter-repo다. 레포를 분할하거나 통합할 때, 혹은 history에서 시크릿을 영구 제거할 때 사용한다. filter-branch의 현대적 대체재로, Python 기반으로 100배 이상 빠르다.
# services/api/ 만 추출해 새 레포로
$ git clone monorepo api-extracted
$ cd api-extracted
$ git filter-repo --path services/api --path-rename services/api/:
$ git remote add origin <new-url>
$ git push -u origin main
--path-rename services/api/:는 services/api/ 안의 파일들을 root로 올린다. 반대로 여러 작은 레포를 모노레포로 통합하려면 각 레포에서 --path-rename :services/api/로 prefix를 추가한 후 --allow-unrelated-histories로 merge한다.
정리
- Submodule의 핵심은 mode
160000gitlink다. parent는 sub의 특정 SHA만 pin하고, 객체는 sub의.git에 있다. .gitmodules(공유)와.git/config(로컬) 두 층위를 이해해야 URL 변경 시sync가 왜 필요한지 이해된다.- Subtree는 외부 tree를 parent에 통합한다. 단일 clone이지만 parent가 무거워지고 push back이 복잡하다.
- 선택 기준은 “분리가 필요한가(submodule) vs 단순함이 우선인가(subtree)”. 대부분의 범용 라이브러리 의존성은 패키지 매니저가 더 나은 답이다.
git filter-repo는 history 자체를 재작성하는 도구다. 모노레포 분할, 통합, 시크릿 제거에 쓰며filter-branch의 현대적 대체재다.
다음 글에서는 대용량 파일을 Git이 어떻게 처리하는지, pack 파일 포맷과 Git LFS의 내부 동작을 추적한다.