안녕하세요. J4J입니다.
이번 포스팅은 Next.js 16에서 개편된 프리페치 동작인 Layout Deduplication과 Incremental Prefetching이 실제로 어떻게 동작하는지 살펴보는 시간을 가져보려고 합니다.
기존 프리페치 방식의 한계
React로 App Router의 리스트 화면을 만들다 보면, 링크가 많아질수록 페이지 이동이 오히려 무거워지는 상황을 마주치게 됩니다.
Next.js의 Link 컴포넌트는 뷰포트에 들어오거나 hover되면 해당 라우트를 미리 내려받아 두는 프리페치를 자동으로 수행합니다.
이때 프리페치 대상 범위는 loading.js 파일이 있는지에 따라 달라지는데, loading.js가 없으면 페이지 전체를, 있으면 레이아웃부터 첫 loading 경계까지를 미리 받아둡니다.
저의 경우 상품 링크가 수십 개씩 늘어선 리스트 페이지를 다루면서, Network 탭에 비슷한 크기의 요청이 링크 개수만큼 줄줄이 찍히는 것을 보고 뭔가 낭비되고 있다는 느낌을 받은 적이 있습니다.
Next.js 16 이전에는 각 Link가 이 프리페치를 독립적으로 수행했기 때문에, 여러 링크가 같은 레이아웃을 공유하더라도 그 레이아웃이 링크 개수만큼 중복으로 전송되었습니다.
실제로 Next.js 팀이 공개한 예시에 따르면, 상품 링크 50개가 있는 페이지에서는 공유 레이아웃이 50번 개별적으로 다운로드되었습니다.
다음으로는 Next.js 16이 이 문제를 어떤 방식으로 해결했는지, 그리고 별도 설정이 필요한지 살펴보겠습니다.
Next.js 16 라우팅 개편, 별도 설정이 필요할까
Layout Deduplication과 Incremental Prefetching은 next.config.ts에 별도 옵션을 추가하지 않아도 Next.js 16으로 업그레이드하는 것만으로 모든 앱에 자동으로 적용됩니다.
다만 Next.js 16.3에서 추가된 Partial Prefetching(cacheComponents와 partialPrefetching 플래그를 함께 켜야 하는 별도 옵트인 기능)과는 다른 기능이므로 혼동하지 않는 것이 좋습니다.
이 포스팅에서 다루는 Layout Deduplication과 Incremental Prefetching은 Next.js 16.0부터 플래그 없이 이미 자동 적용되어 있는 기본 동작입니다.
그러면 이 자동 적용된 개편이 실제로 어떻게 동작하는지, Layout Deduplication부터 살펴보겠습니다.
Layout Deduplication, 세그먼트 캐싱으로 레이아웃 재사용하기
그러면 Layout Deduplication은 무엇일까요?
Layout Deduplication은 레이아웃, 페이지 등 라우트 트리를 구성하는 세그먼트 단위로 프리페치 캐시를 저장해, 여러 링크가 같은 조상 세그먼트를 공유하면 그 세그먼트의 페이로드를 한 번만 내려받는 동작입니다.
다만 이 포스팅에서 다루는 프리페치 동작은 next dev로 띄운 개발 서버에서는 재현되지 않으므로, npm run build로 빌드한 뒤 npm start로 프로덕션 모드에서 실행해야 Network 탭에서 확인할 수 있습니다.
공유 레이아웃 아래 여러 상품 링크가 있는 화면으로 직접 확인해 보겠습니다.
// app/routing-demo/products/layout.tsx
import { ReactNode } from 'react'
async function getCategories() {
await new Promise((resolve) => setTimeout(resolve, 300))
return ['전자기기', '도서', '생활용품']
}
export default async function ProductsLayout({ children }: { children: ReactNode }) {
const categories = await getCategories()
return (
<div className="flex flex-col gap-4 p-6">
<nav className="flex gap-2 text-sm text-gray-500">
{categories.map((category) => (
<span key={category}>{category}</span>
))}
</nav>
{children}
</div>
)
}
// app/routing-demo/products/page.tsx
import Link from 'next/link'
const products = Array.from({ length: 20 }, (_, index) => ({
id: index + 1,
name: `상품 ${index + 1}`,
}))
export default function ProductsPage() {
return (
<ul className="flex flex-col gap-2">
{products.map((product) => (
<li key={product.id}>
<Link
href={`/routing-demo/products/${product.id}`}
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
>
{product.name}
</Link>
</li>
))}
</ul>
)
}
// app/routing-demo/products/[id]/page.tsx
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <p>상품 {id}번 상세 페이지입니다.</p>
}
이렇게 작성하면 상품 목록 페이지에 진입해 20개 링크가 차례로 뷰포트에 들어올 때, 각 링크는 개별적으로 프리페치되지만 ProductsLayout이 반환하는 카테고리 세그먼트는 처음 한 번만 요청되고 이후에는 캐시된 값이 재사용됩니다.
체감할 수 있는 것 중 하나는, 브라우저 개발자 도구의 Network 탭을 열고 상품 목록을 스크롤해 보면 첫 번째 프리페치 요청의 응답 크기는 ProductsLayout 세그먼트를 포함해 상대적으로 크지만 이후 요청들은 이미 캐시된 레이아웃 세그먼트를 제외한 상품 상세 페이지 세그먼트만 담겨 눈에 띄게 작아진 크기를 유지하는 것을 확인할 수 있다는 점입니다.
이때 요청 목록에서는 next-router-prefetch 헤더로 프리페치 요청임을, next-router-segment-prefetch 헤더로 어떤 세그먼트를 대상으로 하는지를 구분할 수 있습니다.
다음으로는 프리페치 큐가 뷰포트 상태에 따라 어떻게 달라지는지, 그리고 프리페치해 둔 데이터가 무효화되면 이를 어떻게 감지하는지 살펴보겠습니다.
Incremental Prefetching, 무효화된 프리페치 재요청 감지하기
그러면 Incremental Prefetching은 무엇일까요?
Incremental Prefetching은 이미 캐시에 있는 세그먼트는 다시 요청하지 않고, 아직 캐시에 없는 세그먼트만 골라서 프리페치하는 동작입니다.
이 동작은 화면에 보이는 링크의 상태에 따라서도 달라지는데, 링크가 화면 밖으로 스크롤되면 진행 중이던 프리페치 요청이 취소되고, hover되거나 다시 뷰포트에 들어오면 우선순위가 높아져 먼저 요청됩니다.
추가적으로 이미 프리페치해 둔 데이터가 서버에서 무효화되는 경우도 함께 알아둘 필요가 있습니다.
revalidateTag나 revalidatePath를 호출하면 그 대상과 연결된 프리페치 캐시도 함께 무효화되어 새 데이터로 다시 프리페치됩니다.
cacheTag와 revalidateTag를 태그 단위로 조합해서 캐시를 무효화하는 구체적인 방법은 Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리를 참고해 주시길 바랍니다.
Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리
Next.js 16의 새로운 캐싱 모델인 Cache Components를 통해 use cache, cacheTag, revalidateTag, updateTag, refresh 등의 기능으로 컴포넌트 단위의 캐시 관리를 통합하는 방법을 설명합니다.
jforj.tistory.com
router.prefetch의 두 번째 인자로 넘기는 onInvalidate는 이미 프리페치해 둔 데이터가 무효화되어 다시 요청되었을 때 한 번 호출되는 콜백입니다.
다만 onInvalidate만 넘기면 PrefetchOptions 타입에 kind 속성이 없다는 타입 에러가 발생하므로 주의가 필요합니다.
kind는 정적 라우트는 페이지 전체를, 동적 라우트는 loading 경계까지만 가져오는 Link의 기본 동작으로 프리페치할지(auto), 항상 페이지 전체를 가져올지(full) 지정하는 옵션입니다.
다만 이 값의 타입인 PrefetchKind 열거형은 next/navigation에서 공개적으로 내보내지 않기 때문에, 문자열 리터럴 auto를 넘긴 뒤 router.prefetch의 매개변수 타입으로 캐스팅해서 사용하는 것이 좋습니다.
직접 버튼을 눌러 캐시를 무효화하고 이 콜백이 호출되는 시점을 콘솔에서 확인해 보겠습니다.
이 콜백 역시 next dev가 아닌 프로덕션 모드에서만 발생하며, 브라우저 콘솔을 미리 열어 두지 않으면 클릭 시점에 한 번만 찍히는 로그를 놓치기 쉽습니다.
// app/routing-demo/invalidate-demo/PrefetchLink.tsx
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
export default function PrefetchLink({ href, children }: { href: string; children: string }) {
const router = useRouter()
const hasPrefetched = useRef(false)
useEffect(() => {
if (hasPrefetched.current) return
hasPrefetched.current = true
router.prefetch(href, {
kind: 'auto',
onInvalidate: () => {
console.log(`${href} 프리페치가 무효화되어 다시 요청됩니다.`)
},
} as Parameters<typeof router.prefetch>[1])
}, [href, router])
return (
<Link
href={href}
prefetch={false}
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
>
{children}
</Link>
)
}
// app/routing-demo/invalidate-demo/detail/page.tsx
export default function FeaturedProductDetailPage() {
return <p>최근 갱신 시각: {new Date().toISOString()}</p>
}
캐시 무효화를 트리거하는 invalidateFeaturedProduct는 Server Action으로 작성합니다.
Server Action의 기본 문법과 동작 방식은 React Server Action, API Route 없이 서버 함수를 호출하는 방법을 참고해 주시길 바랍니다.
React Server Action, API Route 없이 서버 함수를 호출하는 방법
Server Action을 사용하면 별도 API Route 없이 클라이언트에서 서버 함수를 직접 호출할 수 있습니다. React 19에서 정식 도입된 이 기능이 보일러플레이트를 줄이고 API 키 같은 민감한 데이터를 서버에서만 안전하게 다루는 방법을 설명합니다.
jforj.tistory.com
// app/routing-demo/invalidate-demo/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function invalidateFeaturedProduct() {
revalidatePath('/routing-demo/invalidate-demo/detail')
}
// app/routing-demo/invalidate-demo/InvalidateButton.tsx
'use client'
import { invalidateFeaturedProduct } from './actions'
export default function InvalidateButton() {
return (
<button
className="cursor-pointer rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
onClick={() => invalidateFeaturedProduct()}
>
캐시 무효화
</button>
)
}
// app/routing-demo/invalidate-demo/page.tsx
import InvalidateButton from './InvalidateButton'
import PrefetchLink from './PrefetchLink'
export default function InvalidateDemoPage() {
return (
<div className="flex flex-col gap-4 p-6">
<PrefetchLink href="/routing-demo/invalidate-demo/detail">추천 상품 상세 보기</PrefetchLink>
<InvalidateButton />
</div>
)
}
이렇게 작성하면 추천 상품 상세 보기 링크가 마운트될 때 PrefetchLink 내부에서 router.prefetch가 한 번 호출되고, 캐시 무효화 버튼을 누르면 actions.ts의 invalidateFeaturedProduct가 revalidatePath를 호출해 그 프리페치를 무효화합니다.
이 순간 브라우저 콘솔에 "/routing-demo/invalidate-demo/detail 프리페치가 무효화되어 다시 요청됩니다." 로그가 찍히는 것을 확인할 수 있습니다.
다만 onInvalidate 콜백은 해당 prefetch 호출당 최대 한 번만 호출되므로, 버튼을 여러 번 눌러도 두 번째 호출부터는 콘솔 로그가 다시 찍히지 않습니다.
이때 한 번만 호출되는 것은 onInvalidate 콜백 자체일 뿐이며, revalidatePath에 의한 실제 캐시 무효화와 데이터 갱신은 버튼을 누를 때마다 매번 정상적으로 일어납니다.
로그를 다시 확인하고 싶다면 페이지를 새로고침하는 것이 좋습니다.
새로고침하면 PrefetchLink가 다시 마운트되면서 router.prefetch가 새로 호출되고 onInvalidate도 새로 등록되기 때문입니다.
다음으로는 이 개편이 가지는 트레이드오프를 살펴보겠습니다.
프리페치 요청 건수 트레이드오프와 흔히 하는 오해
Next.js 팀은 이 개편이 개별 프리페치 요청 건수를 늘릴 수 있지만 총 전송량은 크게 줄이는 트레이드오프라고 공식적으로 밝히고 있습니다.
여기서 흔히 오해하기 쉬운 지점이 하나 있습니다.
Layout Deduplication이 줄이는 것은 공유 세그먼트의 전송 바이트이지 요청 자체의 개수가 아니므로, 같은 라우트를 가리키는 링크가 여러 개 있어도 그 각각은 여전히 개별 프리페치 요청을 보냅니다.
그러므로 Network 탭에서 요청 건수 자체가 줄어들지 않는다고 해서 Layout Deduplication이 동작하지 않는다고 오해하지 않는 것이 좋으며, 응답 크기(response size) 기준으로 확인하는 것이 정확합니다.
다만 요청 건수 증가가 신경 쓰인다면, 각 요청의 응답 크기가 이전보다 작아졌는지를 함께 살펴 실제로 절감되는 부분이 무엇인지 구분해서 판단하는 것이 좋습니다.
이상으로 Next.js 16에서 개편된 Layout Deduplication과 Incremental Prefetching의 동작 방식에 대해 간단하게 알아보는 시간이었습니다.
읽어주셔서 감사합니다.
'SPA > Next' 카테고리의 다른 글
| React Compiler와 Next.js 16: useMemo/useCallback이 사라지는 이유 (0) | 2026.08.02 |
|---|---|
| Turbopack, Next.js 16 기본 번들러 전환과 webpack 커스텀 설정 마이그레이션 (0) | 2026.08.01 |
| Next.js 16 proxy.ts, middleware.ts를 대체하는 네트워크 경계 재정의 (1) | 2026.07.27 |
| Next.js 16 Cache Components: use cache부터 updateTag까지 캐시 무효화 총정리 (0) | 2026.07.26 |
| React Server Action, API Route 없이 서버 함수를 호출하는 방법 (0) | 2026.07.05 |
댓글