Suspense 도입과 Waterfall 현상 해결하기

sxungchxn.dev·2023년 2월 13일
19

Frontend

목록 보기
4/7

⏳ 로딩 상태에 대한 분기처리

사내 프로젝트에서 환경 설정 페이지의 추가적인 기능들을 구현하는 부분을 맡게되었다. 서버 상태를 관리하고 이용하는데 있어서 React Query 를 활용하고 있었는데 대부분의 구조가 아래와 같이 명령형으로 서버 로딩상태에 대해 분기 처리되고 있었다.

const SettingPage = () => {
  const { data, isLoading } = useSettingQuery(); 
  	
  if(isLoading){
    return null;
  }
  
  return (
   	<SettingPageForm>
      {...}
    </SettingPageForm>
  )
}

로딩 중일때는 어떠한 컴포넌트도 보여주고 있지 않다가 로딩이 완료되면 띄우고자 하는 컴포넌트를 띄우는 방식이다. 이러한 방식은 컴포넌트가 자신의 핵심 로직 외에도 로딩상태에 대한 로직을 포함하고 있어야만 했다. 이는 코드의 가독성을 떨어뜨리고 컴포넌트의 독립성을 해치는 좋지 못한 패턴으로 여겨졌다. 특히나 요구사항을 추가해야하는 나로써는 위의 로직을 그대로 사용한다면 아래와 같은 코드를 작성해야했다.

const SettingPage = () => {
  const { data: setting1, isLoading: isLoading1 } = useSettingQuery(); 
  const { data: setting2, isLoading: isLoading2 } = useSettingQuery2();
  const { data: setting3, isLoading: isLoading3 } = useSettingQuery3();
  	
  if(isLoading1 || isLoading2 || isLoading3){
    return null;
  }
  
  return (
   	<SettingPageForm>
      {...}
    </SettingPageForm>
  )
}

사용해야되는 쿼리가 늘어남에 따라 로딩 상태에 대한 개수도 늘어나고 이는 if 조건문을 비대하게 만들어버린다. 이를 개선하고자 비동기 상태에 대한 처리 로직을 컴포넌트 외부로 따로 뽑아 처리할 수 있는 Suspense 를 도입하기로 했다.


👏 Suspense를 통해 선언형 컴포넌트로 바꾸기

Suspense 란 컴포넌트 내에서 사용하는 비동기적 데이터를 불러오는 동안 인자로 지정한 fallback 을 대체해서 보여주었다가 비동기처리가 완료되면 컴포넌트의 UI를 보여주는 역할을 하는 녀석이다.

function wrapPromise(promise) {
  let status = 'pending'
  let response

  const suspender = promise.then(
    (res) => {
      status = 'success'
      response = res
    },
    (err) => {
      status = 'error'
      response = err
    },
  )

  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender
      case 'error':
        throw response
      default:
        return response
    }
  }

  return { read }
}

export default wrapPromise


/*****************************************************/
  
function fetchData(url) {
  const promise = fetch(url)
    .then((res) => res.json())
    .then((res) => res.data)

  return wrapPromise(promise)
}


/*****************************************************/

import { Suspense } from 'react';
  
const resource = fetchData('fetchURL...');

const UserPage = () => {
  const userData = resource.read()

  return (
    <div>
      <Suspense fallback={<p>Loading user details...</p>}>
        <UserProfile data={userData} />
      </Suspense>
    </div>
  )
}

export default UserWelcome

본래 Suspense 를 사용하려면 위와 같이 promise 객체를 인자로 받아 상태에 따라 반환값을 달리해주는 함수를 구현한뒤 이를 컴포넌트 내부에서 사용해야한다. 위에서 보다시피 구현체가 복잡하며 컴포넌트 위에 리소스 변수를 선언해야되는 등 사용하기가 까다롭다.

다행히도 React Query 를 비롯한 다양한 서버 상태 관리 라이브러리에서 Suspense 기능을 손쉽게 사용할 수 있도록 알맞은 API를 제공해주고 있다.


const fetcher = (url) => axios.get(url);


/*****************************************************/
  
  
const UserPage = () => {
 	const { data } = useQuery(['user'], fetcher, { suspense: true });
  
  return (
    <div>
        <UserProfile data={userData} />
    </div>
  )
}

/*****************************************************/

<Suspense fallback={<p>loading...</p>}>
  <UserPage/>
</Suspense>

Suspense를 사용하기 위해서 리액트 쿼리에 해줘야할 일은 평소처럼 사용하던 querysuspense 옵션을 추가해주면 된다.

사내 프로젝트에서도 Suspense 를 통해서 로딩상태는 제거하고 컴포넌트를 간결하게 선언할 수 있었다.

const SettingPage = () => {
  const { data: setting1 } = useSettingQuery(); 
  const { data: setting2 } = useSettingQuery2();
  const { data: setting3 } = useSettingQuery3();
  
  return (
   	<SettingPageForm>
      {...}
    </SettingPageForm>
  )
}

/*****************************************************/


<Suspense fallback={<p>Loading</p>}>
  <SettingPage ... />
</Suspense>

특히나 로딩상태로 인해 조건문이 줄줄이 붙어있던 부분이 사라지면서 컴포넌트 파일안에서는 컴포넌트 핵심로직에만 집중할 수 있게 되었다.

🛝 Waterfall 발생

여기서 Waterfall 이란 순차적으로 물흐르듯 네트워크 상의 흐름이 발생하는 것을 뜻한다.

위의 사진을 보면 좀더 와닿을텐데 3개의 쿼리를 위해서 3번의 API 호출을 하는 과정에서 각각의 호출이 순차적으로 이루어진다는 것이다. 이는 컴포넌트의 코드를 읽는 과정에서 query 부분을 만날때마다 Suspense 에 의해서 API 호출로 인한 로딩이 발생하는 동안 fallback 컴포넌트로 대체되기 때문에 일어나는 현상이다.

이는 하나의 컴포넌트에 여러번의 비동기처리가 발생해야되기 때문에 발생한 것이다. 이를 해결해볼 수 있는 방법은 useQuery 대신 useQueries 훅을 사용해보는 것이다.

const SettingPage = () => {
  /*const { data: setting1 } = useSettingQuery(); 
  const { data: setting2 } = useSettingQuery2();
  const { data: setting3 } = useSettingQuery3();*/
  
  const results = useQueries([
    {
      queryKey: ['setting1'],
      queryFn: fetchData1,
      suspense: true,
    },
    {
      queryKey: ['setting2'],
      queryFn: fetchData2,
      suspense: true,
    },
    {
      queryKey: ['setting3'],
      queryFn: fetchData3,
      suspense: true,
    },
  ]);
  
  return (
   	<SettingPageForm>
      {...}
    </SettingPageForm>
  )
}

/*****************************************************/


<Suspense fallback={<p>Loading</p>}>
  <SettingPage ... />
</Suspense>

useQueries 훅은 다수의 쿼리를 처리할때 useQuery를 대신해서 사용할 수 있으며 suspense 옵션을 지정해주면 여러개의 쿼리도 병렬로 처리해준다. 하지만 이는 4.5 버전부터 사용이 가능했다. 특히나 현재 사내프로젝트에서는 리액트 쿼리 v3를 사용하고 있는 터였다. 버전업하기에는 여건이 되지 않는 터라 다른 방법을 고안해야했다.


<Suspense fallback={<p>Loading</p>}>
  <SettingPage ... />
</Suspense>

/*****************************************************/

const SettingPage = () => {
  
  ...
 
  return (
   	<SettingPageForm>
      <SettingSection1/>
      <SettingSection2/>
      <SettingSection3/>
    </SettingPageForm>
  )
}

const SettingSection1 = () => {
    const { data: setting1 } = useSettingQuery1(); 
  
  	return (
      	...
    );
}

const SettingSection2 = () => {
    const { data: setting2 } = useSettingQuery2(); 
  
  	return (
      	...
    );
}

const SettingSection3 = () => {
    const { data: setting3 } = useSettingQuery3(); 
  
  	return (
      	...
    );
}

다행히도 작업중인 페이지 컴포넌트 내부에는 각각의 쿼리가 하나의 섹션에만 사용되고 있어서 위와 같이 섹션별로 컴포넌트를 분리하고 각 섹션에서 하나의 쿼리만을 호출하여 사용하도록 분리했다. 이를 통해 아래와 같이 병렬적으로 세개의 API가 동시에 처리되는 것을 확인할 수 있었다.

⛳️ 출처 및 참고자료

profile
🏠 버튼을 누르면 더 많은 글들을 보실 수 있습니다

0개의 댓글