이전 장에서는 스트리밍을 통해 대시보드의 초기 로드 성능을 개선했다. 이제 /invoices 페이지로 이동하여 검색 및 페이지네이션을 추가하는 방법을 알아보자!
이번 장에서는 아래와 같은 주제를 다룬다.
- Next.js API(searchParams, usePathname 및 useRouter)를 사용하는 방법을 알아보세요.
- URL 검색 매개변수를 사용하여 검색 및 페이지네이을 구현합니다.
코딩 시작
/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 { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
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>
);
}
작업할 페이지와 컴포넌트에 익숙해지는 데는 시간이 필요한 내용이다, 아래와 같은 내용의 작업을 할 예정이다.
- <Search/>를 사용하면 사용자가 특정 송장을 검색할 수 있다.
- <Pagination/>을 사용하면 사용자가 송장 페이지 사이를 이동할 수 있다.
- <Table/>은 송장을 표시한다.
검색 기능은 클라이언트와 서버에 걸쳐 있다. 사용자가 클라이언트에서 송장을 검색하면 URL 매개변수가 업데이트되고 서버에서 데이터를 가져오며 테이블은 새 데이터로 서버에서 다시 렌더링된다.
검색에 URL 매개변수를 사용하는 이유는?
위에서 언급한 대로 URL 검색 매개변수를 사용하여 검색 상태를 관리하게 된다. 클라이언트측 상태를 사용하여 수행하는 데 익숙하다면 이 패턴이 새로운 것일 수 있다.
URL 매개변수를 사용하여 검색을 구현하면 다음과 같은 몇 가지 이점이 있다.
- 북마크 가능 및 공유 가능 URL : 검색 매개변수가 URL에 있으므로 사용자는 향후 참조 또는 공유를 위해 검색 쿼리 및 필터를 포함하여 애플리케이션의 현재 상태를 북마크할 수 있다.
- 서버 측 렌더링 및 초기 로드 : URL 매개변수를 서버에서 직접 사용하여 초기 상태를 렌더링할 수 있으므로 서버 렌더링을 더 쉽게 처리할 수 있다.
- 분석 및 추적 : URL에 직접 검색어와 필터가 있으면 추가 클라이언트 측 논리 없이도 사용자 행동을 더 쉽게 추적할 수 있다.
검색 기능 추가
검색 기능을 구현하는 데 사용할 Next.js 클라이언트 후크는 다음과 같다.
- useSearchParams - 현재 URL의 매개변수에 액세스할 수 있다. 예를 들어, 이 URL /dashboard/invoices?page=1&query=pending에 대한 검색 매개변수는 {page: '1', query: 'pending'}과 같다.
- usePathname - 현재 URL의 경로 이름을 읽을 수 있다. 예를 들어 /dashboard/invoices 경로의 경우 usePathname은 '/dashboard/invoices'를 반환합니다.
- useRouter - 프로그래밍 방식으로 클라이언트 컴포넌트 내의 경로 간 탐색을 활성화한다. 사용할 수 있는 방법은 여러 가지가 있다.
구현 단계에 대한 간략한 개요는 다음과 같다.
- 사용자의 입력을 캡처한다.
- 검색 매개변수로 URL을 업데이트한다.
- URL을 입력 필드와 동기화 상태로 유지한다.
- 검색어를 반영하도록 테이블을 업데이트한다.
1. 사용자 입력 캡처
<Search> 컴포넌(/app/ui/search.tsx)로 이동하면 다음을 확인할 수 있다.
- "use client" - 이는 클라이언트 컴포넌트로, 이벤트 리스너와 후크를 사용할 수 있음을 의미한다.
- <input> - 검색어 입력창이다.
새로운 handleSearch 함수를 생성하고 <input> 엘리먼트에 onChange 리스너를 추가하자. onChange는 입력 값이 변경될 때마다 handlerSearch를 호출한다.
/app/ui/search.tsx
'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>
);
}
개발자 도구에서 콘솔을 열어 올바르게 작동하는지 테스트한 다음 검색 필드에 입력해보자. 콘솔에 기록된 검색어가 표시되어야 한다.
사용자의 검색 입력을 캡처하고 있음을 알 수 있다. 이제 검색어로 URL을 업데이트해야 한다.
2. 검색 매개변수로 URL을 업데이트
'next/navigation'에서 useSearchParams 후크를 가져와 변수에 할당한다.
/app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
console.log(term);
}
// ...
}
handleSearch 내에서 새 searchParams 변수를 사용하여 새 URLSearchParams 인스턴스를 만든다.
/app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
}
// ...
}
URLSearchParams는 URL 쿼리 매개변수를 조작하기 위한 유틸리티 메서드를 제공하는 웹 API 이다. 복잡한 문자열 리터럴을 생성하는 대신 이를 사용하여 ?page=1&query=a와 같은 매개변수 문자열을 가져올 수 있다.
다음으로 사용자 입력에 따라 params 문자열을 설정하자. 입력이 비어 있으면 삭제할 것이다.
/app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams } from 'next/navigation';
export default function Search() {
const searchParams = useSearchParams();
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
}
// ...
}
이제 쿼리 문자열이 생겼다. Next.js의 useRouter 및 usePathname 후크를 사용하여 URL을 업데이트할 수 있다.
'next/navigation'에서 useRouter 및 usePathname을 가져오고, handlerSearch 내에서 useRouter()의 replace 메소드를 사용한다.
/app/ui/search.tsx
'use client';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useSearchParams, usePathname, useRouter } from 'next/navigation';
export default function Search() {
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()}`);
}
}
실행결과 및 위 소스를 분석해 보면 다음과 같다.
- ${pathname}은 현재 경로이며, 여러분의 경우 "/dashboard/invoices"이다.
- 사용자가 검색창에 입력하면 params.toString()은 이 입력을 URL 친화적인 형식으로 변환한다.
- replace(${pathname}?${params.toString()})은 사용자의 검색 데이터로 URL을 업데이트한다. 예를 들어, 사용자가 "Lee"를 검색하는 경우 /dashboard/invoices?query=lee 이다.
- Next.js의 클라이언트 측 탐색 덕분에 페이지를 다시 로드하지 않고도 URL이 업데이트된다(페이지 간 탐색 장에서 배웠다).
3. URL과 입력의 동기화 유지
입력 필드가 URL과 동기화되고 공유 시 채워지도록 하려면 searchParams에서 읽어서 defaultValue를 입력에 전달할 수 있다.
/app/ui/search.tsx
<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 vs. value / Controlled vs. Uncontrolled
상태를 사용하여 입력 값을 관리하는 경우 value 속성을 사용하여 제어되는 컴포넌트로 만든다. 이는 React가 입력 상태를 관리한다는 의미한다.
그러나 상태를 사용하지 않으므로 defaultValue를 사용할 수 있다. 이는 기본 입력이 자체 상태를 관리한다는 의미다. 상태 대신 URL에 검색어를 저장하므로 괜찮다.
4. 테이블 업데이트
마지막으로 검색 쿼리를 반영하도록 테이블 컴포넌트를 업데이트해야 한다.
송장 페이지로 다시 이동하자.
페이지 컴포넌는 searchParams라는 속성을 허용하므로 현재 URL 매개변수를 <Table> 컴포넌트에 전달할 수 있다.
/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 { lusitana } from '@/app/ui/fonts';
import { Suspense } from 'react';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
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라는 두 prop이 쿼리와 일치하는 송장을 반환하는 fetchFilteredInvoices() 함수에 전달되는 것을 볼 수 있다.
/app/ui/invoices/table.tsx
// ...
export default async function InvoicesTable({
query,
currentPage,
}: {
query: string;
currentPage: number;
}) {
const invoices = await fetchFilteredInvoices(query, currentPage);
// ...
}
이러한 변경 사항이 적용되면 계속해서 테스트해 보자. 용어를 검색하면 URL을 업데이트하여 서버에 새 요청을 보내고 서버에서 데이터를 가져오고 검색어와 일치하는 송장만 반환됨을 알 수 있다.
useSearchParams() 후크와 searchParams prop 을 언제 사용할까?
검색 매개변수를 추출하기 위해 두 가지 다른 방법을 사용했다는 것을 눈치챘을 것이다. 둘 중 하나를 사용하는지 여부는 클라이언트에서 작업하는지 서버에서 작업하는지에 따라 다르다.
- <Search>는 클라이언트 컴포넌트이므로 useSearchParams() 후크를 사용하여 클라이언트에서 매개변수에 액세스했다.
- <Table>은 자체 데이터를 가져오는 서버 컴포넌트이므로 페이지 컴포넌트에서 <Table>컴포넌트로 searchParams prop을 전달할 수 있다.
일반적으로 클라이언트에서 매개변수를 읽으려면 서버로 돌아갈 필요가 없도록 useSearchParams() 후크를 사용하면된다.
Best Practice : Debouncing
Next.js로 검색을 구현했다! 하지만 이를 최적화하기 위해 해야할 일이 남아있다.
handleSearch 함수 내에 다음 console.log를 추가하자.
/app/ui/search.tsx
function handleSearch(term: string) {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}
그런 다음 검색 창에 "Emil"을 입력하고 개발 도구에서 콘솔을 확인하자.
Dev Tools Console
Searching... E
Searching... Em
Searching... Emi
Searching... Emil
키를 누를 때마다 URL을 업데이트하므로 키를 누를 때마다 데이터베이스를 쿼리하게 된다! 우리 애플리케이션이 작기 때문에 이것은 문제가 되지 않지만, 애플리케이션에 수천 명의 사용자가 있고 각 사용자가 키를 누를 때마다 데이터베이스에 새로운 요청을 보내는 경우를 상상해 보자.
디바운싱은 함수가 실행될 수 있는 속도를 제한하는 프로그래밍 방식이다. 우리의 경우에는 사용자가 입력을 중단한 경우에만 데이터베이스를 쿼리하려고 한다.
디바운싱 작동 방식:
- 트리거 이벤트: 디바운싱되어야 하는 이벤트(예: 검색 상자의 키 입력)가 발생하면 타이머가 시작된다.
- 대기: 타이머가 만료되기 전에 새로운 이벤트가 발생하면 타이머가 재설정된다.
- 실행: 타이머가 카운트다운 끝에 도달하면 디바운스된 함수가 실행된다.
디바운싱 기능을 수동으로 생성하는 등 몇 가지 방법으로 디바운싱을 구현할 수 있다. 일을 단순하게 유지하기 위해 use-debounce라는 라이브러리를 사용하겠다.
Terminal
npm i use-debounce
<Search> 컴포넌트에서 useDebouncedCallback이라는 함수를 가져온다.
/app/ui/search.tsx
// ...
import { useDebouncedCallback } from 'use-debounce';
// Inside the Search Component...
const handleSearch = useDebouncedCallback((term) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
이 함수는 handlerSearch의 내용을 래핑하고 사용자가 입력을 중지한 후 특정 시간(300ms) 후에만 코드를 실행한다.
이제 검색창에 다시 입력하고 개발 도구에서 콘솔을 연다. 아래처럼 표시되어야 한다.
Dev Tools Console
Searching... Emil
디바운싱을 통해 데이터베이스로 전송되는 요청 수를 줄여 리소스를 절약할 수 있다.
페이지네이션 추가
검색 기능을 도입한 후에는 테이블에 한 번에 6개의 송장만 표시되는 것을 볼 수 있다. 이는 data.ts의 fetchFilteredInvoices() 함수가 페이지당 최대 6개의 송장을 반환하기 때문이다.
페이지네이션을 추가하면 사용자가 여러 페이지를 탐색하여 모든 송장을 볼 수 있다. 검색에서와 마찬가지로 URL 매개변수를 사용하여 페이지네이션을 구현하는 방법을 살펴보겠다.
<Pagination/> 컴포넌트로 이동하면 이것이 클라이언트 컴포넌트라는 것을 알 수 있다. 데이터베이스 비밀이 노출될 수 있으므로 클라이언트에서 데이터를 가져오고 싶지 않다(API 계층을 사용하지 않는다는 점을 기억하자). 대신 서버에서 데이터를 가져와서 컴포넌트에 prop으로 전달할 수 있게 구성할 것이다.
/dashboard/invoices/page.tsx에서 fetchInvoicesPages라는 새 함수를 가져오고 searchParams의 쿼리를 인수로 전달한다.
/app/dashboard/invoices/page.tsx
// ...
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 (
// ...
);
}
fetchInvoicesPages는 검색 쿼리를 기반으로 총 페이지 수를 반환한다. 예를 들어, 검색어와 일치하는 청구서가 12개 있고 각 페이지에 청구서 6개가 표시되는 경우 총 페이지 수는 2가 된다.
다음으로 totalPages prop을 <Pagination/> 컴포넌트에 전달한다.
/app/dashbord/invoices/page.tsx
// ...
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>
);
}
<Pagination/> 컴포넌트로 이동하여 usePathname 및 useSearchParams 후크를 가져온다. 이를 사용하여 현재 페이지를 가져오고 새 페이지를 설정하고, 이 컴포넌트의 코드 주석 처리도 제거해야 한다. <Pagination/> 로직을 아직 구현하지 않았으므로 애플리케이션이 일시적으로 중단된다. 지금 해보자!
/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;
// ...
}
다음으로 <Pagination> 컴포넌트 내에 createPageURL이라는 새 함수를 만들자. 검색과 마찬가지로 URLSearchParams를 사용하여 새 페이지 번호를 설정하고 pathName을 사용하여 URL 문자열을 만든다.
/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()}`;
};
// ...
}
무슨일이 일어나고 있는지 분석해 보면 다음과 같다.
- createPageURL은 현재 검색 매개변수의 인스턴스를 생성한다.
- 그런 다음 "페이지" 매개변수를 제공된 페이지 번호로 업데이트한다.
- 마지막으로 경로 이름과 업데이트된 검색 매개변수를 사용하여 전체 URL을 구성한다.
<Pagination> 컴포넌트의 나머지 부분은 스타일 지정 및 다양한 상태(첫 번째, 마지막, 활성, 비활성화 등)를 처리한다. 이 과정에서는 자세히 다루지 않지만, createPageURL이 호출되는 위치를 확인하려면 코드를 살펴보자.
마지막으로 사용자가 새 검색어를 입력하면 페이지 번호를 1로 재설정하려고 한다. <Search> 컴포넌트에서 handlerSearch 함수를 업데이트하면 될 것이다.
/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');
if (term) {
params.set('query', term);
} else {
params.delete('query');
}
replace(`${pathname}?${params.toString()}`);
}, 300);
요약
URL 매개변수 및 Next.js API를 사용하여 검색 및 페이지네이션을 구현했다.
요약하면 이 장의 내용은 다음과 같다.
클라이언트 상태 대신 URL 검색 매개변수를 사용하여 검색 및 페이지 매기기를 처리했다.
- 서버에서 데이터를 가져왔다.
- 보다 원활한 클라이언트 측 전환을 위해 useRouter 후크를 사용하고 있다.
이러한 패턴은 클라이언트 측 React로 작업할 때 익숙했던 패턴과 다르지만 이제 URL 검색 매개변수를 사용하고 이 상태를 서버로 가져오는 것의 이점을 더 잘 이해할 수 있을 것이다.
'Next.js 개발 가이드 > 06. Learn Next.js 공식 가이드' 카테고리의 다른 글
13. 에러 처리 (0) | 2023.12.24 |
---|---|
12. 데이터 변경 (0) | 2023.12.24 |
10. 부분 사전 렌더링(Partial Prerendering - Optional) (0) | 2023.12.23 |
09. 스트리밍 (0) | 2023.12.23 |
08. 정적 렌더링, 동적 렌더링 (0) | 2023.12.23 |