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%)을 한다. 이 신선 구간 동안 엣지는 오리진에 접속조차 하지 않고 캐시본을 내보내므로, S3에 새 HTML이 올라가 있어도 반영되지 않는다.

TTL 타이머는 POP별·경로별로 독립이라 “나는 새 화면, 저쪽은 구 화면”이 재현성 없이 발생한다. 로컬에서 재현 안 돼도 문제가 없는 게 아니다.

진단 — 지금 어느 상태인가

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'
x-cache 의미
Miss from cloudfront 엣지 캐시에 없어 오리진에서 가져옴 (최신인 게 아니라 캐시가 비었을 뿐)
Hit from cloudfront 엣지 캐시본 서빙, 오리진 접속 안 함 — 여기가 구버전 서빙 지점
RefreshHit from cloudfront TTL 만료 후 오리진 재검증 → 304 → 캐시본 서빙 (max-age=0의 정상 동작)

판정

  • HTML에 cache-control없다 → 헤더 설계 미적용. 아래 적용 대상
  • HTML에 max-age=0인데 Hit + age 증가 → 배포판 Minimum TTL이 오리진 헤더를 무시하는 중. Cache Policy를 손본다
  • HTML이 RefreshHit → 목표 상태. 정상

약한 ETag(W/"...")와 vary: Accept-Encoding은 CloudFront 엣지 자동 압축의 흔적이다. S3 원본은 강한 ETag를 그대로 갖고 있고 재검증도 정상 동작하니 무시해도 된다.

핵심 레시피 — 오브젝트 종류별로 정반대 헤더

대상 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이 먼저 올라가면 참조할 청크가 아직 없는 창이 생긴다.
  • 1단계는 --delete 없음. 구 해시 에셋을 남겨야 배포 순간 열려 있던 탭의 lazy chunk 404를 막는다.
  • --acl public-read는 두 명령 모두에. 한쪽만 빼면 그 오브젝트가 비공개가 되어 403.
  • 이 순서라면 1 성공 + 2 실패여도 “새 에셋 + 구 HTML”이라 서비스는 정상 동작하고 배포만 미반영된다.

확인

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 증가면 그때만 Cache Policy의 Minimum TTL을 확인한다.

함정

--exclude는 삭제 판단에도 걸린다. --exclude '_next/static/*'에 걸린 원격 객체는 --delete 대상에서도 빠진다. 구 해시 에셋이 남아 chunk 404가 원천 차단되는 건 의도한 동작이지만, _next/static/**이 무한 누적되므로 S3 lifecycle rule이 따라와야 한다 (prefix _next/static/, 만료 30일쯤. 버킷 설정이라 한 번만 걸면 된다).

_next/data/**는 exclude에 안 걸려 매 배포 정리된다. 구 buildId 데이터가 즉시 사라지지만 Next.js가 prefetch 실패 시 full page navigation으로 폴백하므로 보이는 오류는 없다. 신경 쓰이면 --exclude '_next/data/*'를 추가하고 lifecycle에 위임한다.

invalidation 비용 통념은 반대다. 과금은 경로 단위인데 와일드카드는 몇 개를 매칭하든 1 path다. 경로 200개 열거 = 200 paths, /* = 1 path. 월 1,000 path 무료라 하루 30번 배포해도 무료 범위다. /* 한 줄이 가장 싸고 확실하다.

브라우저 캐시는 되돌릴 수 없다. 헤더 없이 서빙된 응답을 받은 브라우저는 휴리스틱으로 수 시간 자체 보관하고, invalidation은 여기 닿지 않는다. 헤더 적용 후에도 일부 사용자는 각자 캐시가 만료될 때까지 구버전을 본다. 기다리는 것 외에 방법이 없고, 헤더 적용 이후 배포분부터는 재발하지 않는다.

갈림길: invalidation 권한이 없다면

cloudfront:ListDistributions + cloudfront:CreateInvalidation이 CodeBuild 서비스 롤에 없으면 3단계에서 AccessDenied.

헤더 설계가 invalidation을 불필요하게 만든다. HTML에 max-age=0,must-revalidate가 박히면 엣지가 매 요청 재검증하므로 무효화 없이도 새 배포가 반영된다. 3단계는 “즉시 반영 보장” 보험일 뿐이다 — 권한이 없으면 세 줄을 통째로 지우고 1·2단계만으로 운영한다.

단 전환 최초 1회는 반드시 밀어야 한다. 헤더 없이 올라간 구 HTML이 아직 엣지에 있다. 콘솔에서 /* 무효화(CLI 권한과 무관)하거나, Default TTL(기본 24시간)이 지나 자연 만료되기를 기다린다.

갈림길: 업로드 도구가 prefix별 헤더를 못 줄 때

aws s3 sync를 안 건드리고 엣지에서 해결한다.

  1. Behavior 분리/_next/static/*용 behavior 추가, 나머지는 Default
  2. Cache Policy — static 쪽 Min/Default/Max TTL을 크게, Default behavior는 TTL 0
  3. Response Headers Policy — 뷰어로 나가는 Cache-Control을 덮어쓴다. 오리진에 헤더가 없어도 강제 적용

파이프라인과 캐시 정책이 분리되어 더 깔끔하다. 단 --delete 부재 문제는 여전히 lifecycle로 보완해야 한다.

부속

배포 ID는 CodeBuild가 자동 제공하지 않는다(CODEBUILD_*에 없음). 배포판을 삭제·재생성하기 전까지 영구 고정이고 민감 정보도 아니라, 단일 서비스면 env.variables에 하드코딩이 가장 단순하다. 위 buildspec의 별칭 조회 방식은 배포판을 재생성해도 buildspec을 안 건드리는 대신 ListDistributions 권한(Resource: "*")과 실패 지점이 하나 는다.

한 번 조회해 하드코딩할 때:

aws cloudfront list-distributions \
  --query "DistributionList.Items[?Aliases.Items[?contains(@, 'example.com')]].Id" \
  --output text

배포 ID 고정 ≠ invalidation 매번 생성. 무효화 요청은 매 배포 새로 만들어지지만 항상 같은 배포 ID를 향해 날린다.

필요 IAM — 업로드는 s3:PutObject·s3:PutObjectAcl·s3:DeleteObject·s3:ListBucket, 무효화는 cloudfront:CreateInvalidation(별칭 조회 시 ListDistributions). 기존에 aws s3 sync ... --delete가 돌고 있었다면 S3 권한은 이미 갖춰졌을 가능성이 높다.

--acl public-read가 동작한다는 건 버킷이 ACL 활성 상태라는 뜻이다. 2023-04 이후 생성된 버킷 기본값은 BucketOwnerEnforced--acl 자체가 에러다. 여유 있을 때 OAC + 버킷 정책으로 옮기는 걸 별도 과제로 잡는다.

#582raw