- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — status 한 번에 12초
클론에 40분이 걸리고, 브랜치를 바꾸면 커피를 타러 갈 수 있고, 상태 확인 한 번에 12초가 지나갑니다.
$ time git status
On branch main
nothing to commit, working tree clean
real 0m12.417s
user 0m1.902s
sys 0m9.884s
이 상태에서 사람들이 가장 먼저 시도하는 것은 검색 상단의 처방입니다. 얕은 클론으로 받고, 정리 명령을 강하게 돌리고, 큰 파일은 LFS로 옮깁니다. 셋 다 상황에 따라 맞는 처방이지만, 원인을 구분하지 않고 적용하면 대개 아무것도 빨라지지 않고 되돌리기만 어려워집니다.
저장소가 느려지는 이유는 크게 세 갈래입니다. 커밋 이력이 긴 것, 추적하는 파일 수가 많은 것, 큰 바이너리가 들어 있는 것. 셋은 서로 다른 병목을 만들고 처방도 겹치지 않습니다.
먼저 측정한다 — 세 갈래를 구분하는 법
가장 빠른 진단은 객체 통계입니다.
$ git count-objects -vH
count: 1042
size: 8.31 MiB
in-pack: 2841903
packs: 14
size-pack: 6.42 GiB
prune-packable: 0
garbage: 0
size-garbage: 0 bytes
여기서 팩 크기가 커밋 수에 비해 지나치게 크다면 바이너리를 의심합니다. 팩 개수가 두 자리라면 정리가 밀린 것입니다. 이제 나머지 두 축을 셉니다.
$ git rev-list --count HEAD
84213
$ git ls-files | wc -l
212847
커밋 8만 개는 요즘 하드웨어에서 크게 문제가 되지 않습니다. 반면 워킹 트리의 파일 21만 개는 상태 확인이 12초 걸리는 이유로 충분합니다. 상태 확인은 기본적으로 추적 중인 모든 경로에 대해 파일 정보를 확인하기 때문입니다.
큰 객체가 어디에 있는지도 직접 셀 수 있습니다.
$ git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| awk '$1 == "blob"' | sort -k3 -n -r | head -5
blob 4d7a2146 104857600 design/hero.psd
blob 91c0ffee 87031808 assets/renders/intro-01.mov
blob 2b7d13aa 73400320 assets/renders/intro-02.mov
blob a04e1f39 52428800 vendor/sdk/ios-framework.zip
blob 77bc09d1 41943040 design/board-v3.psd
어느 명령이 어디서 시간을 쓰는지까지 보고 싶다면 내장 추적을 켭니다.
$ GIT_TRACE2_PERF=1 git status 2>&1 | grep -E 'region|data' | tail -20
측정 없이 고르는 처방은 대체로 가장 부작용이 큰 것을 고릅니다. 순서는 항상 세는 것이 먼저입니다.
받아 오는 양을 줄인다 — 얕은 클론과 부분 클론은 대체재가 아니다
가장 흔한 조언이 얕은 클론입니다.
$ git clone --depth=1 https://github.com/example/monorepo.git
전송량은 확실히 줄지만, 이 클론에는 이력이 없습니다. 그 결과 이력에 의존하는 명령이 전부 무력해집니다. 로그는 커밋 하나에서 끝나고, 특정 줄을 누가 언제 왜 바꿨는지 추적할 수 없고, 두 브랜치의 공통 조상을 찾을 수 없어 머지와 리베이스가 실패합니다. 태그 기반 버전 문자열 생성도 동작하지 않습니다. 나중에 이력이 필요해져서 깊이를 늘리는 작업은 처음부터 다시 받는 것보다 서버에 더 무거운 경우도 많습니다.
부분 클론은 다른 물건입니다. 커밋과 트리는 전부 받고 파일 내용만 필요할 때 가져옵니다.
$ git clone --filter=blob:none https://github.com/example/monorepo.git
Cloning into 'monorepo'...
remote: Enumerating objects: 2841903, done.
remote: Counting objects: 100% (2841903/2841903), done.
Receiving objects: 100% (1204418/1204418), 412.55 MiB | 22.31 MiB/s, done.
Resolving deltas: 100% (812004/812004), done.
Updating files: 100% (48211/48211), done.
이쪽은 로그도 이분 탐색도 공통 조상 계산도 전부 정상입니다. 대가는 예전 파일 내용이 필요한 순간에 네트워크를 타는 지연입니다. 오래된 줄의 이력을 추적하는 작업은 눈에 띄게 느려집니다.
용량 기준으로 거르는 중간 형태도 있습니다. 큰 파일만 나중에 받게 하는 방식으로, 별도 서버 없이 바이너리 문제의 상당 부분을 완화합니다.
$ git clone --filter=blob:limit=1m https://github.com/example/monorepo.git
| 방식 | 줄이는 것 | 그대로 동작하는 것 | 깨지거나 느려지는 것 | 적합한 곳 |
|---|---|---|---|---|
| 얕은 클론 | 커밋 이력 | 최신 스냅숏 빌드 | 로그, 줄 단위 이력, 공통 조상, 태그 기반 버전 | 버리는 CI 체크아웃 |
| 부분 클론 blob:none | 파일 내용 | 로그, 이분 탐색, 공통 조상 | 오래된 파일 접근 시 네트워크 지연 | 개발자 클론의 기본값 |
| 부분 클론 tree:0 | 트리와 파일 내용 | 커밋 메타데이터 조회 | 경로 지정 로그와 대부분의 diff | 커밋 정보만 필요한 도구 |
| 희소 체크아웃 | 워킹 트리 파일 수 | 이력 전체 | 범위 밖 경로의 편집 | 모노레포에서 한 영역만 볼 때 |
정리하면 이렇습니다. 얕은 클론은 버릴 체크아웃을 위한 도구이고, 개발자 머신의 기본값으로 권할 만한 것은 부분 클론입니다. 자동화 파이프라인에서도 머지 기준점 계산이 필요하다면 얕은 클론은 이미 잘못된 선택입니다.
작업 트리를 줄인다 — 희소 체크아웃과 파일 감시
파일 수가 병목이라면 클론 방식으로는 해결되지 않습니다. 워킹 트리에 실제로 펼치는 경로를 줄여야 합니다.
$ git clone --filter=blob:none --no-checkout https://github.com/example/monorepo.git
$ cd monorepo
$ git sparse-checkout init --cone --sparse-index
$ git sparse-checkout set services/payments libs/common
$ git checkout main
$ git ls-files | wc -l
2143
여기서 옵션 두 개가 중요합니다. 콘 모드는 디렉터리 접두어만으로 판정해 매칭 비용을 거의 없앱니다. 옛 문서에 나오는 무시 규칙 스타일의 자유 패턴은 경로마다 패턴 전체를 돌려야 해서, 파일이 많아질수록 오히려 느려집니다. 희소 인덱스 옵션은 인덱스 파일 자체에서 범위 밖 디렉터리를 항목 하나로 접어 둡니다. 워킹 트리만 줄이고 인덱스를 그대로 두면 상태 확인은 여전히 21만 개 항목을 훑습니다.
그다음이 파일 감시입니다. 상태 확인이 느린 근본 원인은 변경 여부를 알아내려고 모든 경로의 정보를 읽는다는 것인데, 운영체제가 변경 알림을 주면 그럴 필요가 없습니다. Git 2.37부터는 별도 도구 없이 내장 감시자가 들어 있습니다.
$ git config core.fsmonitor true
$ git config core.untrackedCache true
$ git status # 첫 실행은 캐시를 채우느라 느립니다
$ time git status
real 0m0.512s
추적되지 않는 파일 캐시를 함께 켜는 이유는, 감시자가 알려 주는 것은 변경된 경로일 뿐이고 새 파일 탐색은 별도의 디렉터리 순회이기 때문입니다. 둘을 같이 켜야 효과가 납니다.
읽기를 빠르게 한다 — 커밋 그래프와 정리 작업
이력이 길어서 느린 경우의 처방은 따로 있습니다. 로그와 공통 조상 계산은 커밋 객체를 하나씩 열어 부모를 따라가는 작업인데, 이 정보를 미리 계산해 별도 파일에 담아 두면 압축 해제 자체를 건너뛸 수 있습니다.
$ git commit-graph write --reachable --changed-paths
Expanding reachable commits in commit graph: 84213, done.
Writing out commit graph in 4 passes: 100% (336852/336852), done.
$ time git log --oneline -- services/payments | wc -l
1841
real 0m0.63s
경로를 지정한 로그가 특히 크게 달라집니다. 변경 경로 옵션이 커밋마다 어떤 경로가 바뀌었는지에 대한 확률적 필터를 함께 저장하기 때문에, 대부분의 커밋을 열어 보지도 않고 건너뜁니다.
이런 보조 파일들을 손으로 관리할 필요는 없습니다. Git 2.31부터 유지보수 작업을 예약해 두는 기능이 표준으로 들어왔습니다. 예전처럼 정리 명령을 크론에 걸어 두던 관행을 대체합니다.
$ git maintenance start
$ git config --get-all maintenance.repo
/Users/dev/work/monorepo
여기서 흔한 잘못된 조언 하나를 짚어야 합니다.
# 널리 퍼져 있지만 대개 손해입니다
$ git gc --aggressive
이 옵션은 기존 델타 체인을 전부 버리고 훨씬 큰 탐색 창으로 압축을 처음부터 다시 계산합니다. 수 기가바이트 저장소에서는 CPU를 몇 시간씩 쓰고, 그렇게 얻는 용량 이득은 보통 크지 않습니다. Git이 평소에 만드는 델타가 이미 충분히 좋기 때문입니다. 팩 개수가 많아 조회가 느린 것이 문제라면, 필요한 것은 재계산이 아니라 재배치입니다.
$ git repack --geometric=2 -d --write-midx
$ git multi-pack-index write
기하급수적 재패킹은 작은 팩들만 골라 합치고 큰 팩은 그대로 둡니다. 다중 팩 인덱스는 여러 팩을 하나의 조회 색인으로 묶어 팩 개수가 조회 비용에 미치는 영향을 없앱니다. 정리의 목표는 팩을 하나로 만드는 것이 아니라 조회를 한 번에 끝내는 것입니다.
큰 바이너리 — LFS가 하는 일과 하지 않는 일
디자인 파일이나 영상이 저장소에 들어 있으면 다른 처방이 전부 무력해집니다. 바이너리는 델타 압축이 거의 듣지 않아서, 100메가바이트 파일을 열 번 수정하면 저장소가 1기가바이트씩 자랍니다.
Git LFS가 하는 일은 생각보다 단순합니다. 지정한 경로에 대해 커밋 시점에 파일 내용을 짧은 포인터 텍스트로 바꿔치기하고, 체크아웃 시점에 실제 내용을 별도 서버에서 내려받습니다. Git이 저장하는 것은 언제나 포인터뿐입니다.
$ git lfs track "*.psd"
$ cat .gitattributes
*.psd filter=lfs diff=lfs merge=lfs -text
$ git cat-file -p HEAD:design/hero.psd
version https://git-lfs.github.com/spec/v1
oid sha256:4d7a214614ab2935c943f9e0ff69d22eafe21c1b8e4a6d0f0a2d0b1e6dbf3a9c
size 104857600
여기서 가장 많이 오해되는 지점이 있습니다. LFS를 오늘 도입해도 어제 커밋한 바이너리는 저장소에서 사라지지 않습니다. 이미 만들어진 커밋의 트리는 여전히 원래 블롭을 가리키고, 그 블롭은 팩 안에 그대로 있습니다. 클론 용량은 1바이트도 줄지 않습니다. 과거까지 정리하려면 이력을 다시 쓰는 별도 작업이 필요하고, 그것은 다음 절의 이야기와 같은 대가를 치릅니다.
$ git lfs migrate import --include="*.psd,*.mov" --everything
migrate: Sorting commits: ..., done.
migrate: Rewriting commits: 100% (84213/84213), done.
migrate: Updating refs: ..., done.
한계도 분명히 알아 둘 만합니다. 서버가 LFS를 지원해야 하고 대개 용량과 대역폭에 별도 요금이 붙습니다. 로컬 캐시는 따로 관리해야 합니다. 그리고 LFS는 바이너리를 다른 곳으로 옮길 뿐, 병합할 수 있게 만들어 주지는 않습니다. 두 사람이 같은 디자인 파일을 고치면 충돌은 여전히 사람이 손으로 골라야 합니다. 그 문제를 진짜로 줄이려면 잠금 기능을 쓰거나, 애초에 산출물을 저장소 밖으로 빼는 편이 낫습니다.
$ git lfs prune # 오래된 로컬 캐시 정리
$ GIT_LFS_SKIP_SMUDGE=1 git clone ... # 포인터만 받고 나중에 선택적으로 내려받기
$ git lfs pull --include="design/hero.psd"
한 가지 덧붙이면, 앞에서 본 용량 기준 부분 클론은 별도 서버 없이 비슷한 효과의 일부를 냅니다. 새로 시작하는 저장소라면 LFS 도입 전에 이 선택지를 먼저 검토할 만합니다.
이미 커버린 저장소 — filter-repo와 모든 해시가 바뀌는 대가
과거의 바이너리나 잘못 들어간 대용량 파일을 실제로 없애려면 이력을 다시 써야 합니다. 예전 문서에 나오는 filter-branch는 Git 공식 문서 자체가 사용을 만류하고 있고, 지금의 권장 도구는 filter-repo입니다.
$ pip install git-filter-repo
$ git filter-repo --path assets/renders --invert-paths
Parsed 84213 commits
New history written in 132.71 seconds; now repacking/cleaning...
Completely finished after 218.04 seconds.
$ git count-objects -vH
in-pack: 1932450
size-pack: 1.14 GiB
6.42기가바이트가 1.14기가바이트가 됐습니다. 여기까지는 좋은 소식이고, 대가는 지금부터입니다.
이력을 다시 쓴다는 것은 모든 커밋의 부모가 바뀐다는 뜻이고, 부모가 바뀌면 커밋 해시가 전부 바뀝니다. 그 결과는 저장소 하나에 그치지 않습니다.
- 열려 있는 모든 PR이 존재하지 않는 커밋을 가리키게 됩니다.
- 태그와 릴리스를 다시 밀어야 하고, 특정 커밋 해시를 고정해 둔 배포 설정과 서브모듈 참조가 전부 깨집니다.
- 팀원 전원이 새로 클론해야 합니다. 기존 클론에서 받아 오면 옛 이력과 새 이력이 둘 다 들어와 저장소가 두 배가 됩니다. filter-repo가 작업 후 원격 설정을 일부러 지우는 이유가 이것입니다.
- 서버 쪽에서도 즉시 사라지지 않습니다. 호스팅 서비스에는 PR 참조와 포크가 옛 커밋을 붙들고 있어, 실제 삭제까지는 별도의 정리 요청이 필요한 경우가 많습니다.
그래서 이 작업은 명령이 아니라 계획이 필요합니다. 머지를 잠그고, 시점을 공지하고, 재작성하고, 강제로 밀고, 전원이 다시 클론하고, 오래된 브랜치를 정리하는 순서입니다. 되돌릴 수 없는 처방은 이 글의 처방 중 이것 하나뿐이고, 그래서 마지막에 놓여 있습니다.
마치며 — 처방보다 분류가 먼저입니다
이 글의 명령들을 다 외울 필요는 없습니다. 기억할 것은 순서입니다. 커밋 수와 파일 수와 팩 크기를 먼저 세고, 그 결과에 맞는 처방을 하나씩 적용합니다.
이력이 길어서 느리면 부분 클론과 커밋 그래프입니다. 파일이 많아서 느리면 희소 체크아웃과 파일 감시이고, 이때 인덱스까지 함께 줄이지 않으면 효과가 반으로 떨어집니다. 바이너리가 문제면 LFS나 용량 기준 부분 클론이고, 과거까지 지우려면 이력 재작성이라는 되돌릴 수 없는 결정을 감수해야 합니다.
대부분의 팀에서 가장 큰 이득을 주는 두 줄은 유지보수 예약과 파일 감시를 켜는 것입니다. 부작용이 없고, 되돌리기 쉽고, 체감이 즉시 옵니다. 이력 재작성은 그 두 줄로 해결되지 않는다는 것이 확인된 뒤에 꺼내도 늦지 않습니다.
객체와 팩파일이 실제로 어떻게 생겼는지는 Git 내부 구조로 이해하는 명령어 편에서 이어집니다.