페이지를 옮기는 세 가지 방법
Article
로그인 버튼을 누르면 대시보드로 보내고 싶다. <Link>로는 안 된다. 로그인이 성공했을 때만 옮겨야 하기 때문이다.
이렇게 쓰고 싶어진다.
async function handleSubmit(e) {
e.preventDefault();
const ok = await login(e.target);
if (ok) window.location.href = "/dashboard";
}
동작은 한다. 그런데 브라우저가 페이지를 통째로 다시 받는다. 2강에서 <a> 태그를 쓰지 말라던 이유가 그대로 재현된다.
이동을 시작하는 주체는 셋이다. 사용자가 누르거나, 브라우저의 코드가 부르거나, 서버가 결정한다.
<Link>
사용자가 누른다
- 메뉴 · 목록 · 버튼
- 미리 받아둔다
- 대부분 이걸 쓴다
useRouter()
브라우저의 코드가 부른다
- 제출 성공 후 이동
"use client"필요- 누를 곳이 없을 때
redirect()
서버가 결정한다
- 권한 없음 · 주소 변경
- 화면을 그리기 전에
- 자바스크립트가 꺼져도 동작
Link 가 실제로 하는 일
<Link>는 <a> 태그를 그린다. 다만 클릭을 가로챈다.
- 클릭브라우저 기본 동작을 막는다
- 바뀐 부분만 요청레이아웃은 그대로
- history.pushState주소창만 바꾼다
- 화면 교체새로고침 없음
주소창은 바뀌었는데 페이지는 다시 받지 않았다. 레이아웃도, 그 안의 상태도 살아 있다. 4강에서 "레이아웃은 다시 렌더링되지 않는다"고 한 것이 이 흐름 위에서 벌어지는 일이다.
넘길 수 있는 값이 몇 개 있다.
| prop | 기본값 | 무엇을 바꾸나 |
|---|---|---|
| href | 필수 | 문자열 또는 { pathname, query } |
| replace | false | true 면 뒤로가기 기록을 남기지 않는다 |
| scroll | true | false 면 스크롤 위치를 유지한다 |
| prefetch | 자동 | false 면 미리 받지 않는다 |
replace는 로그인 페이지에 쓴다. 로그인 후 대시보드에서 뒤로가기를 눌렀는데 다시 로그인 화면이 나오면 이상하기 때문이다.
외부 사이트로 가는 링크는 <Link>를 쓸 이유가 없다. 미리 받아둘 것도, 유지할 상태도 없다. 그냥 <a href="https://…">를 쓴다.
지금 어디 있는지 안다
메뉴에서 현재 페이지를 표시하려면 지금 주소를 알아야 한다. usePathname이 알려준다.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
export default function Nav() {
const pathname = usePathname();
return (
<nav>
<Link href="/" className={pathname === "/" ? "on" : ""}>
홈
</Link>
<Link href="/about" className={pathname === "/about" ? "on" : ""}>
소개
</Link>
</nav>
);
}
맨 위의 "use client"가 눈에 걸릴 것이다. 훅은 브라우저에서만 돌기 때문이다. 3강에서 서버 컴포넌트가 터미널에 console.log를 찍는 걸 봤다. 그 컴포넌트에는 훅이 없다.
이 경계를 8강과 9강에서 제대로 긋는다. 지금은 use로 시작하는 것을 쓰면 "use client"가 필요하다는 정도로 넘어간다.
| 훅 | 돌려주는 것 | 쓰는 곳 |
|---|---|---|
| usePathname() | "/posts/1" | 활성 메뉴 표시 |
| useParams() | { id: "1" } | 클라이언트에서 [id] 읽기 |
| useSearchParams() | ?page=2 읽기 | 필터 · 검색어 |
| useRouter() | 이동 함수 묶음 | 코드로 이동시킬 때 |
usePathname()은 물음표 뒤를 빼고 준다. /posts?page=2에서 "/posts"만 나온다.
코드로 이동시킨다
useRouter는 이동 함수를 묶어서 준다.
"use client";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
async function handleSubmit(e) {
e.preventDefault();
const ok = await login(e.target);
if (ok) router.push("/dashboard");
else alert("로그인 실패");
}
return <form onSubmit={handleSubmit}>…</form>;
}
window.location.href와 달리 페이지를 다시 받지 않는다. <Link>를 클릭한 것과 똑같이 동작한다.
| 함수 | 하는 일 |
|---|---|
| router.push("/a") | 이동하고 기록을 남긴다 |
| router.replace("/a") | 이동하되 기록을 남기지 않는다 |
| router.back() | 뒤로 |
| router.forward() | 앞으로 |
| router.refresh() | 주소는 그대로, 서버에서 다시 받아온다 |
| router.prefetch("/a") | 미리 받아둔다 |
refresh()가 특이하다. 주소를 바꾸지 않고 서버 컴포넌트만 다시 실행해 새 데이터를 받아온다. 클라이언트 컴포넌트의 상태는 살아 있다. 입력하던 폼이 날아가지 않는다.
댓글을 달고 목록을 갱신할 때 쓰던 방법이다. 다만 지금은 더 나은 길이 있다. 11강의 Server Actions와 14강의 캐시 무효화에서 다시 만난다.
잘못 임포트하면 이렇게 터진다. 코드는 멀쩡해 보이는데 에러 메시지가 라우터 얘기를 한다.
- Error: NextRouter was not mounted.
- https://nextjs.org/docs/messages/next-router-not-mounted
두 라우터의 함수 이름도 미묘하게 다르다. next/router에는 router.pathname과 router.query가 있었지만, next/navigation의 router에는 없다. 대신 usePathname()과 useSearchParams()를 따로 부른다. 이동시키는 것과 읽는 것을 분리했다.
물음표 뒤를 읽는 두 가지 방법
5강에서 page.js가 searchParams를 props로 받는 걸 봤다. 클라이언트에서는 훅으로 읽는다. 같은 값을 두 경로로 읽는 셈이다.
await searchParams
서버 컴포넌트
page.js만 받는다- 값이 바뀌면 서버가 다시 그린다
- 데이터를 가져올 때
useSearchParams()
클라이언트 컴포넌트
- 어느 컴포넌트든 부른다
get("page")로 꺼낸다- 입력값을 주소에 반영할 때
검색창처럼 사용자가 타이핑한 값을 주소에 넣고 싶을 때는 훅 쪽을 쓴다.
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
export default function SearchBox() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function handleChange(e) {
const params = new URLSearchParams(searchParams);
params.set("q", e.target.value);
router.replace(`${pathname}?${params}`);
}
return <input onChange={handleChange} defaultValue={searchParams.get("q") ?? ""} />;
}
replace를 쓴 것이 중요하다. push로 하면 글자 하나 칠 때마다 뒤로가기 기록이 쌓인다. 뒤로가기를 열 번 눌러야 원래 페이지로 돌아간다.
주소를 상태 저장소로 쓰는 셈이다. 검색 결과를 그대로 링크로 공유할 수 있고, 새로고침해도 검색어가 남는다. React 편 18강에서 Context로 끌어올리던 상태 중 일부는 이렇게 주소에 두는 게 낫다.
- Error: useSearchParams() should be wrapped in a suspense boundary at page "/search".
import { Suspense } from "react";
export default function Page() {
return (
<Suspense fallback={<input disabled />}>
<SearchBox />
</Suspense>
);
}
경계 바깥은 미리 만들어두고, 안쪽만 요청을 기다린다. 15강에서 이 경계를 직접 그으며 스트리밍을 배운다.
서버가 이동시킨다
권한이 없는 사용자를 로그인 페이지로 보내야 한다. 화면을 그려서 보낸 다음 브라우저에서 옮기면 늦다. 그리기 전에 결정한다.
import { redirect } from "next/navigation";
export default async function Dashboard() {
const user = await getUser();
if (!user) redirect("/login");
return <h1>{user.name}</h1>;
}
redirect()를 부르면 그 아래 코드는 실행되지 않는다. 4강의 notFound()와 똑같다. 내부적으로 예외를 던져 렌더링을 중단시키기 때문이다.
let user;
try {
user = await getUser();
} catch (e) {
console.error(e);
}
if (!user) redirect("/login");
서버 컴포넌트 · 라우트 핸들러 · Server Actions 어디서든 부를 수 있다. 클라이언트 컴포넌트에서 부르면 안 된다. 거기서는 useRouter()를 쓴다.
| 함수 | 상태 코드 | 쓰는 곳 |
|---|---|---|
| redirect("/login") | 307 임시 | 권한 없음 · 조건부 이동 |
| permanentRedirect("/new") | 308 영구 | 주소가 아예 바뀌었을 때 |
차이는 검색엔진이 본다. 307은 원래 주소를 계속 색인하고, 308은 새 주소로 옮긴다. 옛 글의 주소 체계를 바꿨다면 permanentRedirect를 쓴다.
프리페치 — 누르기 전에 이미 받아뒀다
2강에서 던져둔 이야기를 회수한다. <Link>가 화면에 보이기만 해도 Next.js는 그 페이지를 미리 내려받는다.
개발 서버에서는 이게 꺼져 있다. 매번 컴파일해야 해서 느려지기 때문이다. 프리페치를 확인하려면 npm run build && npm start로 확인한다. 개발 모드에서 링크가 느리다고 뭘 고칠 필요는 없다.
정리하면
- 이동의 주체는 셋이다. 사용자(
<Link>), 브라우저의 코드(useRouter), 서버(redirect). <Link>는<a>를 그리고 클릭을 가로챈다. 안에<a>를 또 넣지 않는다.- 훅은 전부 **
next/navigation**에서 가져온다.next/router는 Pages Router의 것이다. - 훅을 쓰는 컴포넌트에는
"use client"가 필요하다. 이 경계는 8·9강에서 긋는다. router.replace()는 뒤로가기 기록을 남기지 않는다. 검색창처럼 자주 바뀌는 주소에 쓴다.useSearchParams()를 쓰면<Suspense>로 감싸야 빌드가 통과한다.redirect()는 예외를 던진다.try블록 안에서 부르면catch가 삼킨다.- 영구 이전은
permanentRedirect()다. 307과 308의 차이를 검색엔진이 본다. - 프리페치는 자동이고, 개발 모드에서는 꺼져 있다.
다음 강의에서는 주소를 만들지 않는 폴더와, 한 화면에 페이지 둘을 동시에 띄우는 법을 본다. 폴더 이름에 괄호를 치면 규칙이 달라진다.