13. Next.js 네비게이션 속도 개선

13. Next.js 네비게이션 속도 개선

Authors
Date
Tags
Published
Published
Slug

네비게이션이 느리다?

Next.js에서 네비게이션 바를 구현하였다. 하지만, 페이지 이동이 생각보다 많이 느린 문제점이 있었다.
notion image
 

분석하기

각 page 컴포넌트를 확인해보았다.
export default async function Page({ searchParams, }: { searchParams?: { query?: string; page?: string; }; }) { const query = searchParams?.query?.toLowerCase() || ""; const ports = await fetchFilteredPorts(query); return ( <div className="w-full"> <div className="flex w-full items-center justify-between"> <h1 className={`${lusitana.className} text-2xl`}>Ports</h1> </div> <div className="my-4 flex items-center justify-between gap-2 md:mt-8"> <Search placeholder="Port 검색..." /> <CreatePort /> </div> <PortsTableAgGrid ports={ports} /> </div> ); }
각 페이지는 모두 위와 비슷한 형식이었다.
각 페이지 자체가 비동기 컴포넌트이기 때문에 data fetching동안 지연이 발생하는것으로 이해했다.
 

해결하기

첫번째로 든 생각은 prefetch를 이용하는 것이었다.
prefetch을 이용한다면 사용자가 페이지를 이동하기 전, 미리 데이터를 fetch하기 때문에 페이지 전환이 더 빠를것이다.
Navbar컴포넌트에 prefetch을 추가해보자.
<> {links.map((link) => { const LinkIcon = link.icon; return ( <Link key={link.name} href={link.href} className={clsx( "flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-gray-200 hover:text-gray-600 md:flex-none md:justify-start md:p-2 md:px-3", { "bg-gray-300 text-gray-700": pathname === link.href, }, )} > <LinkIcon className="w-6" /> <p className="hidden md:block">{link.name}</p> </Link> ); })} </>
그런데.. Navbar 컴포넌트는 Link 컴포넌트를 통해 페이지 이동이 구현되어있었고, Link컴포넌트는 prefetch가 기본적으로 prefetch가 구현되어있는것으로 알고 있었다.
 
예상대로 prefetch가 이뤄진다. 하지만 이상한 점이 있다.
바로 rsc fetch가 두번 이뤄진다는 것이었다. 동시에 이뤄지지 않는점이 이상했다. 😒
  1. 첫 랜더링에서는 viewPort에서 모든 Link에 prefetch를 한다.
  1. 그 이후 클릭 시 또 한번 발생한다.
  1. 그 다음부터는 마우스 호버시에도 패치가 발생한다.
notion image
 
정상적이라면, prefetch에서 데이터를 모두 불러오므로, 클릭시에 바로 페이지 이동이 이뤄져야할 것이라고 생각했다. 🤔
하지만 공식문서를 살펴보니..
 
📄
prefetch 속성
  • null (default): Prefetch behavior depends on whether the route is static or dynamic. For static routes, the full route will be prefetched (including all its data). For dynamic routes, the partial route down to the nearest segment with a loading.js boundary will be prefetched.
  • true: The full route will be prefetched for both static and dynamic routes.
  • false: Prefetching will never happen both on entering the viewport and on hover.
각 페이지는 dynamic routes로 구현되어있고 prefetch속성을 비워두었으므로 prefetch는 부분적으로 loading.js 까지만 된다. 😲
 
1fetch는 loading.js까지의 prefetch이고, 2 fetch는 나머지 데이터에 대한 fetch일 것으로 이해했다.
그런데 마우스 호버시 1 을 재사용하지 않고 3 을 새로 패치한다.

의문..

위에서 prefetch가 동작하는 방식이 의문이었다.😠
왜 hover시에 prefetch가 또 이루어지는것일까?
Link가 화면에 표시될 때 prefetch한 1 을 재사용해야하지 않나?
필자는 이상함을 감지하고 최신버전과 비교해보기로 했다.
최신버전과 비교하니 다르게 동작하는 부분을 찾을 수 있었다! 😮
현재 버전과 최신 버전에서 테스트를 거친 후 아래와 같이 정리할 수 있었다.
📎
14.1.4 - prefetch true 일때 2 생략됨
14.1.4 - prefetch null 일때 (상위 내용과 같음) 14.2.0-canary.61 - prefetch true일때 2 , 3 생략됨
14.2.0-canary.61 - prefetch null일때 3 생략됨
14.2.0-canary.61 버전이 정상적으로 동작하는것 같다고 판단했다.
아무래도 현재 버전에 버그가 존재하는듯 보인다.
최근 cache관련한 패치가 이루어지고 있는듯 보이는데, 아마 관련 이슈가 있을듯 하다.
vercel/next.js PRs
(다 찾아보고 싶었지만, 그러기는 무리였다.. 😫)
 
 
 
현재 14.1.4버전에서 문제를 해결하기 위해서
모든 prefetch를 활성화하기 위해 Link컴포넌트에 prefetch={true}를 추가했다.
notion image
이제 마우스를 hover할 때, 한 번 prefetch가 이루어지며, 마우스를 hover하는것으로 모든 데이터를 불러오게 된다.
요청이 완료된 후 클릭하면, 지연없이 페이지가 바로 이동한다.
기존 1364ms(최대)에서 613ms까지 지연시간을 단축시킬 수 있었다. 👍
 
하지만 이 방법은 문제가 생길 여지가 있다.
사용자가 마우스를 hover한 뒤 화장실을 1분동안 갔다온 뒤, <Link>를 클릭하고 페이지를 이동한다면 사용자는 1분 전의 데이터를 보게 될 것이다.
데이터의 변경이 잦은 이커머스 백오피스와 같은 페이지에서는 심각한 문제를 초래할 수 있다. ⚠️
현재 글쓴이의 개발중인 애플리케이션은 stale한 데이터를 보여줘서는 안된다고 판단했기에 이를 개선해야했다.
 

4.17 추가

최신 업데이트로 인한 변경 탓인지, prefetch={true} 옵션을 추가하면 router cache가 5분으로 동작한다.
데이터가 자주 변화하는 페이지라면 prefetch={true} 를 사용하지 않는것이 좋을것이다.
💡
Router Cache Duration
The cache is stored in the browser's temporary memory. Two factors determine how long the router cache lasts:
  • Session: The cache persists across navigation. However, it's cleared on page refresh.
  • Automatic Invalidation Period: The cache of an individual segment is automatically invalidated after a specific time. The duration depends on how the resource was prefetched:
    • Default Prefetching (prefetch={null} or unspecified): 30 seconds
    • Full Prefetching: (prefetch={true} or router.prefetch): 5 minutes
While a page refresh will clear all cached segments, the automatic invalidation period only affects the individual segment from the time it was prefetched.

그렇다면 어떻게 개선해야할까?

fresh한 데이터를 보여줘야하는 페이지에서 prefetch를 통해 지연시간을 해결하는것은 무리가 있다.
지연시간 자체를 단축하기보다는 ui측면에서 사용자 경험을 향상시키는 방법을 고려해보자.
 
notion image
페이지 라우팅이 느리다고 느끼는 원인은, 클릭에 즉각적으로 페이지가 이동하지 않는다는 점이다.
그리고, 즉각적으로 페이지가 이동하지 않는 주 원인은 테이블의 데이터를 불러오는 data fetching 지연시간이다.
그렇다면, 테이블을 제외한 ui는 지연시간없이 즉각적으로 변경할 수 있을것이다.
 

해결하기 2

data fetch가 되는동안 Suspense와 fallback을 이용한 skeleton ui를 보여주는 방법으로 사용자 경험을 개선할 수 있다.
 
기존의 페이지 컴포넌트의 구조를 추상화하여 표현하면 아래와 같다.
<div> <Title/> <Search/> <CreateButton/> <Table/> </div>
 
여기에 Suspense와 fallback을 추가하면
<div> <Title/> <Search/> <CreateButton/> <Suspense fallback={<SkeletonUI/>}> <Table/> </Suspense> </div>
이러한 형식이 된다.
 
하지만 문제가 있다. 비동기 컴포넌트를 Suspense의 children으로 추가하여야 효과가 있다.
Table은 클라이언트 컴포넌트이고 비동기 컴포넌트가 아니기 때문에 이를 해결하기 위해 page와 Table사이의 data fetch를 하는 비동기 컴포넌트가 필요하다.
 
이를 위해 중간 컴포넌트인 AsyncTable을 컴포넌트를 구현하였다.
export default async function AsyncTable({ query }: { query: string }) { const [quotations, currencies, ports, ctnrs, incoterms] = await Promise.all([ fetchFilteredQuotations(query), fetchCurrencies(), fetchPorts(), fetchCtnrs(), fetchIncoterms(), ]); return <QuotationsTable quotations={quotations} currencies={currencies} ports={ports} ctnrs={ctnrs} incoterms={incoterms} />; }
 
 
 
 
 
 
 
 
 
 

+

최신 버전에는 partial prerendering을 통해 dynamic content만 prerendering을 하는 방법이 있는것 같다.
하지만 공식문서에서도 아직 프로덕션 환경에서는 적합하지 않다고 하니 이 기능은 채택하지 않았다.