Next.js에서 URL 검색 매개변수를 활용한 검색 및 페이징 구현

이전 Chapter에서는 Partial Prerendering을 통해 Next.js에서 경로의 정적 부분을 미리 렌더링하고, 동적 부분은 사용자가 요청할 때까지 지연시키는 방법에 대해 알아보았습니다.

이번 Chapter에서는 URL 검색 매개변수를 사용하여 검색 및 페이징을 구현하는 방법에 대해 알아보겠습니다.

📋 목차

  1. 시작 코드 설정
  2. 검색 기능 추가
  3. 디바운싱으로 성능 최적화
  4. 페이징 기능 구현

1. 시작 코드 설정

본격적으로 시작하기 앞서, /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>
  );
}

검색 기능의 작동 원리

검색 기능은 클라이언트와 서버 두 곳에 걸쳐 작동합니다.

  1. 클라이언트: 사용자가 검색하면 URL 매개변수가 업데이트
  2. 서버: 업데이트된 매개변수를 통해 데이터를 불러옴
  3. 렌더링: 테이블 컴포넌트가 새로운 데이터를 기반으로 서버에서 다시 렌더링

개발 단계 개요

  1. 사용자의 입력을 감지
  2. 검색 매개변수로 URL을 업데이트
  3. 입력 필드와 URL을 동기화
  4. 검색 쿼리에 따라 테이블을 업데이트

💡 왜 URL 검색 매개변수를 사용할까?

클라이언트 측 상태로 검색 상태를 관리하는 것에 익숙하다면, 이 패턴이 새로울 수 있습니다. URL 매개변수를 통해 검색 상태를 관리하는 방법은 다음과 같은 이점이 있습니다:

1. 북마크 가능 및 공유 가능한 URL

검색 매개변수가 URL에 포함되어 있기 때문에 사용자는 검색 쿼리와 필터를 포함한 현재 애플리케이션 상태를 북마크하거나 공유할 수 있습니다.

2. 서버 사이드 렌더링 및 초기 로드

URL 매개변수는 서버에서 초기 상태를 렌더링하는 데 직접 사용될 수 있어 SSR(서버 사이드 렌더링)을 처리하기가 더 쉬워집니다.

3. 분석 및 추적

검색 쿼리와 필터가 URL에 직접 포함되어 있기 때문에, 추가 클라이언트 측 로직 구현 없이 사용자 행동을 추적하기가 더 쉽습니다.


2. 검색 기능 추가

검색 기능을 구현하기 위해 사용할 Next.js 클라이언트 hook들을 알아보겠습니다.

주요 Hook 소개

  • useSearchParams: 현재 URL의 매개변수에 접근
    • 예: URL /dashboard/invoices?page=1&query=pending의 검색 매개변수는 {page: '1', query: 'pending'}
  • usePathname: 현재 URL의 경로명을 읽기
    • 예: 경로 /dashboard/invoices의 경우 '/dashboard/invoices'를 반환
  • useRouter: 클라이언트 컴포넌트 내에서 프로그래밍 방식으로 경로 간 이동 가능

2-1. 사용자 입력 감지

먼저 <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() 메소드를 호출합니다.

2-2. 검색 매개변수로 URL 업데이트

이제 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 API
  • pathname: 현재 URL 경로명 (예: "/dashboard/invoices")
  • replace(): 사용자의 검색 데이터를 포함하여 URL을 업데이트
    • 예: 사용자가 "Lee"를 검색하면 /dashboard/invoices?query=lee가 됨

🚀 클라이언트 측 탐색의 장점

Next.js의 클라이언트 측 탐색 기능으로 페이지를 새로고침하지 않고도 URL을 업데이트할 수 있습니다. 이는 SPA(Single Page Application) 방식으로 동작하며, 더 빠르고 부드러운 사용자 경험을 제공합니다.

2-3. URL과 입력 상태 동기화

입력 필드가 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 vs. value

  • defaultValue: 입력값을 state로 관리하지 않는 경우, 네이티브 입력이 자체적으로 값을 관리
  • value: 입력값을 state로 관리하는 경우, React가 input 태그의 입력 상태를 관리 (제어 컴포넌트)

2-4. 테이블 업데이트

마지막으로 검색 쿼리를 반영하여 테이블 컴포넌트를 업데이트해야 합니다.

기본 페이지 컴포넌트searchParams라는 props를 받아올 수 있습니다. Next.js는 URL의 search paramsprops로 자동으로 전달해주며, 이를 전달받는 컴포넌트는 디렉터리 경로별 기본 페이지 컴포넌트입니다.

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 컴포넌트에서 데이터 처리

<Table> 컴포넌트에서는 querycurrentPageprops를 받아 fetchFilteredInvoices() 함수를 호출하여 해당 쿼리에 맞는 invoices 데이터를 반환받습니다.

// /app/ui/invoices/table.tsx
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

📌 useSearchParams hook vs searchParams props 사용 시기

검색 파라미터를 추출하는 방법으로 2가지 방법을 사용했습니다. 어떤 방법을 사용할지는 클라이언트/서버 중 어디에서 작업을 수행하는지에 따라 다릅니다.

  • 클라이언트 컴포넌트: useSearchParams hook 사용
  • 서버 컴포넌트: searchParams props 사용

3. 디바운싱으로 성능 최적화

현재 코드에서는 사용자가 값을 입력할 때마다 handleSearch() 메소드가 실행되어 URL이 업데이트됩니다. 이는 매 입력마다 데이터베이스 쿼리를 날리게 되어 성능상 문제가 될 수 있습니다.

디바운싱이란?

디바운싱(Debouncing)은 함수가 실행되는 빈도를 제한하는 프로그래밍 방법입니다. 사용자가 입력을 멈췄을 때만 데이터베이스에 쿼리를 날리는 것이 목표입니다.

디바운싱 미사용: 입력할 때마다 입력값 감지하여 검색 API 호출

디바운싱 사용: 입력 멈출 때 입력값 완전감지하여 검색 API 호출

🔄 디바운싱 vs 쓰로틀링

  • 쓰로틀링: 마지막 함수가 호출되고 일정시간이 지나기 전에는 다시 호출되지 않도록 하는 방법
  • 디바운싱: 연이어 호출되는 함수 중, 마지막이나 처음 함수만 호출되도록 하는 방법

디바운싱 구현

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 동안 재입력이 발생하지 않아야 콜백함수가 실행됩니다.


4. 페이징 기능 구현

4-1. 페이징 구현 전 알아둘 점

검색 기능을 도입한 후, 테이블에는 한 번에 6개의 송장만 표시됩니다. 이는 fetchFilteredInvoices() 함수가 페이지당 최대 6개의 송장을 반환하기 때문입니다.

페이징 기능을 추가하면 사용자가 모든 송장to 보기 위해 다른 페이지로 이동할 수 있게 됩니다.

🔒 보안 고려사항

<Pagination/> 컴포넌트는 클라이언트 컴포넌트입니다. 클라이언트에서 데이터를 가져오게 되면 데이터베이스 비밀이 노출될 수 있으므로, 서버에서 데이터를 가져와 컴포넌트에 props로 전달하는 것이 좋습니다.

4-2. 페이징 코드 구현

먼저 페이지 컴포넌트를 수정하여 전체 페이지 수를 계산하고 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>
  );
}

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;

  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 검색 매개변수를 활용하여 다음 기능들을 구현했습니다:

  • 검색 기능: URL 매개변수를 통한 실시간 검색
  • 디바운싱: 성능 최적화를 위한 검색 지연
  • 페이징: 대량 데이터의 효율적인 표시
  • URL 동기화: 북마크 가능하고 공유 가능한 상태 관리

이러한 패턴들은 사용자 경험을 향상시키고, SEO에 유리하며, 서버 사이드 렌더링을 효과적으로 활용할 수 있게 해줍니다.

📚 참고자료

profile
프론트엔드 입문 개발자입니다.

0개의 댓글