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를 활용하면 스피너 지옥에서 탈출할 수 있습니다
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`에서 반환한 Promise를 Suspense가 감지하고 처리합니다.
또한 만약 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`을 표시하는 동안 컴포넌트 트리를 언마운트하기 때문입니다.
'React' 카테고리의 다른 글
[React] React + SSR과 서버 컴포넌트는 뭐가 다를까? (2) | 2024.12.27 |
---|---|
[React] React Query 알아보기 03 - React Query with SSR (1) | 2024.11.24 |
[React] useSyncExternalStore 알아보기 (0) | 2024.11.09 |
[React] 헤드리스 컴포넌트란 무엇일까 (1) | 2024.11.07 |
[React] Suspense 이해하고 활용하기 03 - Suspense 발전 과정 (1) | 2024.10.02 |