Next.js & Supabase로 검색 결과 페이지네이션 구현하기

검색 결과 페이지네이션의 필요성

검색 결과가 많아지면 슈퍼베이스의 쿼리 개수 제한(1,000개)을 초과하거나 성능이 저하될 수 있다.

이를 해결하기 위해 검색 결과를 n개씩 나눠서 가져오는 페이지네이션 기능을 구현했다.

 

page 쿼리 파라미터 추가

검색 페이지의 URL을 /search?q=hello&page=2와 같이 변경하여 검색어와 페이지 정보를 포함하도록 설정한다.

 

검색어로 URL 업데이트하기

import { useRouter, useSearchParams, usePathname } from "next/navigation";

const { replace } = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

const query = searchParams.get("q")?.toString(); // URL에서 가져온 검색어
const [searchQuery, setSearchQuery] = useState(""); // 사용자가 입력한 검색어

// 검색 버튼 클릭 시 검색어로 URL 업데이트
const handleSearch = () => {
  // 검색어가 없을 경우 종료
  if (!searchQuery) {
    return;
  }

  const params = new URLSearchParams(searchParams);
  params.set("q", searchQuery);
  replace(`${pathname}?${params.toString()}`);
};
  • URL 쿼리 파마리터를 조작하기 위한 메서드를 제공하는 URLSearchParams를 사용하여 ?q=hello&page=2 같은 파라미터 문자열을 가져온다.
  • 사용자가 입력한 값에 따라 파라미터 문자열을 set()한다.
  • usePathname() 훅의 replace() 메서드를 이용해서 URL을 사용자의 입력값으로 업데이트한다.
    • pathname/search와 같이 현재 경로를 가리킨다.
    • params.toString()은 사용자의 새로운 입력값을 반영한 쿼리 파라미터를 URL 친화적인 형식으로 변환한다.

 

검색어 입력

스크린샷 2025-03-30 165220.png

<input
    defaultValue={searchParams.get('q')?.toString()}
    onChange={(e) => setSearchQuery(e.target.value)}
    placeholder="검색어를 입력하세요"
/>
<button onClick={handleSearch}>검색</button>
  • searchParams에서 쿼리 값을 읽어 입력 필드가 URL과 동기화되도록 한다.
  • 검색 버튼을 클릭하면 handleSearch()가 실행되어 URL이 업데이트된다.

 

슈퍼베이스에서 검색 결과 페이징 처리

// src/app/api/search/route.ts
export async function GET(req: Request) {
  try {
    const { searchParams } = new URL(req.url);
    const searchQuery = searchParams.get("q");

    // 페이지네이션
    const page = Number(searchParams.get("page")) || 1;
    const pageSize = 100; // 한 페이지 데이터 개수
    const from = (page - 1) * pageSize;
    const to = from + pageSize - 1;

    // title, author 필드에서 검색어를 포함하는 데이터 조회, 날짜 내림차순 정렬
    const { data, count, error } = await supabase
      .from("calendar")
      .select("*", { count: "exact" }) // 총 개수 가져오기
      .or(`title.like.%${searchQuery}%,author.like.%${searchQuery}%`)
      .order("date", { ascending: false })
      .range(from, to); // OFFSET & LIMIT 적용

    return NextResponse.json({ data, total: count }, { status: 200 });
  } catch (error) {
    // 에러 처리
  }
}
  • 쿼리 파라미터에서 검색어와 현재 페이지를 가져와서 fromto를 계산한다.
  • 슈퍼베이스 쿼리에 range(from, to) 조건을 추가해서 검색 결과를 페이지 단위로 가져온다.
    • 이는 슈퍼베이스에서 LIMITOFFSET 역할을 한다.
  • or() 조건으로 검색 쿼리를 실행한다.
    • titleauthor 컬럼에서  LIKE 연산자를 사용해서 searchQuery 문자열이 포함되어 있는 데이터를 출력한다.
  • 만약 검색 결과의 총 개수도 얻고 싶다면 select(){count: "exact"} 옵션을 추가한다.
    • total: count 값을 NextResponse.json() 응답에 추가한다.

 

페이지네이션 UI 구현

const response = await fetch(
    `/api/search?q=${searchQuery}&page=${currentPage}`
);
  • 검색 결과를 불러올 때마다 search API를 호출한다.

 

import Link from "next/link";
import { pageSize } from "@/lib/constants";
import { usePathname, useSearchParams } from "next/navigation";

export default function Pagination({ totalItems }: { totalItems: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();

  const currentPage = Number(searchParams.get("page")) || 1; // 현재 페이지
  const totalPages = Math.ceil(totalItems / pageSize); // 전체 페이지 수

  // 페이지네이션 버튼 목록 생성
  const getPageNumbers = () => {
    const pages = [];
    const maxVisiblePages = 5; // 최대 5개의 페이지 버튼 표시

    let start = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
    let end = Math.min(totalPages, start + maxVisiblePages - 1);

    if (end - start < maxVisiblePages - 1) {
      start = Math.max(1, end - maxVisiblePages + 1);
    }

    for (let i = start; i <= end; i++) {
      pages.push(i);
    }

    return pages;
  };

  // 페이지 클릭 시 URL 생성
  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set("page", pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

  // pageSize보다 작은 경우 페이지네이션 버튼을 표시하지 않음
  if (totalItems <= pageSize) {
    return null;
  }

  return (
    <div className="flex justify-center mt-5 space-x-2">
      {/* 이전 페이지 버튼 */}
      {/* pointer-events-none로 클릭 비활성화 */}
      <Link
        href={createPageURL(currentPage - 1)}
        className={`px-2 py-1 rounded-md ${currentPage === 1 ? "text-gray-300 pointer-events-none" : "text-gray-600 hover:bg-gray-100"}`}
        aria-disabled={currentPage === 1}
      >
        &lt;
      </Link>

      {/* 페이지 번호 버튼 */}
      {getPageNumbers().map((page) => (
        <Link
          key={page}
          href={createPageURL(page)}
          className={`px-2 py-1 rounded-md ${currentPage === page ? "text-black font-bold" : "text-gray-600 hover:bg-gray-100"}`}
        >
          {page}
        </Link>
      ))}

      {/* 다음 페이지 버튼 */}
      <Link
        href={createPageURL(currentPage + 1)}
        className={`px-2 py-1 rounded-md ${currentPage === totalPages ? "text-gray-300 pointer-events-none" : "text-gray-600 hover:bg-gray-100"}`}
        aria-disabled={currentPage === totalPages}
      >
        &gt;
      </Link>
    </div>
  );
}
  • 이동하고자 하는 페이지의 번호를 클릭하면 새로운 URL을 생성해서 Link 컴포넌트를 통해 페이지를 이동한다.
    • Link를 사용하면 Next.js의 클라이언트 사이드 네비게이션을 활용할 수 있다.
  • 첫 페이지와 마지막 페이지의 Link 기능을 비활성화하고자 할 때는 pointer-events-none 속성을 추가한다.

 

완성된 페이지네이션 UI

페이지네이션.png

다음과 같은 UI의 페이지네이션 기능이 완성되었다.

 

이제 검색 결과가 많더라도 빠르고 효율적으로 페이지를 나누어 조회할 수 있다.
✅ 검색 시 URL 업데이트 ?q=검색어&page=2
✅ Supabase range()를 활용한 페이징 쿼리 적용
✅ < 1 2 3 4 5 > UI 구현

 

Reference