React

[React] React Query 알아보기 02 - React Query with Suspense

joseph0926 2024. 11. 24. 09:05

Suspense의 필요성

서버 상태를 다룰 때, 중요하게 고려해야 할 점 중 하나가 데이터를 요청하는 시점과 실제로 데이터를 받는 시점 사이에 일정한 시간이 존재한다는 점입니다.

이 말은 즉, 데이터를 요청해도 즉시 도착하지 않을 뿐더러 언제 도착할지, 실제로 도착할지 모른다는 점입니다.

리액트 쿼리에서는 이러한 부분을 해결하기 위해 `status`를 다루는 `isLoading`, `isPending` 등을 이용하여 데이터의 상태를 다루었습니다.

 

예를 들어 아래와 같습니다.

function useIssues(search) {
  return useQuery({
    queryKey: ['issues', search],
    queryFn: () => fetchIssues(search),
    enabled: search !== '',
  });
}

function IssueList({ search }) {
  const { data, status, isPending } = useIssues(search);

  if (isPending) {
    return <div>...</div>;
  }

  if (status === 'error') {
    return <div>이슈 데이터를 가져오는 중 오류가 발생했습니다</div>;
  }

  if (status === 'success') {
    return (
      <p>
        <ul>
          {data.items.map((issue) => (
            <li key={issue.id}>{issue.title}</li>
          ))}
        </ul>
      </p>
    );
  }

  return <div>검색어를 입력해주세요</div>;
}

 

물론 이 방식으로도 처리 가능하지만, 문제는 특정 쿼리(예시 기준 `useIssues`)에 대한 처리만 가능하다는 점입니다.

가장 이상적인 방법은 앱 어디에서든 발생하는 로딩 상태를 상위 수준에서 관리할 수 있는 핸들러가 있으면 편할 것입니다.

그리고 그것을 해주는 것이 React의 `Suspense`입니다.

 

Suspense는 비동기 작업의 로딩 상태를 조정할 수 있도록 도와주는 React 컴포넌트입니다.

Suspense는 로딩 상태에 대해 Error Boundaries가 오류를 처리하는 방식과 유사하게 작동합니다.

 

자세한 내용은 제가 블로그에 정리한 아래 글들을 확인해주세요.

01 - Suspense를 활용하면 스피너 지옥에서 탈출할 수 있습니다

02 - Promise를 던지는 방법, use 훅

03 - Suspense 발전 과정

 

Suspense의 사용법은 간단합니다.

앱 내에서 상위 수준의 로딩 경계를 만들고 싶을 때는, 자식 컴포넌트를 Suspense로 감싸기만 하면 됩니다.

<Suspense fallback={<Loading />}>
  <Repos />
</Suspense>

 

이렇게 하면 React는 자식 컴포넌트에 필요한 코드와 데이터가 모두 로드될 때까지 지정한 로딩 대체 UI(`fallback`)를 표시합니다.

리액트 쿼리에는 리액트 쿼리와 Suspense를 함께 활용하도록 도와주는 `useSuspenseQuery` 훅이 존재합니다.

 

useSuspenseQuery

`useSuspenseQuery`를 사용하면 비동기 라이프사이클 관리를 React 자체에 맡기는 것과 같습니다.

 

React는 `queryFn`에서 반환된 Promise를 감지하고, 해당 Promise가 해결될 때까지 Suspense 컴포넌트의 `fallback`을 표시합니다.

 

이러한 동작 방식은 리액트 19부터 도입되는 `use` 훅과 유사합니다.

`use` 훅에서 관리하는 Promise를 React가 감지하고 Suspense에서 감지하듯이, `useSuspenseQuery`의 `queryFn`에서 반환한 PromiseSuspense가 감지하고 처리합니다.

또한 만약 Promise가 거부되면, 가장 가까운 ErrorBoundary로 오류를 전달합니다.

function useRepoData(name) {
  return useSuspenseQuery({
    queryKey: ['repoData', name],
    queryFn: () => fetchRepoData(name),
  });
}

function Repo({ name }) {
  const { data } = useRepoData(name);

  return (
    <>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </>
  );
}

export default function App() {
  return (
    <AppErrorBoundary>
      <React.Suspense fallback={<p>...</p>}>
        <Repo name='tanstack/query' />
      </React.Suspense>
    </AppErrorBoundary>
  );
}

 

여기서 주목할 점은, 서버 데이터를 활용하는 컴포넌트 안에서 더 이상 `isPending`이나 `status`를 확인할 필요가 없다는 것입니다.

 

`useSuspenseQuery`를 사용하면, 컴포넌트가 실제로 렌더링될 때 데이터를 사용할 수 있다는 것을 보장할 수 있으므로, 로딩 중 상태나 오류 처리를 분리할 수 있습니다.

 

Queries와 Suspense

리액트 쿼리를 이용하여 여러 개의 데이터를 병렬로 가져오는 경우, React가 수신한 모든 Promise를 "수집"한 뒤 모든 작업이 완료될 때까지 통합된 `fallback`을 표시합니다. 이게 아래 예제의 병렬 1입니다.

 

또한 만약 각 Query를 개별적인 Suspense 경계로 감싼다면, 데이터가 준비될 때마다 개별적으로 화면에 표시됩니다. 이게 예제의 병렬 2입니다.

// 병렬 1
export default function App() {
  return (
    <AppErrorBoundary>
      <React.Suspense fallback={<p>...</p>}>
        <Repo name="tanstack/query" />
        <Repo name="tanstack/table" />
        <Repo name="tanstack/router" />
      </React.Suspense>
    </AppErrorBoundary>
  )
}

// 병렬 2
export default function App() {
  return (
    <AppErrorBoundary>
      <React.Suspense fallback={<p>...loading query</p>}>
        <Repo name='tanstack/query' />
      </React.Suspense>
      <React.Suspense fallback={<p>...loading table</p>}>
        <Repo name='tanstack/table' />
      </React.Suspense>
      <React.Suspense fallback={<p>...loading router</p>}></React.Suspense>
    </AppErrorBoundary>
  );
}

 

Suspense에서의 워터폴(Waterfall) 문제와 해결 방법 - useSuspenseQueries

Suspense를 사용할 때 염두에 두어야 할 중요한 점 중 하나는 Suspense가 컴포넌트 구성(Component Composition)에 의존한다는 것입니다.

 

즉, 컴포넌트 내에서 하나의 비동기 작업이 실행되면, 그 컴포넌트 전체가 중단됩니다.

 

워터폴(Waterfall) 시나리오란?

워터폴 시나리오는 여러 비동기 작업이 순차적으로 실행될 때 발생합니다.

즉, 첫 번째 작업이 완료된 후에야 두 번째 작업이 시작되는 방식입니다. 이로 인해 전체 데이터 로딩 시간이 증가하게 됩니다.

하나의 컴포넌트에서 여러 Query 사용 시 문제점

하나의 컴포넌트 내에서 `useSuspenseQuery`를 여러 번 호출하여 여러 Query를 실행하려고 하면, 이러한 Query들은 병렬로 실행되지 않습니다

대신, 첫 번째 Query가 완료될 때까지 컴포넌트가 중단되고, 그 후 두 번째 Query가 실행되어 다시 중단되는 방식으로 순차적으로 실행됩니다.

 

이는 워터폴 시나리오를 초래하여 전체 로딩 시간이 불필요하게 길어지는 결과를 낳습니다.

function Component() {
  const { data: data1 } = useSuspenseQuery(['data1'], fetchData1);
  const { data: data2 } = useSuspenseQuery(['data2'], fetchData2);

  return (
    <div>
      <div>{data1}</div>
      <div>{data2}</div>
    </div>
  );
}

export default function App() {
  return (
    <React.Suspense fallback={<p>Loading...</p>}>
      <Component />
    </React.Suspense>
  );
}

 

위 코드에서 `Component`는 두 개의 `useSuspenseQuery`를 호출합니다. 첫 번째 Query가 완료될 때까지 컴포넌트가 중단되고, 그 후 두 번째 Query가 실행되어 다시 중단됩니다. 이로 인해 두 Query는 순차적으로 실행되며, 전체 로딩 시간이 증가합니다.

워터폴 시나리오를 피하는 방법

워터폴 시나리오를 피하기 위해 다음 두 가지 방법을 사용할 수 있습니다:

 

1. 컴포넌트를 분리하여 Query 당 하나의 컴포넌트 사용

2. `useSuspenseQueries` 훅 사용하여 여러 Query를 병렬로 실행

 

1. 컴포넌트 분리: Query 당 하나의 컴포넌트

각 Query를 별도의 컴포넌트로 분리하고, 각 컴포넌트를 개별적인 Suspense 경계로 감싸면, Query들이 병렬로 실행됩니다.

이렇게 하면 하나의 Query가 완료되더라도 다른 Query는 중단되지 않고 계속 실행됩니다.

function useRepoData(name) {
  return useSuspenseQuery({
    queryKey: ['repoData', name],
    queryFn: () => fetchRepoData(name),
  });
}

function Repo({ name }) {
  const { data } = useRepoData(name);

  return (
    <>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
      <strong>👀 {data.subscribers_count}</strong>{' '}
      <strong>✨ {data.stargazers_count}</strong>{' '}
      <strong>🍴 {data.forks_count}</strong>
    </>
  );
}

export default function App() {
  return (
    <AppErrorBoundary>
      <React.Suspense fallback={<p>...loading query</p>}>
        <Repo name='tanstack/query' />
      </React.Suspense>
      <React.Suspense fallback={<p>...loading table</p>}>
        <Repo name='tanstack/table' />
      </React.Suspense>
    </AppErrorBoundary>
  );
}

 

위 코드에서는 `Repo` 컴포넌트를 별도로 만들어 각 `Repo`를 개별적인 Suspense 경계로 감쌌습니다.

이로 인해 각 Query가 병렬로 실행되며, 하나의 Query가 완료되더라도 다른 Query의 실행에 영향을 주지 않습니다.

2. `useSuspenseQueries` 훅 사용

`useSuspenseQueries` 훅을 사용하면 여러 Query를 한 번에 병렬로 실행할 수 있습니다. 이를 통해 워터폴 시나리오를 방지하고 로딩 시간을 단축할 수 있습니다.

function Component() {
  const results = useSuspenseQueries({
    queries: [
      { queryKey: ['data1'], queryFn: fetchData1 },
      { queryKey: ['data2'], queryFn: fetchData2 },
    ],
  });

  const [result1, result2] = results;
  const data1 = result1.data;
  const data2 = result2.data;

  return (
    <div>
      <div>{data1}</div>
      <div>{data2}</div>
    </div>
  );
}

export default function App() {
  return (
    <React.Suspense fallback={<p>Loading...</p>}>
      <Component />
    </React.Suspense>
  );
}

 

위 코드에서 `useSuspenseQueries` 훅을 사용하여 두 개의 Query를 동시에 실행합니다. 이렇게 하면 두 Query가 병렬로 실행되어 전체 로딩 시간이 단축됩니다.

 

useSuspenseQuery vs useQuery

`useSuspenseQuery`는 `useQuery`와 유사하지만 다음과 같은 차이점이 있습니다

1. `enabled` 옵션을 지원하지 않음

`useSuspenseQuery`는 항상 데이터를 보장해야 하므로, `enabled` 옵션으로 Query를 비활성화할 수 없습니다.

2. `placeholderData`를 지원하지 않음

Suspense는 비동기 작업이 완료될 때까지 `fallback`을 표시하는 것을 목표로 하므로, `placeholderData`와는 상충됩니다.

`useSuspenseQuery`는 `placeholderData`를 지원하지 않으므로, React의 내장 트랜지션 개념을 사용하여 이전 데이터를 유지할 수 있습니다.

 

이를 위해 `useTransition` 훅을 사용합니다.

function RepoList({ sort, page, setPage }) {
  const { data } = useRepos(sort, page);
  const [isPreviousData, startTransition] = useTransition();

  return (
    <div>
      <ul style={{ opacity: isPreviousData ? 0.5 : 1 }}>
        {data.map((repo) => (
          <li key={repo.id}>{repo.full_name}</li>
        ))}
      </ul>
      <div>
        <button
          onClick={() => {
            startTransition(() => {
              setPage((p) => p - 1);
            });
          }}
          disabled={isPreviousData || page === 1}
        >
          Previous
        </button>
        <span>Page {page}</span>
        <button
          disabled={isPreviousData || data?.length < PAGE_SIZE}
          onClick={() => {
            startTransition(() => {
              setPage((p) => p + 1);
            });
          }}
        >
          Next
        </button>
      </div>
    </div>
  );
}

 

`staleTime` 자동 설정

Suspense를 사용할 때는 짧은 기본 `staleTime`이 자동으로 설정됩니다.

이는 React가 `fallback`을 표시하는 동안 컴포넌트 트리를 언마운트하기 때문입니다.