티스토리 뷰

10. 부분 Prerendering

이 기능은 Next.js 14에 도입된 실험적 기능으로 사용하지 않으려면 건너뛰어도 된다.

 

10-1. 정적 콘텐츠와 동적 콘텐츠 결합하기

현재 경로 내에서 동적 함수(ex. noStore(), cookies() 등)를 호출하면 전체 경로가 동적이 된다.

오늘날 대부분의 웹 앱이 이 방식으로 구축된다. 전체 애플리케이션이나 특정 경로에 대해 정적 렌더링과 동적 렌더링 중에 선택한다.

 

그러나 대부분의 경로는 완전히 정적이거나 동적이지 않다.

정적 콘텐츠와 동적 콘텐츠가 모두 포함된 경로가 있을 수 있다.

이커머스 사이트의 경우, 제품 페이지의 대부분을 사전 렌더링할 수 있지만 유저의 장바구니와 추천 제품을 동적으로 가져오고 싶을 수 있다.

 

대시보드 경로를 분할하는 방법을 확인해보자.

 

  • <SideNav> 컴포넌트는 데이터에 의존하지 않으며 유저에게 개인화되지 않음 ⇨ 정적
  • <Page> 컴포넌트는 자주 변경되는 데이터에 의존하고 유저에게 개인화됨 ⇨ 동적

 

 

 

 

 

 

 

 

10-2. Prerendering이란?

일부 부분을 동적으로 유지하면서 정적 로딩 셸을 사용하여 경로를 렌더링할 수 있는 기능이다.

즉, 경로의 동적 부분을 분리할 수 있다.

 

사용자가 경로를 방문할 때

  • 정적 경로 셸이 제공되어 빠른 초기 로드를 보장함
  • 셸에는 동적 콘텐츠가 비동기식으로 로드되는 구멍이 남음
  • 비동기 홀을 병렬로 스트리밍되어 페이지의 전체 로드 시간을 줄임

 

 

10-3. Prerendering은 어떻게 작동하는가?

Prerendering은 React의 Concurrent API를 활용하고 Suspense를 사용하여 일부 조건이 충족될 때까지(ex. 데이터 로드) 애플리케이션의 렌더링 부분을 연기한다. 

폴백은 다른 정적 콘텐츠와 함께 초기 정적 파일에 포함된다. 빌드 시(또는 유효성 재검사 중에) 경로 정적 부분이 사전 렌더링되고 나머지 부분은 유저가 경로를 요청할 때까지 연기된다.

Suspens에서 컴포넌트를 랩핑하면 컴포넌트 자체가 동적으로 만들어지는 것이 아니라 오히려 Suspense가 경로의 정적 부분과 동적 부분 사이의 경계로 사용된다.

 

Prerendering의 가장 큰 장점은 이를 사용하기 위해 코드를 변경할 필요가 없다는 것이다.

Suspense를 사용하여 경로의 동적 부분을 랩핑하는 한 Next.js는 경로의 어느 부분이 정적이고 어느 부분이 동적인지 알 수 있다.

 

 

 


11. Search와 Pagination 추가하기

11-1. invoices 페이지에 코드 추가하기

/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>
  );
}

 

  1. <Search /> - 유저가 특정 송장을 검색할 수 있음
  2. <Pagination /> - 유저가 청구서 페이지 간의 탐색할 수 있음
  3. <Table /> - 송장을 표시

검색 기능은 클라이언트와 서버에 걸쳐 있다.

유저가 클라이언트에서 송장을 검색하면 URL 매개변수가 업데이트되고 서버에서 데이터를 가져오며 테이블은 새 데이터로 서버에서 다시 렌더링된다.

 

 

 

11-2. URL 검색 매개변수를 사용하는 이유?

URL 검색 매개변수를 사용해 검색 상태를 관리한다.

URL 매개변수를 사용해 검색을 구현하면 몇 가지 이점이 있다.

  • 북마크 기능 및 공유 가능한 URL : 검색 매개변수가 URL에 있으므로 유저는 향후 참조 또는 공유를 위해 검색 쿼리 및 필터를 포함해 애플리케이션의 현재 상태를 북마크에 추가할 수 있다.
  • 서버 사이드 렌더링 및 초기 로드 : URL 매개변수를 서버에서 직접 사용하여 초기 상태를 렌더링할 수 있으므로 서버 렌더링을 더 쉽게 처리할 수 있다.
  • 분석 및 추적 : URL에 직접 검색어와 필터가 있으면 추가 클라이언트 측 로직 없이도 유저의 행동을 더 쉽게 추적할 수 있다.

 

 

 

11-3. 검색 기능 추가하기

검색 기능을 구현하는 데 사용할 Next.js 클라이언트 훅은 다음과 같다.

  • useSearchParams - 현재 URL의 매개변수에 액세스할 수 있음
  • usePathname - 현재 URL의 경로명을 읽을 수 있음
  • useRouter - 프로그래밍 방식으로 클라이언트 컴포넌트 내의 경로 탐색을 활성화함

 

1. 사용자 입력 캡쳐

/app/ui/search.tsx 파일의 <Search> 컴포넌트에서 다음을 확인할 수 있다.

  • "use client" - 클라이언트 컴포넌트로 이벤트 리스너와 훅을 사용할 수 있다.
  • <input> - 검색 입력

handleSearch 함수를 생성하고 <input> 요소에 onChange 리스너를 추가한다.

export default function Search({ placeholder }: { placeholder: string }) {
  function handleSearch(term: string) {
    console.log(term);
  }
 
  return (
    <div className="relative flex flex-1 flex-shrink-0">
	// ...
      <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);
        }}
      />
      // ...
    </div>
  );
}

 

 

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

'next/navigation'에서 useSearchParams 훅을 가져와 변수에 할당한다.

import { useSearchParams } from 'next/navigation';
 
export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    console.log(term);
  }
  // ...
}

 

handleSearch 내부에 searchParams 변수를 사용하는 새 URLSearchParams 인스턴스를 생성한다.

 export default function Search() {
  const searchParams = useSearchParams();
 
  function handleSearch(term: string) {
    const params = new URLSearchParams(searchParams);
  }
  // ...
}

URLSearchParams는 URL 쿼리 매개변수를 조작하기 위한 유틸리티 메소드를 제공하는 웹 API이다.

복잡한 문자열 리터럴을 생성하는 대신 ?page=1&query=a와 같은 매개변수 문자열을 가져올 수 있다.

 

다음으로 set을 사용해 사용자 입력을 기반으로 하는 params 문자열을 생성하고 입력이 비어있으면 쿼리를 삭제한다.

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');
    }
  }
  // ...
}

 

useRouter와 usePathname 훅을 사용해 URL을 업데이트하자!

'next/navigation'에서 useRouter와 usePathname을 가져와 handleSearch 내부에서 useRouter( )의 replace 메소드를 사용한다.

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()는 유저의 검색 테이터로 URL을 업데이트한다.

 

3. URL과 입력의 동기화 유지

input 필드가 URL과 동기화되고 공유 시 채워지도록 하려면 searchParams에서 읽어와 defaultValue를 input에 전달한다.

<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()}
/>

 

 

4. 테이블 업데이트

검색 쿼리를 반영하도록 테이블 컴포넌트를 업데이트해야 한다.

invoices 페이지로 이동해 현재 URL 매개변수를 <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 { 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> 컴포넌트로 이동하면 2개의 props인 query와 currentPage가 쿼리와 일치하는 송장을 반환하는 fetchFilteredInvoices( ) 함수에 전달되는 것을 볼 수 있다.

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

 

검색을 하면 URL을 업데이트하여 서버에 새 요청을 보내고 서버에서 데이터를 가져오고 검색어와 일치하는 청구서만 반환된다.

 

 

💜 Debouncing

검색을 최적화하자!

handleSearch 함수에서 console.log를 찍어보면 키를 누를 때마다 URL이 업데이트되기 때문에 데이터베이스를 쿼리하게 된다.

애플리케이션이 작을 때는 문제가 되지 않지만 사용자가 많아질 경우 각 사용자가 키를 누를 때마다 데이터베이스에 새로운 요청을 보내게 된다면...?!

 

디바운싱은 함수가 실행될 수 있는 속도를 제한하는 프로그래밍 방식이다.

여기에서는 유저가 입력을 중단한 경우에만 데이터베이스를 쿼리해보자.

 

더보기

디바운싱 작동 방식

  1. Trigger Event : 디바운싱되어야 하는 이벤트(검색창의 키 입력 등)가 발생하면 타이머가 시작된다.
  2. Wait(대기) : 타이머가 만료되기 전에 새로운 이벤트가 발생하면 타이머가 재설정된다.
  3. Execution(실행) : 타이머가 카운트다운 끝에 도달하면 디바운싱된 함수가 실행된다.

 

디바운싱 기능을 수동으로 생성하는 등 몇 가지 방법으로 디바운싱을 구현할 수 있지만 단순하게 유지하기 위해 use-debounce 라이브러리를 사용한다.

 

use-debounce를 설치한다.

npm i use-debounce

 

<Search> 컴포넌트에서 useDebouncedCallback 함수를 가져온다.

import { useDebouncedCallback } from 'use-debounce';
 
export default function Search({ placeholder }: { placeholder: string }) {
  // ...
  const handleSearch = useDebouncedCallback((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()}`);
  }, 300);

	// ...
}

 

handleSearch의 내용을 랩핑하고 유저가 입력을 중지한 후 특정 시간(300ms) 후에만 코드를 실행한다.

디바운싱을 통해 데이터베이스로 전송되는 요청 수를 줄여 리소스를 절약할 수 있다!

 

 

 

11-4. 페이지네이션 추가

현재 테이블에 한 번에 6개의 송장만 표시된다.

이는 data.ts의 fetchFilteredInvoices( )함수가 페이지당 최대 6개의 송장을 반환하기 때문이다.

 

페이지네이션을 추가하면 유저가 여러 페이지를 탐색하여 모든 청구서를 볼 수 있다.

<Pagination /> 컴포넌트는 클라이언트 컴포넌트로 데이터베이스 비밀이 노출될 수 있어 데이터를 가져오고 싶지 않을 것이다,

대신 서버에서 데이터를 가져와 컴포넌트에 prop으로 전달할 수 있다.

/dashboard/invoices/page.tsx에서 fetchInvoicesPages 함수를 호출하고 searchParams로 부터 query를 인자로 전달한다.

// ...
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가 된다.

 

<Pagination />에 totalPages prop을 전달한다.

// ...
 
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 훅을 가져온다.

이를 사용해 현재 페이지를 가져오고 새 페이지를 설정한다.

// app/ui/invoices/pagination.tsx
'use client';
// ...
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 문자열을 만든다.

'use client';
 
// ...
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 함수가 하는 동작은

  1. 현재 검색 매개변수의 인스턴스를 생성한다.
  2. 그 다음 "page"매개변수를 제공된 페이지 번호로 업데이트한다.
  3. 경로 이름과 업데이트된 검색 매개변수를 사용해 전체 URL을 구성한다.

나머지 <Pagination> 컴포넌트는 스타일 지정 및 다양한 상태를 처리한다.

 

마지막으로 유저가 새 검색어를 입력하면 페이지 번호를 1로 재설정한다.

<Search> 컴포넌트에서 handleSearch함수를 업데이트 한다.

// app/ui/search.tsx
'use client';
 
// ...
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);

 

 

11-5. 요약

  • 클라이언트 상태 대신 URL 검색 매개변수를 사용해 검색 및 페이지네이션을 처리한다.
  • 서버에서 데이터를 가져온다.
  • 보다 원활한 클라이언트 측 전환을 위해 useRouter 훅을 사용한다.

 

 

 

728x90