---
type: note
kind: Recipe
title: Next.js SSG + S3 + CloudFront 캐시 정책
description: SSG로 옮긴 뒤 "배포해도 구버전 HTML이 서빙되는" 문제를 헤더 설계로 해결하는 레시피 모음.
tags: ['aws', 'cloudfront', 'nextjs', 'cache', 'deploy']
status: release
ctime: 2026-09-01
mtime: 2026-09-01
---

정적 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별·경로별로 독립이라 "나는 새 화면, 저쪽은 구 화면"이 재현성 없이 발생한다. **로컬에서 재현 안 돼도 문제가 없는 게 아니다.**

## 진단 — 지금 어느 상태인가

```bash
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` → 목표 상태. 정상

> [!NOTE]
> 약한 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라 대역폭 낭비 없음 |

```yaml
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"이라 서비스는 정상 동작하고 배포만 미반영된다.

## 확인

```bash
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: "*"`)과 실패 지점이 하나 는다.

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

```bash
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 권한은 이미 갖춰졌을 가능성이 높다.

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