Post

Next.js (4. Linking and Navigating) 정리

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가 있으면 부분 프리페칭

동적 라우트를 프리페칭하지 않으면 서버 응답 대기가 발생한다. 이를 개선하려면 스트리밍을 사용한다.

Server Rendering without Streaming

1-3. 스트리밍 (Streaming)


스트리밍은 동적 라우트를 전체가 아닌 준비된 부분부터 순차적으로 클라이언트에 전송하는 기능이다.
페이지 일부가 아직 로딩 중이더라도 사용자는 빠르게 콘텐츠를 볼 수 있다.

동적 라우트에서 스트리밍을 활용하면 부분 프리페칭이 가능해진다. 공유 레이아웃과 로딩 스켈레톤을 미리 요청할 수 있다.

How Server Rendering with Streaming Works

스트리밍을 사용하려면 라우트 폴더에 loading.tsx를 추가한다.

loading.js special file

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.pushStatewindow.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는 현재 기록을 덮어쓴다.
뒤로 가기 지원 여부가 두 메서드를 선택하는 기준이다.

This post is licensed under CC BY 4.0 by the author.