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
#306

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'

Miss: 브라우저 → 엣지 → 오리진(S3)까지 가서 새로 받는다. 엣지 캐시가 비어 있었을 뿐 최신이라는 뜻은 아니다. Hit: 브라우저 → 엣지에서 멈추고 오리진에는 가지 않은 채 캐시본을 돌려준다 — 구버전이 서빙되는 지점이다. RefreshHit: 브라우저 → 엣지 → 오리진에 재검증 → 304 → 엣지가 캐시본을 돌려준다 — max-age=0 의 정상 동작이고 HTML 의 목표 상태다. 엣지의 TTL 타이머는 POP·경로별로 독립이라 새 화면과 구 화면이 재현성 없이 섞인다.

  • 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단계 무효화 — 어디까지 닿나

create-invalidation –paths ‘/’ 는 모든 엣지 POP 의 캐시본을 비운다. 브라우저 캐시(휴리스틱)에는 닿지 않는다. 과금은 경로 단위라 경로 200개를 열거하면 200 paths, / 는 몇 개를 매칭하든 1 path 이고 월 1,000 path 까지 무료다. 헤더 적용 전에는 Cache-Control 없는 구 HTML 이 엣지에 Default TTL(24시간) 동안 남으므로 전환 최초 1회는 무효화가 필수다. 헤더 적용 후에는 HTML 이 max-age=0 이라 엣지가 매 요청 재검증하므로 무효화는 즉시 반영을 위한 보험일 뿐이다.

확인

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을 본다.

#582