본문 바로가기
SPA/Next

Next.js 16.3 Instant Navigations로 완성하는 즉각적인 화면 전환

by J4J 2026. 8. 30.
300x250
반응형

안녕하세요. J4J입니다.

 

이번 포스팅은 Next.js 16.3에 새로 추가된 Instant Navigations, 그 중에서도 cacheComponents와 partialPrefetching 플래그로 켜는 Partial Prefetching과 관련 devtools 기능들을 살펴보는 시간을 가져보려고 합니다.

 

 

 

prefetch={true}와 loading.tsx, 프리페치 범위를 고르는 이분법

 

앞서 다룬 프리페치 개편에서는 Layout Deduplication과 Incremental Prefetching이 같은 레이아웃을 공유하는 링크들의 중복 전송을 줄여주는 과정을 살펴봤습니다.

 

더 자세한 내용은 Next.js 16, Layout Deduplication과 Incremental Prefetching 동작 원리을 참고해 주시길 바랍니다.

 

Next.js 16, Layout Deduplication과 Incremental Prefetching 동작 원리

안녕하세요. J4J입니다. 이번 포스팅은 Next.js 16에서 개편된 프리페치 동작인 Layout Deduplication과 Incremental Prefetching이 실제로 어떻게 동작하는지 살펴보는 시간을 가져보려고 합니다. 기존 프리페치 방식의 한계 React로 App Router의 리스트 화면을 만들다 보면, 링크가 많아질수록 페이지 이동이 오히려 무거워지는 상황을 마주치게 됩니다. Next.js의 Link 컴포넌트는 뷰포트에 들어오거나 hover되면 해당 라우트를 미리 내려받아 두는 프리페치를 자동으로 수행합니다. 이때 프리페치 대상 범위는 loading.js 파일이 있는지에 따라 달라지는데, loading.js가 없으면 페이지 전체를, 있으면 레이아웃부터 첫 loading 경계까지를 미..

jforj.tistory.com

해당 포스팅에서 다룬 개편은 어디까지나 "이미 정해진 프리페치 범위를 얼마나 효율적으로 받아오는가"에 대한 이야기였습니다.

 

그런데 "그 범위를 애초에 얼마나 넓게 잡을지"는 여전히 두 가지 선택지 사이에서 골라야 하는 문제로 남아 있었습니다.

 

loading.tsx가 없는 라우트로 향하는 링크는 페이지 전체를 프리페치하고, loading.tsx가 있는 라우트로 향하는 링크는 레이아웃부터 첫 loading 경계까지만 프리페치합니다.

 

// loading.tsx가 없으면 → 페이지 전체를 프리페치
// loading.tsx가 있으면 → 레이아웃부터 첫 loading 경계까지만 프리페치
// 그 사이의 세밀한 조절은 불가능

 

저의 경우 정적인 상품 설명과 실시간 재고 수치가 함께 있는 상세 페이지를 만들면서 이 이분법이 아쉬웠던 적이 있습니다.

 

loading.tsx를 넣지 않으면 실시간 재고 값이 응답에 포함되기 전까지 상품 설명조차 프리페치되지 않았고, loading.tsx를 넣으면 페이지 전체가 로딩 상태로 취급되어 이미 정적으로 확정된 상품 설명까지 매번 로딩 스피너 뒤에 가려졌습니다.

 

정적인 부분과 동적인 부분을 같은 페이지 안에서 서로 다르게 취급할 방법이 없었던 것입니다.

 

그러면 Next.js 16.3은 이 이분법을 어떻게 없앴는지 살펴보겠습니다.

 

 

반응형

 

 

cacheComponents와 partialPrefetching으로 셸 나눠 프리페치하기

 

Partial Prefetching은 라우트마다 URL에 의존하지 않는 공용 렌더링 결과(App Shell)만 기본적으로 프리페치하고, 링크에 prefetch={true}를 명시한 경우에만 그 라우트가 use cache로 캐시해 둔 URL별 데이터까지 포함해서 프리페치하는 동작입니다.

 

Instant Navigations라는 이름의 기능 묶음 중 하나이며, next.config.ts에 두 플래그를 함께 켜야 사용할 수 있습니다.

 

// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

 

partialPrefetching은 cacheComponents가 켜져 있어야만 동작하는 기능이라, cacheComponents 없이 partialPrefetching만 켜면 next dev와 next build가 설정 검증 단계에서 바로 에러를 던집니다.

 

두 플래그를 함께 켠 뒤, 정적인 부분과 동적인 부분이 섞인 상세 페이지로 직접 확인해 보겠습니다.

 

// app/instant-navigations/partial-prefetch/data.ts
import { cacheLife } from 'next/cache'

export async function getProduct(id: string) {
  'use cache'
  cacheLife('max')

  await new Promise((resolve) => setTimeout(resolve, 100))

  return {
    id,
    name: `상품 ${id}`,
    description: '이 설명은 use cache로 캐시되어 셸에 포함되는 정적 정보입니다.',
  }
}

 

getProduct는 use cache로 캐시했으므로 Partial Prefetching이 추출하는 셸에 포함됩니다.

 

// app/instant-navigations/partial-prefetch/LiveStock.tsx
async function getStock() {
  await new Promise((resolve) => setTimeout(resolve, 800))

  return Math.floor(Math.random() * 50)
}

export default async function LiveStock() {
  const stock = await getStock()

  return <p className="text-sm">현재 재고: {stock}개</p>
}

 

반대로 LiveStock은 use cache 없이 매 요청마다 새로 실행되는 일반 async 컴포넌트이므로, prefetch={true}를 쓰더라도 프리페치 대상에 포함되지 않고 네비게이션 이후 별도로 스트리밍됩니다.

 

다만 params를 페이지 컴포넌트 최상단에서 곧바로 await하면, 그 아래에서 getProduct를 use cache로 캐시했더라도 Next.js가 이 라우트를 프리렌더링할 수 없다는 빌드 에러를 던집니다.

 

params를 읽는 지점 자체가 Suspense 경계 밖에 있으면, 그 아래에서 실제로 무엇을 하는지와 무관하게 페이지 전체가 셸을 가질 수 없다고 판단되기 때문입니다.

 

그러므로 params를 읽고 getProduct를 호출하는 부분을 별도 컴포넌트로 분리해, 페이지 최상단이 아니라 Suspense 경계 안에서 실행되도록 옮기는 것이 좋습니다.

 

 

 

 

// app/instant-navigations/partial-prefetch/ProductInfo.tsx
import { getProduct } from './data'

export default async function ProductInfo({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const product = await getProduct(id)

  return (
    <>
      <h1 className="text-2xl font-bold">{product.name}</h1>
      <p className="text-sm text-gray-600">{product.description}</p>
    </>
  )
}

 

// app/instant-navigations/partial-prefetch/[id]/page.tsx
import { Suspense } from 'react'
import LiveStock from '../LiveStock'
import ProductInfo from '../ProductInfo'

export default function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <div className="flex flex-col gap-4 p-6">
      <Suspense fallback={<p>상품 정보를 불러오는 중...</p>}>
        <ProductInfo params={params} />
      </Suspense>
      <Suspense fallback={<p>재고 확인 중...</p>}>
        <LiveStock />
      </Suspense>
    </div>
  )
}

 

이제 페이지 컴포넌트 자체는 어떤 데이터도 직접 읽지 않고 두 Suspense 경계만 정의하므로 프리렌더링이 가능해지고, 상품 이름과 설명이 담긴 ProductInfo용 셸 하나와 LiveStock용 셸 하나가 각각 독립적으로 준비됩니다.

 

상품 이름과 설명은 id마다 값이 달라지는 URL별 데이터이므로, 기본 App Shell만으로는 프리페치되지 않고 링크에 prefetch={true}를 직접 명시해야 use cache로 캐시해 둔 값까지 프리페치에 포함됩니다.

 

// app/instant-navigations/partial-prefetch/page.tsx
import Link from 'next/link'

const productIds = ['1', '2', '3']

export default function PartialPrefetchListPage() {
  return (
    <ul className="flex flex-col gap-2 p-6">
      {productIds.map((id) => (
        <li key={id}>
          <Link
            href={`/instant-navigations/partial-prefetch/${id}`}
            prefetch={true}
            className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          >
            상품 {id}
          </Link>
        </li>
      ))}
    </ul>
  )
}

 

이렇게 작성하면 목록에서 상품 링크를 클릭하는 순간 상품명과 설명은 이미 프리페치되어 있던 값이라 즉시 나타나고, 재고 수치만 "재고 확인 중..." 상태를 거쳐 뒤늦게 채워집니다.

 

다만 이 동작 역시 next dev로 띄운 개발 서버에서는 재현되지 않으므로, npm run build로 빌드한 뒤 npm start로 프로덕션 모드에서 실행해야 셸과 스트리밍이 분리되는 것을 확인할 수 있습니다.

 

그러면 셸 없는 라우트를 만들면 실제로 무슨 일이 벌어지는지, 인스턴트 내비게이션 검증을 통해 살펴보겠습니다.

 

 

 

 

인스턴트 내비게이션 검증, 셸 없는 라우트가 빌드를 막는 이유

 

인스턴트 내비게이션 검증은 cacheComponents를 켠 앱의 모든 라우트를 대상으로, Suspense나 use cache를 거치지 않고 동적 데이터를 읽는 부분이 있는지 자동으로 확인하는 동작이며, dev 서버에서는 DevTools의 Instant Insights 패널에 경고로, 빌드 시점에는 실제 에러로 나타납니다.

 

셸이 전혀 없는 라우트를 하나 만들어 이 검증이 실제로 어떻게 반응하는지 확인해 보겠습니다.

 

// ❌ 캐싱도, Suspense 경계도, instant = false도 없는 상태
async function getSlowData(id: string) {
  await new Promise((resolve) => setTimeout(resolve, 1200))

  return { id, message: '로딩 셸 없이 1.2초를 기다린 뒤에야 나타나는 콘텐츠입니다.' }
}

export default async function SlowDetailPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const data = await getSlowData(id)

  return <p className="p-6">{data.message}</p>
}

 

이렇게 작성하면 npm run build 시점에 "Next.js encountered uncached or runtime data during prerendering"라는 에러와 함께 빌드 자체가 실패합니다.

 

에러 메시지는 이를 고칠 수 있는 세 가지 방법을 함께 제시합니다.

 

  • stream: Suspense로 감싸기
  • cache: use cache로 캐싱하기
  • block: instant = false로 블로킹을 허용하기

 

앞의 두 방법은 이미 Partial Prefetching 데모에서 사용한 방법과 같지만, 이 라우트는 셸이 없는 상태 자체를 보여주는 것이 목적이므로 세 번째 방법인 instant = false를 선택합니다.

 

// app/instant-navigations/slow-detail/[id]/page.tsx
export const instant = false

async function getSlowData(id: string) {
  await new Promise((resolve) => setTimeout(resolve, 1200))

  return { id, message: '로딩 셸 없이 1.2초를 기다린 뒤에야 나타나는 콘텐츠입니다.' }
}

export default async function SlowDetailPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const data = await getSlowData(id)

  return <p className="p-6">{data.message}</p>
}

 

// app/instant-navigations/slow-detail/page.tsx
import Link from 'next/link'

export default function SlowDetailListPage() {
  return (
    <div className="p-6">
      <Link
        href="/instant-navigations/slow-detail/1"
        className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
      >
        로딩 셸 없는 상세 페이지로 이동
      </Link>
    </div>
  )
}

 

instant = false를 추가하면 빌드는 다시 통과하지만, 이 값은 프리렌더링 자체를 비활성화하는 것이 아니라 그 라우트를 인스턴트 내비게이션 검증 대상에서 빼주는 것일 뿐입니다.

 

그러므로 이 링크로 이동하면 여전히 1.2초 동안 화면이 그대로 블로킹되며, 검증 대상에서 빠진 만큼 Instant Insights 패널에도 더 이상 경고가 표시되지 않습니다.

 

그러므로 instant = false는 문제를 감추는 용도가 아니라, 인증·테넌트 분기처럼 애초에 미리 보여줄 셸이 없어 블로킹이 불가피한 라우트에서만 의도적으로 선택하는 것이 좋습니다.

 

그러면 이렇게 검증을 통과한 라우트가 실제로 어떤 형태로 빌드되는지, 프리렌더링 자체를 다루는 Better ISR을 통해 살펴보겠습니다.

 

 

 

 

Better ISR, 프리렌더링 안 한 라우트도 즉시 셸부터

 

generateStaticParams로 라우트 일부만 빌드 타임에 프리렌더링하면, 목록에 없는 나머지 라우트는 두 가지 방식 중 하나를 선택해야 했습니다.

 

loading.tsx로 로딩 셸을 보여주더라도 그 라우트는 프리렌더링되지 않은 상태로 남아 매 방문마다 다시 서버 렌더링을 거쳤고, loading.tsx 없이 두면 첫 방문자가 셸조차 없이 서버 렌더링이 끝날 때까지 그대로 블로킹되었습니다.

 

즉 자주 방문되지 않아 굳이 미리 만들어 두지 않은 페이지일수록, 셸도 없이 기다리거나 매번 다시 렌더링되거나 둘 중 하나를 감수해야 했던 것입니다.

 

Better ISR은 generateStaticParams 목록에 없는 라우트도 첫 방문 시 즉시 로딩 셸을 보여준 뒤, 백그라운드에서 완전히 렌더링된 페이지로 업그레이드하고 그 결과를 캐시에 저장하는 동작입니다.

 

그러므로 프리렌더링 여부와 무관하게 모든 방문자가 즉시 셸부터 보게 되고, 이후 방문자부터는 캐시된 완성된 콘텐츠를 곧바로 받게 됩니다.

 

// app/instant-navigations/isr-demo/[id]/page.tsx
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'

const knownIds = ['1', '2']

export async function generateStaticParams() {
  return knownIds.map((id) => ({ id }))
}

async function getArticle(id: string) {
  'use cache'
  cacheLife('days')

  await new Promise((resolve) => setTimeout(resolve, 500))

  return {
    id,
    title: `아티클 ${id}`,
    body: '빌드 타임에 미리 만들어졌거나, 처음 방문 시 즉시 셸을 보여준 뒤 백그라운드에서 완성됩니다.',
  }
}

async function ArticleContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const article = await getArticle(id)

  return (
    <article className="flex flex-col gap-2 p-6">
      <h1 className="text-xl font-bold">{article.title}</h1>
      <p className="text-sm text-gray-600">{article.body}</p>
    </article>
  )
}

export default function IsrDemoPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <Suspense fallback={<p className="p-6">아티클을 불러오는 중...</p>}>
      <ArticleContent params={params} />
    </Suspense>
  )
}

 

ArticleContent가 params를 IsrDemoPage 최상단이 아니라 Suspense 경계 안에서 await하는 이유는 앞서 Partial Prefetching에서 ProductInfo를 분리했던 이유와 같습니다.

 

params를 읽는 지점이 Suspense 밖에 있으면 getArticle을 use cache로 캐시했더라도 이 라우트는 셸을 가질 수 없다는 빌드 에러가 발생하기 때문입니다.

 

이 Suspense의 fallback인 "아티클을 불러오는 중..."이 바로 generateStaticParams 목록에 없는 아티클이 첫 방문 시 실제로 받게 되는 셸이며, 목록에 있는 아티클은 빌드 타임에 이 fallback 없이 곧바로 완성된 상태로 준비됩니다.

 

// app/instant-navigations/isr-demo/page.tsx
import Link from 'next/link'

const articleIds = ['1', '2', '3']

export default function IsrDemoListPage() {
  return (
    <ul className="flex flex-col gap-2 p-6">
      {articleIds.map((id) => (
        <li key={id}>
          <Link
            href={`/instant-navigations/isr-demo/${id}`}
            className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
          >
            아티클 {id}
            {id === '3' ? ' (빌드 타임에 미리 만들지 않음)' : ''}
          </Link>
        </li>
      ))}
    </ul>
  )
}

 

아티클 1, 2는 generateStaticParams 목록에 있어 빌드 타임에 이미 완성되어 있고, 아티클 3은 목록에 없지만 첫 방문 시에도 즉시 셸을 받은 뒤 백그라운드에서 업그레이드됩니다.

 

 

 

다만 getArticle에도 use cache가 없으면 이 라우트는 앞서 다룬 인스턴트 내비게이션 검증을 통과하지 못하므로, 아티클 3의 업그레이드가 실제로 동작하려면 getArticle도 getProduct와 마찬가지로 use cache로 캐싱해야 합니다.

 

그러면 이렇게 백그라운드에서 업그레이드되어 캐시에 저장된 결과는 얼마나 오래 유지되는 것일까요?

 

Better ISR은 이 캐시 유지 기간을 위한 별도의 revalidate 설정을 새로 만들지 않았으며, 업그레이드된 페이지의 신선도는 그 페이지가 읽는 use cache 함수의 cacheLife 프로파일이 그대로 결정하는 값입니다.

 

cacheLife 호출 자체는 필수가 아니며, 호출하지 않으면 stale 5분, revalidate 15분, expire 없음(사실상 무기한)인 default 프로파일이 자동으로 적용됩니다.

 

getArticle에 지정한 cacheLife('days')는 stale 5분, revalidate 1일, expire 1주로, 하루에 한 번 정도 갱신되는 게시물 콘텐츠에 맞춘 값입니다.

 

앞서 getProduct에 지정했던 cacheLife('max')는 stale 5분, revalidate 30일, expire 1년으로, 값이 거의 바뀌지 않는 콘텐츠에 맞춘 값입니다.

 

Next.js 공식 문서 역시 이 흐름을 Pages Router의 getStaticPaths fallback: true와 동등한 동작으로 설명하며, getStaticProps의 revalidate 옵션도 App Router에서는 use cache와 cacheLife로 대체된다고 안내하고 있습니다.

 

다만 페이지가 얼마나 자주 백그라운드 업그레이드를 다시 시도할지(예: 트래픽에 따라 그 주기를 조절하는 기능)를 별도로 제어하는 API는 이 글을 쓰는 시점 기준으로 아직 제공되지 않으며, Next.js 팀은 이 부분을 추후 추가할 계획이라고 밝히고 있습니다.

 

그러면 이렇게 개발 중에 실제 네비게이션이 어떤 순서로 보일지, 그리고 나중에 리팩터링으로 이 순서가 깨지지 않도록 지키는 방법까지 마지막으로 살펴보겠습니다.

 

 

 

 

Navigation Inspector와 Playwright instant()로 회귀 방지하기

 

Next.js는 개발 모드에서 프리페치를 비활성화하기 때문에, 지금까지 만든 셸이 실제로 어떤 순서로 나타나는지를 next dev 화면만으로는 확인하기 어렵습니다.

 

Navigation Inspector는 페이지 로드나 클라이언트 네비게이션을 셸 단계에서 일시 정지시켜, 사용자가 실제로 보게 될 로딩 상태를 개발 중에도 눈으로 확인할 수 있게 해주는 devtool입니다.

 

개발 서버의 Next.js DevTools에서 이 도구를 열어 앞서 만든 partial-prefetch 라우트로 이동하면, 상품명과 설명이 담긴 셸과 "재고 확인 중..." 상태가 실제로 어느 시점에 나뉘는지를 정지 화면으로 확인할 수 있습니다.

 

다만 이렇게 눈으로 확인해 둔 순서도 나중에 리팩터링 과정에서 조용히 깨질 수 있습니다.

 

가령 LiveStock과 같은 위치에 cookies()를 읽는 컴포넌트가 새로 추가되거나 Suspense 경계가 옮겨지면, 즉시 보이던 셸이 조용히 블로킹으로 바뀔 수 있습니다.

 

이런 회귀를 잡기 위해 @next/playwright의 instant() 테스트 헬퍼를 사용합니다.

 

// terminal
$ npm install -D @playwright/test @next/playwright

 

 

 

 

// e2e/instant-navigation.spec.ts
import { expect, test } from '@playwright/test'
import { instant } from '@next/playwright'

test('상품 이름은 재고 확인 없이도 즉시 보인다', async ({ page }) => {
  await page.goto('/instant-navigations/partial-prefetch')

  await instant(page, async () => {
    await page.click('a[href="/instant-navigations/partial-prefetch/1"]')
    await expect(page.locator('h1')).toContainText('상품 1')
    await expect(page.getByText('재고 확인 중...')).toBeVisible()
  })

  await expect(page.getByText(/현재 재고/)).toBeVisible()
})

 

instant() 콜백 안에서 실행한 assertion은 네트워크 응답을 기다리지 않고 즉시 참이어야 하는 상태를 검증하며, 콜백 밖의 assertion은 평소처럼 응답을 기다립니다.

 

그러므로 누군가 나중에 LiveStock을 Suspense 밖으로 꺼내거나 getProduct에서 use cache를 지우면, 원인과 무관하게 이 테스트가 실패하면서 즉시 셸이 깨졌다는 사실을 알려줍니다.

 

지금까지 살펴본 기능들은 모두 cacheComponents와 partialPrefetching이라는 같은 전제 위에서 동작하는 Instant Navigations의 구성 요소입니다.

 

  • Partial Prefetching, Instant Insights
  • Better ISR, Navigation Inspector
  • Playwright instant() 테스트 헬퍼

 

아직은 옵트인 상태이지만, Next.js 팀은 이 동작을 향후 메이저 버전의 기본값으로 삼을 계획이라고 밝히고 있습니다.

 

 

 

 

 

 

 

이상으로 Next.js 16.3의 Instant Navigations, Partial Prefetching과 관련 devtools 기능들에 대해 간단하게 알아보는 시간이었습니다.

 

읽어주셔서 감사합니다.

 

 

 

728x90
반응형

댓글