Next.js 링크와 내비게이션 (Linking and Navigating)

Next.js 공식 문서 Getting Started: Linking and Navigating 내용을 바탕으로, App Router 기준의 내비게이션 동작 원리와 튜닝 포인트를 정리한 자료입니다. citeturn4view0


1. 개요

  • Next.js에서는 라우트가 기본적으로 서버에서 렌더링됩니다.
  • 이 때문에 새 라우트를 보여 주기 전에 클라이언트가 서버 응답을 기다려야 하는 지연 시간이 발생할 수 있습니다.
  • Next.js는 이를 줄이기 위해 다음 기능을 기본 제공하여 빠르고 부드러운 내비게이션을 제공합니다. citeturn4view0
    • Prefetching
    • Streaming
    • Client-side transitions

이 문서에서는 위 기능들이 어떻게 동작하는지, 그리고 동적 라우트나 느린 네트워크 환경에서 어떻게 최적화할 수 있는지를 설명합니다. citeturn4view0


2. 내비게이션 동작 방식 (How navigation works)

Next.js 내비게이션을 이해하려면 다음 네 가지 개념을 알아두면 좋습니다. citeturn4view0

  • Server Rendering
  • Prefetching
  • Streaming
  • Client-side transitions

2.1 Server Rendering

  • App Router에서 Layouts, Pages는 기본적으로 React Server Component입니다.
  • 초기 접속과 그 이후의 내비게이션 모두, 서버에서 Server Component Payload를 생성한 뒤 클라이언트로 전송합니다. citeturn4view0
  • 서버 렌더링에는 두 가지 방식이 있습니다.
    1. Static Rendering (Prerendering)
      • 빌드 타임 또는 revalidation 시점에 실행되고,
      • 결과가 캐시에 저장됩니다.
    2. Dynamic Rendering
      • 클라이언트 요청이 들어올 때마다 서버에서 렌더링합니다.

정리: 서버 렌더링의 단점은, 새 라우트를 보여 주기 전에 항상 서버 응답을 기다려야 한다는 점입니다. Next.js는 이것을 prefetching + client-side transitions로 보완합니다. citeturn4view0


2.2 Prefetching

Prefetching은 사용자가 실제로 이동하기 전에 백그라운드에서 미리 라우트 데이터를 가져오는 과정입니다. citeturn4view0

  • 사용자가 링크를 클릭할 때쯤에는 이미 데이터가 준비되어 있어, 거의 즉시 페이지가 전환되는 느낌을 줍니다.
  • Next.js는 next/link<Link> 컴포넌트로 연결된 라우트를, 뷰포트에 들어오거나 hover 되었을 때 자동으로 prefetch합니다. citeturn4view0
// app/layout.tsx
import Link from 'next/link'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <nav>
          {/* 뷰포트에 들어오거나 hover되면 자동 prefetch */}
          <Link href="/blog">Blog</Link>
          {/* a 태그는 prefetch 없음 */}
          <a href="/contact">Contact</a>
        </nav>
        {children}
      </body>
    </html>
  )
}

얼마나 prefetch하는지는 라우트 유형에 따라 다릅니다. citeturn4view0

  • 정적 라우트 (Static Route): 전체 라우트를 prefetch
  • 동적 라우트 (Dynamic Route): prefetch를 건너뛰거나, 해당 경로에 loading.tsx가 있을 경우 부분 prefetch

요약: 동적 라우트는 필요 이상으로 서버 작업을 하지 않기 위해 prefetch 범위를 줄입니다. 다만 이 경우 **“클릭 후 잠깐 멈춘 것 같은 느낌”**을 줄 수 있어, 아래의 Streaming과 함께 사용하는 것이 중요합니다. citeturn4view0


2.3 Streaming

Streaming은 서버가 전체 페이지를 다 렌더링할 때까지 기다리지 않고, 준비된 부분부터 순차적으로 클라이언트에 전송하는 방식입니다. citeturn4view0

  • 사용자는 일부 UI라도 빨리 볼 수 있어 체감 속도가 빨라집니다.
  • 특히 동적 라우트에서 유용합니다.
    • 공통 레이아웃 영역과 **로딩 스켈레톤(loading skeleton)**을 미리 요청해 둘 수 있습니다. citeturn4view0

Streaming을 사용하려면 해당 라우트 폴더에 loading.tsx 파일을 생성합니다. citeturn4view0

// app/dashboard/loading.tsx
export default function Loading() {
  // 이 UI는 라우트가 로딩되는 동안 먼저 보여집니다.
  return <LoadingSkeleton />
}
  • Next.js는 내부적으로 page.tsx 내용을 <Suspense>로 감싸고,
  • prefetch된 loading.tsx UI를 먼저 보여준 뒤, 실제 데이터가 준비되면 최종 콘텐츠로 교체합니다. citeturn4view0

loading.tsx의 장점 citeturn4view0

  • 내비게이션 직후 즉시 피드백 제공
  • 공통 레이아웃은 그대로 인터랙티브 상태 유지, 내비게이션 중단도 가능
  • Core Web Vitals 지표(TTFB, FCP, TTI) 개선

2.4 Client-side transitions

전통적인 서버 렌더링 내비게이션은: citeturn4view0

  • 전체 페이지 리로드가 발생하고,
  • 상태가 초기화되며,
  • 스크롤 위치가 리셋되고,
  • 잠시 동안 상호작용이 막힙니다.

Next.js는 <Link> 컴포넌트를 통한 **클라이언트 사이드 전환(client-side transition)**으로 이를 피합니다. citeturn4view0

  • 페이지를 리로드하지 않고, 동적으로 콘텐츠만 교체합니다.
  • 그 과정에서:
    • 공통 레이아웃 및 UI는 유지하고,
    • 현재 페이지를 prefetch된 로딩 상태 또는 새 페이지로 교체합니다.

정리: 서버 렌더링 앱이지만, 클라이언트 렌더링 앱 같은 부드러운 라우팅 경험을 제공하며, prefetching과 streaming을 함께 사용하면 동적 라우트에서도 빠른 전환이 가능합니다. citeturn4view0


3. 전환이 느려지는 원인과 대처법 (What can make transitions slow?)

기본 최적화가 있어도, 아래와 같은 경우 전환이 여전히 느리게 느껴질 수 있습니다. 문서에서 제시하는 대표 원인은 다음과 같습니다. citeturn4view0

3.1 loading.tsx가 없는 동적 라우트

  • 동적 라우트로 이동할 때, 서버 응답이 올 때까지 보여줄 UI가 없으면 사용자는 앱이 멈춘 것처럼 느낄 수 있습니다. citeturn4view0
  • 해결책:
    • 해당 동적 라우트(예: app/blog/[slug]) 폴더에 loading.tsx를 추가합니다.
// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <LoadingSkeleton />
}

팁: 개발 모드에서는 Next.js Devtools로 해당 라우트가 정적인지(dynamic/static) 확인할 수 있습니다. citeturn4view0


3.2 generateStaticParams가 없는 동적 세그먼트

  • 어떤 동적 세그먼트는 사실 빌드 타임에 **미리 렌더링(prerender)**할 수 있습니다.
  • 그런데 generateStaticParams가 없으면, Next.js는 해당 라우트를 요청 시점에 동적으로 렌더링하게 됩니다. citeturn4view0
  • 해결책: generateStaticParams를 정의하여 빌드 시점에 가능한 경로를 모두 생성합니다.
// app/blog/[slug]/page.tsx
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
  // ...
}

정리: 미리 알 수 있는 동적 경로는 generateStaticParams를 통해 정적 생성 → 캐시 활용을 하는 것이 전환 속도에 유리합니다. citeturn4view0


3.3 느린 네트워크 (Slow networks)

  • 네트워크가 느리거나 불안정한 경우, prefetch가 클릭 전에 완료되지 않을 수 있습니다.
  • 이때 정적/동적 라우트 모두에서 loading.tsx UI가 바로 보이지 않을 수 있음에 주의해야 합니다. citeturn4view0

이럴 때는 useLinkStatus을 사용하여 전환 상태에 맞는 즉각적인 피드백을 줄 수 있습니다. citeturn4view0

// 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' : ''}`}
    />
  )
}
  • CSS에서 **animation-delay (예: 100ms)**와 opacity 0 → 1을 활용해,
  • **일정 시간 이상 걸리는 전환에만 로딩 힌트를 보여주도록 “디바운스”**할 수 있습니다. citeturn4view0

3.4 Prefetch 비활성화 (Disabling prefetching)

<Link>에서 **prefetch={false}**를 설정하면 prefetch를 끌 수 있습니다. citeturn4view0

<Link prefetch={false} href="/blog">
  Blog
</Link>
  • 장점:
    • 매우 많은 링크(예: 무한 스크롤 테이블)에서 불필요한 리소스 사용을 줄일 수 있음
  • 단점: citeturn4view0
    • 정적 라우트도 클릭 시점에야 데이터를 가져오게 됨
    • 동적 라우트는 이동 전에 항상 서버 렌더링을 기다려야 함

Hover 시에만 Prefetch 하기

완전히 끄기보다는, hover 시점에만 prefetch하여 리소스 사용과 체감 속도 사이에서 타협할 수도 있습니다. citeturn4view0

// 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>
  )
}
  • 뷰포트에 보이는 모든 링크를 prefetch하는 대신,
  • 사용자가 실제로 관심을 보인(hover한) 링크만 선택적으로 prefetch합니다.

3.5 Hydration이 끝나지 않은 상태 (Hydration not completed)

  • <Link>Client Component이므로, 하이드레이션이 완료되어야 prefetch를 시작할 수 있습니다.
  • 초기 방문 시 JS 번들이 너무 크면, 하이드레이션이 지연되고 prefetch 시작도 늦어질 수 있습니다. citeturn4view0

개선 방법: citeturn4view0

  • @next/bundle-analyzer 플러그인으로 번들 크기를 분석하고, 불필요하게 큰 의존성을 줄입니다.
  • 가능한 로직은 클라이언트 대신 서버 컴포넌트로 옮겨 클라이언트 JS 부담을 줄입니다.

4. 예제: Native History API 활용

Next.js에서는 브라우저의 기본 History API를 사용해도 페이지 리로드 없이 주소를 변경할 수 있습니다.
이때 호출은 Next.js Router와 통합되어, usePathname, useSearchParams와도 연동됩니다. citeturn4view0

4.1 window.history.pushState

  • 새 히스토리 엔트리를 추가합니다.
  • 사용자는 브라우저 뒤로 가기 버튼으로 이전 상태로 돌아갈 수 있습니다.

예: 상품 목록 정렬 기준을 쿼리스트링으로 반영하기 citeturn4view0

'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>
    </>
  )
}

4.2 window.history.replaceState

  • 현재 히스토리 엔트리를 교체합니다.
  • 사용자는 이전 상태로 되돌아갈 수 없습니다. citeturn4view0

예: 현재 pathname을 기준으로 **언어(locale)**를 변경하기 citeturn4view0

'use client'

import { usePathname } from 'next/navigation'

export function LocaleSwitcher() {
  const pathname = usePathname()

  function switchLocale(locale: string) {
    // 예: '/en/about', '/fr/contact'
    const newPath = `/${locale}${pathname}`
    window.history.replaceState(null, '', newPath)
  }

  return (
    <>
      <button onClick={() => switchLocale('en')}>English</button>
      <button onClick={() => switchLocale('fr')}>French</button>
    </>
  )
}

5. 정리

이 문서에서 다룬 핵심 포인트를 요약하면 다음과 같습니다.

  1. 서버 렌더링 + Prefetch + Streaming + Client-side transitions 조합으로
    • 서버 렌더링 앱이지만 클라이언트 앱처럼 빠른 전환을 제공한다.
  2. 동적 라우트에서는
    • loading.tsx부분 prefetch + 로딩 UI를 제공하고,
    • generateStaticParams가능한 경로는 빌드 타임에 정적 생성하는 것이 좋다.
  3. 리소스 최적화가 필요할 때는
    • prefetch={false} 또는 hover 시 prefetch 전략으로 제어할 수 있다.
  4. 느린 네트워크나 큰 번들로 인해 전환이 느릴 수 있으므로
    • useLinkStatus, @next/bundle-analyzer, Server Component 활용 등으로 체감 속도와 번들 크기를 함께 관리해야 한다.
  5. 필요하다면 브라우저 History API(pushState, replaceState)를 활용하여
    • 쿼리스트링이나 locale 변경 등을 라우터와 자연스럽게 연동할 수 있다.

Next.js 내비게이션을 튜닝할 때, 이 문서를 스터디에서 **“내비게이션 구조와 최적화 포인트 체크리스트”**처럼 활용하시면 좋습니다.