Dev Thinking
21완료

내비게이션 – Link, usePathname, useRouter

2025-09-18
8분 읽기

공식문서 기반의 Next.js 입문기

들어가며

지난 글에서 App Router의 중첩 레이아웃과 동적 라우팅이 파일 구조를 통해 어떻게 복잡한 URL 패턴을 자연스럽게 처리하는지 살펴보았습니다. 이제 그 경계 안에서 페이지를 이동하는 내비게이션에 대해 생각해봅니다.

리액트 환경에서는 리액트 라우터라는 라우트 설정 라이브러리로 내비게이션을 직접 구성했지만, Next.js App Router에서는 파일 기반 라우팅 위에 최적화된 내비게이션 도구들이 준비되어 있습니다. Link 컴포넌트와 라우터 훅들이 어떻게 클라이언트 사이드 전환을 지원하면서도 서버 사이드 렌더링의 장점을 살리는지, 그리고 현재 경로 표시나 프로그래매틱 내비게이션(코드 기반 내비게이션)은 어떻게 구현되는지 차분히 탐색해보겠습니다.

이러한 내비게이션 설계는 단순한 페이지 이동을 넘어, Part 2 전체를 통해 살펴보겠지만, 다음 편인 스타일링(2-3편)과 최적화(2-4편)와도 밀접하게 연결됩니다. 내비게이션에서 결정된 사용자 흐름이 스타일링 전략을 유도하고, 최적화 기회를 만들어내는 만큼, 신중하게 접근해보겠습니다.

내비게이션 도구 체계

Next.js의 내비게이션은 선언적 접근(Link 컴포넌트)과 프로그래매틱 접근(라우터 훅)을 제공합니다. 각 도구는 특정 역할을 가지며, 함께 사용할 때 내비게이션 기능을 구성합니다.

Link 컴포넌트는 HTML의 <a> 태그를 대체하면서 클라이언트 사이드 라우팅을 지원합니다. 기본적으로는 같은 방식으로 동작하지만, 페이지 전환 시 전체 페이지 새로고침 대신 JavaScript로 경로를 변경합니다.

import Link from "next/link";
 
export function Navigation() {
  return (
    <nav>
      <Link href="/dashboard">대시보드</Link>
      <Link href="/settings">설정</Link>
    </nav>
  );
}

이 컴포넌트의 핵심은 href 속성입니다. App Router에서는 app/ 폴더 구조에 대응하는 경로를 지정하면 됩니다. /dashboardapp/dashboard/page.tsx에, /settings/profileapp/settings/profile/page.tsx에 연결됩니다.

usePathname: 현재 경로 상태 읽기

내비게이션에서 자주 필요한 것은 "지금 어느 페이지에 있는가?"입니다. usePathname 훅은 현재 URL 경로를 반환하며, 이를 통해 활성 링크를 시각적으로 표시할 수 있습니다.

"use client";
 
import { usePathname } from "next/navigation";
import Link from "next/link";
 
export function NavMenu() {
  const pathname = usePathname();
 
  return (
    <nav>
      <Link href="/dashboard" className={pathname === "/dashboard" ? "active" : ""}>
        대시보드
      </Link>
      <Link href="/settings" className={pathname.startsWith("/settings") ? "active" : ""}>
        설정
      </Link>
    </nav>
  );
}

이 훅은 클라이언트 컴포넌트에서만 사용할 수 있으며, pathname이 변경될 때마다 컴포넌트를 리렌더링합니다. 특히 중첩 라우트에서 startsWith()를 활용하면 부모 경로의 활성 상태를 효과적으로 표시할 수 있습니다. 클라이언트에 대한 자세한 설명은 3-1에서 할 예정입니다. 당장은 코드파일 맨 위에 'use client'만 선언하면 클라이언트 컴포넌트로 변경된다는 것만 알고 계셔도 충분합니다.

useRouter: 프로그래매틱 내비게이션 제어

코드에서 경로를 변경해야 할 때는 useRouter 훅을 사용합니다. 특정 조건에 따라 페이지를 이동시키는 경우에 유용합니다. usePathname과 동일하게 클라이언트 컴포넌트에서만 사용가능합니다.

"use client";
 
import { useRouter } from "next/navigation";
 
export function LoginForm() {
  const router = useRouter();
 
  const handleSubmit = async (formData) => {
    const result = await login(formData);
 
    if (result.success) {
      router.push("/dashboard"); // 성공 시 대시보드로 이동
    } else {
      router.refresh(); // 실패 시 현재 페이지 새로고침
    }
  };
 
  return <form onSubmit={handleSubmit}>{/* 폼 필드들 */}</form>;
}

주요 메소드로는 push()(히스토리에 추가), replace()(현재 히스토리 교체), refresh()(현재 페이지 새로고침), back()(이전 페이지로 이동)이 있습니다. 이런 메서드들을 상황에 따라 적절히 조합하면, 폼 제출 후 조건에 따른 리다이렉트나 캐시된 데이터를 강제로 다시 불러오는 흐름을 깔끔하게 표현할 수 있습니다.

왜 이렇게 분리되어 설계되었을까요? Link는 선언적 UI를 위한 것이고, usePathname은 상태 표시를 위한 것이며, useRouter는 로직 기반 내비게이션을 위한 것입니다. 각 도구가 특정 상황에 최적화되어 있어 전체 내비게이션 아키텍처가 유연해집니다.

기능 구현 및 비교

이제 App Router의 전반적인 구조를 다시 떠올리며, 먼저 리액트 단독 환경에서 내비게이션을 어떻게 다루는지 짚어보고 싶습니다.

리액트 단독 구성 – 리액트 라우터 기반 내비게이션

// 선언적 내비게이션
import { Link, NavLink } from "react-router-dom";
 
export function Navigation() {
  return (
    <nav>
      <NavLink to="/dashboard" className={({ isActive }) => (isActive ? "active" : "")}>
        대시보드
      </NavLink>
      <Link to="/settings">설정</Link>
    </nav>
  );
}
 
// 프로그래매틱 내비게이션
import { useNavigate } from "react-router-dom";
 
export function ActionButton() {
  const navigate = useNavigate();
 
  const handleClick = () => {
    navigate("/dashboard");
  };
 
  return <button onClick={handleClick}>이동</button>;
}

리액트 라우터에서는 Routes/Route로 경로를 명시적으로 정의하고, Link/NavLink로 내비게이션을 구현합니다.

리액트 vs Next.js 비교표

구분리액트 (리액트 라우터 중심)Next.js (App Router)
라우트 정의Routes/Route 컴포넌트로 명시적 선언app/ 폴더 구조 자동 매핑
기본 링크Link 컴포넌트로 클라이언트 라우팅next/link 컴포넌트로 라우팅
활성 상태 표시NavLink의 isActive 콜백usePathname 직접 비교
프로그래매틱 이동useNavigate 훅useRouter 훅
레이아웃 공유Outlet 컴포넌트로 수동 배치폴더별 layout.tsx 자동 중첩
프리페치 동작prefetch 속성으로 선택적 제어Link 컴포넌트 기본 프리페치
서버 렌더링별도 설정 필요 (리액트 라우터 DOM)App Router 기본 지원

내비게이션에 대해 좀 더 자세히 알아보기

Next.js의 내비게이션은 파일 기반 라우팅 위에서 자동 프리페치와 부분 렌더링을 결합합니다. 내비게이션 시 레이아웃의 일부만 교체됩니다.

프리페치(prefetch)와 부분 렌더링의 시너지

Link 컴포넌트는 기본적으로 프리페치를 수행하며, 부분 렌더링과 결합되어 작동합니다.

// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
  return (
    <div className="dashboard-container">
      <Sidebar /> {/* 이 컴포넌트는 페이지 이동 시 유지됨 */}
      <main>{children}</main> {/* 이 부분만 교체됨 */}
    </div>
  );
}

대시보드 영역에서 페이지를 이동하면 사이드바는 그대로 유지되고 메인 콘텐츠만 교체됩니다. Link의 자동 프리페치가 백그라운드에서 다음 페이지를 미리 로드하므로, 클릭 즉시 콘텐츠가 나타나는 듯한 부드러운 전환이 가능합니다.

프리페치 동작을 제어할 수도 있습니다:

<Link href="/expensive-page" prefetch={false}>
  무거운 페이지
</Link>

또는 특정 조건에서만 프리페치를 활성화할 수 있습니다:

<Link href="/analytics" prefetch={userRole === "admin"}>
  관리자 전용 분석
</Link>

라우트 그룹 내에서의 내비게이션 패턴

라우트 그룹은 2-1편에서도 봤듯이 관련 페이지를 이름 없는 폴더로 묶어 하나의 레이아웃을 공유하게 만드는 방식으로, 서로 다른 영역을 논리적으로 구분하면서도 그룹 내에서는 상태와 내비게이션을 매끄럽게 유지하도록 돕습니다.

이 구조를 활용한다면면 다음과 같은 효과를 얻을 수 있습니다:

  • 공통 UI의 자연스러운 유지: 사이드바, 탑바처럼 각 그룹에서 반복되는 요소를 layout에 두면, 페이지 간 이동에도 해당 UI가 다시 그려지지 않고 그대로 유지되므로 사용자가 인터페이스 흐름을 잃지 않습니다.
  • 권한·설정·SEO 처리를 그룹 단위로 통합: layout에 인증 검사/헤더 메타 정보 설정 등을 포함시키면 각 페이지에서 중복 코드를 작성하지 않고도 전체 그룹에 일관된 제어가 가능합니다.
  • 내비게이션 상태가 이어지는 UX: layout이 유지되면서 usePathname이나 메뉴 상태가 초기화되지 않아, 드롭다운 열림 상태 같은 UI 상태를 자연스럽게 이어가는 구현이 가능해집니다.
app/
├── (marketing)/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── about/
│   │   └── page.tsx
│   └── blog/
│       └── page.tsx
└── (dashboard)/
    ├── layout.tsx
    ├── page.tsx
    └── analytics/
        └── page.tsx

각 그룹의 layout.tsx에서 내비게이션을 구성하면 그룹 내 이동이 효율적입니다:

// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
  return (
    <div>
      <DashboardNav /> {/* 그룹 내 내비게이션 */}
      {children}
    </div>
  );
}

그룹 간 이동은 다른 레이아웃을 로드하지만, 그룹 내 이동은 기존 레이아웃을 재사용합니다. 이런 구조로 경로가 바뀌어도 UI 일부가 유지되는 방식을 구현합니다.

라우트 그룹이 많아질수록 각 네비게이션에 명확한 역할(role)과 현재 상태 표시가 필요하고, 다음 섹션의 접근성 패턴이 그 역할을 대신해서 확실히 반복적으로 설명해줍니다.

접근성 고려 사항

내비게이션은 접근성 측면에서도 중요한 역할을 합니다. Next.js에서는 다음과 같은 패턴을 권장합니다:

"use client";
 
import Link from "next/link";
import { usePathname } from "next/navigation";
 
export function AccessibleNav() {
  const pathname = usePathname();
 
  return (
    <nav role="navigation" aria-label="메인 내비게이션">
      <Link href="/dashboard" aria-current={pathname === "/dashboard" ? "page" : undefined}>
        대시보드
      </Link>
      {/* 다른 링크들 */}
    </nav>
  );
}

스크린 리더를 위한 aria-current 속성(현재 페이지 표시)과 role 속성(요소의 역할 정의)을 적절히 사용합니다. 또한 포커스 관리를 위해 Link 컴포넌트는 자동으로 적절한 포커스 이동을 처리합니다.

예상 질문

Q. Link 컴포넌트를 사용할 때 주의해야 할 점은 무엇인가요?

Link는 기본적으로 클라이언트 사이드 전환을 수행하지만, 외부 링크에는 사용하지 말아야 합니다. 외부 링크에는 일반 <a> 태그를 사용하세요. 또한 target="_blank"를 사용할 때는 보안상 rel="noopener noreferrer"를 함께 지정하는 것이 좋습니다. 이렇게 하면 새 창이 원본 페이지의 window 객체에 접근하지 못해 보안성이 확보됩니다.

Q. useRouter와 window.history.pushState의 차이점은?

useRouter는 Next.js의 라우터 시스템과 통합되어 있어, App Router의 로딩 상태나 에러 경계와 함께 작동합니다. 반면 window.history.pushState는 브라우저의 기본 히스토리 API를 직접 조작하므로, Next.js의 최적화 기능을 활용할 수 없습니다.

Q. 내비게이션에서 상태 관리는 어떻게 해야 하나요?

URL이 상태의 유일한 소스로 유지하는 것이 좋습니다. 복잡한 필터나 정렬 상태는 URL 쿼리 파라미터로 관리하고, useSearchParams 훅을 활용하세요. 이렇게 하면 브라우저의 뒤로가기/앞으로가기 버튼이 제대로 작동하고, 페이지 새로고침 시에도 상태가 유지됩니다.

내비게이션 설계의 트레이드오프

장점

  • 빠른 전환 속도: 클라이언트 사이드 라우팅으로 페이지 전체 새로고침을 방지하여 사용자 경험이 부드러워짐
  • SEO와 성능 균형: 서버 사이드 렌더링의 SEO 이점을 유지하면서도 클라이언트 전환으로 빠른 내비게이션 제공
  • 접근성 자동 지원: 자동 포커스 관리와 ARIA 속성으로 스크린 리더와 키보드 내비게이션 지원
  • 성능 최적화: 자동 프리페치와 부분 렌더링으로 불필요한 리소스 로드를 방지하고 체감 속도 향상

단점

  • 학습 곡선 증가: 리액트 라우터에 익숙한 개발자가 Next.js의 usePathname/useRouter 패턴을 익히는 데 시간 소요
  • 상태 관리 복잡성: 대규모 앱에서 내비게이션 상태, 활성 링크 표시, 프로그래매틱 이동을 동시에 관리하기 어려움
  • 하이드레이션 불일치 위험: 서버와 클라이언트에서 내비게이션 상태가 다를 때 발생하는 렌더링 불일치 문제

균형 맞추기 팁

작은 규모의 앱에서는 Link와 usePathname으로 간단한 내비게이션을 구현하고, 대규모 앱에서는 중앙화된 내비게이션 설정 파일을 만들어 일관성을 유지하세요. 또한 하이드레이션 문제를 방지하기 위해 클라이언트 컴포넌트에서는 useEffect로 초기 상태를 설정하는 패턴을 활용하세요.

요약

  • Next.js 본질: 파일 기반 라우팅과 결합된 선언적 Link 컴포넌트와 프로그래매틱 라우터 훅의 조합
  • 주요 강점: 자동 프리페치와 부분 렌더링의 시너지, App Router 구조 활용, 접근성 지원
  • 핵심 도구: usePathname으로 활성 상태 표시, useRouter로 코드 기반 내비게이션, 라우트 그룹별 구조화
  • 실무적 가치: 중앙화된 내비게이션 시스템 구축과 SEO·UX 균형 유지

참조