[deep-frontend] Suspense와 Waterfall, Error Boundary -1

Zoey·2023년 11월 9일
0

로딩 상태에 대한 분기처리

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 } = useSettingQuery();
    const { data: setting3, isLoading: isLoading3 } = useSettingQuery();
  	
  	if(isLoading1 || isLoading2 || isLoading3) {
    	return null;
    }
  	
  	return (
    	<SettingPageForm>
      		{...}
      	</SettingPageForm>
    )
}

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

Suspense란?

Suspense를 사용하면 컴포넌트가 렌더링 하기 전에 다른 작업이 먼저 이루어지도록 대기한다.

Suspense는 React Componenet 내부에서 비동기적으로 다른 요소를 불러올 때 해당 요소가 불러와질 때까지 Component의 렌더링을 잠시 멈추는 용도로 사용할 수 있는 컴포넌트다.

import { Suspense, lazy } from 'react';

const HugeComponent = lazy(() => import('./HugeComponent'));

const ComponentWithSuspense = () => {
  return (
    <Suspense fallback={<Spinner />}>
      <HugeComponent />
    </Suspense>
  );
};

export default ComponentWithSuspense;

위 컴포넌트는 내부적으로 를 화면에 그려주는 역할을 한다. 는 이름과 같이 엄청나게 용량이 큰 컴포넌트여서, 이 컴포넌트가 화면에 그려져야 할 때 비동기적으로 사용자에게 전달되기를 바란다.

그 바를 이루기 위해 위 코드에서는 lazy를 사용해서 를 비동기적으로 불러오게끔 구성하였다.
컴포넌트에서 를 Suspense를 사용해서 컴포넌트 내부에 비동기적으로 불러오고, 가 불러와지는 중에는 Suspense의 fallback Prop을 통해 를 화면에 보여준다.

특정 컴포넌트를 비동기적으로 불러와서 화면에 보여주는데, 비동기 로딩이 진행 중인 상태에는 그에 맞추어 스피너를 화면에 노출하는 이 상황, 화면을 어떻게 그릴지에 집중하는 것이 아니라 무엇을 보여줄 것인지에 집중하였다고 보여진다.

"선언형 컴포넌트를 사용한 컴포넌트는 「무엇을(WHAT) 보여줄 것이냐」에 집중한다."

위 예시의 Suspense와 Lazy를 사용한 "동적으로 컴포넌트 불러오기"는 Router와 함께 Code Splitting에 주로 상요된다.

데이터를 불러오기 위한 Suspense

위에서 Suspense를 사용하여 비동기적으로 컴포넌트를 불러오는 기능에 대해서 살펴보았다. Suspense를 사용하여 동적으로 컴포넌트를 불러오는 화면을 선언적으로 구성할 수 있었다.

그렇다면 Suspense를 사용해서 비동기 데이터도 선언적으로 처리할 수 있을까? 만약 가능하다면 API 호출 상황에 따라 화면을 어떻게 그릴지를 고민하지 않고 "API 로딩 중인 경우"와 "비동기 데이터가 불러와진 경우"에 따라 무엇을 사용자에게 보여줄지를 바탕으로 컴포넌트를 구성할 수 있을 것이다.

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

function wrapPromise(promise) {
	let status = 'pending'
    let response
    
    const suspender = promise.then(
    	(res) => {
        	status = 'success'
          	reponse = res
        },
      	(err) => {
        	status = 'error'
          	reponse = 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 UserPage

본래 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를 사용하기 위해서 리액트 쿼리에 해줘야할 일은 평소처럼 query에 suspense 옵션을 추가해주면 된다.
이렇게 하면 Suspense를 통해서 로딩 상태를 제거하고 컴포넌트를 간결하게 선언할 수 있다.
특히 로딩상태로 인해 조건문이 줄줄이 붙어있던 부부닝 사라지면서 컴포넌트 파일안에서 컴포넌트 핵심 로직에만 집중할 수 있게 된다.

Waterfall

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

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

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

const SettingPage = () => {
	/* 
    const { data: setting1, isLoading: isLoading1 } = useSettingQuery();
    const { data: setting2, isLoading: isLoading2 } = useSettingQuery();
    const { data: setting3, isLoading: isLoading3 } = useSettingQuery();
    */
  
  const result = useQuerise([
    	{
          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 버전부터 사용이 가능하다.

혹시 4.5 이하의 버전이라면 다음과 같이 수정이 가능하다.

<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가 동시 처리가 가능해진다.

Error Boundary

에러 경계는 하위 컴포넌트 트리의 어디에서든 자바스크립트 에러를 기록하며 깨진 컴포넌트 트리 대신 fallback UI를 보여주는 React 컴포넌트이다.

Error Boundary는 React Component 내부에서 에러가 발생한 경우 사용자에게 잘못된 UI나 빈 화면을 보여주는 대신 미리 정의해 둔 Fallback UI를 화면에 보여주기 위한 컴포넌트이다.

Error Boundary의 기본적인 사용 방법은 다음과 같다.

import { Component } from 'react';

class MyCustomErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 폴백 UI가 보이도록 상태를 업데이트 한다.
    return { hasError: true };
  }
  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있다.
    logErrorToMyService(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 폴백 UI를 커스텀하여 렌더링할 수 있다.
      return <h1>Something went wrong.</h1>;
    }

    return this.props.children;
  }
}

const App = () => {
  return (
    <MyCustomErrorBoundary>
      <MyApp />
    </MyCustomErrorBoundary>
  );
};

export default App;

Error Boundary는 getDerivedStateFromError 또는 componentDidCatch(혹은 둘 다) 멤버 함수를 갖는 React Component이다. 하위 컴포넌트 렌더링 과정에서 에러가 발생할 경우 (마치 catch {} 구문처럼) 상위 Error Boundary에서 에러를 받아 Fallback UI를 처리하거나 Error Tracker로 에러 리포팅을 할 수 있다.

Error Boundary에서 에러를 받아 Fallback UI를 처리? 언뜻보면 선언형 컴포넌트라고 생각할 수 있다. 하지만 선언형 컴포넌트를 사용한 컴포넌트는 "무엇을 보여울 것이냐"에 집중한다.

Error Boundary를 잘 사용하면 애플리케이션 내부에서 "에러"가 발생한 상황을 사용자에게 우아하게 보여줄 수 있다. 컴포넌트 내부에서 state를 통해 에러 UI를 관리하고 사용자에게 보여주는 것이 아니라, 에러가 발생한 상황에 어떤 화면을 Fallback으로 보여줄 것인지를 고민할 수 있는 것이다.

Error Boundary를 더 쉽게 쓰기 위한 react-error-boundary

Error Boundary를 더 쉽게 사용하기 위해 react-error-boundary라는 Component를 사용할 수 있다.

react-error-boundary는 getDerivedStateFromError나 componentDidCatch를 사용하여 직접 에러 UI 상태를 구현해야 하는 Error Boundary를 추상화하여 아래와 같이 사용할 수 있게 정이한 컴포넌트이다.

import { ErrorBoundary } from 'react-error-boundary';

import {sendErrorToErrorTracker} from '../utils';

const UserProfileFallback = ({ error, resetErrorBoundary }) => (
	<div>
  		<p> 에러: {error.message} </p>
		<button onClick={() => {resetErrorBoundary()}}> 다시 시도하기 </button>
  	</div>
);

const handleOnError = (error) => sendErrorToErrorTracker(error);

const User = () => (
	<ErrorBoundary
  		FallbackComponent={UserProfileFallback}
		onError={handleOnError}
  	>
  		<UserProfile>
  	</ErrorBoundary>
)

export default User

react-error-boundary를 사용하면 컴포넌트에서 제고하는 FallbackComponent나 onError같은 Props를 사용하여 사용자에게 Fallback UI를 편리하게 보여주고 AEM에 에러 리포팅을 수행하는 등의 기능을 편리하게 구현할 수 있다.

더 나아가 resetErrorBoundary 함수를 FallbackComponent 컴포넌트의 Props로 제공하므로 "다시 시도"등의 UI 요소도 쉽게 추가할 수 있으니, Error Boundary 사용이 필요한 상황에서 선택지 중 하나로 고려하기 좋읗 것이다.

profile
프론트엔드 개발자가 되기위해 기록하고 공유하는 Zoey 블로그입니다.

0개의 댓글