Next.js Route Loader & Mini CSS Extract Plugin - prefetch와 CSS chunk 에러 처리
prefetchViaDom - route-loader.ts
function prefetchViaDom(
href: string,
as: string,
link?: HTMLLinkElement
): Promise<any> {
return new Promise<void>((resolve, reject) => {
const selector = `
link[rel="prefetch"][href^="${href}"],
link[rel="preload"][href^="${href}"],
script[src^="${href}"]`
if (document.querySelector(selector)) {
return resolve()
}
link = document.createElement('link')
// The order of property assignment here is intentional:
if (as) link!.as = as
link!.rel = `prefetch`
link!.crossOrigin = process.env.__NEXT_CROSS_ORIGIN!
link!.onload = resolve as any
link!.onerror = () =>
reject(markAssetError(new Error(`Failed to prefetch: ${href}`)))
// `href` should always be last:
link!.href = href
document.head.appendChild(link)
})
}
- 이미 prefetch/preload된 리소스면 스킵
<link rel="prefetch">생성 후 head에 추가href는 항상 마지막에 설정 (브라우저 요청 타이밍 제어)
CSS Chunk Load Error - Mini CSS Extract Plugin
Template.indent([
"var errorType = event && event.type;",
"var realHref = event && event.target && event.target.href || fullhref;",
'var err = new Error("Loading CSS chunk " + chunkId + " failed.\\n(" + errorType + ": " + realHref + ")");',
'err.name = "ChunkLoadError";',
// TODO remove `code` in the future major release to align with webpack
'err.code = "CSS_CHUNK_LOAD_FAILED";',
"err.type = errorType;",
"err.request = realHref;",
"if (linkTag.parentNode) linkTag.parentNode.removeChild(linkTag)",
"reject(err);",
]),
ChunkLoadError커스텀 에러 생성- 실패한
<link>태그 DOM에서 제거 후 reject
Next.js SSG + S3 + CloudFront 캐시 정책
정적 SPA에서 Next.js SSG로 옮길 때 배포해도 구버전 HTML이 서빙되는 문제를 헤더 설계로 해결한다. example.com 기준.
왜 문제가 생기는가
SPA는 HTML이 index.html 하나라 진입점만 무효화하면 됐다. SSG는 라우트마다 HTML 오브젝트가 생기고 각자 독립된 TTL로 엣지에 캐시된다.
Cache-Control 이 없으면 CloudFront는 Default TTL(미설정 시 86400초), 브라우저는 휴리스틱 캐싱((Date - Last-Modified) × 10%)을 적용한다. 그 동안 엣지는 오리진에 가지 않는다 — 로컬에서 재현 안 돼도 문제다.
디버깅 — 오리진 → 엣지 → 브라우저 순으로 좁힌다
배포했는데 구버전이 보이면 어느 층이 구버전을 들고 있는지부터 가른다. 안쪽부터 본다 — 오리진이 구버전이면 바깥을 볼 이유가 없다.
1. 오리진에 새 HTML이 올라갔나
aws s3api head-object --bucket example.com --key index.html
LastModified가 배포 시각보다 이전 → 배포가 안 됐다. buildspec 2단계 로그를 본다CacheControl이 없다 → 헤더 미적용. 2단계에서Hit로 드러난다
2. 엣지가 무엇을 내보내나
curl -sI https://example.com/ | grep -iE 'cache-control|x-cache|age'
curl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'
cache-control없음 +Hit→ 헤더 미적용 → 헤더 레시피 + 전환 최초 1회/*무효화max-age=0인데Hit+age증가 → Minimum TTL이 오리진 헤더를 무시. Cache Policy 수정RefreshHit·Miss→ 엣지는 정상. 3으로
약한 ETag(W/"...")·vary: Accept-Encoding은 엣지 자동 압축 흔적이다. 무시한다.
3. 브라우저만 구버전인가
curl 은 새 버전인데 브라우저만 구버전 → 헤더 없이 받았던 응답을 휴리스틱으로 들고 있다. invalidation이 못 닿는다. 만료를 기다린다. 헤더 적용 이후 배포분부터는 재발하지 않는다.
곁증상
| 증상 | 원인 | 처방 |
|---|---|---|
| 배포 직후 lazy chunk 404 | 1단계에 --delete, 또는 HTML이 에셋보다 먼저 올라감 |
순서·--delete 위치 |
| 일부 오브젝트만 403 | 한쪽 sync에 --acl public-read 누락 |
두 명령 모두에 |
--acl 자체가 에러 |
버킷이 BucketOwnerEnforced |
--acl 빼고 OAC + 버킷 정책 |
| 3단계 AccessDenied | 롤에 cloudfront:CreateInvalidation 없음 |
3단계 무효화 |
오브젝트 종류별로 정반대 헤더
| 대상 | Cache-Control | 이유 |
|---|---|---|
_next/static/** |
public,max-age=31536000,immutable |
파일명에 콘텐츠 해시 — 내용이 바뀌면 이름이 바뀐다 |
*.html, 데이터 파일 |
public,max-age=0,must-revalidate |
매 요청 재검증, 안 바뀌었으면 304 |
version: 0.2
env:
variables:
BUCKET: example.com
phases:
post_build:
commands:
# 1) 해시 에셋: 영구 캐시, 삭제 없음
- aws s3 sync ./out/_next/static s3://$BUCKET/_next/static
--acl public-read
--cache-control 'public,max-age=31536000,immutable'
# 2) HTML·데이터: 매 요청 재검증, 구 HTML 정리
- aws s3 sync ./out s3://$BUCKET
--acl public-read
--exclude '_next/static/*'
--cache-control 'public,max-age=0,must-revalidate'
--delete
# 3) 엣지 무효화 (권한 있을 때만 — 아래 참고)
- export CF_DIST_ID=$(aws cloudfront list-distributions
--query "DistributionList.Items[?Aliases.Items[?contains(@, '$BUCKET')]].Id"
--output text)
- test -n "$CF_DIST_ID" || (echo "CF_DIST_ID 조회 실패" && exit 1)
- aws cloudfront create-invalidation --distribution-id $CF_DIST_ID --paths '/*'
순서를 지켜야 한다
- 에셋(1) → HTML(2). 단일 sync는 순서를 보장하지 않아, HTML이 먼저 가면 청크가 없는 창이 생긴다. 이 순서면 2가 실패해도 “새 에셋 + 구 HTML”로 정상 동작한다.
- 1단계는
--delete없음. 구 해시 에셋이 남아야 열려 있던 탭의 lazy chunk 404가 안 난다. --acl public-read는 둘 다에. 빠지면 그 오브젝트가 403.
3단계 무효화 — 어디까지 닿나
확인
aws s3api head-object --bucket example.com --key index.html
aws s3api head-object --bucket example.com --key '_next/static/<hash>/...'
curl -sI https://example.com/a | grep -iE 'cache-control|x-cache|age'
| 대상 | cache-control | x-cache | age |
|---|---|---|---|
| HTML | public,max-age=0,must-revalidate |
반복 요청 시 RefreshHit |
없거나 0 |
_next/static/** |
public,max-age=31536000,immutable |
Hit |
계속 증가 |
HTML이 Hit + age 증가일 때만 Minimum TTL을 본다.