[GitHub] → push → [GitHub Actions] → build → [S3 Bucket] → [CloudFront CDN] → 사용자
output: 'export'로 정적 HTML 생성)// next.config.mjs
const nextConfig = {
output: 'export', // 정적 HTML 생성 → ./out 디렉토리
images: {
unoptimized: true, // 정적 배포에서는 이미지 최적화 비활성화
},
}
yarn build 실행 시 ./out 디렉토리에 정적 파일이 생성된다:
out/
├── _next/
│ └── static/
│ └── chunks/ ← JS chunk 파일들 (해시 포함)
│ ├── app/
│ │ └── page-12e1a2d8251d49e1.js
│ ├── webpack-739516a8b9f8eeb4.js
│ └── ...
├── index.html
├── about.html
└── ...
_next/static/chunks/ 안의 파일들은 빌드마다 해시가 바뀐다. 이게 캐시 전략의 핵심이다.
name: Deploy to Production
on:
push:
branches: [main]
paths-ignore:
- '**.md'
env:
AWS_REGION: ap-northeast-2
S3_BUCKET: s3://my-app-prod
CLOUDFRONT_DISTRIBUTION_ID: EXXXXXXXXXXXXX
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
- name: Install dependencies
run: yarn install
- name: Build
run: yarn build
# AWS credentials는 GitHub 리포지토리 Settings > Secrets and variables > Actions에 등록
# - AWS_ACCESS_KEY_ID: IAM 사용자의 Access Key ID
# - AWS_SECRET_ACCESS_KEY: IAM 사용자의 Secret Access Key
# IAM 사용자에게 S3, CloudFront 권한이 필요하다
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Deploy to S3
run: |
# Step 1~4 (아래에서 상세 설명)
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
--paths "/*"
정적 파일을 하나의 s3 sync로 올리면 안 된다. 파일 종류별로 캐시 정책이 다르기 때문이다.
aws s3 sync ./out/_next $S3_BUCKET/_next \
--cache-control "public, max-age=31536000, immutable"
_next/static/chunks/page-12e1a2d8251d49e1.js 같은 파일들--delete 없음 → 이전 빌드의 chunk를 보존왜 이전 chunk를 보존하는가?
배포 시점에 사이트를 이용 중인 사용자의 브라우저는 이전 빌드의 HTML을 들고 있다.
이 HTML은 이전 해시의 chunk를 참조하고 있으므로, 이전 chunk가 삭제되면
클라이언트 사이드 네비게이션(링크 클릭) 시 chunk 로드에 실패한다.
aws s3 sync ./out $S3_BUCKET \
--content-type "text/html" \
--cache-control "no-cache, no-store, must-revalidate" \
--metadata-directive REPLACE \
--exclude "_next/*" \
--exclude "*.jpg" --exclude "*.png" --exclude "*.jpeg" \
--exclude "*.svg" --exclude "*.json" --exclude "*.ico" \
--exclude "*.txt" --exclude "*.xml" \
--exclude "*.js" --exclude "*.css" \
--delete
<script src="/_next/static/chunks/page-{hash}.js"> 참조가 있다# .html 확장자 제거 (about.html → about)
for file in $(find ./out -name "*.html"); do
mv "$file" "${file%%.html}"
done
aws s3 sync ./out $S3_BUCKET \
--content-type "text/html" \
--cache-control "no-cache, no-store, must-revalidate" \
--metadata-directive REPLACE \
--exclude "_next/*" \
--exclude "*.jpg" --exclude "*.png" --exclude "*.jpeg" \
--exclude "*.svg" --exclude "*.json" --exclude "*.ico" \
--exclude "*.txt" --exclude "*.xml" \
--exclude "*.js" --exclude "*.css"
/about.html 대신 /about으로 접근할 수 있도록 확장자 없는 파일도 업로드aws s3 sync ./out $S3_BUCKET \
--exclude "*" \
--include "*.jpg" --include "*.png" --include "*.jpeg" \
--include "*.svg" --include "*.json" --include "*.ico" \
--include "*.txt" --include "*.xml" \
--include "*.js" --include "*.css" \
--exclude "_next/*" \
--cache-control "public, max-age=86400" \
--delete
--exclude "_next/*" → Step 1에서 보존한 이전 chunk를 삭제하지 않도록 보호aws cloudfront create-invalidation \
--distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
--paths "/*"
_next 파일은 immutable 캐시이므로 무효화와 무관하게 해시로 구분| 파일 종류 | 경로 예시 | Cache-Control | --delete |
|---|---|---|---|
| JS/CSS chunk (해시) | _next/static/chunks/*.js | 1년, immutable | 없음 (이전 빌드 보존) |
| HTML | index.html, about | no-cache, must-revalidate | 있음 |
| 이미지/폰트/기타 | *.png, *.ico, robots.txt | 1일 | 있음 |
핵심 원칙:
AWS CLI의 필터는 순서대로 평가된다. 이걸 모르면 의도치 않은 결과가 나온다.
--exclude "*" # 1) 전부 제외
--include "*.js" # 2) JS 파일만 포함
--exclude "_next/*" # 3) 그 중 _next/ 안의 JS는 다시 제외
마지막에 매칭되는 규칙이 적용된다. _next/static/chunks/page.js는:
1. --exclude "*" → 제외됨
2. --include "*.js" → 포함됨
3. --exclude "_next/*" → 다시 제외됨 ← 최종 결과
_next/ 폴더에 --delete를 안 쓰면 이전 빌드의 chunk가 계속 쌓인다. 하지만:
Step 4의 --exclude "_next/*"는 반드시 필요하다. Step 1에서 --delete 없이 이전 chunk를 보존하더라도, Step 4에서 --include "*.js" --delete를 사용하면 _next/ 안의 이전 JS 파일까지 삭제 대상이 된다. 이전 단계의 의도를 후속 단계가 무효화하지 않도록 주의하자.
s3 sync --delete를 여러 단계로 나눠 쓸 때, 각 단계의 include/exclude 범위가 겹치지 않는지 확인해야 한다. 특히 _next/ 같은 해시된 에셋 디렉토리는 모든 단계에서 명시적으로 제외하는 것이 안전하다.
해시된 에셋은 절대 삭제하면 안 된다. 활성 사용자의 브라우저가 참조하고 있을 수 있다. 삭제는 Lifecycle Policy 등 별도의 정리 작업으로 분리하자.