노트

버전 스큐와 정적 자산 오프로드

Version Skew

인프라#nextjs#performance · 연결된 개념 8개

쉽게 말하면

버전 스큐는 개정 전 교재를 든 학생이 새 교재에서는 빠진 쪽을 펴 달라고 하는 상황 같아요. 브라우저에 떠 있는 옛 빌드가 새 서버에는 없는 예전 JS 파일을 찾다가 404, 즉 ChunkLoadError가 나요.

비유가 깨지는 곳 해결은 옛 판의 쪽도 계속 펼 수 있게 남겨 두는 거예요. 정적 자산은 CDN 버킷에서 지우지 않고 쌓아 둬요. 또 deploymentId로 버전 차이를 감지해 전체 새로고침을 하는데, 이때 컴포넌트 상태는 사라져요.

버전 스큐(Version Skew)는 새 버전을 배포한 뒤에도 브라우저에 떠 있는 이전 빌드의 클라이언트가 새 서버와 통신하면서 생기는 불일치다. 대표 증상은 이전 HTML이 참조하던 JS 청크를 늦게 불러오려다 404가 나는 ChunkLoadError다.

왜 생기나

번들러는 청크 파일 이름에 내용 해시를 붙인다(chunks/a1b2c3.js). 빌드가 바뀌면 이름도 바뀐다.

사용자가 이전 빌드의 HTML을 받아 둔 사이에 배포로 트래픽이 새 빌드 서버로 넘어가면, 나중에 지연 로딩하는 이전 해시 청크 요청이 새 서버로 가서 404가 난다.

sequenceDiagram
  participant B as 브라우저
  participant LB as 로드밸런서
  participant O as 이전 빌드 서버
  participant N as 새 빌드 서버
  B->>LB: 페이지 요청
  LB->>O: 전달
  O-->>B: 이전 빌드 HTML (이전 해시 청크 참조)
  Note over LB,N: 배포 - 트래픽을 새 빌드 서버로 넘김 (블루-그린 · 롤링)
  B->>LB: 다른 화면에서 이전 해시 청크 지연 로딩
  LB->>N: 전달
  N-->>B: 404 (.next/static에 이전 해시 파일 없음)

정적 자산을 앱 서버가 직접 서빙하면, 지금 트래픽을 받는 빌드의 파일만 존재하는 구조라서 생긴다. Next.js 문서는 같은 원인으로 서버 함수 ID 불일치와 prefetch 데이터 비호환도 꼽는다.

assetPrefix로 정적 자산 떼어 내기

assetPrefix를 설정하면 /_next/static/...(.next/static 폴더의 JS·CSS) 참조 URL 앞에 CDN 호스트가 붙는다. URL은 빌드 때 HTML·JS에 박히므로 실행 서버의 환경 변수가 아니라 빌드 단계에 값을 넣어야 한다. public/ 폴더 파일은 바뀌지 않는다.

before  /_next/static/chunks/X.js                      → 앱 서버
after   https://cdn.example.com/_next/static/chunks/X.js → CDN·오브젝트 스토리지

이렇게 정적 파일 서빙을 앱 서버에서 떼어 CDN에 넘기는 것을 오프로드(offload)라고 한다. 효과는 두 가지다.

  • 스큐 404 해소: 빌드마다 .next/static을 같은 버킷에 지우지 않고 누적 업로드한다. 파일 이름이 내용 해시라 이전 빌드와 새 빌드 파일이 충돌 없이 공존한다. 이전 HTML이 이전 청크를 요청해도 버킷에 남아 있어서 200이 난다. 동기화 도구의 삭제 옵션으로 이전 파일을 지우면 404가 다시 생긴다. 오래된 파일은 수명 주기 규칙으로 정리한다
  • 앱 서버 부하 감소: 페이지 하나에 청크·CSS·폰트 요청이 수십~수백 개씩 몰린다. 이 요청이 노드 프로세스를 거치지 않으니, 재시작 직후의 콜드 서버도 렌더에만 집중한다

업로드 대상은 .next/static뿐이다. .next의 나머지(서버 코드·설정)는 공개하면 안 된다. standalone 출력이 .next/static을 기본으로 빼는 것도 이 구성을 전제로 하기 때문이다.

deploymentId: 감지와 새로고침

deploymentId(또는 NEXT_DEPLOYMENT_ID)를 주면 Next.js는 정적 자산 URL에 ?dpl=<id>를 붙이고, 클라이언트 내비게이션 요청과 응답 헤더로 버전을 주고받는다. 서버와 클라이언트의 ID가 다르면 클라이언트 내비게이션 대신 전체 새로고침을 해서 새 버전으로 맞춘다(Next.js 16 기준).

  • ?dpl=은 캐시 무효화용이다. Next.js 서버는 이 값을 보고 이전 버전 파일을 찾아 주지 않는다
  • 그래서 deploymentId는 "감지하고 복구 동작을 트리거"하는 장치이고, 이전 청크를 실제로 내주는 것은 누적 저장소다. 둘을 같이 써야 한다. 저장소가 없으면 새로고침 전 지연 로딩에서는 여전히 404가 날 수 있다
  • 여러 인스턴스를 띄우면 같은 배포의 인스턴스는 모두 같은 ID를 써야 한다
  • 새로고침되면 useState 같은 컴포넌트 상태는 사라진다. URL이나 웹 스토리지에 둔 상태만 남는다

불변 정적 자산(Immutable Static Assets)

?dpl=은 배포마다 바뀌니, 내용이 그대로인 청크도 배포할 때마다 브라우저가 다시 받는다. Next.js 16.3은 어댑터가 지원하면 내용 해시로 이름 붙인 자산을 /_next/static/immutable/* 아래에 내보내고 ?dpl= 없이 요청한다. 이 파일들은 배포끼리 공유하는 이름 공간에 살아서, 바뀌지 않은 청크는 브라우저 캐시와 업로드를 배포를 넘어 재사용한다. 대신 그 파일을 쓰는 배포가 살아 있는 동안 지우거나 덮어쓰면 안 된다. 위의 "누적 저장소"를 프레임워크가 공식화한 셈이다.

  • 켜는 쪽은 어댑터(modifyConfig에서 supportsImmutableAssets)다. 앱에서는 supportsImmutableAssets: false로 끄는 용도이고, 지원하지 않는 환경에서 켜면 배포가 깨질 수 있다고 문서가 경고한다
  • public/ 파일 같은 나머지 정적 자산은 계속 ?dpl=이 붙는 배포 단위 자산이다

플랫폼의 Skew Protection

Vercel의 Skew Protection은 한 걸음 더 나아가 버전 고정(version locking)을 한다. 프레임워크가 정적 자산·내비게이션·prefetch 요청에 배포 ID를 붙이면, 플랫폼이 그 요청을 처음 페이지를 준 배포로 보낸다. 이전 배포를 통째로 살려 두는 방식이라 자산뿐 아니라 서버 함수도 맞는다. 기본 보존 기간은 배포 생성 후 1일이고, Pro·Enterprise 플랜 기능이다(2026 기준). 직접 운영한다면 위의 "누적 자산 + deploymentId" 조합이 그 일부를 흉내 내는 셈이다.

배포 전략 자체는 GitOps와 수직 확장과 수평 확장, CDN 캐시 동작은 브라우저 캐시과 이어진다.

출처: Next.js - Self-Hosting: Version Skew · Next.js - deploymentId · Next.js - supportsImmutableAssets · Next.js - Supporting Immutable Static Assets · Next.js - assetPrefix · Vercel - Skew Protection · Malte Ubl - Version skew

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • Activity와 숨겨진 라우트 보존

    Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

  • bfcache(뒤로·앞으로 가기 캐시)

    페이지를 떠날 때 DOM과 JS 힙까지 통째로 메모리에 얼려 두었다가, 뒤로·앞으로 가기에서 그대로 되살리는 브라우저 캐시.

  • XSS

    XSS(Cross-Site Scripting)는 공격자가 넣은 스크립트가 내 사이트의 출처(origin) 권한으로 사용자 브라우저에서 실행되는 공격이다. 같은 출처의 코드로 돌기 때문에 same-origin-policy가 막아 주지 못한다. 그 스크립트는 페이지를 바꾸고, 로그인한 사용자 행세를 하며 요청을 보내고, JS가 읽을 수 있는 데이터(localStorage의 토큰 등)를 빼 갈 수 있다.

  • loading 경계와 레이아웃 끌어올리기

    Next.js App Router에서 loading.tsx는 같은 세그먼트의 page와 그 아래(하위 layout 포함)를 suspense로 감싸지만, **같은 세그먼트의 layout은 감싸지 않는다**. 그래서 데이터를 기다릴 필요가 없는 공통 UI(탭바, 필터 헤더)를 page에서 layout으로 끌어올리면 스켈레톤에 덮이지 않고 처음부터 실제 모습으로 보인다.

  • 커넥션 드레이닝과 무중단 재시작

    배포 중 서버를 재시작하는 몇 초 동안 로드밸런서가 그 서버로 요청을 보내면 502가 난다. 먼저 로드밸런서에서 빼고(드레이닝), 진행 중인 요청을 마친 뒤 재시작하고, 준비되면 다시 넣는다.

보기 옵션