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

Next.js 앱을 self-hosted 환경(Kubernetes, VM + Docker 등)에서 origin으로 운영하면서, 트래픽이 늘수록 두 가지가 동시에 보였습니다. 첫째, 첫 로딩이 느리다는 피드백. 둘째, origin 서버와 네트워크 구간에서 egress·대역폭·요청 처리 부하가 예상보다 컸습니다.

원인은 단순했습니다. 페이지 한 번 열 때 HTML 한 장뿐 아니라 수십~백여 개의 JS/CSS chunk가 전부 Next.js origin을 경유했습니다. origin은 SSR·API 처리용 compute인데, 정적 파일까지 같은 경로로 내려주면 CPU·메모리·네트워크 egress가 함께 소모됩니다. k8s라면 pod autoscale까지 연쇄적으로 늘어납니다. 사용자에게는 멀리 있는 origin에서 chunk를 받는 지연, 운영 측면에서는 같은 트래픽을 성능과 비용 양쪽에서 두 번 내는 구조였습니다.

CDN 도입은 “모든 것을 edge에 올리자”가 아니라, 빌드 산출물만 object storage + CDN으로 빼고 HTML·API는 origin에 남기자는 결정이었습니다. Next.js의 assetPrefix가 그 분기점입니다. 아래 내용은 특정 클라우드에 한정되지 않으며, S3·R2 + CloudFront·Cloudflare CDN 등 어떤 조합에도 같은 원리가 적용됩니다.

As-Is vs To-Be

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

As-Is: origin만 쓸 때

모든 트래픽이 단일 origin으로 모입니다. 문제는 세 가지로 정리됩니다.

  1. 페이지 1회 로드에 수십~수백 개의 정적 요청이 전부 origin을 거침
  2. 정적 파일 전송도 origin CPU·메모리·소켓을 점유하고, k8s 환경에서는 불필요한 pod 확장을 유발
  3. 사용자와 가까운 edge가 아니라 단일 origin에서 파일을 서빙 → TTFB·다운로드 지연, internet egress 비용 누적

To-Be: object storage + CDN + origin

CDN 도입은 “Next.js 전체를 CDN에 올린다”가 아니라, 빌드 산출물만 storage에 두고 HTML은 origin에 남긴다는 분리입니다. 성능·비용 이득의 대부분은 /_next/static offload에서 옵니다.

트래픽 경로 이유
/_next/static/* storage → CDN content hash + commit SHA, edge 캐시
public/ (CDN 업로드 대상) storage → CDN <script>·assetPrefix로 참조되는 파일
public/ (CSS url() 등) origin 절대 경로 /...는 document origin 기준
Web Worker 등 origin same-origin 정책
HTML, SSR, API, RSC origin assetPrefix 범위 밖

pre-production과 production은 같은 storage 버킷을 쓰더라도 {env}/{commitSha}/ prefix로 논리적으로 격리합니다. 이 분기는 아래 트러블슈팅에서 다시 다룹니다.

구현: assetPrefix와 배포 파이프라인

next.config — 환경·commit SHA 기반 assetPrefix

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

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

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

로컬 개발에서는 prefix를 쓰지 않고, preprod·prod에서는 반드시 서로 다른 env prefix (/prod/ vs /preprod/)를 씁니다.

CI/CD — build → storage upload → origin deploy

순서가 중요합니다. 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,immutable"

  - 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

롤백 시 origin 배포만 되돌리면 HTML이 이전 commit SHA prefix를 참조합니다. storage에 구버전 prefix를 lifecycle rule로 일정 기간 유지해야 broken asset을 막을 수 있습니다.

storage + CDN 설정

[
  {
    "AllowedOrigins": ["https://app.example.com", "https://preprod.example.com"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600
  }
]

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

캐싱·HTTP/3·압축

파일 종류별 Cache-Control

파일 Cache-Control 비고
/_next/static/* max-age=31536000, immutable commit SHA prefix + content hash
HTML (origin) no-cache 또는 짧은 max-age 매 배포마다 새 document
Web Worker (public/) origin, 짧은 캐시 same-origin 필수

Next.js 파일명의 [contenthash]파일 단위 변경을, commit SHA prefix는 배포 단위 격리를 담당합니다. 구버전 HTML이 구버전 asset URL을 참조할 때 신규 배포 asset과 섞이지 않게 하고, 롤백 시 broken asset도 막을 수 있습니다.

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

비용이 왜 줄어드는가

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

Cloud Run에서 나가는 돈 세 가지

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

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

CDN은 왜 더 싼가

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

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

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

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

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

적용 결과

아래는 실제 측정 환경 기준 비교입니다.
As-Is: GCP Cloud Run만 (정적·동적 모두 origin)
To-Be: GCP Cloud Run + GCS + Cloud CDN (assetPrefix로 정적 offload)

As-Is: 페이지 로드의 대부분 요청이 Cloud Run을 경유해 지연·요청 과금·egress(데이터 전송료)가 함께 발생했습니다.
To-Be: 정적 asset은 Cloud CDN edge에서 cache hit, Cloud Run은 HTML/API만 처리합니다.

성능·트래픽 수치는 CDN 도입 전후 2주간 프로덕션 median입니다. 비용은 GCP Billing Reports에서 동일 기간 SKU별로 집계했습니다.

성능·트래픽

구분 As-Is (Cloud Run only) To-Be (Cloud Run + CDN)
정적 asset 경로 Browser → Cloud Run Browser → Cloud CDN → GCS
페이지 1회 로드 시 Cloud Run 요청 HTML 1 + static 80~120건 HTML 1 + API 1~5건
정적 파일 TTFB origin RTT 200~400ms edge cache hit 20~50ms
LCP (Lighthouse median) 3.2s 1.8s
Cloud CDN cache hit ratio 94%
Cloud Run 월간 requests 12M 2M (−83%)
Cloud Run → 사용자 데이터 전송 (egress) 800 GB 120 GB (−85%)
preprod / prod asset 격리 prefix 미분리 → 분기 코드 오염 위험 {env}/{sha}/ prefix로 격리
배포 파이프라인 build → Cloud Run deploy build → GCS upload → Cloud Run deploy

비용 — Before / After (GCP Billing, 월간)

아래는 위에서 설명한 구조가 bill에 어떻게 찍혔는지입니다. 핵심만 보면 Cloud Run에서 빠진 $290 vs CDN·storage 신규 $42입니다.

무엇이 줄었/늘었나 As-Is (Cloud Run only) To-Be (Cloud Run + CDN)
Cloud Run 요청 (페이지 1회 ≈ 100건) 1,200만 건 / $186 200만 건 / $31
Cloud Run CPU·메모리 $142 $89
Cloud Run egress
(서버 → 사용자 데이터 전송료)
800 GB / $96 120 GB / $14
Cloud Run 합계 $424 $134
CDN + storage (정적 파일 배달) $42
전체 합계 $424 $176
페이지 1,000회당 (동일 트래픽) ≈ $3.53 ≈ $1.47

정리하면, CDN은 “추가 비용”이 아니라 Cloud Run이 비싸게 하던 일(정적 파일 배달)을 더 싼 전문가에게 넘긴 것에 가깝습니다. egress·요청·CPU가 함께 줄어든 이유도 같습니다 — JS chunk 100개를 Cloud Run이 보내지 않으니까요.

성능·비용·운영 안정성 세 축이 같은 아키텍처 변경에서 동시에 개선됐습니다. HTML/SSR 구간은 Cloud Run에 그대로 남아 있으므로, “전체가 빨라졌다”가 아니라 “정적 구간과 Cloud Run 요청·부하·비용이 줄었다”고 말하는 것이 정확합니다.

변하지 않은 것

트러블슈팅

Web Worker — CDN으로 옮기면 깨진다

assetPrefix 적용 후 Worker 기반 기능만 동작하지 않을 수 있습니다.

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

Worker 스크립트 URL이 document origin과 same-origin이 아니면 classic Worker 생성이 거부됩니다. Worker만 origin에서 서빙하고, /_next/static은 CDN을 유지하는 하이브리드가 정상 패턴입니다.

const worker = new Worker("/image-worker.js");
// → https://app.example.com/image-worker.js

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

CSS url()public/ 파일은 CDN이 아니라 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입니다. Worker와 다른 규칙이지만, 결과적으로 public/ 중 상당수가 CDN offload 대상이 아닙니다.

해결

/* 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) */
}

preprod / prod CDN prefix 공유 — 분기 코드 오염

env prefix 없이 commit SHA만 경로에 두면, prod 배포가 preprod가 올린 asset을 덮어쓰거나 preprod HTML이 prod chunk를 로드할 수 있습니다. preprod 전용 feature flag UI가 production에서 보이는 식의 환경 간 불일치로 이어집니다.

https://cdn.example.com/prod/abc1234/_next/static/...
https://cdn.example.com/preprod/def5678/_next/static/...

이 문제는 “캐시”가 아니라 환경 간 코드 일관성 문제입니다. preprod HTML의 script src에 /preprod/, prod에 /prod/가 포함되는지 배포 후 검증합니다.

CORS — font cross-origin

Access to font at 'https://cdn.example.com/.../font.woff2' from origin
'https://app.example.com' has been blocked by CORS policy.

@font-face woff2는 CORS 검사 대상입니다. object storage CORS를 설정하면 해결됩니다.

배포 순서 — storage upload 전 deploy 시 404

origin deploy가 storage upload보다 먼저 실행되면, 새 HTML이 아직 없는 asset URL을 참조해 chunk load failure가 납니다. upload 실패 시 deploy step을 skip하도록 CI를 구성합니다.

next/image

/_next/image 경로는 assetPrefix와 별개입니다. 별도 이미지 CDN을 쓰고 있다면 custom loader로 연결하거나, 별도 전략을 설계해야 합니다.

요약

  1. assetPrefix/_next/static만 storage + CDN에 두고, HTML·API는 origin에 남긴다.
  2. commit SHA + env prefix로 캐시 버스팅과 preprod/prod 격리를 동시에 달성한다.
  3. CDN은 Cloud Run이 비싸게 하던 정적 배달을 storage+CDN($42)으로 넘겨, 전체 bill $424→$176(페이지 1,000회당 $3.53→$1.47)으로 줄었다.
  4. Web Worker·CSS url('/...')·font CORS 등 origin 필수 경로는 함께 설계한다.
  5. HTML/SSR은 Cloud Run에 그대로 — “정적 구간과 Cloud Run 부하·비용” 관점에서 결과를 해석한다.