Skip to content

React Suspense 도입 과정에서 발생한 성능 저하 개선하기

이지은 (오월) edited this page Dec 20, 2023 · 5 revisions

React Suspense 도입하기

명령형 프로그래밍으로 인한 복잡한 로직 발생

알고션 페이지는 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는 Suspense 컴포넌트 내부에서 발생한 Promise를 catch하여
해당 Promise가 resolve 되기 전까지 컴포넌트의 렌더링을 잠시 멈추고
대체 컴포넌트를 보여줄 수 있게 해주는 기능을 제공합니다.

이러한 Suspense의 특성과 데이터를 fetching하는 동안 Promise를 throw하는 비동기 데이터 관리 라이브러리를 함께 이용하면
선언적으로 로딩 로직을 구현할 수 있게 됩니다.


Suspense에 로딩과 관련된 로직을 위임하면
컴포넌트는 아무리 데이터 fetching이 많아지더라도 더 이상 로딩 상태 로직이 복잡해 질까봐 걱정하지 않아도 됩니다.

완전한 관심사 분리가 이루어지면서 개발 효율성이 증가하게 됩니다.


Suspense 도입

React v.18 이상, React-Query v5 버전

React SuspenseReact-Query를 이용하여 다음과 같이 로딩 로직을 분리했습니다.

React-Query에서 기본 제공되는 메서드로 데이터 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로 인해 발생한 성능 저하 개선하기

Suspense를 도입하자, 또다른 예상치 못한 문제가 발생했습니다.
바로 Waterfall 현상이 발생한다는 것이었습니다.​

Waterfall 현상이란​

데이터 fetching이 폭포처럼 순차적으로 일어나는 것을 의미합니다.

Suspense컴포넌트 내부에서 데이터 fetching을 실행했을 때,​
모든 API가 동시에 실행될 것이라는 예측과 달리 아래 그림과 같이 순차적으로 실행되었습니다.​

이는 모든 데이터 로딩이 완료될 때까지의 시간이
극단적으로 길어지는 성능 저하 문제로 이어졌습니다.

image

Waterfall 현상 발생 원인

Waterfall 현상이 발생했던 원인은
Suspense가 Promise resolve 될 때 까지 모든 후순위 작업을 일시중지하기 때문이었습니다.

이러한 특성으로 인해 해당 컴포넌트 내부의 API들이 순차적으로 실행되었고
랜더링이 완료되기 까지의 시간을 극단적으로 늘어났습니다.

따라서 순차적 호출을 제거하여 랜더링 속도를 개선할 필요가 있었습니다.


Waterfall 현상을 제거하여 랜더링 속도 개선하기

Waterfall 현상을 제거하기 위한 방법으로는 2가지가 있었습니다.​

하나는 react query에서 기본 제공되는 useSuspenseQueries 메서드를 이용하는 것이며,​
다른 하나는 Suspense당 하나의 비동기만 담당하도록 API를 각 다른 Suspense로 감싸주는 것이었습니다.​

두 방법의 장단점은 아래와 같았습니다.

||useSuspenseQueries|Suspense 분리| ||--|--| |가독성|좋음|나쁨| |코드 유연성|나쁨|좋음|

두 방법의 장단점을 비교한 결과 현재 컴포넌트 로직에는 코드 유연성보다 가독성이 중요하다고 판단**하여​
useSuspenseQueries를 이용하는 방법으로 Waterfall 현상을 해결했습니다.

// 개선 후
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>
  );
};

랜더링 성능 개선 결과

useSuspenseQueries를 적용한 결과​
모든 데이터 fetching이 비동기적으로 실행되면서
아래 그림과 같이 랜더링 성능을 개선할 수 있었습니다.​

개선 전 개선 후
image image

🌊 ALGOCEAN

TEAM : 강서(대문)구

기획

아키텍처

스프린트 계획회의

데일리스크럼

팀 회고

개발 일지

태호

more

지호

more

지은

more

승규

more

멘토링 일지

Clone this wiki locally