← all posts
DEV 2026.05.05 · 10 min read Intermediate

Submodule과 Subtree — 외부 레포 통합의 두 가지 철학

gitlink 객체 구조부터 subtree merge strategy, filter-repo 모노레포 마이그레이션까지, 외부 레포를 통합하는 두 가지 근본적으로 다른 접근을 추적한다.


Git에서 외부 레포를 가져오는 방법은 두 가지다 — submodule과 subtree. 둘은 같은 문제를 풀지만, 데이터 모델부터 협업 흐름까지 근본적으로 다른 선택을 한다. 이 차이를 모르면 “왜 sub 변경이 parent에 안 보이지?”와 “왜 clone 후 vendor 디렉토리가 비어있지?” 사이에서 영원히 헤맨다.

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도 함께 온다. initupdate도 없다.

--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하는 것보다 훨씬 무겁다.

두 철학의 트레이드오프

같은 문제를 푸는 두 방식의 차이는 결국 **“분리를 유지할 것인가, 통합할 것인가”**다.

트레이드오프
측면SubmoduleSubtree
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 160000 gitlink다. 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의 내부 동작을 추적한다.