이전 Chapter에서는 Partial Prerendering을 통해 Next.js에서 경로의 정적 부분을 미리 렌더링하고, 동적 부분은 사용자가 요청할 때까지 지연시키는 방법에 대해 알아보았습니다.
이번 Chapter에서는 URL 검색 매개변수를 사용하여 검색 및 페이징을 구현하는 방법에 대해 알아보겠습니다.
본격적으로 시작하기 앞서, /app/dashboard/invoices/page.tsx
경로에 파일을 추가하고 아래 코드를 붙여넣어주세요.
<Search/>
: 사용자가 특정 송장을 검색할 수 있도록 하는 컴포넌트<Pagination/>
: 사용자가 송장의 페이지 간을 탐색할 수 있게 하는 컴포넌트<Table/>
: 송장을 표시하는 컴포넌트import Pagination from "@/app/ui/invoices/pagination";
import Search from "@/app/ui/search";
import Table from "@/app/ui/invoices/table";
import { CreateInvoice } from "@/app/ui/invoices/buttons";
import { InvoicesTableSkeleton } from "@/app/ui/skeletons";
import { Suspense } from "react";
import { lusitana } from "../ui/font";
export default async function Page() {
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
{/* <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense> */}
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
검색 기능은 클라이언트와 서버 두 곳에 걸쳐 작동합니다.
클라이언트 측 상태로 검색 상태를 관리하는 것에 익숙하다면, 이 패턴이 새로울 수 있습니다. URL 매개변수를 통해 검색 상태를 관리하는 방법은 다음과 같은 이점이 있습니다:
검색 매개변수가 URL에 포함되어 있기 때문에 사용자는 검색 쿼리와 필터를 포함한 현재 애플리케이션 상태를 북마크하거나 공유할 수 있습니다.
URL 매개변수는 서버에서 초기 상태를 렌더링하는 데 직접 사용될 수 있어 SSR(서버 사이드 렌더링)을 처리하기가 더 쉬워집니다.
검색 쿼리와 필터가 URL에 직접 포함되어 있기 때문에, 추가 클라이언트 측 로직 구현 없이 사용자 행동을 추적하기가 더 쉽습니다.
검색 기능을 구현하기 위해 사용할 Next.js 클라이언트 hook들을 알아보겠습니다.
useSearchParams
: 현재 URL의 매개변수에 접근/dashboard/invoices?page=1&query=pending
의 검색 매개변수는 {page: '1', query: 'pending'}
usePathname
: 현재 URL의 경로명을 읽기/dashboard/invoices
의 경우 '/dashboard/invoices'
를 반환useRouter
: 클라이언트 컴포넌트 내에서 프로그래밍 방식으로 경로 간 이동 가능먼저 <Search>
컴포넌트의 내용을 확인해보겠습니다.
참고:
"use client"
지시어는 클라이언트 컴포넌트(RSC)에서 사용되며, 이벤트 리스너 및 hook을 사용하기 위해 필요합니다.
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
export default function Search({ placeholder }: { placeholder: string }) {
function handleSearch(term: string) {
console.log(term);
}
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
위 코드에서는 <input>
태그에서 사용자 입력이 발생할 때마다 handleSearch()
메소드를 호출합니다.
이제 useSearchParams
hook을 import하여 URL을 업데이트해보겠습니다.
"use client";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}
return (
// ... 이전과 동일한 JSX
);
}
매개변수가 없을 때
매개변수가 있을 때 (URLSearchParams의 size가 늘어남)
URLSearchParams
: URL 쿼리 매개변수를 조작하기 위한 유틸리티 메서드를 제공하는 Web APIpathname
: 현재 URL 경로명 (예: "/dashboard/invoices"
)replace()
: 사용자의 검색 데이터를 포함하여 URL을 업데이트/dashboard/invoices?query=lee
가 됨Next.js의 클라이언트 측 탐색 기능으로 페이지를 새로고침하지 않고도 URL을 업데이트할 수 있습니다. 이는 SPA(Single Page Application) 방식으로 동작하며, 더 빠르고 부드러운 사용자 경험을 제공합니다.
입력 필드가 URL과 동기화되고 공유할 때 입력 필드가 자동으로 채워지도록 하기 위해 searchParams
에서 값을 읽어와 defaultValue
로 전달합니다.
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get("query")?.toString()}
/>
defaultValue
: 입력값을 state로 관리하지 않는 경우, 네이티브 입력이 자체적으로 값을 관리value
: 입력값을 state로 관리하는 경우, React가 input 태그의 입력 상태를 관리 (제어 컴포넌트)마지막으로 검색 쿼리를 반영하여 테이블 컴포넌트를 업데이트해야 합니다.
기본 페이지 컴포넌트는 searchParams
라는 props
를 받아올 수 있습니다. Next.js는 URL의 search params
를 props
로 자동으로 전달해주며, 이를 전달받는 컴포넌트는 디렉터리 경로별 기본 페이지 컴포넌트입니다.
import Pagination from "@/app/ui/invoices/pagination";
import Search from "@/app/ui/search";
import Table from "@/app/ui/invoices/table";
import { CreateInvoice } from "@/app/ui/invoices/buttons";
import { InvoicesTableSkeleton } from "@/app/ui/skeletons";
import { Suspense } from "react";
import { lusitana } from "../../ui/font";
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) {
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
{/* <Pagination totalPages={totalPages} /> */}
</div>
</div>
);
}
<Table>
컴포넌트에서는 query
와 currentPage
의 props
를 받아 fetchFilteredInvoices()
함수를 호출하여 해당 쿼리에 맞는 invoices 데이터를 반환받습니다.
// /app/ui/invoices/table.tsx
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
검색 파라미터를 추출하는 방법으로 2가지 방법을 사용했습니다. 어떤 방법을 사용할지는 클라이언트/서버 중 어디에서 작업을 수행하는지에 따라 다릅니다.
useSearchParams
hook 사용searchParams
props 사용현재 코드에서는 사용자가 값을 입력할 때마다 handleSearch()
메소드가 실행되어 URL이 업데이트됩니다. 이는 매 입력마다 데이터베이스 쿼리를 날리게 되어 성능상 문제가 될 수 있습니다.
디바운싱(Debouncing)은 함수가 실행되는 빈도를 제한하는 프로그래밍 방법입니다. 사용자가 입력을 멈췄을 때만 데이터베이스에 쿼리를 날리는 것이 목표입니다.
디바운싱 미사용: 입력할 때마다 입력값 감지하여 검색 API 호출
디바운싱 사용: 입력 멈출 때 입력값 완전감지하여 검색 API 호출
use-debounce
라이브러리를 사용하여 디바운싱을 구현해보겠습니다.
pnpm i use-debounce
"use client";
import { useDebouncedCallback } from "use-debounce";
import { MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const handleSearch = useDebouncedCallback((term: string) => {
const params = new URLSearchParams(searchParams);
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 300);
return (
<div className="relative flex flex-1 flex-shrink-0">
<label htmlFor="search" className="sr-only">
Search
</label>
<input
className="peer block w-full rounded-md border border-gray-200 py-[9px] pl-10 text-sm outline-2 placeholder:text-gray-500"
placeholder={placeholder}
onChange={(e) => {
handleSearch(e.target.value);
}}
defaultValue={searchParams.get("query")?.toString()}
/>
<MagnifyingGlassIcon className="absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
);
}
이제 사용자가 입력을 멈춘 후 300ms 동안 재입력이 발생하지 않아야 콜백함수가 실행됩니다.
검색 기능을 도입한 후, 테이블에는 한 번에 6개의 송장만 표시됩니다. 이는 fetchFilteredInvoices()
함수가 페이지당 최대 6개의 송장을 반환하기 때문입니다.
페이징 기능을 추가하면 사용자가 모든 송장to 보기 위해 다른 페이지로 이동할 수 있게 됩니다.
<Pagination/>
컴포넌트는 클라이언트 컴포넌트입니다. 클라이언트에서 데이터를 가져오게 되면 데이터베이스 비밀이 노출될 수 있으므로, 서버에서 데이터를 가져와 컴포넌트에 props
로 전달하는 것이 좋습니다.
먼저 페이지 컴포넌트를 수정하여 전체 페이지 수를 계산하고 Pagination 컴포넌트에 전달합니다.
// /app/dashboard/invoices/page.tsx
import Pagination from "@/app/ui/invoices/pagination";
import Search from "@/app/ui/search";
import Table from "@/app/ui/invoices/table";
import { CreateInvoice } from "@/app/ui/invoices/buttons";
import { InvoicesTableSkeleton } from "@/app/ui/skeletons";
import { Suspense } from "react";
import { lusitana } from "../../ui/font";
import { fetchInvoicesPages } from "@/app/lib/data";
export default async function Page({
searchParams,
}: {
searchParams?: {
query?: string;
page?: string;
};
}) {
const query = searchParams?.query || "";
const currentPage = Number(searchParams?.page) || 1;
const totalPages = await fetchInvoicesPages(query);
return (
<div className="w-full">
<div className="flex w-full items-center justify-between">
<h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
</div>
<div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
<Search placeholder="Search invoices..." />
<CreateInvoice />
</div>
<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
<Table query={query} currentPage={currentPage} />
</Suspense>
<div className="mt-5 flex w-full justify-center">
<Pagination totalPages={totalPages} />
</div>
</div>
);
}
// /app/ui/invoices/pagination.tsx
'use client';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Link from 'next/link';
import { generatePagination } from '@/app/lib/utils';
import { usePathname, useSearchParams } from 'next/navigation';
export default function Pagination({ totalPages }: { totalPages: number }) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set('page', pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
// ... 나머지 컴포넌트 로직
}
사용자가 새 검색 쿼리를 입력할 때 페이지 번호를 1로 리셋해야 합니다.
// /app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';
export default function Search({ placeholder }: { placeholder: string }) {
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();
const handleSearch = useDebouncedCallback((term) => {
const params = new URLSearchParams(searchParams);
params.set('page', '1'); // 검색 시 페이지를 1로 리셋
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
// ... 나머지 컴포넌트
}
이번 챕터에서는 Next.js에서 URL 검색 매개변수를 활용하여 다음 기능들을 구현했습니다:
이러한 패턴들은 사용자 경험을 향상시키고, SEO에 유리하며, 서버 사이드 렌더링을 효과적으로 활용할 수 있게 해줍니다.