next/font/google는 woff2 파일을 fonts.gstatic.com에서 빌드 타임에 내려받는다. Google이 파일 해시를 교체하자 deploy가 404로 실패하기 시작했는데, 로컬에서는 재현할 수 없었다. 같은 fetch가 몇 초 전에 내 머신에서는 이미 성공했기 때문이다. 해결책: 폰트를 저장소에 직접 넣고(vendoring) next/font/local을 쓰는 것. 그러면 그 바이트는 내가 소유한 입력이 된다.
증상
deploy가 두 번 실패했다. 앱에서 바뀐 건 아무것도 없었다 — 같은 commit, 같은 lockfile, 같은 Node.
Error while requesting resource
Received response with status 404 ...
Module not found: Can't resolve '@vercel/turbopack-next/internal/font/google/font'마지막 줄은 21번 찍혔다. 404는 fonts.gstatic.com에서 온 것이었다.
내가 넘겨짚은 것
deploy 컨테이너에서 뭔가를 망가뜨렸다고 생각했다. 잘못된 의존성 해석, 깨진 Docker 레이어, 요청을 삼키는 proxy. 같은 명령을 내 머신에서 — next build, Next.js 16.2.10 — 돌리면 빌드가 잘 됐으니까. 두 번 다. 그린이었다.
그게 함정이다. "로컬에서는 빌드된다"가 컨테이너가 망가졌다는 증거처럼 느껴졌다. 사실 아무것도 증명하지 못하는 정보였다.
그게 왜 틀렸나
next/font/google는 런타임 CDN 링크가 아니다. 빌드 타임 다운로더다. 패키지에 같이 딸려 오는 Next 공식 문서 node_modules/next/dist/docs/01-app/03-api-reference/02-components/font.md:13에 그대로 적혀 있다:
모든 Google Fonts도 편하게 쓸 수 있다. CSS와 폰트 파일은 빌드 타임에 다운로드되어 나머지 정적 에셋과 함께 셀프 호스팅된다. 브라우저에서 Google로 가는 요청은 전혀 없다.
브라우저는 요청을 보내지 않는다. 요청을 보내는 건 내 빌드다. 그 요청 하나하나가 내 코드와 아무 상관 없는 이유로 빌드를 실패시킬 기회다.
node_modules/next/dist/compiled/@next/font/dist/google/에 들어 있는 로더가 실제로 하는 일은 이렇다:
get-google-fonts-url.js가 옵션으로css2?family=...URL을 만든다.fetch-css-from-google-fonts.js가fetch-resource.js를 통해 그 CSS를 GET한다.fetch-resource.js는 Chrome 104 user agent를 하드코딩해서 Google이 ttf 대신 woff2를 주도록 만든다.find-font-files-in-css.js가src: url(...)항목을 뽑아낸다 — 완전히 해시가 박히고 버전이 붙은fonts.gstatic.com경로다.fetch-font-file.js가 각각을 내려받고 빌드가.next/static/media로 내보낸다.
hermetic이 죽는 지점은 3번이다. 그 URL들은 내가 통제하는 안정적인 입력이 아니다. 내 빌드가 돌던 그 순간에 Google이 CSS 응답에 넣어둔 것일 뿐이다.
그리고 fetch-resource.js가 200이 아닌 응답에 대해 가진 정책은 딱 하나다:
if (res.statusCode !== 200) {
reject(new Error(errorMessage || `Request failed: ${url} (status: ${res.statusCode})`));
return;
}이게 retry(fn, 3)으로 감싸져 있고, 그 실체는 async-retry다. 즉 최초 시도에 세 번을 더해서, 네 번 다 404를 뱉을 URL에 네 번 요청한다.
교체가 일어난 구간
실패한 요청은 .../inter/v20/UcCB3Fwr...woff2에 대한 것이었다. 같은 패밀리로 css2 엔드포인트를 직접 때려보니 Google은 .../inter/v20/UcCO3Fwr...woff2를 돌려주고 있었다.
UcCB 대 UcCO. 한 글자 차이. 실행 중에 일어난 해시 교체다: 내가 가진 CSS는 더 이상 존재하지 않는 파일 경로를 가리키고 있었다.
그런데 왜 내 머신에서는 빌드가 됐나
첫 가설은 오래된 캐시였다. 내 .next가 교체 이전 것이라 빌드는 디스크에 있던 정상 응답을 재생하고, 컨테이너는 살아 있는 엔드포인트를 때린다는 것. 뻔하고 깔끔하고 — 이 셋업에서는 — 틀렸다.
로더의 캐시는 loader.js 최상단에서 만들어지는 인메모리 Map 두 개(cssCache, fontCache)다. 클라이언트 컴파일러와 서버 컴파일러가 같은 URL을 두 번 fetch하지 않게 하려고 존재하고, 프로세스와 함께 사라진다. Turbopack의 디스크 캐시라면 프로세스를 넘어 살아남겠지만, experimental.turbopackFileSystemCacheForBuild는 opt-in이고 Next 16에서는 기본 off이며(node_modules/next/dist/docs/01-app/03-api-reference/08-turbopack.md:204) 이 프로젝트는 켜두지 않았다. 여기서 .next/cache가 실제로 담고 있던 것: .tsbuildinfo, 작은 info 파일 두 개, 그리고 fetch-cache 엔트리 정확히 하나 — 191,476 바이트, content-type: application/octet-stream, wasm 매직 바이트, url 필드는 data:application/octet-stream;base64,…. data URI다. 폰트도 아니고, 애초에 네트워크 호출도 아니다. 재생할 Google 응답 같은 건 내 디스크에 없었다.
그러니 두 머신 다 진짜로 Google에 물어본 것이다. 서로 다른 초에, 각각 최대 네 번씩 물어봤고, 호스트의 시도가 우연히 정상 응답에 걸린 것뿐이다.
.next를 이름만 바꾸고 다시 빌드해보기도 했다 — 그리고 호스트에서 실패가 재현돼서 증거처럼 느껴졌다. 아니었다: 나쁜 구간이 아직 열려 있었을 뿐이고, 똑같은 클린 빌드가 한 시간 뒤에는 통과했다. 그래도 지우지 말고 이름을 바꿔라, 비용이 0이니까. 하지만 재현 한 번이 메커니즘을 증명해주지는 않는다. 캐시를 탓하기 전에 그 캐시가 존재하는지부터 확인해라.
더 고약한 부분: 하나의 결함에 두 개의 심각도
호스트에서는 404가 찍혀도 next build의 종료 코드는 0이었다. 컨테이너 안에서는 같은 404가 Turbopack의 모듈 해석 에러가 되고 빌드가 죽었다.
exit 0인 경우는 retry.js가 떠드는 것이지, 실패를 허용한 게 아니다. 재시도할 때마다 다시 시도하기 전에 에러를 로그로 뱉는다:
onRetry(e, attempt) {
console.error(e.message + `\n\nRetrying ${attempt}/${retries}...`)
}즉 폰트 404로 가득 찬 로그는 "3번째 시도에서 복구됨"일 수도 있고 "4번째 시도에서 죽음"일 수도 있는데, 줄 자체는 어느 쪽이든 똑같아 보인다. 무서워 보이는 텍스트 말고 종료 코드를 읽어라.
production 빌드에서 로더가 하지 않는 일은 조용히 degrade하는 것이다 — catch가 환경에 따라 갈리고, fallback face를 받는 건 dev뿐이다:
if (isDev) {
// ...return a fallback @font-face instead of throwing
} else {
throw err
}그 분기가 로컬 작업에서의 진짜 함정이다. next dev에서는 똑같이 죽은 URL이 override 메트릭이 붙은 local(...) 시스템 폰트로 대체된다. 그래서 deploy를 죽이는 결함이 내 머신에서는 살짝 어긋난 타이포그래피로만 보인다.
Next.js 16은 next build의 기본 번들러를 Turbopack으로 바꿨고(node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md:116), Turbopack 경로에서는 던져진 에러가 해석 불가능한 @vercel/turbopack-next/internal/font/google/font 모듈로 바뀐다 — 내 로그에서는 21개였다.
진단 순서
그대로 가져다 써라. 추측을 없애줬다:
- 에러에 나온 woff2 URL 그대로
curl -I. 404면 → 파일이 사라진 것이지 네트워크 문제가 아니다. - 같은 패밀리의
css2?family=...엔드포인트를 최신 Chrome user agent로curl. 200이 오는데src: url(...)의 해시가 다르면 → 장애가 아니라 교체가 확정이다. mv .next .next-bad하고 로컬에서 다시 빌드. 여기서 실패하면 살아 있는 재현이다 — 다만 캐시 덕이라고 결론 내리기 전에 캐시가 있기는 했는지 확인해라. Turbopack의 빌드 캐시는 opt-in이라, 오래된.next는 보통 무죄다.- 그 클린 빌드를 호스트에서도, 시간 간격을 두고 두 번 돌려라. 다들 건너뛰는 게 이 단계고, "파일이 사라졌다"와 "파일이 10분 동안 사라져 있었다"를 갈라주는 것도 이 단계다.
한 시간 뒤, 클린 빌드가 404 하나 없이 통과했다. 이 버그의 모양이 그렇다: 상태가 아니라 구간 — 그래서 어느 머신에서든 그린 빌드 한 번은 답이 되지 않는다.
해결책과 그 솔직한 비용
woff2 파일을 저장소에 넣고 next/font/local로 바꿔라. 그러면 폰트 바이트는 내가 소유하고 git에 커밋된 입력이 되고, 빌드는 타이포그래피 때문에 네트워크 호출을 한 번도 하지 않는다.
비용은 진짜고, 아닌 척할 생각도 없다:
- 업데이트는 내 몫이 된다. Google이 패밀리를 다시 내놔도 공짜 업그레이드는 없다. 그게 바로 핵심이지만 — 이제 내 일이다.
- 서브셋은 직접 만들어야 한다 — 그리고 아마 생각보다 많이 내보내고 있을 것이다.
subsets: ["latin"]은 무엇이 다운로드되는지를 걸러주지 않는다. 이 값은css2URL까지 가지도 않는다.loader.js가subsetsToPreload로findFontFilesInCss에 넘길 뿐이라, 어떤 파일에 preload 힌트가 붙을지만 정한다. Google 응답에 있는 모든@font-face가 fetch되고 출력된다. Launch는 8개 패밀리 전부에subsets: ["latin"]을 선언하는데도 woff2 파일을 35개 내보내고, 그중 파일명에.ppreload 마커가 붙은 건 9개다. 그러니 vendoring은 문자셋을 실제로 줄일 기회이기도 하다(fonttools의pyftsubset) — 하지만.next/static/media에서 woff2를 그대로 복사해 온다면 용량 면에서는 아무것도 바뀌지 않는다. - 저장소 용량. 나한테 확장성이 나쁜 부분이 이거다. 내가 파는 다섯 개 템플릿을 합치면 로더가 선언하는 Google 패밀리가 51개다:
folio-template/src/app/layout.tsx12개,lens-template/src/app/fonts.ts6개,levain-template/src/app/fonts.ts8개,launch-template/src/lib/fonts.ts8개,booking-template/src/lib/fonts.ts17개. weight, style, subset이 조합되면 이게 확 불어난다: launch 템플릿의 클린 빌드는.next/static/media에 woff2를 35개 내보내고, booking은 92개다(ls .next/static/media | grep -c woff2).
그러니 솔직한 버전은 이렇다: 실제로 렌더링하는 폰트만 저장소에 넣고 나머지는 지워라. 17개 패밀리를 제공하는 테마 스위처는 빌드 타임 HTTP 의존성 17개라는 뜻이고, 그중 아무거나 하나가 화요일에 deploy를 404로 떨어뜨릴 수 있다.
여기서 실패한 빌드는 Launch 것이다 — 라이브 데모는 https://launch.violettadev.com.
다음번에 잡아내는 법
- 로컬 그린은 신호가 아니다. 같은 commit에서 내 머신은 통과하고 CI가 실패하면, 기본 설명은 네트워크를 서로 다른 두 순간에 샘플링했다는 것이지 한쪽 머신이 망가졌다는 게 아니다. 원인을 찾아 나서기 전에 실패한 빌드를 다시 돌려라.
- exit 0이어도 빌드 로그에서 폰트 404를 grep해라. 그건 이번에는 재시도가 이겼다는 뜻이다. 같은 fetch, 같은 취약함, 빨간 deploy까지 한 번 남았다.
- 빌드 타임 fetch는 전부 의존성으로 취급해라. 폰트, wasm 블롭, 원격 스키마, 빌드 타임 CMS 호출. 내 빌드가 어떤 네트워크 호출을 하는지 적어둬라. 대부분은 자기 프로젝트에 대해 그 질문에 답하지 못하고, 나도 빌드가 404 더미로 알려주기 전까지는 그랬다.