[React] Suspense 이해하고 활용하기 01 - Suspense를 활용하면 스피너 지옥에서 탈출할 수 있습니다
function TrafficCard() {
const { data, isLoading } = useSWR('/api/traffic', fetcher);
if (isLoading) {
return <Spinner />;
}
return <Card>{/* `data`를 사용하는 내용 */}</Card>;
}
function OfferCard() {
const { data, isLoading } = useSWR('/api/offer', fetcher);
if (isLoading) {
return <Spinner />;
}
return <Card>{/* `data`를 사용하는 내용 */}</Card>;
}
function Dashboard() {
return (
<>
<TrafficCard />
<OfferCard />
</>
);
}
위의 코드는 "traffic" 데이터와 "offer"데이터를 각각 가져오는 컴포넌트들을 하나의 페이지에서 렌더링하는 예제 코드입니다.
각 컴포넌트에서 데이터를 가져오는 동안 "Dashboard"페이지에서는 로딩 스피너가 각각 돌아갈것입니다.
솔직히 말해서 그냥 보면 문제가 없는것처럼 보입니다. 오히려 매우 흔한 로직입니다.
하지만 지금처럼 두개의 데이터가 아니라 여러개의 데이터 패칭 컴포넌트가 존재하는 경우에는 어떻게 될까요?
대시보드에 많게는 10개의 스피너가 돌아갈것이고, 데이터가 도착할때마다 재렌더링이 발생할것입니다.
이것은 Cumulative Layout Shift (CLS)의 점수를 매우 낮추는 결과로 이어질 수 있습니다.
다시말해서 유저가 해당 페이지에 접근하고 페이지를 탐색하는 동안 레이아웃이 계속 움직일 것이고, 이는 사용자 경험을 낮출 수 있습니다.
물론 데이터 패칭 로직을 상위 컴포넌트에서 모두 처리한 후 props 드릴링으로 데이터를 내려주는 방법도 하나의 해결책이 될 수 있습니다.
function Dashboard() {
const {
data: trafficData,
isLoading: trafficIsLoading,
} = useSWR('/api/traffic', fetcher);
const {
data: offerData,
isLoading: offerIsLoading,
} = useSWR('/api/offer', fetcher);
const {
data: placementsData,
isLoading: placementsIsLoading,
} = useSWR('/api/placements', fetcher);
const {
data: audienceData,
isLoading: audienceIsLoading,
} = useSWR('/api/audience', fetcher);
const {
data: budgetData,
isLoading: budgetIsLoading,
} = useSWR('/api/budget', fetcher);
const {
data: insightsData,
isLoading: insightsIsLoading,
} = useSWR('/api/insights', fetcher);
// ...
}
하지만 이 방법은 흔히 말하는 안티패턴입니다.
관심사 분리가 전혀 안되어있고, 모든 로직이 하나의 컴포넌트에 모여있어서 가독성, 성능등의 이슈가 발생할 수 있습니다.
이러한 문제를 React 팀에서는 2018년부터 인지하고 있었으며, 이러한 문제의 해결책으로 `Suspense`라는 개념을 제시합니다
function Dashboard() {
return (
<React.Suspense fallback={<Spinner />}>
<TrafficCard />
<OfferCard />
</React.Suspense>
);
}
`Suspense`의 사용법은 간단합니다.
위의 예시처럼 로딩이 필요한 컴포넌트들을 `<Suspense>`로 래핑하고 `fallback` prop에 로딩 UI 컴포넌트를 제공하면됩니다.
이러면 children에 속하는 컴포넌트 그룹들의 로딩이 전부 종료되면 렌더링이 됩니다.
즉 처음 해결책으로 제시한 상위 컴폰넌트로 데이터 패칭 로직을 끌어올려서 해결한 방법과 같은 사용자 경험을 주면서, 개발자 경험은 컴포넌트 분리등이 유지된채로 존재할 수 있습니다.
Suspense은 이름에서 알 수 있듯이 children 그룹의 데이터 패칭이 끝나기 전까지 렌더링을 중단시켜줍니다.
이렇게 한 문장으로 작성하면 매우 간단한 기능을 수행하는 것처럼 보이지만 조금만 생각해보면 의문점을 찾을 수 있습니다.
어떻게 하위 컴포넌트 그룹의 컴포넌트들의 로딩 상태를 판단할 수 있을까요?
사실 리액트는 사이드 이펙트에 대한 정보를 알 방법이 없습니다.
리액트 컴포넌트에서 `fetch`등을 이용해서 데이터를 가져오는 로직을 작성해도 이는 리액트 핵심 역할(렌더링)과는 무관한 사이드 이펙트입니다. 즉 리액트 이 상황에 대해서 관여하지 않습니다.
그러면 `Suspense`는 어떻게 로딩 상태를 캐치할까요?
자세히 들여다 보면 `Suspense`는 일종의 경계를 생성합니다.
그리고 하위 컴포넌트에서는 각각 데이터 패칭 요청 자체를 `throw`합니다. 이 던져진 요청을 경계에서 캐치하고 로딩 상태를 판단합니다.
조금더 자세히 말하면, 데이터 요청은 `Promise`이고, Promise에는 `pending`, `rejected`, `fulfiled` 상태가 존재합니다.
각 컴포넌트들은 이 Promise가 pending이라면 요청을 throw합니다.
function TrafficCard() {
const [data, setData] = React.useState(null);
const [fetchRequest] = React.useState(() => {
return fetch('/api/traffic')
.then((res) => res.json())
.then((data) => {
setData(data);
});
});
if (fetchRequest.status === 'pending') {
// pending이면 throw
throw fetchRequest;
}
return ...;
}
그리고 이 던져진 상태를 캐치하여 Suspense는 fallback을 표시할지, 컨텐츠를 렌더링할지 판단합니다