Next.js + CDN으로 성능과 비용 모두 개선하기

많은 프론트엔드팀이 놓치는 Next.js에서의 정적 파일 서빙

Next.js 앱을 Vercel이 아닌 self-hosted 배포 환경에서 운영할 때 많은 팀이 놓치는 부분 중 하나가 정적 파일을 어떻게 전달할 것인가? 에 관한 것입니다. 브라우저가 웹페이지에 접근하면 Next.js 서버는 페이지 HTML, JS 번들, CSS 파일을 전부 '직접' 제공합니다. 물론 Vercel 플랫폼으로 배포한다면 그들의 CDN으로 최적화하여 전달되지만 많은 개발 팀은 self-hosted 환경을 이용하며 이 환경에서는 모든 요청을 origin(Next.js 서버)에서 직접 처리합니다. 이 점이 서버와 네트워크 비용, 성능에 영향을 줍니다.

최근에 제가 속한 팀에서는 self-hosted Next.js 서버 환경을 운영하고 있습니다. 결제 방식이 서버를 계속 가동하는 '인스턴스 기반'이 아닌 '요청 기반'을 사용하는 경우가 있고, 요청을 처리할 때만 청구되는 방식이기에 정적 파일을 제공할 때도 Next.js 서버의 리소스를 소모하게 됩니다. 그래서 요청을 최대한 줄이는 것이 비용 절감과 성능에 중요했습니다.

요청 기반 서버 결제 방식 참고 이미지

이를 해결하기 위해 CDN을 도입하여 서버에서 연산이 필요 없는 정적 리소스들은 object storage + CDN으로 빼고 HTML·API는 origin에 남기자는 의사결정을 내렸습니다. Next.js의 assetPrefix를 이용하면 정적 리소스들에 대해 다른 origin을 요청하도록 분리할 수 있습니다. 아래 내용은 특정 클라우드에 한정되지 않으며, S3·R2 + CloudFront·Cloudflare CDN 등 어떤 조합에도 같은 원리가 적용됩니다.

CDN 도입 시 고려할 점과 요구사항 정의

CDN은 정적 파일을 '캐싱'으로 빠르게 전달할 수 있는 장점이 있지만, 반대로 가장 조심히 다루어야 할 부분도 '캐싱'입니다. 예기치 못한 상황을 최대한 방지하기 위해 아래와 같은 요구사항을 정의하고 구현을 진행했습니다.

  1. 새로운 배포가 트리거될 시 이전 캐시를 사용하지 않고 새로운 정적 파일이 캐싱되야하며 롤백 시에도 이전 버전의 정적 파일이 캐싱을 사용해야 한다.
  2. same-origin 정책을 유지해야만 하는 정적 파일들은 origin에 유지해야한다.
  3. JS가 아닌 CSS에서 url('/...')로 참조하는 정적 파일이 있다면 origin에 유지해야한다.
  4. 이미지나 폰트는 최적화된 포맷 또는 사용자에게 보여지는 만큼의 크기로 제공해야한다.
  5. 캐싱 정책은 안정적으로 유지하면서 가능한 최대한 길게 설정하여 성능을 최적화해야한다.

CDN 도입 전후 인프라 구조 비교

CDN 도입 전후 예상되는 인프라 구조를 나란히 비교하면 아래와 같습니다. 연산과 same-origin 정책을 유지해야만 하는 정적 파일은 origin에 유지하고 나머지 정적 파일은 CDN을 통해 전달됩니다.

AS-IS:origin만
flowchart TB
  Browser["Browser"]
  Origin["Next.js origin<br/>(self-hosted)"]
  Browser --> Origin
  Origin --> App["HTML / SSR / API / RSC"]
  Origin --> Static["/_next/static<br/>JS, CSS, font"]

  classDef bottleneck fill:#fee,stroke:#c55,color:#333
  class Origin,Static bottleneck
TO-BE:storage + CDN + origin
flowchart TB
  Browser["Browser"]
  CDN["CDN"]
  Storage["Object Storage"]
  Origin["Next.js origin<br/>HTML, SSR, API, Worker"]
  Browser -->|"/_next/static, public 일부"| CDN
  CDN --> Storage
  Browser --> Origin

  classDef offloaded fill:#efe,stroke:#5a5,color:#333
  class CDN,Storage offloaded

Step 1. 배포마다 새 정적 파일이 캐싱된다

왜 필요한가

CDN의 핵심 위험은 구버전 asset이 남아 있는 것입니다. 배포 후에도 edge에 이전 JS·CSS가 남아 있으면 chunk mismatch, hydration 오류, 기능 장애로 이어집니다. 캐시가 가장 조심스러운 지점이므로, 가장 먼저 배포 단위 격리를 설계했습니다.

구현

assetPrefix{env}/{commitSha}를 붙여 URL 자체가 배포마다 달라지게 합니다.

// next.config.js
const commitSha = process.env.COMMIT_SHA; // CI: git rev-parse --short HEAD
const env = process.env.APP_ENV;

const assetPrefix =
  commitSha && env
    ? `https://cdn.example.com/${env}/${commitSha}`
    : undefined;

module.exports = {
  assetPrefix,
  // 로컬 dev: COMMIT_SHA / APP_ENV 미설정 → same-origin
};

CI/CD 순서도 중요합니다. HTML이 새 asset URL을 참조하기 전에 storage에 파일이 있어야 합니다.

steps:
  - name: build
    run: |
      export COMMIT_SHA=$(git rev-parse --short HEAD)
      export APP_ENV=prod
      npm ci && npm run build

  - name: upload-static
    run: |
      # object storage CLI: 환경에 맞게 사용
      aws s3 sync .next/static \
        s3://my-assets-bucket/${APP_ENV}/${COMMIT_SHA}/_next/static \
        --cache-control "public,max-age=31536000"

  - name: deploy-origin
    run: |
      # kubectl rollout, docker compose, systemd 등 환경에 맞게
      kubectl set env deployment/my-app \
        COMMIT_SHA=$COMMIT_SHA APP_ENV=prod
      kubectl rollout status deployment/my-app
GCS 버킷 내 폴더 구조

"배포마다 새 prefix = 새 URL"이 안전한 캐싱을 위한 핵심 내용입니다. 롤백 시 origin 배포만 되돌리면 HTML이 이전 commit SHA prefix를 참조합니다. 따라서 storage에 구버전 asset도 새 배포에 맞춰 배로 제거하는게 아닌 일정 기간 유지해야 broken asset을 막을 수 있습니다. env prefix 없이 commit SHA만 경로에 두면, 개발 환경의 배포가 운영 환경의 asset을 덮어서 잘못된 chunk를 로드할 수 있기 때문에 env prefix를 추가하여 개발 환경과 운영 환경을 명확히 구분해야 합니다.

검증

Step 2. same-origin 필수 파일은 origin에 유지

왜 필요한가

assetPrefix 적용 후 Web Worker를 사용하는 기능들이 동작하지 않을 수 있습니다. Worker 스크립트 URL이 document origin과 same-origin이 아니면Worker 생성이 거부됩니다.

Failed to construct 'Worker': Script at 'https://cdn.example.com/.../worker.js'
cannot be accessed from origin 'https://app.example.com'.

Service Worker(sw.js)도 same-origin이 필요합니다.

구현

/_next/static은 CDN에 두되, public/ 중 Worker·SW 파일은 CDN 업로드에서 제외하고 origin에서 서빙하는 하이브리드 패턴을 씁니다. 기존에 aliased 파일 경로를 사용하고 있다면 이를 assetPrefix에 적용되므로 `/`로 시작하는 경로로 수정이 필요합니다.

하이브리드 서빙 구조도: /_next/static은 CDN, Web Worker·SW는 origin
new Worker('/workers/timer.worker.js')
// → https://app.example.com/workers/image-worker.js (CDN이 아님)

검증

Step 3. CSS url('/...') 참조 파일은 origin에 유지

왜 필요한가

assetPrefix/_next/static의 JS·CSS chunk URL에 붙습니다. CSS 파일 자체는 CDN에서 내려와도, 그 안의 background-image: url('/images/pattern.svg')처럼 루트 절대 경로로 적힌 public/ 참조는 브라우저가 HTML document origin 기준으로 해석합니다.

/* globals.css 빌드 후 /_next/static/.../*.css 는 CDN에서 로드 */
.hero {
  background-image: url("/images/hero-bg.png");
  /* 브라우저 요청: https://app.example.com/images/hero-bg.png ← origin */
  /* CDN URL이 아님: https://cdn.example.com/.../images/hero-bg.png */
}

CSS 명세상 url(/path)는 CSS 파일이 호스트된 CDN origin이 아니라 문서 origin의 absolute path입니다. public/을 storage에만 올리고 origin 서빙을 끊으면 배경·아이콘 이미지가 404가 됩니다.

구현

해결은 두 가지 경로가 있습니다.

  1. CSS url('/...')로 쓰는 public/ 파일은 origin에 그대로 두고 CDN upload에서 제외 (Step 2 Worker와 같은 하이브리드)
  2. CDN에 올릴 거라면 CSS Module에서 import해 webpack이 /_next/static/media/로 번들링하게 함 → assetPrefix 경로를 탐
/* CSS Module import 시 webpack이 /_next/static/media/ 로 번들링 */
.hero {
  background-image: url("./hero-bg.png");
  /* 빌드 후: url(https://cdn.example.com/prod/sha/_next/static/media/hero-bg.xxx.png) */
}

next/image·inline style의 url()도 동일하게 assetPrefix가 자동으로 붙지 않습니다. Step 4에서 이미지 전략을 다룹니다.

검증

Step 4. 이미지·폰트를 최적화된 포맷·크기로 제공

왜 필요한가

CDN으로 경로만 분리해도 원본 PNG 4MB·미사용 글리프가 포함된 woff는 그대로 전송됩니다. edge cache hit이 나와도 다운로드 바이트가 크면 LCP·비용 모두 손해입니다. “보여지는 만큼만, 최적 포맷으로”가 요구사항입니다.

이미지 최적화

/_next/imageassetPrefix와 별개입니다. 이미지 CDN을 쓰고 있다면 custom loader로 연결합니다.

// next.config.js
module.exports = {
  images: {
    loader: "custom",
    loaderFile: "./lib/image-loader.js",
  },
};
// lib/image-loader.js
export default function imageLoader({ src, width, quality }) {
  const params = new URLSearchParams({
    transform: `w=${width}&quality=${quality}`,
  });
  return `https://cdn.example.com/${src}?${params}`;
  // 예: .../hero.png
}

Vercel로 배포 시 별도의 custom loader를 사용하지 않고 기본적으로 이미지 최적화가 적용됩니다. 하지만 self-hosted 환경에서는 위와 같이 별도로 custom loader가 필요하며 Load Balancer 설정에서 이미지 최적화 서버에서 변환 후 CDN으로 반환되도록 추가적인 인프라 설정이 필요합니다.

next/image의 Image 컴포넌트는 (images 속성이 적용된)next.config.js 파일을 기반으로 아래와 같이 srcset을 자동으로 생성합니다.

이미지 최적화

폰트 최적화

next/font로 self-host하면 woff2가 /_next/static/media/*.woff2로 번들링되어 CDN 경로를 탑니다. subsetting으로 사용 글리프만 포함하고, 포맷은 woff2만 씁니다.

CDN에서 cross-origin으로 폰트를 로드할 때는 CORS 검사가 적용됩니다. object storage에 아래와 같이 CORS를 설정합니다.

Access to font at 'https://cdn.example.com/.../font.woff2' from origin
'https://app.example.com' has been blocked by CORS policy.
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
  }
]

storage·CDN마다 CORS 설정 문법은 다르지만, CDN origin이 cross-origin font를 서빙할 수 있게 해야 합니다.

검증

Step 5. 안정적인 장기 캐싱으로 성능 최적화

왜 필요한가

Step 1에서 배포 단위 격리를 마쳤으니, 이제 “안전하게 오래 캐싱”할 수 있습니다. HTML은 짧게, hash가 붙은 정적 파일은 최대한 길게 설정하여 성능과 안정성을 동시에 만족시킵니다.

구현

먼저 object storage 버킷을 CDN origin으로 연결하고 cdn.example.com DNS·TLS를 설정합니다.

파일 Cache-Control 비고
/_next/static/* max-age=31536000 commit SHA prefix + content hash (Step 1)
HTML (origin) no-cache 또는 짧은 max-age 매 배포마다 새 document

[contenthash] + commit SHA prefix가 있으면 URL이 바뀌므로 1년 캐시도 안전합니다. 구버전 HTML이 구버전 asset URL을 참조할 때 신규 배포 asset과 섞이지 않고, 롤백 시에도 broken asset을 막을 수 있습니다.

HTTP/3(QUIC)과 Brotli/gzip 압축은 CDN·로드밸런서 설정 영역입니다. 앱 코드 변경 없이 클라이언트↔edge 구간에서 이득이 납니다. 다만 이 이득은 정적 asset 다운로드 구간에 해당합니다. HTML TTFB는 여전히 origin SSR·DB 조회 시간에 좌우됩니다.

GCP Cloud CDN 백엔드 버킷 및 캐시 모드 설정 화면

검증

비용이 왜 줄어드는가

Next.js 서버 청구서를 처음 보면 Requests, CPU/Memory, Networking egress 같은 항목이 섞여 있습니다. CDN 도입 효과를 이해하려면 이 세 가지가 무엇인지부터 짚는 편이 낫습니다.

Next.js 서버에서 나가는 돈 세 가지

  1. 요청(Request): HTTP 1번 = 과금 1번. JS 파일 하나를 내려줘도 요청 1건입니다. Next.js 페이지 1회 로드는 대략 요청 100회에 가깝습니다.
  2. CPU·메모리: 요청을 처리하는 동안 컨테이너가 켜져 있는 시간만큼 과금됩니다. 파일을 전송하는 동안에도 CPU·메모리가 소모됩니다.
  3. egress(이그레스): Next.js 서버에서 인터넷 밖, 즉 사용자 브라우저 쪽으로 데이터를 보낼 때 드는 출구 통행료입니다. GB 단위로 청구됩니다. ingress(들어오는 트래픽)와 달리 egress는 대개 유료입니다.

CDN 도입 전에는 JS·CSS·font를 Next.js 서버가 직접 사용자에게 보냈습니다. 그래서 요청 100번 + egress 수 MB가 페이지 조회 1번마다 반복됐습니다. Next.js 서버는 SSR·API 처리용이지, 대용량 정적 파일 배달용 CDN이 아닙니다. 같은 일을 시키면 단가가 맞지 않습니다.

CDN은 왜 더 싼가

역할을 나누면 단가 차이가 드러납니다.

역할 담당 비유
파일 보관 Cloud Storage (object storage) 창고: GB당 저장료가 cheap
사용자에게 배달 Cloud CDN 택배 hub: edge에서 가까이 전달, egress 단가 낮음
HTML·API 처리 Next.js 서버 주방: 요리(SSR)만 담당

같은 1GB를 1,000명에게 보낸다고 가정해 보겠습니다. (실제론 cache hit으로 origin fetch는 훨씬 적습니다.)

CDN egress 단가가 Next.js 서버 egress보다 대략 2~3배 저렴한 경우가 많고, 더 중요한 건 Next.js 서버가 애초에 그 GB를 보내지 않는다는 점입니다. 정적 파일 800GB를 Next.js 서버가 내보내던 구조가 CDN으로 옮겨지면, Next.js 서버 egress는 HTML·API분(120GB)만 남습니다.

CDN·storage 비용($42/월)이 새로 생기지만, Next.js 서버에서 빠지는 요청·egress·CPU($290/월 절감)가 더 커서 합계는 줄어듭니다. 트래픽이 아주 적으면 반대일 수 있으니, 본인 환경에서 한 번 계산해 보는 것이 좋습니다.

적용 결과

비용 비교

아직 모든 개선을 적용하지 못한 부분들도 있습니다. 하지만 위와 같은 기본적인 설정과 점진적인 개선 노력 끝에 비용을 38% 정도를 절감시킬 수 있었습니다.

TL;DR

  1. commit SHA + env prefix assetPrefix로 배포마다 새 정적 URL을 만들어 캐시 격리를 달성
  2. Web Worker·Service Worker는 same-origin 제약으로 origin 하이브리드 서빙을 유지
  3. CSS url('/...') 참조는 origin에 두거나, CSS Module import로 /_next/static/media/ 번들링해 CDN 경로를 참조하도록 함
  4. 이미지는 custom loader + WebP transform, 폰트는 woff2 subset + CORS로 최적화된 바이트만 전송
  5. content hash 기반 장기 캐시와 CDN edge(HTTP/3, Brotli)로 정적 구간 TTFB를 줄임
  6. CDN은 서버가 직접 비싸게 하던 정적 서빙을 storage+CDN($42)으로 넘겨, 전체 bill $424→$176(페이지 1,000회당 $3.53→$1.47)으로 줄임. HTML/SSR은 origin에서 유지