Next.js Partial rendering, Search

Minboy·2024년 3월 4일
2
post-thumbnail

Chapter 10

Combining Static and Dynamic Content

지금까지의 웹 페이지들은 어떤 특정 라우트 내에서 noStorecookies() 와 같은 동적 함수들을 호출하면 라우트 전체가 동적으로 바뀐다.

하지만 대부분의 라우트들은 전체 페이지가 정적이거나 동적 하나만으로 결정되지 않고, 어느 부분은 정적이고 어느 부분은 동적이다. 예를들면 소셜 미디어 피드를 생각해보자. 포스트들은 정적이지만, 포스트들의 좋아요는 동적일 것이다. 전자 상거래 사이트를 생각해보면 제품 설명은 정적이지만, 유저 카트는 동적일 것이다.

What is Partial Prerendering?

Next.js 14에 새로 추가된 기능인 Partial Prerendering을 통해 페이지 내의 정적인 부분과 동적인 부분들을 분리해 렌더링할 수 있게 되었다.

13

만약 사용자가 특정 라우트에 방문하면,

  • 정적인 부분들이 먼저 제공되고, 덕분에 초기 로딩이 빨라진다.
  • 비동기적으로 로드될 동적인 컨텐츠들은 구멍으로 남아있다.
  • 비동기 구멍들이 병렬적으로 로드됨에 따라 전체 페이지 로드 타임을 줄인다.

이 방식은 라우트 전체가 정적이거나 동적인 요즈음의 웹 페이지들과는 달리 한 라우트내에 정적인 부분들을 먼저 준비주고, 동적인 부분들을 병렬적으로 처리해준다. 결국 로드 타임을 줄이고 사용자 경험을 개선할 수 있으며, Next.js 팀은 이것이 웹 어플리케이션의 디폴트 렌더링 모델이 될 잠재력이 있다고 생각하고 있다고 한다.

How does Partial Prerendering work?

Partial Prerendering은 리액트의 Concurrent API들과 Suspense를 이용한다.

초기 정적파일에 fallback이 다른 정적 컨텐츠들과 함께 삽입되고, 빌드타임 또는 재평가되는 동안 라우트의 정적인 부분들은 prerendered - 사전 렌더링 된다. 나머지 부분(동적인 부분들)은 라우트의 유저 요청이 올 때까지 지연된다.

컴포넌트를 Suspense로 감싸면 컴포넌트가 동적으로 변하는게 아닌, Suspense를 라우트의 정적과 동적 경계로 활용할 수 있다는 것에 주목하자. (컴포넌트를 동적으로 바꾸려면 unstable_noStore를 사용했던 것을 떠올리자.)

Partial Prerendering의 놀라운 점은 따로 코드를 변경할 필요가 없다는 점이다. 앞서 진행했던 것처럼 라우트의 동적인 부분을 Suspense로 감싸주기만 하면 Next.js는 라우트의 어떤 부분이 정적이고 동적인지 알아서 판단할 것이다.

Summary

지금까지 데이터 페칭을 위해 진행한 최적화를 복습해보자.

  1. 서버와 DB간의 레이턴시 감소를 위해 DB를 같은 리전에 생성
  2. 데이터 페칭을 리액트 서버 컴포넌트에서 진행함으로써 높은 비용의 데이터 페칭과 로직을 서버단으로 옮기고 클라이언트 사이드의 JS 번들 사이즈를 줄일 수 있었다. 또한 이를 통해 DB Secrete을 클라이언트에 노출하는 위험을 없앨 수 있었다.
  3. SQL을 사용하여 필요한 데이터만 가져오므로 각 요청에 대해 전송되는 데이터의 양과 메모리 내 데이터를 변환하는 데 필요한 JavaScript의 양을 줄였다.
  4. 필요한 곳에서 JS를 이용해 데이터 페칭을 병렬화 했다.
  5. 느린 데이터 요청이 전체 페이지를 블로킹하는 것을 예방하기 위해 스트리밍을 구현했고, 이를 통해 유저가 모든 페이지가 로드되기 전에도 UI와 상호작용할 수 있게 해주었다.
  6. 데이터 페칭 코드를 해당 데이터를 필요로하는 컴포넌트 내부로 옮기고, 이를 통해 라우트의 각 부분들을 분리해 Partial Prerendering을 위해 동적으로 준비될 수 있게 해주었다.

Chapter 11

Why use URL search params?

  • 북마크 및 공유 가능한 URL : 서치 파라미터가 URL 안에 있기 때문에, 유저는 서치 쿼리나 필터를 포함한 어플리케이션의 현재 상태를 북마크해 향후에 재방문 또는 공유할 수 있다.
  • 서버 사이드 렌더링과 초기 로드 : URL 파라미터들은 초기 로드시에 즉시 서버로 전달되어 서버 렌더링을 쉽게 해준다.
  • 분석과 추적 : 서치쿼리들과 필터들을 URL에 직접 갖고있는것은 추가적인 클라이언트 로직 없이 유저의 행동을 추적하기 쉽게 해준다.

Adding the search functionality

  • useSearchParams - 현재 URL의 파라미터들에 접근할 수 있게 해준다. 예를들어 /dashboard/invoices?page=1&query=pending 과 같은 URL의 서치 파라미터는 {page: '1', query: 'pending' } 과 같은 모습이다.
  • usePathname - 현재 URL의 pathname을 읽을 수 있게 해준다. 예를들어 /dashboard/invoices 라우터에서 usePathname'/dashboard/invoice' 를 리턴 할 것이다.
  • useRouter - 라우트 간의 이동을 가능하게 해준다.

유저의 검색을 구현하는 단계는 다음과 같다.

  1. 유저의 입력을 받는다.
  2. 서치 파라미터로 URL을 업데이트한다.
  3. URL을 입력 필드와 동기화 상태로 유지한다.
  4. 서치 쿼리를 반영해 테이블을 업데이트한다.

유저의 입력을 URL에 반영시키는 방법을 알아보자.

'use client';
...
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()}`);
	}
...
			<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()}
      />
...

input의 onChange를 통해 유저의 입력이 들어오면 handleSearch 함수로 입력을 전달한다.

handleSearch함수에서는 useSearchParams로 가져온 서치 파라미터들을 통해 새로운 URLSearchParams 객체를 만들어 유저의 입력을 넣어준다.

이후 usePathname 을 통해 가져온 pathname과 서치 파라미터들을 합쳐 URL을 만들고, useRouter 의 replace 함수를 통해 해당 URL로 유저를 이동시킨다.

input의 defaultValue 값을 서치 파라미터에서 가져온 값으로 지정해줌으로써 동기화시켜준다.

이렇게 유저의 입력을 서치 파라미터로 전달하고, URL을 변경시키는 방법을 알아보았다. 이제 URL에서 서치 파라미터들을 읽어와 사용하는 법을 알아보자.


...
export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
...
			<Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>
...

Page 컴포넌트들은 searchParams 라는 prop을 기본적으로 받을 수 있다. 이 prop을 통해 현재 URL의 서치 파라미터들에 접근하여 사용할 수 있다.

When to use the useSearchParams() hook vs the searchParams prop?

서치 파라미터들을 추출하는 두가지 방법이 있다는 것을 확인했다. 둘 중에 어떤 것을 사용해야 할지는 현재 서치 파라미터를 활용하는 위치가 클라이언트 단인지, 서버 단인지에 달려있다.

  • 앞서 <Search> 컴포넌트는 클라이언트 컴포넌트였고, 클라이언트에서 서치 파라미터에 접근하기 위해서는 useSearchParams() 훅을 사용해야한다.
  • <Table> 컴포넌트는 데이터를 페치하는 서버 컴포넌트이고, page로부터 searchParams prop을 전달하여 사용할 수 있다.

일반적으로 클라이언트에서 서치 파라미터들을 읽고 싶다면 서버로 다시 돌아가는 현상을 방지하기 위해 useSearchParams() 훅을 사용한다.

Best practice: Debouncing

현재는 유저의 매 키보드 입력 하나하나 마다 URL을 업데이트하고 있고, 이말인 즉 매 키보드 입력마다 DB에 쿼리를 보내고 있다는 말이다. 앱이 작고 유저가 적을때는 상관없지만, 유저수가 수천명만 넘어가도 매 키보드 입력 하나하나마다 DB에 쿼리를 보내는 것은 서버에 많은 부하를 줄 것이다.

이를 방지하기 위한 기법이 Debouncing으로, 함수가 실행되는 횟수를 제한하는 기법이다.

Debouncing의 작동 원리

  1. 이벤트 발생 : 디바운스 되어야 할 이벤트(서치 박스의 키 입력과 같은)가 발생하면 타이머가 시작된다.
  2. 대기 : 타이머가 종료되기 전에 새로운 이벤트가 발생한다면 타이머가 리셋된다.
  3. 실행 : 타이머가 종료되면, 함수가 실행된다.

Debouncing을 구현하는 방법은 정말 다양하지만, 여기서는 간단히 하기 위해 use-debounce 라는 라이브러리를 사용한다.

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

이렇게 함수를 useDebounceCallback 이라는 훅으로 감싸주면 우리가 지정한 300ms의 타이머가 종료될 때, 즉 유저가 300ms동안 입력을 멈추고 기다릴 때만 해당 함수가 실행될 것이다.

profile
🐧

0개의 댓글

관련 채용 정보