알고션 페이지는 SPA (Single Page Application) 특성상 동적 데이터 fetching으로 인한 CLS (Cumulative Layout Shift)을 막기 위해 로딩 상태에서는 로딩 화면을 보여줍니다.
기존에 로딩 상태를 제어하는 로직은 아래 코드와 같이 조건문을 통한 명령형 프로그래밍 방식
으로 작성되었습니다. 이는 상황이 다양해 질수록 코드가 점점 지저분해지고 복잡해 진다는 명령형 프로그래밍 방식의 고질적인 문제로 이어졌습니다.
// 명령형 방식으로 로딩 상태를 제어하는 로직. 매우 복잡하다.
const MyComponent = () => {
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState(null);
const updateData = async () => {
if (isLoading) return;
setIsLoading(() => true);
const data = await getServerData();
setData(() => data);
setIsLoading(() => false);
};
useEffect(() => {
updateData();
}, []);
return (
<>
{isLoading && <Loading />}
{!isLoading && !!data && <ChildComponent data={data} />}
</>
);
};
당시 알고션 페이지는 실제로 동작하는 컴포넌트라는 특성상 예시보다 훨씬 더 지저분하고 복잡한 로직을 가지고 있었습니다. 🥲
앞으로 페이지 기능이 확장되는 것을 고려해 봤을 때 데이터 fetching 기능이 추가될수록 로딩 상태를 관리하는 로직은 점점 더 복잡해질 것이 확실했으며, 이는 결국 유지보수에 큰 문제가 되리라 판단했습니다.
따라서 로딩과 관련된 로직을 컴포넌트와 분리하여 선언적으로 다루기 위해 Suspense
를 도입하기로 결정했습니다.
Suspense
는 Suspense 컴포넌트 내부에서 발생한 Promise를 catch하여 해당 Promise가 resolve 되기 전까지 컴포넌트의 렌더링을 잠시 멈추고 대체 컴포넌트를 보여줄 수 있게 해주는 기능을 제공합니다.
이러한 Suspense의 특성과 데이터를 fetching하는 동안 Promise를 throw하는 비동기 데이터 관리 라이브러리를 함께 이용하면 선언적으로 로딩 로직을 구현할 수 있습니다.
Suspense에 로딩과 관련된 로직을 위임하면 컴포넌트는 아무리 데이터 fetching이 많아지더라도 더 이상 로딩 상태 로직이 복잡해 질까봐 걱정하지 않아도 됩니다. 즉 완전한 관심사 분리를 통해 코드 유지보수성이 증가합니다.
React v.18 이상, React-Query v5 버전
React Suspense
와 React-Query
를 이용하여 다음과 같이 로딩 로직을 분리했습니다.
Suspense가 로딩 상태를 감지하기 위해선 해당 컴포넌트가 Promise를 던져주는 것이 필요합니다. 다행히 React-Query에서 기본 제공되는 메서드 : useSuspenseQuery
에 해당 기능이 기본으로 제공됩니다. useSuspenseQuery
로 데이터 fetching 로직을 구현한 후 로딩 처리가 필요한 컴포넌트 상단에 선언적으로 Suspense
를 감싸주어 데이터를 fetching이 이루어지는 동안 Suspense
가 알아서 로딩 화면을 보여주도록 했습니다.
const MySuspenseComponent = () => {
const { data: data1 } = useSuspenseQuery({
queryKey: ["DATA_1"],
queryFn: getServerData,
});
const { data: data2 } = useSuspenseQuery({
queryKey: ["DATA_2"],
queryFn: getServerData,
});
return (
**<Suspense fallback={<Loading />}>**
<ChildComponent data={data1} />
<ChildComponent data={data2} />
**</Suspense>**
);
};
Suspense를 도입하자, 또다른 예상치 못한 문제가 발생했습니다.
바로 Waterfall
현상이 발생한다는 것이었습니다.
데이터 fetching이 폭포처럼 순차적으로 일어나는 것을 의미합니다.
Suspense
컴포넌트 내부에서 데이터 fetching을 실행했을 때, 모든 API가 동시에 실행될 것이라는 예측과 달리 아래 그림과 같이 순차적으로 실행되었습니다. 이는 모든 데이터 로딩이 완료될 때까지의 시간이 극단적으로 길어지는 성능 저하 문제로 이어졌습니다.
Waterfall 현상이 발생했던 원인은 Suspense가 Promise resolve 될 때 까지 모든 후순위 작업을 일시중지하기 때문이었습니다. 이러한 특성으로 인해 해당 컴포넌트 내부의 API들이 순차적으로 실행되었고 랜더링이 완료되기 까지의 시간을 극단적으로 늘어났습니다.
따라서 순차적 호출을 제거하여 랜더링 속도를 개선할 필요가 있었습니다.
Waterfall 현상을 제거하기 위한 방법으로는 2가지가 있었습니다.
하나는 react query
에서 기본 제공되는 useSuspenseQueries
메서드를 이용하는 것이며, 다른 하나는 Suspense
당 하나의 비동기만 담당하도록 API를 각 다른 Suspense
로 감싸주는 것이었습니다.
두 방법의 장단점은 아래와 같았습니다.
useSuspenseQueries | Suspense 분리 | |
---|---|---|
가독성 | 좋음 | 나쁨 |
코드 유연성 | 나쁨 | 좋음 |
두 방법의 장단점을 비교한 결과 현재 컴포넌트 로직에는 코드 유연성보다 가독성이 중요하다고 판단하여 useSuspenseQueries
를 이용하는 방법으로 Waterfall 현상을 해결했습니다.
// useSuspenseQueries 적용
const MySuspenseComponent = () => {
const getData1 = {
queryKey: ["DATA_1"],
queryFn: getServerData,
};
const getData2 = {
queryKey: ["DATA_1"],
queryFn: getServerData,
};
const { data } = useSuspenseQueries({
queries: [getData1, getData2]
};
const [data1, data2] = data;
return (
<Suspense fallback={<Loading />}>
<ChildComponent data={data1} />
<ChildComponent data={data2} />
</Suspense>
);
};
useSuspenseQueries
를 적용한 결과 모든 데이터 fetching이 비동기적으로 실행되면서 아래 그림과 같이 랜더링 성능을 개선할 수 있었습니다.
개선 전 | 개선 후 |
---|---|
코드를 선언적으로 관리하고 필요 이상으로 복잡해지는 것을 방지하기 위해 React Suspense를 도입해 보았습니다.
React Suspense를 도입하는 과정에서 Waterfall 현상으로 인한 랜더링 성능 저하가 발생하였으나, 개선 방법 비교 분석을 통해 React Query의 useSuspenseQueries
방식을 선택하여 성능 저하를 개선할 수 있었습니다.
마치 React가 로딩 로직 핸들링을 쉽게 하고 싶은 제 마음을 이미 알고 있었다는 것 마냥 Suspense
기능을 기본 제공하고 있었다는 것이 인상깊었습니다. 개발자들 마음은 다 똑같은 걸까요. 😁 다음에는 ErrorBoundary를 이용하여 일괄적 에러 핸들링을 도전해보고 싶다는 생각이 드네요 :D