링크와 네비게이션
링크와 네비게이션 (Linking and Navigating)
Next.js에서 라우트는 기본적으로 서버에서 렌더링돼요. 그러다 보면 새 라우트를 보여주기 전에 클라이언트가 서버 응답을 기다려야 할 때가 많죠. Next.js는 여기에 프리페칭(prefetching) 과 스트리밍(streaming), 클라이언트 사이드 전환(client-side transitions) 을 기본 제공해서 네비게이션이 빠르고 반응성 있게 동작하도록 만들어 줘요. 이 글에서는 네비게이션이 어떻게 동작하는지, 그리고 동적 라우트와 느린 네트워크에서 어떻게 최적화할 수 있는지 살펴볼게요.
네비게이션은 어떻게 동작하나요?
네비게이션이 어떻게 동작하는지 이해하려면 서버 렌더링, 프리페칭, 스트리밍, 클라이언트 사이드 전환 이 네 가지 개념을 먼저 알아두면 좋아요.
서버 렌더링 (Server Rendering)
Next.js의 레이아웃과 페이지는 기본적으로 React 서버 컴포넌트예요. 첫 방문이든 이후 네비게이션이든, 서버 컴포넌트 페이로드(Server Component Payload, RSC Payload)를 서버에서 생성한 뒤 클라이언트로 보내요.
서버 렌더링은 언제 일어나느냐에 따라 두 종류로 나뉘어요.
- 프리렌더링(Prerendering) — 빌드 시점이나 재검증(revalidation) 시점에 일어나고, 결과가 캐시돼요.
- 동적 렌더링(Dynamic Rendering) — 클라이언트 요청에 응답하기 위해 요청 시점에 일어나요.
서버 렌더링의 단점은 새 라우트를 보여주기 전에 클라이언트가 서버 응답을 기다려야 한다는 점이에요. Next.js는 사용자가 곧 방문할 것 같은 라우트를 프리페칭하고, 클라이언트 사이드 전환을 수행하는 방식으로 이 지연을 완화해요.
프리페칭 (Prefetching)
프리페칭은 사용자가 실제로 이동하기 전에 배경에서 라우트를 미리 로드하는 과정이에요. 사용자가 링크를 클릭할 때쯤엔 다음 라우트를 렌더링할 데이터가 이미 클라이언트에 준비돼 있으니, 이동이 즉각적으로 느껴지죠.
Next.js는 <Link> 컴포넌트로 연결된 라우트를 사용자의 뷰포트에 들어오면 자동으로 프리페칭해요.
import Link from 'next/link'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<nav>
{/* 링크가 호버되거나 뷰포트에 들어오면 프리페칭됨 */}
<Link href="/blog">Blog</Link>
{/* 프리페칭 없음 */}
<a href="/contact">Contact</a>
</nav>
{children}
</body>
</html>
)
}
얼마나 프리페칭되는지는 라우트가 정적인지 동적인지에 따라 달라져요.
- 정적 라우트(Static Route) — 전체 라우트가 프리페칭돼요.
- 동적 라우트(Dynamic Route) — 프리페칭이 생략되거나,
loading.tsx가 있으면 부분적으로 프리페칭돼요.
사용자가 방문하지 않을 수도 있는 라우트에 대해 불필요한 서버 작업을 피하려고, 동적 라우트는 프리페칭을 생략하거나 부분만 하는 거예요. 다만 이 경우 네비게이션 전에 서버 응답을 기다려야 해서, 사용자에겐 앱이 반응하지 않는 것처럼 느껴질 수 있어요.
스트리밍 (Streaming)
스트리밍은 전체 라우트가 렌더링될 때까지 기다리지 않고, 동적 라우트의 일부가 준비되는 대로 서버가 클라이언트로 보내는 방식이에요. 페이지의 일부가 아직 로딩 중이어도 사용자는 무언가를 더 빨리 보게 되죠.
동적 라우트는 이 덕분에 부분 프리페칭이 가능해져요. 즉 공유 레이아웃과 로딩 스켈레톤을 미리 요청해 둘 수 있는 거예요.
스트리밍을 쓰려면 라우트 폴더에 loading.tsx를 만들면 돼요. Next.js는 내부적으로 page.tsx의 내용을 <Suspense> 경계로 감싸고, 라우트가 로딩되는 동안 프리페칭된 폴백 UI를 보여준 뒤 준비가 되면 실제 내용으로 바꿔 줘요.
export default function Loading() {
// 라우트가 로딩되는 동안 보여줄 폴백 UI
return <LoadingSkeleton />
}
loading.tsx가 주는 이점을 정리하면 이래요.
- 네비게이션이 즉시 시작되고 사용자에게 시각적 피드백을 줘요.
- 공유 레이아웃은 계속 인터랙티브하고, 네비게이션은 중간에 끊을 수 있어요.
- TTFB, FCP, TTI 같은 Core Web Vitals가 개선돼요.
클라이언트 사이드 전환 (Client-side transitions)
전통적으로 서버 렌더링 페이지로의 이동은 전체 페이지를 다시 로드했어요. 그러면 상태가 초기화되고, 스크롤 위치가 리셋되며, 인터랙티브가 막히죠.
Next.js는 <Link> 컴포넌트의 클라이언트 사이드 전환으로 이를 피해요. 페이지를 다시 로드하는 대신, 공유 레이아웃과 UI는 유지하면서 현재 페이지를 프리페칭된 로딩 상태나 새 페이지로 바꿔치기해요.
클라이언트 사이드 전환은 서버 렌더링 앱이 마치 클라이언트 렌더링 앱처럼 느껴지게 만들어 줘요. 프리페칭·스트리밍과 함께 쓰면 동적 라우트에서도 빠른 전환이 가능해져요.
전환이 느려지는 이유는?
이런 최적화 덕분에 네비게이션은 빠르고 반응성 있게 동작해요. 그래도 특정 조건에서는 전환이 느리게 느껴질 수 있는데, 흔한 원인과 개선 방법을 살펴볼게요.
동적 라우트에 loading.tsx가 없을 때
동적 라우트로 이동할 때는 결과를 보여주기 전에 클라이언트가 서버 응답을 기다려야 해요. 이 때문에 앱이 반응하지 않는 것처럼 보일 수 있죠.
동적 라우트에는 loading.tsx를 추가해 부분 프리페칭을 켜고, 즉시 네비게이션을 시작하며, 라우트가 렌더링되는 동안 로딩 UI를 보여주길 권해요.
export default function Loading() {
return <LoadingSkeleton />
}
generateStaticParams가 없는 동적 세그먼트
동적 세그먼트가 프리렌더링될 수 있는데도 generateStaticParams가 없어서 못 하고 있다면, 그 라우트는 요청 시점의 동적 렌더링으로 폴백돼요. 빌드 시점에 정적으로 생성되도록 generateStaticParams를 추가하면 돼요.
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// ...
}
느린 네트워크
느리거나 불안정한 네트워크에서는 사용자가 링크를 클릭하기 전에 프리페칭이 끝나지 않을 수 있어요. 정적·동적 라우트 모두 영향을 받을 수 있고, 아직 프리페칭되지 않았기 때문에 loading.js 폴백이 바로 나타나지 않을 수 있어요.
인지된 성능을 개선하려면 useLinkStatus 훅으로 전환이 진행되는 동안 즉각적인 피드백을 보여줄 수 있어요.
'use client'
import { useLinkStatus } from 'next/link'
export default function LoadingIndicator() {
const { pending } = useLinkStatus()
return (
<span aria-hidden className={`link-hint ${pending ? 'is-pending' : ''}`} />
)
}
프리페칭 끄기
<Link> 컴포넌트의 prefetch prop을 false로 설정하면 프리페칭을 해제할 수 있어요. 무한 스크롤 테이블처럼 큰 링크 목록을 렌더링할 때 불필요한 리소스 사용을 피하는 데 유용해요.
<Link prefetch={false} href="/blog">
Blog
</Link>
다만 프리페칭을 끄면 정적 라우트는 사용자가 링크를 클릭할 때만 가져오고, 동적 라우트는 클라이언트가 이동하기 전에 서버에서 먼저 렌더링해야 해요.
프리페칭을 완전히 끄지 않고 리소스 사용을 줄이려면 호버할 때만 프리페칭하도록 제한할 수도 있어요. 그러면 뷰포트의 모든 링크가 아니라 사용자가 방문할 가능성이 더 높은 라우트로만 프리페칭이 한정돼요.
예시: 네이티브 History API
Next.js는 네이티브 window.history.pushState / window.history.replaceState 메서드로 페이지를 다시 로드하지 않고 브라우저 히스토리 스택을 갱신할 수 있게 해 줘요. 두 호출은 Next.js 라우터에 통합되므로 usePathname, useSearchParams와 동기화돼요.
pushState는 히스토리 스택에 새 항목을 추가해 이전 상태로 돌아갈 수 있게 해 주고, replaceState는 현재 항목을 교체해 이전 상태로 돌아갈 수 없게 해요. 예를 들어 정렬을 바꾸거나 로케일을 전환할 때 유용하죠.
'use client'
import { useSearchParams } from 'next/navigation'
export default function SortProducts() {
const searchParams = useSearchParams()
function updateSorting(sortOrder: string) {
const params = new URLSearchParams(searchParams.toString())
params.set('sort', sortOrder)
window.history.pushState(null, '', `?${params.toString()}`)
}
return (
<>
<button onClick={() => updateSorting('asc')}>Sort Ascending</button>
<button onClick={() => updateSorting('desc')}>Sort Descending</button>
</>
)
}
더 알아보기 (Learn more)
- Link Component — 내장
next/link컴포넌트로 빠른 클라이언트 사이드 네비게이션 사용하기 - loading.js — loading 파일 API 레퍼런스
- Prefetching — Next.js에서 프리페칭을 설정하는 방법