Next.js 정적 사이트를 S3 + CloudFront로 배포하기 (GitHub Actions)

유영석·2026년 3월 10일

아키텍처

[GitHub] → push → [GitHub Actions] → build → [S3 Bucket] → [CloudFront CDN] → 사용자
  • Next.js (output: 'export'로 정적 HTML 생성)
  • S3: 정적 파일 호스팅
  • CloudFront: CDN + HTTPS
  • GitHub Actions: CI/CD 파이프라인

Next.js 설정

// 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/ 안의 파일들은 빌드마다 해시가 바뀐다. 이게 캐시 전략의 핵심이다.


GitHub Actions 워크플로우

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 배포: 4단계 전략

정적 파일을 하나의 s3 sync로 올리면 안 된다. 파일 종류별로 캐시 정책이 다르기 때문이다.

Step 1: 해시된 정적 에셋 (장기 캐시)

aws s3 sync ./out/_next $S3_BUCKET/_next \
  --cache-control "public, max-age=31536000, immutable"
  • _next/static/chunks/page-12e1a2d8251d49e1.js 같은 파일들
  • 파일명에 해시가 포함되어 있으므로 1년 캐시 + immutable 설정
  • --delete 없음 → 이전 빌드의 chunk를 보존

왜 이전 chunk를 보존하는가?

배포 시점에 사이트를 이용 중인 사용자의 브라우저는 이전 빌드의 HTML을 들고 있다.
이 HTML은 이전 해시의 chunk를 참조하고 있으므로, 이전 chunk가 삭제되면
클라이언트 사이드 네비게이션(링크 클릭) 시 chunk 로드에 실패한다.

Step 2: HTML 파일 (캐시 없음)

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
  • HTML은 항상 최신 버전을 받아야 하므로 캐시 없음
  • HTML 안에 <script src="/_next/static/chunks/page-{hash}.js"> 참조가 있다
  • 새로고침하면 항상 최신 HTML → 최신 chunk 해시를 참조

Step 3: Clean URL 처리

# .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으로 접근할 수 있도록 확장자 없는 파일도 업로드
  • CloudFront Function에서 URL 리라이트와 함께 사용

Step 4: 기타 정적 에셋 (중기 캐시)

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
  • 이미지, 폰트, 루트의 JS/CSS 등
  • 1일 캐시
  • --exclude "_next/*" → Step 1에서 보존한 이전 chunk를 삭제하지 않도록 보호

CloudFront 캐시 무효화

aws cloudfront create-invalidation \
  --distribution-id $CLOUDFRONT_DISTRIBUTION_ID \
  --paths "/*"
  • 모든 경로의 edge 캐시를 무효화
  • 비동기로 실행되며, 전파에 1~15분 소요
  • _next 파일은 immutable 캐시이므로 무효화와 무관하게 해시로 구분

캐시 전략 요약

파일 종류경로 예시Cache-Control--delete
JS/CSS chunk (해시)_next/static/chunks/*.js1년, immutable없음 (이전 빌드 보존)
HTMLindex.html, aboutno-cache, must-revalidate있음
이미지/폰트/기타*.png, *.ico, robots.txt1일있음

핵심 원칙:

  • 해시가 포함된 파일: 장기 캐시 + 이전 버전 보존
  • 해시가 없는 파일: 짧은 캐시 또는 캐시 없음 + 오래된 파일 삭제

--exclude/--include 평가 순서

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/*" → 다시 제외됨 ← 최종 결과


이전 chunk 누적 관리

_next/ 폴더에 --delete를 안 쓰면 이전 빌드의 chunk가 계속 쌓인다. 하지만:

  • 각 빌드당 chunk 총 용량은 수 MB 수준
  • S3 스토리지 비용은 무시할 수 있는 수준 ($0.025/GB/월)
  • 필요하다면 S3 Lifecycle Policy로 일정 기간이 지난 파일을 자동 삭제하는 것도 가능하다

주의사항

  1. Step 4의 --exclude "_next/*"는 반드시 필요하다. Step 1에서 --delete 없이 이전 chunk를 보존하더라도, Step 4에서 --include "*.js" --delete를 사용하면 _next/ 안의 이전 JS 파일까지 삭제 대상이 된다. 이전 단계의 의도를 후속 단계가 무효화하지 않도록 주의하자.

  2. s3 sync --delete를 여러 단계로 나눠 쓸 때, 각 단계의 include/exclude 범위가 겹치지 않는지 확인해야 한다. 특히 _next/ 같은 해시된 에셋 디렉토리는 모든 단계에서 명시적으로 제외하는 것이 안전하다.

  3. 해시된 에셋은 절대 삭제하면 안 된다. 활성 사용자의 브라우저가 참조하고 있을 수 있다. 삭제는 Lifecycle Policy 등 별도의 정리 작업으로 분리하자.

profile
ENFP 풀스택 개발자

0개의 댓글