Next.js (4. Linking and Navigating) 정리
0. 링크와 내비게이션
Next.js는 라우트를 기본적으로 서버에서 렌더링한다.
이 때문에 클라이언트는 새 라우트를 보여주기 위해 서버 응답을 기다려야 한다.
Next.js는 이를 프리페칭, 스트리밍, 클라이언트 사이드 전환으로 해결해 내비게이션을 빠르게 유지한다.
1. 내비게이션 동작 원리
1-1. 서버 렌더링 (Server Rendering)
Next.js의 레이아웃과 페이지는 기본적으로 React Server Component다.
최초 방문과 이후 내비게이션 모두, 서버에서 Server Component Payload를 생성한 뒤 클라이언트로 전송한다.
서버 렌더링은 언제 실행되느냐에 따라 두 가지로 나뉜다.
| 방식 | 실행 시점 | 특징 |
|---|---|---|
| 프리렌더링 (Prerendering) | 빌드 타임 또는 재검증 시 | 결과가 캐시됨 |
| 동적 렌더링 (Dynamic Rendering) | 요청 시점 | 요청마다 서버에서 실행 |
서버 렌더링의 단점은 클라이언트가 서버 응답을 기다려야 한다는 점이다.
Next.js는 프리페칭과 클라이언트 사이드 전환으로 이 지연을 해결한다.
1-2. 프리페칭 (Prefetching)
프리페칭은 사용자가 이동하기 전에 백그라운드에서 미리 라우트를 로드하는 기능이다.
사용자가 링크를 클릭하는 시점에 이미 다음 라우트의 데이터가 준비되어 있어 내비게이션이 즉각적으로 느껴진다.
<Link> 컴포넌트로 연결된 라우트는 뷰포트에 들어오는 순간 자동으로 프리페칭된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// app/layout.tsx
import Link from 'next/link'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<nav>
{/* 뷰포트 진입 또는 hover 시 프리페칭 */}
<Link href="/blog">Blog</Link>
{/* 프리페칭 없음 */}
<a href="/contact">Contact</a>
</nav>
{children}
</body>
</html>
)
}
프리페칭 범위는 라우트가 정적인지 동적인지에 따라 달라진다.
| 라우트 종류 | 프리페칭 범위 |
|---|---|
| 정적 라우트 | 전체 라우트를 프리페칭 |
| 동적 라우트 | 프리페칭 생략. loading.tsx가 있으면 부분 프리페칭 |
동적 라우트를 프리페칭하지 않으면 서버 응답 대기가 발생한다. 이를 개선하려면 스트리밍을 사용한다.
1-3. 스트리밍 (Streaming)
스트리밍은 동적 라우트를 전체가 아닌 준비된 부분부터 순차적으로 클라이언트에 전송하는 기능이다.
페이지 일부가 아직 로딩 중이더라도 사용자는 빠르게 콘텐츠를 볼 수 있다.
동적 라우트에서 스트리밍을 활용하면 부분 프리페칭이 가능해진다. 공유 레이아웃과 로딩 스켈레톤을 미리 요청할 수 있다.
스트리밍을 사용하려면 라우트 폴더에 loading.tsx를 추가한다.
1
2
3
4
5
// app/dashboard/loading.tsx
export default function Loading() {
return <LoadingSkeleton />
}
Next.js는 내부적으로 page.tsx 콘텐츠를 <Suspense> 바운더리로 자동 감싼다.
라우트가 로딩되는 동안 프리페칭된 폴백 UI를 보여주고, 준비되면 실제 콘텐츠로 교체한다.
loading.tsx의 장점:
- 즉각적인 내비게이션과 시각적 피드백 제공
- 공유 레이아웃은 인터랙티브 상태를 유지하며 내비게이션 중단 가능
- Core Web Vitals 개선 (TTFB, FCP, TTI)
1-4. 클라이언트 사이드 전환 (Client-side Transitions)
<Link> 컴포넌트는 전체 페이지 리로드 없이 콘텐츠만 동적으로 교체하는 클라이언트 사이드 전환을 수행한다.
기존 서버 렌더링 방식은 내비게이션 시 전체 페이지를 다시 불러와 기존 상태 초기화, 스크롤 위치 리셋, 모든 인터렉션 차단이 발생했다.
Next.js의 클라이언트 사이드 전환은 다음 방식으로 이를 해결한다.
- 공유 레이아웃과 UI는 그대로 유지
- 현재 페이지를 프리페칭된 로딩 상태 또는 새 페이지로 교체
프리페칭 + 스트리밍 + 클라이언트 사이드 전환이 함께 작동하면,
동적 라우트에서도 클라이언트 렌더링처럼 빠른 내비게이션 경험을 제공할 수 있다.
2. 내비게이션이 느려지는 원인과 해결법
2-1. loading.tsx 없는 동적 라우트
동적 라우트에 loading.tsx가 없으면 클라이언트는 서버 응답이 올 때까지 아무것도 볼 수 없다.
즉, 사용자는 앱이 응답하지 않는 것처럼 느껴진다.
동적 라우트에는 반드시 loading.tsx를 추가해 부분 프리페칭을 활성화하고 즉각적인 내비게이션을 제공한다.
1
2
3
4
5
// app/blog/[slug]/loading.tsx
export default function Loading() {
return <LoadingSkeleton />
}
참고: 개발 모드에서 Next.js Devtools로 해당 라우트가 정적인지 동적인지 확인할 수 있다.
2-2. generateStaticParams 없는 동적 세그먼트
프리렌더링이 가능한 동적 세그먼트임에도 generateStaticParams가 없으면 요청마다 동적으로 렌더링된다.
generateStaticParams를 추가하면 빌드 타임에 정적으로 생성되어 프리페칭과 내비게이션이 빨라진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}))
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
// ...
}
2-3. 느린 네트워크 — useLinkStatus
느리거나 불안정한 네트워크에서는 사용자가 링크를 클릭하기 전에 프리페칭이 완료되지 않을 수 있다.
이 경우 loading.tsx 폴백도 즉시 나타나지 않아 아무런 반응이 없는 것처럼 보인다.
useLinkStatus 훅을 사용해 전환이 진행 중일 때 즉각적인 시각적 피드백을 제공할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
// app/ui/loading-indicator.tsx
'use client'
import { useLinkStatus } from 'next/link'
export default function LoadingIndicator() {
const { pending } = useLinkStatus()
return (
<span aria-hidden className={`link-hint ${pending ? 'is-pending' : ''}`} />
)
}
참고:
초기 애니메이션 지연(예: 100ms)과
opacity: 0을 조합해 빠른 내비게이션에서는 표시되지 않도록 디바운스할 수 있다.
프로그레스 바(progress bar)와 같은 다른 형태의 시각적 피드백 패턴도 사용할 수 있다.
2-4. 프리페칭 비활성화
<Link>의 prefetch prop을 false로 설정하면 프리페칭을 끌 수 있다.
무한 스크롤처럼 링크가 많은 경우 불필요한 리소스 낭비를 줄이고 싶을 때 사용한다.
1
2
3
<Link prefetch={false} href="/blog">
Blog
</Link>
단, 프리페칭을 비활성화하면 트레이드오프가 있다.
| 라우트 종류 | 결과 |
|---|---|
| 정적 라우트 | 클릭 시점에만 데이터를 가져옴 → 느릴 수 있음 |
| 동적 라우트 | 클라이언트가 이동하기 전에 서버에서 먼저 렌더링이 필요함 → 대기 시간이 길어질 수 있음 |
완전히 비활성화하고 싶지 않다면, hover 시에만 프리페칭하는 방식으로 절충할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// app/ui/hover-prefetch-link.tsx
'use client'
import Link from 'next/link'
import { useState } from 'react'
function HoverPrefetchLink({
href,
children,
}: {
href: string
children: React.ReactNode
}) {
const [active, setActive] = useState(false)
return (
<Link
href={href}
prefetch={active ? null : false}
onMouseEnter={() => setActive(true)}
>
{children}
</Link>
)
}
2-5. 하이드레이션 지연
<Link>는 Client Component이므로 하이드레이션이 완료되어야 프리페칭이 시작된다.
초기 방문 시 JS 번들이 크면 하이드레이션이 늦어지고, 그만큼 프리페칭 시작도 늦어진다.
React는 선택적 하이드레이션(Selective Hydration)으로 이를 완화한다.
추가로 아래 방법을 적용할 수 있다.
@next/bundle-analyzer플러그인으로 번들 크기를 분석하고 불필요한 의존성을 제거- 클라이언트 로직을 서버로 이동 (Server Component 활용)
3. 네이티브 History API 활용
Next.js는 브라우저의 window.history.pushState와 window.history.replaceState를 페이지 리로드 없이 사용할 수 있도록 지원한다.
이 두 메서드는 Next.js Router와 통합되어 usePathname, useSearchParams와 동기화된다.
3-1. window.history.pushState
브라우저의 히스토리 스택에 새 항목을 추가한다. 사용자는 이전 상태로 돌아갈 수 있다.
예를 들어, 상품 목록의 정렬 순서를 URL에 반영할 때 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/ui/sort-products.tsx
'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')}>오름차순</button>
<button onClick={() => updateSorting('desc')}>내림차순</button>
</>
)
}
3-2. window.history.replaceState
브라우저의 현재 히스토리 항목을 교체한다. 사용자는 이전 상태로 돌아갈 수 없다.
예를 들어, 언어(locale)를 전환할 때 이전 언어 설정 상태로 돌아갈 필요가 없는 경우에 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// app/ui/locale-switcher.tsx
'use client'
import { usePathname } from 'next/navigation'
export function LocaleSwitcher() {
const pathname = usePathname()
function switchLocale(locale: string) {
const newPath = `/${locale}${pathname}`
window.history.replaceState(null, '', newPath)
}
return (
<>
<button onClick={() => switchLocale('en')}>English</button>
<button onClick={() => switchLocale('ko')}>한국어</button>
</>
)
}
pushState는 히스토리에 기록을 추가하고,replaceState는 현재 기록을 덮어쓴다.
뒤로 가기 지원 여부가 두 메서드를 선택하는 기준이다.



