Git 실무 워크플로우 #7 모노레포와 Git — sparse-checkout·서브모듈·LFS

7 분 소요

지금까지의 여섯 편은 저장소의 크기와 무관하게 통하는 내용이었습니다. 그런데 저장소가 어느 규모를 넘어서면 새로운 종류의 문제가 나타납니다. 여러 프로젝트를 한 저장소에 모은 모노레포에서 clone에 수십 분이 걸리고, 디자인 원본이나 모델 파일 같은 대용량 바이너리가 저장소를 계속 부풀립니다. 이 문제들은 브랜치 전략이나 커밋 정리로는 풀리지 않고, Git이 따로 마련해 둔 도구가 필요합니다.

총 7편으로 구성됩니다.

  • #1 브랜치 전략 — GitHub Flow와 trunk-based
  • #2 rebase vs merge — 결정 기준과 금기
  • #3 interactive rebase — squash·fixup으로 커밋 정리
  • #4 충돌 해결 — 충돌의 구조와 mergetool·rerere
  • #5 PR 운영 — 리뷰 단위·커밋 메시지·draft PR
  • #6 stash·cherry-pick·bisect — 일상 도구 셋
  • #7 모노레포와 Git — sparse-checkout·서브모듈·LFS ← 이번 글

이번 글은 각 도구의 깊은 튜토리얼이 아닙니다. 목표는 어떤 도구가 있고 언제 꺼내 쓰는지의 지도를 그리는 것입니다. 증상을 먼저 확인하고, 그 증상에 맞는 도구를 하나씩 대응시키겠습니다.

저장소가 커지면 무엇이 느려지는가 #

Git 기초에서 확인한 대로 clone은 최신 파일만 받는 것이 아니라 첫 커밋부터 지금까지의 히스토리 전체를 받습니다. 저장소가 작을 때는 이 모델의 장점만 보입니다. 네트워크 없이 모든 작업이 되고, 모든 커밋을 로컬에서 조회할 수 있습니다. 그런데 저장소가 커지면 같은 모델이 비용이 됩니다.

  • clone 시간과 디스크 — 10년 치 히스토리, 수만 개 파일, 그동안 커밋된 모든 바이너리의 모든 버전을 전부 내려받습니다.
  • 일상 명령의 속도git status는 작업 디렉터리의 파일들을 훑어야 하므로, 체크아웃된 파일 수에 비례해 느려집니다. 파일 50만 개짜리 모노레포라면 status 한 번이 수 초씩 걸리기 시작합니다.

정리하면 문제는 두 방향입니다. 받아 오는 양(히스토리와 blob 전체)과 펼쳐 놓는 양(작업 디렉터리의 파일 수)입니다. 앞의 것을 줄이는 도구가 partial clone, 뒤의 것을 줄이는 도구가 sparse-checkout입니다.

partial clone — 히스토리는 전부, 내용은 필요할 때 #

partial clone은 커밋 그래프는 전부 받되, 파일 내용(blob)은 실제로 필요해질 때까지 받지 않는 clone입니다.

partial clone
git clone --filter=blob:none https://github.com/example/big-monorepo.git

--filter=blob:none은 커밋과 디렉터리 구조는 모두 받고 blob은 생략하라는 의미입니다. 체크아웃 시점에 최신 버전의 blob만 내려받고, 과거 버전이 필요한 명령(git log -p, git diff 등)을 실행하면 그때그때 서버에서 가져옵니다. 히스토리 조회가 가끔 네트워크를 거치는 대신, 첫 clone이 극적으로 가벼워집니다.

비슷해 보이는 것으로 shallow clone이 있습니다.

shallow clone — CI 용도
git clone --depth 1 https://github.com/example/big-monorepo.git

--depth 1은 최신 커밋 1개만 받고 히스토리를 아예 잘라 냅니다. 한 번 빌드하고 버리는 CI 환경에는 알맞지만, 히스토리가 없으므로 log 조회와 bisect가 제한되고 이후 fetch 동작에도 잔주름이 생깁니다. 개발 머신에는 partial clone, 일회성 CI에는 shallow clone으로 용도를 구분하면 됩니다.

sparse-checkout — 작업하는 디렉터리만 펼치기 #

모노레포에서 내가 만지는 부분이 services/web 하나뿐이라면, 나머지 수십만 파일을 작업 디렉터리에 펼쳐 둘 이유가 없습니다. sparse-checkout은 체크아웃 대상을 지정한 디렉터리로 좁힙니다.

sparse-checkout 설정
git sparse-checkout init --cone
git sparse-checkout set services/web shared/ui

이제 작업 디렉터리에는 최상위 파일들과 services/web, shared/ui만 남습니다. 히스토리는 그대로 전부 있으므로 log 조회나 브랜치 작업에는 제약이 없고, git status가 훑는 파일 수가 줄어 일상 명령이 다시 빨라집니다. 작업 범위가 바뀌면 git sparse-checkout set을 다시 실행해 조정합니다.

대형 모노레포의 표준 조합은 partial clone과 sparse-checkout을 함께 쓰는 것입니다. git clone --filter=blob:none --sparse <URL>로 받은 뒤 git sparse-checkout set <경로>로 내 작업 영역만 펼치면, 받는 양과 펼치는 양을 동시에 줄일 수 있습니다.

서브모듈 — 다른 저장소를 커밋 단위로 참조 #

방향을 바꿔서, 저장소 하나가 커지는 문제가 아니라 저장소 여러 개를 엮는 문제입니다. 서브모듈은 다른 Git 저장소를 내 저장소의 하위 경로에 특정 커밋으로 고정해 참조하는 기능입니다.

서브모듈 추가
git submodule add https://github.com/example/vendor-lib.git vendor/lib
git commit -m "Add vendor-lib submodule"

내 저장소에 기록되는 것은 vendor-lib의 파일들이 아니라 URL과 커밋 해시 하나입니다. 참조 대상이 정확한 버전으로 고정되므로, 외부 의존성을 소스째 특정 버전으로 묶어 두어야 하는 상황에 적합합니다. 펌웨어가 참조하는 외부 SDK, 포크해서 관리하는 서드파티 라이브러리가 전형적인 예입니다.

다만 서브모듈은 악명도 함께 따라다닙니다. 이유는 대체로 세 가지입니다.

  • clone할 때 git clone --recurse-submodules를 쓰지 않으면 서브모듈 디렉터리가 빈 채로 남습니다. 팀원 한 명이 이 옵션을 빠뜨리는 일이 반복해서 생깁니다.
  • 서브모듈을 새 버전으로 올리는 절차가 별도입니다. 서브모듈 안에서 원하는 커밋을 체크아웃하고, 바깥 저장소에서 바뀐 참조를 다시 커밋해야 합니다.
  • 서브모듈 내부는 기본적으로 detached HEAD 상태라, 안에서 무심코 커밋하면 어느 브랜치에도 속하지 않은 커밋이 생깁니다.

그래서 실무의 판단 기준은 단순합니다. 의존성이 패키지 매니저(npm, pip, Go modules)로 해결된다면 그쪽이 먼저이고, 여러 프로젝트를 늘 함께 고친다면 모노레포가 낫습니다. 서브모듈은 소스째 버전 고정이 꼭 필요한 경우에 남겨 두는 선택지입니다.

Git LFS — 대용량 바이너리를 밖으로 #

마지막은 파일 크기의 문제입니다. Git은 텍스트 소스 코드에 최적화되어 있어, 수백 MB짜리 디자인 원본(PSD)이나 학습된 모델 파일을 커밋하면 저장소가 급격히 무거워집니다. 바이너리는 버전마다 통째로 저장되는 것과 다름없고, 한 번 커밋된 모든 버전이 히스토리에 남아 clone 시간에 영구히 더해집니다.

Git LFS(Large File Storage)는 대용량 파일을 저장소 안에 넣는 대신, 저장소에는 작은 포인터 파일만 커밋하고 실제 내용은 별도 스토리지에 올립니다.

Git LFS 설정
git lfs install                # 저장소에 LFS 훅 설치 (1회)
git lfs track "*.psd"          # PSD 파일을 LFS 대상으로 지정
git add .gitattributes         # 추적 규칙 자체를 커밋에 포함
git add design/main.psd
git commit -m "Add main design file"

git lfs track이 만드는 .gitattributes가 추적 규칙의 실체이므로 반드시 함께 커밋합니다. 이후 체크아웃할 때 포인터가 실제 파일로 자동 교체되어, 사용하는 입장에서는 일반 파일과 거의 같게 보입니다. 주의할 점은 호스팅 비용입니다. GitHub의 LFS 스토리지와 대역폭은 무료 할당량이 작아, 대용량 자산이 많은 팀은 요금제나 별도 스토리지 연동을 미리 확인해야 합니다.

노트
LFS는 앞으로 커밋할 파일에만 적용됩니다. 이미 일반 커밋으로 들어간 대용량 파일은 지금 삭제해도 히스토리 속에 그대로 남아 clone을 계속 무겁게 만듭니다. 비밀키를 커밋했을 때와 같은 구조의 문제이고, 히스토리 자체를 다시 쓰는 복구 절차가 필요합니다. 이 복구는 별도 글에서 다루겠습니다.

증상별 도구 선택 #

이번 편의 지도를 표 하나로 접습니다.

증상도구
clone이 너무 느리고 디스크를 많이 차지한다partial clone (--filter=blob:none)
CI에서 한 번 빌드할 저장소를 빨리 받고 싶다shallow clone (--depth 1)
모노레포에서 일부 디렉터리만 작업한다sparse-checkout (+ partial clone 조합)
다른 저장소를 특정 버전으로 고정해 참조해야 한다서브모듈 (패키지 매니저로 되면 그쪽 우선)
대용량 바이너리를 버전 관리해야 한다Git LFS (기존 커밋분은 히스토리 정리 필요)

시리즈 전체 요약 #

일곱 편의 핵심을 한 줄씩 정리합니다.

  • #1 브랜치 전략은 팀 규모와 배포 방식에 맞춰 GitHub Flow나 trunk-based 중에서 고르는 결정입니다.
  • #2 rebase와 merge는 우열이 아니라 용도의 문제이고, 공유 브랜치의 rebase만은 금기입니다.
  • #3 interactive rebase는 squash와 fixup으로 커밋을 리뷰하기 좋은 단위로 다듬는 도구입니다.
  • #4 충돌은 3-way 머지의 구조로 이해하고, mergetool과 rerere로 해결 비용을 줄입니다.
  • #5 PR은 작게 유지하고, 커밋 메시지와 설명에 변경의 의도를 남기는 것이 리뷰의 질을 결정합니다.
  • #6 stash, cherry-pick, bisect는 작업 전환, 커밋 선별 이동, 회귀 지점 탐색이라는 일상 문제의 전용 도구입니다.
  • #7 저장소가 커지면 partial clone, sparse-checkout, 서브모듈, LFS로 받는 양과 펼치는 양을 관리합니다.

마무리 #

Git 기초 시리즈에서 스냅샷 모델과 세 영역, 브랜치와 원격, 되돌리기까지 기본기를 세웠고, 이번 시리즈에서 팀 협업의 실제 상황들, 곧 브랜치 전략, 히스토리 정리, 충돌, PR 운영, 일상 도구, 대형 저장소 대응까지 확장했습니다. 이 두 시리즈면 일상 업무에서 만나는 Git 상황의 대부분을 스스로 판단할 수 있습니다.

남은 것은 사고가 터진 뒤의 대응입니다. 지운 커밋을 reflog로 살리는 법, 비밀키나 대용량 파일이 커밋됐을 때 히스토리를 다시 쓰는 법 같은 복구 주제를 검색형 단편으로 준비하고 있습니다. 이상으로 Git 실무 워크플로우 시리즈를 마칩니다.

X