Suspense란?
React18에서 Suspense를 다루는 문서를 찾아보면 Suspense의 정의에 대해서
라고 합니다. Suspense는 아직 컴포넌트가 표시 될 준비가 되지 않았을 때, 렌더링 되어야 할 부분을 선언적으로 정의 할 수 있게 해주는 컴포넌트입니다. Suspense는 반응-비동기처럼 데이터를 불러오는 라이브러리가 아니며 Redux처럼 상태를 관리하는 방법도 아니라는 점에 유의해야 합니다. 단지 컴포넌트가 비동기 작업(예: 네트워크 요청)이 완료되기를 기다리는 동안 선언적으로 폴백을 렌더링할 수 있게 해줄 뿐입니다.
Suspense를 사용해서 어떻게 Data Fetching을 하는지 다루기 전에, 기존의 Data Fetching을 하기 위한 방법론에는 어떤것이 있는지, 또 그것들의 문제점이 무엇인지부터 한번 알아봅시다.
function App(){
return (
<>
<ArticlePage/>
...
</>
);
}
function ArticlePage() {
const { articles, isLoading } = useArticlesQuery();
// articles가 로딩되지 않는다면 다른 UI를 보여준다.
if(isLoading){
return (
<Spinner />
);
}
// articles 로딩이 완료되면 해당되는 UI를 보여준다.
// isLoading이 false가 되기 전까지 TrendArticles는 실행조차 되지 못한다.
return (
<>
{articles.map((article, idx) => <ArticleCard key={idx} article={article}/>}
<TrendArticles />
</>
);
}
특징
문제점
Waterfall 현상 발생 : TrendArticles 컴포넌트는 articles 데이터가 로딩이 완료되어야 렌더링 되기 시작합니다. 만약 TrendArticles 내부에 비동기 데이터를 호출하는 작업이 있다면 이는 articles 데이터가 모두 불러와져야 진행이 가능합니다. 이러한 fetch-on-render 방식이 계층 별로 반복되면 데이터 패칭도 순차적으로 일어나면서 렌더링 성능에 나쁜 영향을 끼치게 됩니다.
나쁜 가독성 : 하나의 컴포넌트 코드 내에서 로딩 상태에 대한 로직이 포함되어야 한다는 것 입니다. 이 때문에 ArtciePage 에서 핵심로직에 집중하기 어려워 집니다.
function ArticlePage({ ... }) {
const [allArticles, setAllArticles] = useState([]);
const [trendArticles, setTrendArticles] = useState([]);
useEffect(() => {
// 비동기 작업을 동시에 병렬적으로 실행
Promise.all([fetchAllArticles(), fetchTrendArticles()]).then(([allArticles, trendArticles]) => {
setAllArticles(allArticles);
setTrendArticles(trendArticles);
});
}, []);
return (
<>
<AllArticleList articles={allArticles} />
<TrendArticleList articles={trendArticles} />
</>
);
}
특징
위 예시에서는 기존의 fetch-on-render 방식을 변형해 비동기 데이터들을 호출하는 부분을 최상단에서 몰아 처리할 수 있도록 하고 있습니다.
문제점
불필요한 관심사들의 결합 : 병렬적으로 처리하는 과정에서 불가피하게 서로 다른 비동기 데이터가 Promise.all 로 묶이게 됩니다. 이로 인해 서로 다른 데이터가 하나의 코드에서 호출되게 되며 강한 결합도를 가지게 됩니다.
가령 TrendArticles 를 불러오는 부분이 실패하면 AllArticles 부분도 렌더링 되지 못합니다.
다른 비동기 데이터가 완료 되어야 렌더링이 가능 : 가령 TrendArticles 은 받아올 데이터가 극소수여서 20ms만에 완료되는 한편, AllArticles 는 받아올 데이터가 너무 많아 100ms 가 소요된다고 하자면, 이렇게되면 TrendArticles 의 데이터는 빠르게 로드되었음에도 AllArticles 때문에 렌더링이 역시 지연 되어 버립니다.
function App(){
return (
<>
<Suspense fallback={<Spinner/>}>
<AllArticlePage/>
</Suspense>
<Suspense fallback={<Spinner/>}>
<TrendArticlesPage/>
</Suspense>
...
</>;
}
function AllArticlesPage() {
const { articles } = useArticlesQuery();
return (
<>
{articles.map((article, idx) => <ArticleCard key={idx} article={article}/>}
...
</>
);
}
function TrendArticlesPage() {
const { articles } = useTrendArticlesQuery();
return (
<>
{articles.map((article, idx) => <ArticleCard key={idx} article={article}/>}
...
</>
);
}
특징
렌더링 작업과 비동기 데이터 호출 과정이 동시에 이루어지게 됩니다.
비동기 데이터를 호출하는 과정, fallback UI를 보여주는 과정, 완성된 UI를 보여주는 과정 등 기존의 렌더링 과정들이 여러 작은 태스크들로 쪼개진 뒤 번갈아가며 진행됩니다.
비동기 데이터 호출을 통해 로딩이 발생하면 Suspense 가 이를 포착하여 UI는 fallback 으로 보여주고 로딩이 완료되면 완성된 UI를 보여지게 됩니다.
이를 통해 컴포넌트 내부에선 로딩 상태에 대한 분기 처리가 필요없어져 코드의 가독성도 높아지게 됩니다.
비동기 데이터에 대한 분기처리로 인해 waterfall 현상 역시 사라지게 됩니다.
간단하게 가져오는 예제 코드입니다.
import React, { Suspense } from "react";
import UserWelcome from "./UserWelcome";
import Todos from "./Todos";
const App = () => {
return (
<div className="app">
<h2>Simple Todo</h2>
<Suspense fallback={<p>Loading user details...</p>}>
<UserWelcome />
</Suspense>
<Suspense fallback={<p>Loading Todos...</p>}>
<Todos />
</Suspense>
</div>
);
};
export default App;
아래에서 보는 것과 같이 Todos가 먼저 렌더링 되고, 그 다음에 UserWelcome이 렌더링 된다면 아래와 같은 상황이 생길 것입니다.
Todos컴포넌트가 UserWelcome 컴포넌트가 렌더링 완료되었을 때만 렌더링 되기 원한다면, Todos의 Suspense를 UserWelcome의 Suspense안에 감쌈으로써 해결할 수 있습니다.
<Suspense fallback={<p>Loading user details...</p>}>
<UserWelcome />
<Suspense fallback={<p>Loading Todos...</p>}>
<Todos />
</Suspense>
</Suspense>
*추가로 SuspenseList 방법이 있습니다. 하지만 이것은 리액트 문서에서는 는 React 18의 일부가 아니며 향후 릴리스에 포함될 예정입니다. 라고 설명하고 있습니다. 우선 간단하게 코드만 첨부합니다.
SuspenseList는 그 아래에서 가장 가까운 Suspense 노드의 "공개 순서"를 조정합니다.
function ProfilePage({ resource }) {
return (
<SuspenseList revealOrder="forwards"> <ProfileDetails resource={resource} />
<Suspense fallback={<h2>Loading posts...</h2>}>
<ProfileTimeline resource={resource} />
</Suspense>
<Suspense fallback={<h2>Loading fun facts...</h2>}>
<ProfileTrivia resource={resource} />
</Suspense>
</SuspenseList> );
}
Suspense 로 로딩상태를 분리함으로 인해서 코드는 훨씬 간결하게 처리할 수는 있었으나 만약 응답속도가 매우 빠르게 이루어지는 비동기 요청에 대해서는 Spinner 로 인해서 오히려 깜빡임이 발생할 수 있습니다.
-suspense 문제점 예제 / 출처: React 18 Concurrent 로 UX 개선하기
아래는 relay에서 사용되는 예제입니다. 이는 처음 화면을 로딩할 때 의미가 있지만, 이 경우에는 기존 UI를 숨기고 스피너로 대체할 이유가 없습니다. React가 대기하는 동안에는 이미 있는 것을 계속 표시하면 됩니다.
이를 해결해줄 수 있는 방법으로 리액트 18에서는 useTransition 이라는 API를 제공합니다.
useTransition 을 사용함으로써 Synchronous 렌더링 중에는 한번 렌더링이 시작되면 결과물을 화면에서 보기 전까지는 아무것도 막을 수 없지만, concurrent에서 렌더링은 중단될 수 있습니다.
주로 [loading, startTransition]을 반환합니다.
loading:
작업이 지연되고 있음을 알리는 boolean이며 로딩중을 알릴 수 있다.
startTransition:
낮은 우선순위로 실행할 함수를 인자로 받는다.
이때, 조금더 자세하게 설명하게 되자면 아래와 같이 긴급한 업데이트, 전환 업데이트가 있습니다.
긴급한 업데이트 (urgent updates) : 입력, 클릭, 누르기 같은 다이렉트 상호작용을 반영
전환 업데이트 (transition updates) : UI의 전환
import { startTransition } from 'react';
// 긴급한 업데이트 : 입력하고 있는 값
setInputValue(input);
// startTransition으로 래핑된 업데이트는 긴급하지 않은 것으로 처리되고, 더 긴급한 업데이트가 들어오면 중단된다.
startTransition(() => {
// 전환 업데이트: 입력값에 따른 쿼리값
setSearchQuery(input);
});
위와 같이 startTransition으로 래핑된 업데이트는 전환 업데이트로 처리되며, 긴급한 업데이트가 들어오면 중단된다. 전환이 중단되면 리액트는 stale한 렌더링 작업을 버리고 마지막 업데이트만을 렌더링하게 됩니다.
위에 작성한 것은 위에서 relay에 사용된 예제를 통해서 알아보겠습니다.
refetch 하는 부분을 startTransition 으로 감싸서 급하지 않은 전환 업데이트라는 것을 표시하였습니다.
그렇기 때문에 setSearchString이 우선적으로 업데이트 되어지고 이것이 완료된 후에 refetch가 발생되어 마지막 업데이트만을 렌더링 하게 됩니다. 그렇기 때문에 아래 영상에서와 같이 검색을 하고 있지만 이전 List들이 유지되어지다가 마지막 검색 결과가 보여지는 것을 확인할 수 있습니다.
한곳에서 여러개의 Suspense를 사용할 때 Suspense의 순서가 보장되어야 좋은 UI를 제공할 수 있다.
useTransition을 사용하므로써 Suspense에서 나타나는 문제점을 보완할 수 있다.