정적 사이트 하나를 만들었다. HTML 14장에 CSS 몇 개, JS 세 개가 전부인 단순한 구조다. 그런데 영상이 31개, 290MB였다. 그중 두 개가 134MB와 101MB.

이 두 파일 때문에 한참을 헤맸다. 결론부터 말하면 원점으로 돌아왔다. 그래도 돌아본 값은 했으니 정리해 둔다.

GitHub는 100MB를 안 받는다

영상을 커밋하려는데 뭔가 이상했다. 찾아보니 GitHub는 파일 하나가 100MB를 넘으면 푸시를 거부한다. 내 파일은 133.9MB와 100.9MB. 둘 다 아웃이다.

더 고약한 건 한 번 커밋하면 히스토리에 영영 남는다는 점이다. 로컬 커밋은 그냥 되니까 모르고 진행하다가 푸시할 때 막히고, 그때 빼려면 히스토리를 다시 써야 한다. 원격에 이미 다른 커밋이 올라가 있으면 더 골치 아파진다.

그래서 Git LFS를 붙였다.

brew install git-lfs && git lfs install
# .gitattributes
*.mp4 filter=lfs diff=lfs merge=lfs -text

LFS는 실제 파일을 별도 저장소에 두고 git에는 포인터(몇백 바이트짜리 텍스트)만 남긴다. 파일당 2GB까지 되니 100MB 벽이 사라진다.

이미 커밋돼 있던 작은 mp4들은 그냥 뒀다. 전부 옮기려면 git lfs migrate import --everything으로 히스토리를 다시 써야 하는데, 40MB 줄이자고 force push까지 할 일은 아니었다.

.git이 589MB가 됐다

LFS로 커밋했는데 오히려 커졌다. 왜지?

$ du -sh .git
589M

$ git count-objects -vH
count: 419
size: 339.31 MiB      # loose object 가 339MB

LFS를 켜기 전에 git add 했다가 git reset 한 파일들이 남아 있었다. reset은 인덱스만 되돌리고 객체는 디스크에 그대로 둔다. 250MB가 유령처럼 떠 있었던 것.

git reflog expire --expire-unreachable=now --all
git gc --prune=now
  전 후
.git/objects 339MB (loose) 81MB (pack 66MB)
.git/lfs 250MB 250MB (로컬 캐시라 정상)

히스토리 자체는 66MB로 안 부풀었으니 LFS는 제 역할을 했다. .git/lfs는 로컬 캐시라 원격에는 포인터만 올라간다.

git reset은 객체를 안 지운다. 큰 파일을 잘못 add 했다가 물렸다면 gc까지 해야 진짜로 사라진다.

배포처를 찾아서

소스는 해결됐는데 이제 실제로 서비스할 곳이 필요했다. 정적 사이트니까 어디든 되겠지 싶었는데 전혀 아니었다.

Cloudflare Pages — 파일당 25MB

가장 먼저 후보에 올렸다가 바로 탈락. Pages는 파일 하나가 25MiB를 넘으면 아예 업로드가 안 된다. 프로젝트 전체 크기도 100MB 제한이 있다. 2025년 9월에 한도를 올렸다는 공지가 있길래 봤더니 파일 개수 제한만 늘었고 25MiB는 그대로였다.

GitHub Pages — LFS를 서빙하지 않는다

이건 더 함정이다. LFS 파일을 그대로 내보내지 않고 포인터 텍스트를 서빙한다. 즉 영상 자리에 130바이트짜리 텍스트가 내려간다. 에러도 안 나고 조용히 깨진다.

Netlify — 2026년부터 크레딧제

예전 기억으로 “무료 100GB 대역폭”인 줄 알았는데 바뀌어 있었다. 월 300 크레딧, 1GB당 20크레딧이라 실질 월 15GB. 초과하면 하드 정지다. 134MB 영상이 112회 재생되면 그 달이 끝난다.

Vercel — LFS는 되는데 대역폭이

Vercel은 Git LFS를 공식 지원한다. 설정에서 토글하고 재배포하면 된다. Hobby 대역폭도 100GB로 넉넉하다. 그런데 진짜 병목은 Vercel이 아니라 GitHub였다.

자동 배포는 빌드마다 리포를 clone하고 그때 LFS 파일을 받아간다. GitHub LFS 무료 대역폭이 월 1GB인데 내 LFS 실체가 250MB. 계산하면 배포 네 번이면 그 달 LFS가 잠긴다. 사이트를 고칠 때마다 배포되는 걸 생각하면 못 쓴다.

Cloudflare R2 — 도메인이 발목을

R2는 egress(다운로드 트래픽)가 무료다. 저장 10GB도 무료. 영상 290MB면 여유롭고 몇 명이 보든 대역폭 요금이 0이다. 영상 호스팅에는 사실상 최적이었다.

문제는 주소였다. R2 커스텀 도메인은 Cloudflare 네임서버를 쓰는 도메인만 연결된다. 외부 DNS에서 CNAME만 거는 건 안 되고, Partial CNAME 설정도 R2에는 지원되지 않는다.

즉 assets.example.com을 쓰려면 도메인 전체를 Cloudflare로 옮겨야 하는데, 그 도메인은 10년 가까이 쓴 것이었고 MX 레코드가 물려 있었다. 영상 하나 서빙하자고 메일을 걸 수는 없었다.

반전 — 이미 호스팅이 있었다

여기까지 와서야 확인했다. 이미 웹호스팅 계약이 되어 있었다. 다른 사이트가 돌아가는 중이었고, 내가 만든 게 그걸 교체하는 거였다. 진작 물어볼 걸 그랬다.

스펙을 봤다.

웹 용량       2,000MB
일일 전송량   2,500MB   ← 이게 문제

국내 웹호스팅은 트래픽이 일일 기준인 경우가 많고, 넘으면 사이트가 차단된다. 자정에 리셋된다.

계산해봤다.

  트래픽 일일 2.5GB로
134MB 영상 1회 완주 134MB 18명이면 소진
사이트 3페이지 방문 12.7MB 197명

영상 하나가 일일 한도의 5.4%. 18명이 영상을 끝까지 보면 사이트 전체가 자정까지 죽는다. 이건 못 받아들인다.

그래서 이미지부터 줄였다

영상을 어떻게 하기 전에 줄일 수 있는 건 다 줄이기로 했다. 이미지가 172장 28.9MB였다.

brew install webp
cwebp -q 82 input.jpg -o output.webp

품질을 정하려고 SSIM을 재봤다. 1.0이면 원본과 동일하다는 뜻이다.

품질 샘플 3장 SSIM
q78 854KB 0.9794
q82 1,009KB 0.9818
q88 1,348KB 0.9856
원본 1,840KB 1.0

q88은 용량이 33% 더 큰데 SSIM 개선은 0.4%p뿐이라 q82로 갔다. 결과는 28.9MB → 11.2MB, 39%로 줄었다. 방문 1회가 12.7MB에서 8.9MB가 됐다.

ffmpeg에는 webp 인코더가 없는 빌드가 많다. cwebp를 따로 쓰는 게 편하고, SSIM을 재려면 dwebp로 png로 푼 뒤 ffmpeg에 넣어야 한다. ffmpeg이 webp를 못 읽는다.

그런데 이걸로 부족했다

이미지를 40%로 줄였는데도 큰 영상 문제는 그대로였다. 134MB는 여전히 134MB다. 이미지 최적화의 효과는 “영상을 안 보는 방문자”에게만 있었다.

다만 여기서 중요한 걸 알았다. HTML5 video는 본 만큼만 다운로드한다. preload="none"을 걸어두면 재생 버튼을 누르기 전엔 한 바이트도 안 받고, 10분짜리를 1분만 보고 나가면 13MB 정도만 받는다.

그래서 다시 계산했다.

시나리오 하루 수용
아무도 큰 영상 안 봄 280명
10%가 1분 시청 244명
10%가 끝까지(10분) 시청 112명
30%가 끝까지 시청 51명

10분짜리를 완주하는 사람은 많지 않다. 하루 150~200명은 버틴다는 결론이 나왔고, 필요한 규모가 그 아래였으니 충분했다.

결론 — 원점

결국 아무것도 도입하지 않았다. 이미 있는 웹호스팅에 그냥 올리는 게 답이었다.

후보 걸린 이유
Cloudflare Pages 파일당 25MiB
GitHub Pages LFS 포인터를 서빙
Netlify 월 15GB (기존 호스팅 월 75GB보다 빡빡)
Vercel GitHub LFS 대역폭 = 배포 4회/월
R2 커스텀 도메인에 네임서버 이전 필요
기존 웹호스팅 이미 있고, 계산해보니 충분

한참을 돌아서 제자리로 왔다. 허무하지만 돌아본 덕에 “왜 다른 걸 안 쓰는지”를 근거를 갖고 말할 수 있게 됐다. 나중에 누가 “R2 쓰면 되지 않나요?” 하면 메일 리스크 얘기를 바로 꺼낼 수 있다.

그래도 R2는 썼다 — 테스트 배포로

FTP 접속 정보를 받는 데 시간이 걸렸다. 그동안 검토는 받아야 했다.

여기서 R2가 제 몫을 했다. 커스텀 도메인이 필요한 건 정식 서비스일 때고, 테스트는 r2.dev 임시 주소로 충분하다.

npx wrangler login
npx wrangler r2 bucket create my-bucket

업로드할 때 Content-Type을 확장자별로 꼭 지정해야 한다. 이걸 빼면 브라우저가 HTML을 다운로드 파일로 취급해서 사이트가 아예 안 뜬다.

npx wrangler r2 object put "$BUCKET/$rel" \
  --file="$f" --content-type="text/html; charset=utf-8" --remote

259개 파일을 xargs -P 8로 병렬 업로드했다. 작은 파일부터 올려서 사이트가 먼저 뜨게 하고 영상은 뒤로 미뤘다. 302MB가 몇 분 만에 올라갔다.

대시보드에서 버킷 → Settings → Public access → R2.dev subdomain을 켜면 주소가 나온다.

https://pub-xxxxxxxx.r2.dev/index.html

주의할 점이 하나. R2는 디렉터리 기본 문서를 안 잡아준다. /로 끝나는 주소는 안 열리니 /index.html까지 붙여서 공유해야 한다. 사이트 내부 링크가 상대경로면 그다음부터는 정상 작동한다.

r2.dev는 Cloudflare가 “프로덕션용 아님”으로 못박은 주소다. 접속이 몰리면 429로 스로틀링된다. 실제로 공개 직후 CSS 하나가 401을 낸 적이 있었다. 재시도하니 정상이었다. 검토용으로는 훌륭하고 정식 서비스로는 쓰면 안 된다.

정리

  • 소스에 100MB 넘는 파일이 있다 → Git LFS. 단, 자동 배포를 붙일 거면 GitHub LFS 대역폭(무료 월 1GB)을 먼저 계산할 것
  • 큰 파일을 잘못 add 했다 → reset만으론 안 지워진다. git gc --prune=now까지
  • 정적 호스팅을 고른다 → 파일당 크기 제한부터 확인. Pages류는 25MB~100MB에서 막힌다
  • 영상 트래픽이 걱정된다 → preload="none". 본 만큼만 받는다
  • 이미지를 줄인다 → webp q82. SSIM 0.98이면 눈으로 구분 못 한다
  • 잠깐 보여줄 곳이 필요하다 → R2 + r2.dev. Content-Type 지정 잊지 말 것
  • 그 전에 → 쓰고 있는 호스팅이 이미 있는지부터 확인할 것

마지막 줄이 이 글의 진짜 교훈이다. 그것만 먼저 물어봤어도 한참을 아꼈다.