기록

대용량 GLB 커밋과 Git LFS 적용 시점

TL;DR

  • GitHub은 50 MiB 초과 파일에 경고를 내고, 100 MiB 초과 파일은 push를 막는다.
  • 파일을 삭제하는 커밋은 현재 트리에서만 파일을 제거한다. 이전 커밋의 blob은 히스토리에 남는다.
  • Git LFS 설정은 이후 git add부터 적용된다. 이미 커밋된 대용량 blob을 자동으로 LFS 포인터로 바꾸지 않는다.
  • 기존 히스토리의 대용량 파일을 제거하려면 히스토리 재작성이 필요하다.

3D 뷰어 작업에서 GLB 모델을 public/models/ 아래에 두고 /models/*.glb 경로로 로드했다. Next.js 빌드에 직접 참여하지 않는 정적 파일이므로 아이콘, 폰트, 이미지와 같은 방식으로 다뤘다. 문제는 파일 크기였다. 모델 하나가 100 MiB를 넘으면 GitHub push 단계에서 막힌다. 저장소에 넣을 수 없는 파일을 커밋한 뒤에는 단순 삭제로 해결되지 않는다.

GitHub 파일 크기 제한

GitHub 저장소에는 파일 크기 제한이 있다.

크기 처리
50 MiB 초과 경고
100 MiB 초과 push 차단

60 MiB대 GLB는 경고 구간이라 올라갈 수 있다. 100 MiB를 넘는 GLB는 차단 구간이라 push가 실패한다. 정적 에셋인지 여부는 여기서 중요하지 않다. public/ 아래에 있든, 빌드에 참여하지 않든, Git 객체로 저장되는 순간 같은 제한을 받는다.

삭제 커밋의 범위

대용량 파일을 커밋한 뒤 다음 커밋에서 파일을 삭제해도 push는 여전히 실패할 수 있다. 이유는 Git이 현재 파일 목록만 보내지 않기 때문이다.

commit A: public/models/apple.glb 추가
commit B: 다른 코드 변경
commit C: public/models/apple.glb 삭제

commit C의 트리에는 GLB가 없다. 하지만 브랜치 끝인 commit C를 원격에 보내려면 commit A, commit B, commit C가 모두 필요하다. commit A가 GLB blob을 참조하고 있으므로 해당 blob도 함께 전송 대상이 된다. 삭제 커밋은 “앞으로의 스냅샷에서 파일을 제거한다”는 의미다. 저장소 히스토리에 이미 들어간 blob을 지우는 동작은 아니다.

blob과 tree

Git은 파일 내용을 blob으로 저장하고, 커밋은 tree를 통해 그 blob을 가리킨다.

commit
  -> tree
    -> public/models/apple.glb
      -> blob 176 MiB

파일을 삭제한 커밋은 새 tree를 만든다.

commit
  -> tree
    -> public/models/apple.glb 없음

하지만 이전 커밋의 tree는 바뀌지 않는다. 이전 tree가 대용량 blob을 가리키는 한, 그 blob은 브랜치 히스토리에서 도달 가능하다. 그래서 해결 기준은 “현재 작업 트리에 파일이 있는가”가 아니라 “브랜치 히스토리에서 해당 blob에 도달 가능한가”다. 대용량 blob을 찾을 때는 현재 파일 목록이 아니라 Git 객체를 봐야 한다.

git rev-list --objects HEAD \
  | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \
  | awk '$1=="blob" && $2>50000000 { print $2, $3 }'

이 명령은 현재 체크아웃된 파일이 아니라 HEAD에서 도달 가능한 blob 중 50 MiB를 넘는 항목을 찾는다.

Git LFS가 바꾸는 것

Git LFS는 큰 파일의 실제 바이트를 Git blob에 직접 넣지 않는다. 저장소에는 작은 포인터 파일을 커밋하고, 실제 파일은 LFS 저장소에 따로 둔다. .gitattributes에 GLB 추적 규칙을 추가한다.

*.glb filter=lfs diff=lfs merge=lfs -text

이후 GLB를 git add하면 Git blob에는 실제 모델이 아니라 포인터 텍스트가 들어간다.

version https://git-lfs.github.com/spec/v1
oid sha256:05d480d4bc5eede...
size 71384908

이 포인터는 수백 바이트 수준이다. GitHub의 100 MiB 파일 제한은 Git blob 기준으로 적용되므로, LFS 포인터로 커밋된 GLB는 제한에 걸리지 않는다.

LFS의 적용 시점

Git LFS 규칙은 과거 커밋을 자동으로 바꾸지 않는다.

commit A: apple.glb를 일반 Git blob으로 추가
commit B: .gitattributes에 *.glb LFS 규칙 추가
commit C: orange.glb를 LFS 포인터로 추가

이 경우 commit Corange.glb는 LFS 포인터다. 하지만 commit Aapple.glb는 여전히 일반 Git blob이다. LFS 규칙은 git add 시점의 clean filter로 동작한다. 이미 만들어진 blob은 .gitattributes를 나중에 추가해도 다시 처리되지 않는다. 기존 히스토리의 GLB를 LFS 포인터로 바꾸려면 별도 마이그레이션이 필요하다.

git lfs migrate import --include="*.glb"

이 명령은 히스토리를 재작성한다. 커밋 해시가 바뀌고, 같은 브랜치를 바라보는 다른 작업자는 로컬 히스토리를 맞춰야 한다.

히스토리 재작성의 비용

100 MiB를 넘는 blob 때문에 push가 막힌 상태라면 선택지는 제한적이다.

방법 효과 비용
삭제 커밋 추가 현재 트리에서 제거 이전 blob은 남아 push 차단 가능
LFS 규칙 추가 이후 파일부터 포인터화 기존 blob은 그대로
히스토리 재작성 기존 blob 제거 또는 LFS 변환 커밋 해시 변경

이미 원격에 공유된 브랜치라면 히스토리 재작성은 팀 전체에 영향을 준다. 반대로 아직 push되지 않은 로컬 브랜치라면 비용이 낮다. 대용량 파일을 잘못 커밋한 직후라면 삭제 커밋을 쌓기보다 바로 히스토리를 정리하는 편이 낫다. 재작성 뒤에도 로컬 저장소 크기가 바로 줄지 않을 수 있다. filter-branch 같은 도구는 원래 ref를 refs/original/ 아래에 남길 수 있고, 그 ref가 옛 커밋을 잡고 있으면 대용량 blob도 여전히 도달 가능하다. 로컬에서 실제 객체를 정리하려면 백업 ref와 reflog를 정리한 뒤 GC가 필요하다.

git for-each-ref --format="%(refname)" refs/original/ \
  | xargs -r -n 1 git update-ref -d
git reflog expire --expire=now --all
git gc --prune=now --aggressive

공유 저장소에서는 이 명령을 기계적으로 실행하기 전에 영향 범위를 확인해야 한다. 문제는 파일 하나지만, 히스토리 재작성은 협업자 전체의 clone 상태를 바꾼다.

LFS의 운영 비용

LFS는 Git 저장소의 blob 크기 문제를 해결하지만 파일 자체를 없애지는 않는다. 저장 위치가 Git 객체 데이터베이스에서 LFS 저장소로 바뀐다. 운영 관점에서는 다음 비용이 남는다.

항목 영향
LFS 스토리지 모델 버전이 늘어날수록 저장 용량 증가
LFS 대역폭 clone, CI, 배포 빌드에서 다운로드 발생
도구 의존성 git lfs install이 없는 환경에서 포인터 파일만 받을 수 있음
배포 검증 CI가 실제 LFS 객체를 받은 상태인지 확인 필요

3D 모델처럼 런타임에서 반드시 필요한 파일은 포인터 파일만 있어도 빌드가 통과할 수 있다. 정적 파일 경로는 존재하지만 실제 내용은 GLB가 아니기 때문이다. 따라서 배포 파이프라인에서는 LFS pull 여부와 모델 로딩 검증을 같이 봐야 한다.

git lfs pull
git lfs ls-files

정리

Git LFS는 큰 파일을 다루는 설정이지, 히스토리 청소기가 아니다. 설정 이후에 추가되는 파일은 포인터로 바뀌지만, 이미 커밋된 대용량 blob은 그대로 남는다. 대용량 GLB를 저장소에 넣을 가능성이 있다면 순서는 명확하다.

  1. 첫 커밋 전에 .gitattributes를 만든다.
  2. GLB가 LFS 포인터로 들어갔는지 확인한다.
  3. CI에서 LFS 객체를 실제로 받았는지 검증한다.
  4. 이미 일반 Git blob으로 들어갔다면 삭제 커밋이 아니라 히스토리 재작성을 검토한다.

Git에서 파일을 지우는 것과 히스토리에서 blob을 제거하는 것은 다른 문제다. 이 차이를 놓치면 작업 트리는 깨끗한데 push는 계속 막힌다.

관련 글