URL도 하나의 상태 저장소(state store)입니다.
웹을 사용하다 보면 이런 URL을 자주 보게 됩니다.
/blog?q=react
/blog?category=nextjs
/blog?page=2
이 URL에는 단순한 주소 이상의 정보가 들어 있습니다. 검색어, 카테고리, 페이지 번호처럼 현재 화면의 상태가 담겨 있습니다.
예를 들어 /blog?q=react로 접속하면 "react"를 검색한 상태의 글 목록이 바로 표시됩니다. URL을 그대로 복사해 다른 사람에게 보내면, 상대방도 같은 화면을 보게 됩니다.
이처럼 URL은 단순한 페이지 위치가 아니라 애플리케이션의 상태를 표현하는 공간이기도 합니다.
하지만 Ch.2에서는 검색어와 카테고리 상태를 useState로 관리했습니다. 이 방식에는 몇 가지 한계가 있습니다.
useState | URL 쿼리 스트링 | |
|---|---|---|
| 새로고침 | 상태 유실 | URL이 곧 상태이므로 유지됨 |
| 공유 | 현재 상태를 링크로 전달 불가 | URL을 그대로 공유하면 동일한 화면 |
| 뒤로 가기 | 상태 변경을 추적하지 못함 | 브라우저 히스토리에 자동 기록됨 |
useSearchParams()는 URL의 쿼리 스트링을 읽을 수 있는 Hook입니다.
'use client';
import { useSearchParams } from 'next/navigation';
export default function Example() {
const searchParams = useSearchParams();
// URL: /blog?category=react&q=서버
searchParams.get('category'); // "react"
searchParams.get('q'); // "서버"
searchParams.get('page'); // null (없는 파라미터)
return <p>카테고리: {searchParams.get('category')}</p>;
}
반환값은 읽기 전용 URLSearchParams 객체입니다. get(), has(), toString() 등의 메서드를 사용할 수 있습니다.
useSearchParams()를 사용하면, 해당 클라이언트 컴포넌트를 <Suspense>로 감싸야 합니다. 그렇지 않으면 프로덕션 빌드 시 오류가 발생합니다. 반대로 페이지가 searchParams prop 등으로 이미 동적으로 렌더링되는 경우에는 Suspense가 필요하지 않습니다.
일반적인 페이지 이동에는 <Link>를 사용합니다. 폼 제출 후 이동하거나 조건에 따라 페이지를 이동해야 할 때처럼 코드 로직으로 이동이 필요할 경우 useRouter()를 사용합니다.
'use client';
import { useRouter } from 'next/navigation';
export default function Example() {
const router = useRouter();
const handleSubmit = () => {
// 폼 제출 후 목록으로 이동
router.push('/blog');
};
const handleFilter = (category: string) => {
// URL 쿼리 변경 (히스토리에 추가하지 않음)
router.replace(`/blog?category=${category}`);
}
return (
<div>
<button onClick={handleSubmit}>제출 후 이동</button>
<button onClick={() => router.back()}>뒤로 가기</button>
</div>
);
}
| 메서드 | 동작 | 뒤로 가기 | 사용 예시 |
|---|---|---|---|
router.push(url) | 새 항목을 히스토리에 추가 | 이전 페이지로 돌아감 | 페이지 이동 |
router.replace(url) | 현재 항목을 교체 | 이전 페이지를 건너뜀 | 필터/정렬 변경 |
router.back() | 히스토리에서 뒤로 | - | 뒤로 가기 버튼 |
push, 필터를 바꿀 때처럼 매번 히스토리에 쌓이면 오히려 불편한 경우 replace를 사용합니다.
Ch.2에서 만든 SearchablePostList의 검색 상태를 URL로 옮기면 어떻게 될까요?
// 검색어가 컴포넌트 내부 상태에만 존재
const [query, setQuery] = useState('');
// 문제: 새로고침하면 검색어가 사라짐
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import Link from 'next/link';
type Post = {
id: number;
slug: string;
title: string;
};
export function SearchablePostList({ posts }: { posts: Post[] }) {
const searchParams = useSearchParams();
const router = useRouter();
const query = searchParams.get('q') ?? '';
const filtered = posts.filter((post) =>
post.title.toLowerCase().includes(query.toLowerCase()),
);
const handleSearch = (value: string) => {
const params = new URLSearchParams(searchParams.toString());
if (value) {
params.set('q', value);
} else {
params.delete('q');
}
router.replace(`/blog?${params.toString()}`);
};
return (
<>
<input
type='text'
defaultValue={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder='글 검색...'
/>
<ul>
{filtered.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</>
);
}
이제 /blog?q=서버로 접속하면 "서버"가 검색된 상태가 바로 표시됩니다. URL을 공유하면 상대방도 같은 검색 결과를 볼 수 있습니다.
존재하지 않는 글에 접속하면 어떻게 될까요? 현재는 에러가 발생합니다. notFound() 함수로 이를 우아하게 처리할 수 있습니다:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function Post({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const res = await fetch(`http://localhost:4000/posts?slug=${slug}`);
const posts = await res.json();
if (posts.length === 0) {
notFound(); // 글이 없으면 404 페이지 표시
}
const post = posts[0];
return (
<div>
<h1>{post.title}</h1>
<p>이 글의 내용이 여기에 표시됩니다.</p>
</div>
);
}
notFound()는 렌더링 흐름 안에서 현재 route segment의 렌더링을 중단하고, 가장 가까운 not-found.tsx 파일을 렌더링합니다:
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div>
<h2>글을 찾을 수 없습니다</h2>
<p>요청한 글이 존재하지 않습니다.</p>
<Link href='/blog'>글 목록으로 돌아가기</Link>
</div>
);
}
useSearchParams는 클라이언트 컴포넌트 전용입니다. 서버 컴포넌트에서 URL 쿼리 스트링을 읽으려면 searchParams prop을 사용합니다:
// app/blog/page.tsx (서버 컴포넌트)
export default async function Blog({
searchParams,
}: {
searchParams: Promise<{ category?: string }>;
}) {
const { category } = await searchParams;
// category 값으로 서버에서 바로 필터링된 데이터 조회
const url = category
? `http://localhost:4000/posts?category=${category}`
: 'http://localhost:4000/posts';
const res = await fetch(url);
const posts = await res.json();
return <div>{/* ... */}</div>;
}
searchParams prop으로 읽어 서버에서 바로 데이터를 필터링할 수 있고, 클라이언트 컴포넌트에서는 useSearchParams() Hook으로 읽어 UI 상태에 반영합니다. 실습에서 이 두 가지를 함께 사용합니다.
| 도구 | 용도 | 환경 |
|---|---|---|
Link | 클릭으로 페이지 이동 | 서버 / 클라이언트 |
usePathname() | 현재 URL 경로 읽기 | 클라이언트 |
useSearchParams() | URL 쿼리 스트링 읽기 | 클라이언트 |
useRouter() | 코드로 페이지 이동 (push, replace, back) | 클라이언트 |
searchParams prop | 페이지에서 URL 쿼리 읽기 | 페이지 |
notFound() | 404 페이지 표시 | 렌더링 중 route segment |
redirect() | 렌더링 중 리다이렉트 | 서버 / 클라이언트(렌더링 중) |
<Link>를 사용합니다. Prefetching, 접근성, SEO 등의 이점이 있기 때문입니다. useRouter는 폼 제출 후 리다이렉트, 조건부 이동처럼 사용자 클릭이 아닌 코드 로직에 의한 이동이 필요할 때만 사용합니다.
redirect()는 서버 컴포넌트, Server Action, 그리고 클라이언트 컴포넌트의 렌더링 중에 사용할 수 있습니다. 반면 클릭 핸들러처럼 이벤트에서 이동해야 할 때는 router.push()나 router.replace()를 사용합니다. Ch.3 실습의 createPost action에서 redirect('/blog')를 사용한 것이 서버 리다이렉트의 예시입니다.