페이지 간 링크 생성
Link의 클라이언트 전환과 프리페치를 이해하고 정적·동적 href와 useRouter를 상황에 맞게 사용합니다.
웹 애플리케이션은 정보와 기능을 제공하고 사용자가 자유롭게 이동하게 만드는 것이 본질이므로, 페이지를 잇는 링크(Link)는 꼭 필요한 요소입니다. Next.js App Router에서는 기본 <a> 태그보다 Next.js의 <Link> 컴포넌트를 쓰기를 권합니다. 이 글에서는 <Link>의 사용법과 장점, 그리고 실제 애플리케이션에서 링크를 효율적으로 관리하는 방법을 자세히 알아봅니다.
<a> 태그 대신 <Link>를 쓰는 이유
일반 HTML의 <a> 태그로 페이지를 이동하면 브라우저가 그 페이지를 처음부터 다시 불러옵니다. 전통적인 웹사이트에서는 흔한 일이었지만, React 같은 SPA(Single Page Application) 기반 애플리케이션에서는 화면 전체가 깜빡이거나 새로고침되는 것 같은 경험을 주게 됩니다.
반면 Next.js의 <Link>를 쓰면 몇 가지 중요한 장점이 있습니다. 첫째, 클라이언트 측 이동(Client-side Navigation)으로 페이지 전체를 다시 불러오는 대신 JavaScript로 필요한 부분만 바꾸므로, 브라우저가 새로고침되지 않고 내용만 부드럽게 바뀝니다. 둘째, Next.js는 라우트마다 필요한 코드를 나누어 보내며(<Link>를 더하는 것 자체가 코드 분할의 기준은 아닙니다), 프로덕션의 자동 프리페치는 링크가 뷰포트에 들어오면 정책에 따라 대상 라우트와 데이터를 미리 준비합니다. 다만 네트워크와 동적 데이터의 대기가 남을 수 있어 곧바로 끝나는 전환을 보장하지는 않습니다. 셋째, <Link>는 안에서 여전히 <a> 태그로 렌더링되므로, 검색 엔진 크롤러가 웹사이트의 구조를 파악하고 색인하는 데 문제가 없습니다.
<Link> 컴포넌트 사용법
<Link>를 쓰는 방법은 간단합니다. 먼저 링크를 쓸 컴포넌트 파일의 맨 위에서 'next/link'의 Link를 가져옵니다.
import Link from 'next/link';그다음 <Link>의 href prop에 이동할 경로를 문자열로 지정합니다. 앱 내부의 페이지로 이동할 때는 그 App Router 경로와 같은 URL을 쓰며, href에는 해시나 외부 URL도 쓸 수 있지만 외부 사이트와 다운로드에는 일반 <a>도 알맞습니다. Next.js 13 이상에서는 <Link> 자체가 <a>로 렌더링되므로, 안에 <a> 태그를 다시 넣지 않고 링크 텍스트와 스타일 속성을 <Link>에 바로 둡니다.
import Link from 'next/link';
export default function HomePage() {
return (
<div>
<h1>환영합니다!</h1>
<p>이곳은 저희 웹사이트의 홈 페이지입니다.</p>
<nav>
<ul>
<li>
<Link href="/about">회사 소개 페이지로 이동</Link>
</li>
<li>
<Link href="/dashboard" className="dashboard-link">
대시보드 보러 가기
</Link>
</li>
<li>
<Link href="/blog/my-first-post">
자세한 게시글 보기 (동적 라우트)
</Link>
</li>
</ul>
</nav>
</div>
);
}첫 번째 <li>에서는 <Link>에 이동할 경로와 텍스트를 바로 넣었고, 두 번째 <li>에서는 <Link>에 className을 지정해 버튼처럼 꾸밀 수 있게 했습니다. 세 번째 <li>는 동적 라우트의 예로, href에 동적인 경로를 지정할 수 있다는 것을 보여 주며 뒤에서 자세히 다룹니다.
이 글의 첫 클릭 실습은 앞에서 만든 /about, /dashboard, /dashboard/settings 페이지로 확인합니다. /blog/my-first-post와 뒤의 상품 URL, /heavy-page는 그 대상 페이지를 작성했다는 조건의 예입니다. href만 더한다고 없는 페이지가 만들어지지는 않으므로, 그 파일이 없는 프로젝트에서 나는 404를 Link의 문법 오류와 헷갈리지 않습니다.
동적 경로(Dynamic Paths)와 <Link>
[slug]나 [id]처럼 대괄호로 정의한 동적 라우트 세그먼트를 가진 페이지로 이동할 때도 <Link>를 쓰며, 이때 href에는 실제 경로를 문자열로 넘깁니다. 예를 들어 src/app/products/[id]/page.tsx라는 동적 라우트가 있다면, 특정 상품 페이지로 이동하는 링크는 다음과 같이 작성할 수 있습니다.
// 특정 상품 목록 페이지에서
import Link from 'next/link';
function ProductList() {
const products = [
{ id: 'p001', name: '노트북' },
{ id: 'p002', name: '마우스' },
];
return (
<div>
<h1>상품 목록</h1>
<ul>
{products.map(product => (
<li key={product.id}>
<Link href={`/products/${product.id}`}>
{product.name} 상세 보기
</Link>
</li>
))}
</ul>
</div>
);
}이처럼 JavaScript의 템플릿 리터럴(Template Literal)로 동적인 값을 href에 쉽게 넣을 수 있습니다.
Link 컴포넌트의 다른 prop들 (선택)
<Link>는 href 말고도 몇 가지 쓸모 있는 prop을 제공합니다. replace를 true로 두면 현재 히스토리 스택의 항목을 새 항목으로 바꿉니다(기본값 false). 현재 항목을 이력에 남기지 않을 때 쓰며, 그보다 앞의 방문 기록까지 지우거나 뒤로 가기 자체를 막지는 않습니다.
<Link href="/dashboard" replace>
대시보드로 이동 (현재 이력 교체)
</Link>scroll을 false로 두면 이동한 뒤 Next.js가 스크롤을 관리하지 않습니다. 기본값 true도 언제나 문서 맨 위로 이동하는 것은 아니며, 새 페이지의 영역이 지금 화면에 보이는지 등을 확인해 위치를 조정합니다.
<Link href="/products#section-a" scroll={false}>
상품 페이지 특정 섹션으로 이동 (스크롤 유지)
</Link>prefetch의 기본값은 "auto"나 null이며, 정적·동적 라우트와 설정에 따라 미리 준비하는 범위가 달라집니다. false는 뷰포트에 들어올 때와 마우스를 올릴 때의 프리페치를 모두 끄며, 자동 프리페치는 프로덕션에서만 실행됩니다(Next.js 문서의 Link).
<Link href="/heavy-page" prefetch={false}>
무거운 페이지로 이동 (미리 로딩 안 함)
</Link>더 깊이: 기본 프리페치가 미리 가져오는 범위
기본값 "auto"(또는 null)가 미리 준비하는 범위는 라우트의 종류에 따라 다릅니다. 정적 라우트는 데이터까지 포함한 라우트 전체를 미리 가져오고, 동적 라우트는 가장 가까운 loading.tsx가 있는 세그먼트까지의 일부만 미리 가져옵니다. 그래서 동적 페이지에 loading.tsx를 두면 링크를 누르는 즉시 그 로딩 화면이 보이고, 나머지 내용은 이동한 뒤에 요청됩니다. prefetch={true}를 주면 동적 라우트도 전체를 미리 가져오며, 미리 가져온 데이터가 만료된 뒤 사용자가 링크에 마우스를 올리면 Next.js가 다시 프리페치를 시도합니다.
Next.js 16은 이 프리페치를 저장하는 캐시를 새로 만들었습니다(Next.js 16 발표). 여러 링크가 같은 레이아웃을 함께 쓰면 그 레이아웃은 한 번만 내려받으므로, 같은 레이아웃 아래의 상품 링크가 50개라도 레이아웃을 50번 받지 않습니다. 또한 캐시에 이미 있는 부분은 빼고 없는 부분만 가져오며, 링크가 뷰포트를 벗어나면 요청을 취소하고, 마우스를 올리거나 뷰포트에 다시 들어온 링크를 먼저 가져옵니다. 그 대가로 Network 탭에는 이전보다 작은 프리페치 요청이 더 여러 개 보일 수 있지만, 전송하는 전체 크기는 줄어듭니다.
useRouter 훅 (클라이언트 컴포넌트에서)
<Link>는 선언적으로 페이지를 이동하는 가장 좋은 방법이지만, 폼을 제출한 뒤처럼 특정 이벤트에 따라 프로그래밍 방식으로 이동해야 할 때는 Next.js가 제공하는 useRouter 훅을 쓸 수 있습니다. App Router에서는 next/navigation에서 가져오며, 클라이언트 컴포넌트에서만 쓸 수 있습니다.
"use client"; // 클라이언트 컴포넌트임을 명시
import { useRouter } from 'next/navigation'; // useRouter 임포트
export default function ClientButton() {
const router = useRouter(); // useRouter 훅 사용
const handleClick = () => {
// 버튼 클릭 시 프로그래밍 방식으로 /dashboard/settings 페이지로 이동
router.push('/dashboard/settings');
};
return (
<button onClick={handleClick}>
설정 페이지로 이동 (useRouter)
</button>
);
}client-button.tsx를 저장하는 것만으로 화면에 버튼이 나타나지는 않으므로, 기존 /dashboard 페이지를 아래 실습 버전으로 바꾸어 이 컴포넌트를 가져와 렌더링합니다. 위의 ClientButton 파일은 그대로 쓰고, 이전의 DashboardHomePage와 아래 페이지를 한 파일에 함께 두지 않습니다.
import ClientButton from './client-button';
export default function DashboardNavigationPage() {
return (
<section>
<h1>대시보드 이동 실습</h1>
<ClientButton />
</section>
);
}/dashboard에서 버튼을 누르면 onClick이 router.push('/dashboard/settings')를 부릅니다. 대상은 앞에서 만든 settings 페이지이므로 URL이 /dashboard/settings로 바뀌고 “Dashboard Settings” 제목이 보입니다. 버튼 파일은 클라이언트 컴포넌트이고, 이를 가져오는 위의 Page는 기본 서버 컴포넌트입니다. 단순히 이동하는 링크에는 Link를 쓰고, 이처럼 이벤트를 처리하다가 이동을 불러야 할 때 useRouter를 씁니다. useRouter는 push, replace, refresh, back 같은 메서드로 라우팅을 세밀하게 제어하게 해 주며, 자세한 내용은 뒤에서 다룹니다.
히스토리도 URL과 함께 따라가 봅니다. 현재 이력이 / → /about이고 소개 페이지가 활성 항목이라고 따로 가정합니다. settings로 기본 push하면 / → /about → /dashboard/settings가 되어 한 번 뒤로 갈 곳은 /about입니다. 같은 출발 이력에서 replace로 이동하면 현재의 소개 항목만 바뀌어 / → /dashboard/settings가 되고, 뒤로 갈 곳은 /입니다. 이는 Link의 replace 규칙이며, 스크롤 설정이나 프리페치와는 별개입니다.
탐색 옵션이 바꾸는 결과
같은 초기 history에서 push는 항목을 추가하고 replace는 현재 항목을 바꾼 뒤 back 결과가 달라집니다. 고정 history 역할의 전후 상태를 두 독립 분기로 추적하면 브라우저 기록 변화가 드러납니다.
NEXT · CH3-4
replace는 현재 항목만 교체합니다
홈→소개에서 설정으로 이동하는 예입니다. 이력, 스크롤, 미리 가져오기는 서로 다른 선택이며, replace가 모든 뒤로 가기를 막지는 않습니다.
| 선택 | 구체적인 결과 | 남는 경계 |
|---|---|---|
| 기본 push | 이력이 홈 → 소개일 때 설정으로 가면 홈 → 소개 → 설정 | 뒤로 가면 소개로 돌아갈 수 있음 |
| replace | 같은 출발 이력에서 설정으로 가면 홈 → 설정 | 현재 소개 항목만 교체; 홈 등 더 앞선 이력은 남음 |
| scroll={false} | Next.js가 이동 후 스크롤 위치를 자동 조정하지 않음 | URL에 #id를 넣는 것과 그 요소로 스크롤하는 것은 구분 |
| prefetch={false} | 뷰포트 진입·hover에서 대상 준비를 하지 않음 | 클릭 탐색은 가능하며, 그때 필요한 데이터를 기다릴 수 있음 |
- 기본 push
- 구체적인 결과: 이력이 홈 → 소개일 때 설정으로 가면 홈 → 소개 → 설정남는 경계: 뒤로 가면 소개로 돌아갈 수 있음
- replace
- 구체적인 결과: 같은 출발 이력에서 설정으로 가면 홈 → 설정남는 경계: 현재 소개 항목만 교체; 홈 등 더 앞선 이력은 남음
- scroll={false}
- 구체적인 결과: Next.js가 이동 후 스크롤 위치를 자동 조정하지 않음남는 경계: URL에 #id를 넣는 것과 그 요소로 스크롤하는 것은 구분
- prefetch={false}
- 구체적인 결과: 뷰포트 진입·hover에서 대상 준비를 하지 않음남는 경계: 클릭 탐색은 가능하며, 그때 필요한 데이터를 기다릴 수 있음
이력은 동작을 설명하는 예시이며 실행 기록이 아닙니다. 자동 프리페치를 확인하려면 개발 서버와 production을 구분합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 출발 이력은 홈과 현재 소개 두 항목입니다. 설정으로 push하면 세 항목이 되어 뒤로 갈 때 소개입니다. replace하면 현재 소개만 설정으로 교체해 두 항목이며 뒤로 갈 때 홈입니다. 두 결과는 같은 출발점의 독립 비교입니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 같은 출발 history 두 독립 실험 모두 홈·현재 소개의 두 항목에서 출발합니다. | history: 홈→소개 · 현재: 소개 · 항목 수: 2 | 비교할 출발 상태를 동일하게 고정합니다. |
| push와 replace의 전후 push는 설정을 추가하고 replace는 소개 자리를 설정으로 교체합니다. | push: 홈→소개→설정 · replace: 홈→설정 · 실험: 독립 분기 | 항목 수와 이전 항목이 다르게 바뀝니다. |
| back 결과 push 실험의 back은 소개이고 replace 실험의 back은 홈입니다. | push back: 소개 · replace back: 홈 | 변경한 history가 서로 다른 되돌아가기 결과를 만듭니다. |
탐색 옵션이 바꾸는 결과
같은 초기 history에서 push는 항목을 추가하고 replace는 현재 항목을 바꾼 뒤 back 결과가 달라집니다. 고정 history 역할의 전후 상태를 두 독립 분기로 추적하면 브라우저 기록 변화가 드러납니다.
NEXT · CH3-4
replace는 현재 항목만 교체합니다
홈→소개에서 설정으로 이동하는 예입니다. 이력, 스크롤, 미리 가져오기는 서로 다른 선택이며, replace가 모든 뒤로 가기를 막지는 않습니다.
| 선택 | 구체적인 결과 | 남는 경계 |
|---|---|---|
| 기본 push | 이력이 홈 → 소개일 때 설정으로 가면 홈 → 소개 → 설정 | 뒤로 가면 소개로 돌아갈 수 있음 |
| replace | 같은 출발 이력에서 설정으로 가면 홈 → 설정 | 현재 소개 항목만 교체; 홈 등 더 앞선 이력은 남음 |
| scroll={false} | Next.js가 이동 후 스크롤 위치를 자동 조정하지 않음 | URL에 #id를 넣는 것과 그 요소로 스크롤하는 것은 구분 |
| prefetch={false} | 뷰포트 진입·hover에서 대상 준비를 하지 않음 | 클릭 탐색은 가능하며, 그때 필요한 데이터를 기다릴 수 있음 |
- 기본 push
- 구체적인 결과: 이력이 홈 → 소개일 때 설정으로 가면 홈 → 소개 → 설정남는 경계: 뒤로 가면 소개로 돌아갈 수 있음
- replace
- 구체적인 결과: 같은 출발 이력에서 설정으로 가면 홈 → 설정남는 경계: 현재 소개 항목만 교체; 홈 등 더 앞선 이력은 남음
- scroll={false}
- 구체적인 결과: Next.js가 이동 후 스크롤 위치를 자동 조정하지 않음남는 경계: URL에 #id를 넣는 것과 그 요소로 스크롤하는 것은 구분
- prefetch={false}
- 구체적인 결과: 뷰포트 진입·hover에서 대상 준비를 하지 않음남는 경계: 클릭 탐색은 가능하며, 그때 필요한 데이터를 기다릴 수 있음
이력은 동작을 설명하는 예시이며 실행 기록이 아닙니다. 자동 프리페치를 확인하려면 개발 서버와 production을 구분합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 출발 이력은 홈과 현재 소개 두 항목입니다. 설정으로 push하면 세 항목이 되어 뒤로 갈 때 소개입니다. replace하면 현재 소개만 설정으로 교체해 두 항목이며 뒤로 갈 때 홈입니다. 두 결과는 같은 출발점의 독립 비교입니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 같은 출발 history 두 독립 실험 모두 홈·현재 소개의 두 항목에서 출발합니다. | history: 홈→소개 · 현재: 소개 · 항목 수: 2 | 비교할 출발 상태를 동일하게 고정합니다. |
| push와 replace의 전후 push는 설정을 추가하고 replace는 소개 자리를 설정으로 교체합니다. | push: 홈→소개→설정 · replace: 홈→설정 · 실험: 독립 분기 | 항목 수와 이전 항목이 다르게 바뀝니다. |
| back 결과 push 실험의 back은 소개이고 replace 실험의 back은 홈입니다. | push back: 소개 · replace back: 홈 | 변경한 history가 서로 다른 되돌아가기 결과를 만듭니다. |
위 그림은 이동 옵션에 따라 결과가 어떻게 달라지는지, 홈에서 소개로 간 뒤 설정으로 이동하는 예로 보여 줍니다. 그림의 요약처럼 replace는 현재 항목만 바꾸며, 이력과 스크롤, 미리 가져오기는 서로 다른 선택이고 replace가 모든 뒤로 가기를 막지는 않습니다. 홈과 현재의 소개라는 두 항목에서 출발해 설정으로 push하면 세 항목이 되어 뒤로 가면 소개이고, replace하면 현재의 소개만 설정으로 바뀌어 두 항목이 되며 뒤로 가면 홈입니다. 두 결과는 같은 출발점에서의 독립된 비교이며, replace도 그보다 앞선 홈 항목은 남깁니다.
그림 아래의 표는 선택마다 결과와 남는 조건을 정리합니다. 기본 push는 설정을 더해 뒤로 가면 소개로 돌아갈 수 있고, replace는 현재 소개 항목만 바꾸어 홈 같은 더 앞선 이력은 남깁니다. scroll={false}는 이동한 뒤 Next.js가 스크롤 위치를 자동으로 조정하지 않게 할 뿐이므로, URL에 #id를 넣는 것과 그 요소로 스크롤하는 것은 구분합니다. prefetch={false}는 뷰포트에 들어올 때와 마우스를 올릴 때 대상을 준비하지 않게 하지만, 클릭해서 이동하는 것은 여전히 가능하며 그때 필요한 데이터를 기다릴 수 있습니다. 그림의 마지막 설명처럼 이력은 동작을 설명하는 예일 뿐 실행 기록이 아니며, 자동 프리페치를 확인하려면 개발 서버와 프로덕션을 구분합니다.
정리
<Link>는 페이지 전체를 다시 불러오지 않고 클라이언트 측에서 이동하며, 안에서<a>로 렌더링되므로 다시<a>를 넣지 않습니다.- 동적 라우트로 이동할 때는 템플릿 리터럴로 실제 경로를 만들어
href에 넘기며,href만으로 없는 페이지가 생기지는 않습니다. replace는 현재 이력 항목만 바꾸고,scroll={false}는 스크롤 관리를 끄며,prefetch={false}는 프리페치를 끄지만 클릭 이동은 그대로 됩니다.- 자동 프리페치는 프로덕션에서만 실행됩니다.
- 이벤트를 처리하다가 이동해야 할 때는 클라이언트 컴포넌트에서
next/navigation의useRouter를 씁니다.
연습 문제
1. replace 다음에 push를 더한다면
처음 이력이 / 한 항목이라고 가정합니다. 기본 Link로 /about에 간 뒤 replace로 /dashboard에 가고, 마지막으로 ClientButton의 push로 /dashboard/settings에 갑니다. 각 이동은 정상으로 끝나며 그 사이에 뒤로 가기나 새로고침은 없습니다. 마지막 이력의 순서와 한 번 뒤로 갈 때의 URL을 말해 보세요. replace를 한 번 썼다는 이유로 마지막 push도 이력을 바꿀까요?
해설 보기
/about으로 간 뒤에는 / → /about, replace한 뒤에는 / → /dashboard, 마지막 push 뒤에는 / → /dashboard → /dashboard/settings입니다. 한 번 뒤로 갈 곳은 /dashboard입니다.
replace는 그 이동에서만 현재 항목을 바꾸는 선택이며, 이후의 push 동작을 바꾸지 않습니다. 프리페치를 꺼도 클릭 이동 자체나 이력 규칙이 사라지는 것은 아닙니다.
2. 서버 컴포넌트에서 useRouter
src/app/dashboard/page.tsx에 'use client' 없이 useRouter를 불러 버튼을 만들었더니 오류가 났습니다. 왜 그럴까요? 어떻게 고쳐야 할까요?
해설 보기
page.tsx는 기본으로 서버 컴포넌트이고, useRouter는 브라우저에서 동작하는 훅이라 클라이언트 컴포넌트에서만 쓸 수 있기 때문입니다. 이 글처럼 버튼을 'use client'를 단 client-button.tsx로 떼어 내고, 서버 컴포넌트인 Page에서는 그 컴포넌트를 가져와 렌더링하기만 합니다. 이렇게 하면 Page는 서버 컴포넌트로 남고, 이동을 처리하는 버튼만 클라이언트에서 동작합니다.
3. 링크를 눌렀더니 404
홈 페이지의 “자세한 게시글 보기” 링크를 눌렀더니 404 페이지가 나왔습니다. <Link href="/blog/my-first-post"> 코드에 문제가 있는 걸까요?
해설 보기
코드의 문제라기보다 대상 페이지가 없기 때문일 가능성이 큽니다. href는 이동할 URL을 지정할 뿐 페이지를 만들지 않으므로, src/app/blog/[slug]/page.tsx 같은 파일이 없다면 그 경로는 404가 됩니다. 대상 페이지 파일이 있는지 먼저 확인하고, 동적 라우트는 다음 장에서 만듭니다.
이것으로 3장을 마칩니다. 3장에서는 App Router의 폴더와 파일 규칙, 서버·클라이언트 컴포넌트의 출발점에서 시작해, page.tsx로 페이지와 중첩 라우트를 만들고, layout.tsx로 공통 UI를 감싸며 상태가 유지되는 조건을 확인하고, <Link>와 useRouter로 페이지 사이를 이동하는 방법을 익혔습니다. 다음 장의 동적 라우트 생성부터는 URL의 일부가 바뀌는 동적 라우트와 중첩 레이아웃, Catch-all 세그먼트, 라우트 그룹을 다룹니다.