본문 바로가기
  • David is trying his best.
React

Suspense in React | 리액트 서스펜스

by myDav 2024. 6. 22.
반응형

 

리액스 Suspense 란?

비동기 작업을 보다 효과적으로  관리하여 사용자 경험을 개선하는 기능을 가지고 있다. 서스펜스를 이용하면 컴포넌트에서 필요한 데이터가 준비될 때까지 렌더링을 지연(suspense)시킬 수 있다. 리액트 16.6 버전에서 처음 도입되었다.

 

주로 아래 두 기능을 위해 사용되지만 이미지, 스크립트 또는 다른 비동기 작업을 위해서도 사용될 수 있다.

1. 코드 분할(Code split)

2. 서버 API 호출과 같은 데이터 가져오기(Data fetching) 

 

코드 분할 시 Suspense 사용 예

import React, {suspense, lazy} from 'react';

const LazyComponent = lazy(() => import ('./lazy/component');

function App() {
    return (
        <div>
            <Suspense fallback={<div>Loading...</div>}>
            	<LazyComponent />
            </Suspense>
        </div>
    );
}

 

<LazyComponent> 는 처음 번들 파일에 포함되지 않고, 요청이 있을 때 별도로 불러오게 된다. LazyComponent 컴포넌트가 로딩 중일 때 서스펜스의 fallback 프로퍼티가 보이게 되고, 컴포넌트가 성공적으로 로드되면, 서스펜스는 지연을 멈추고 해당 컴포넌트를 렌더링하게 된다.

 

이렇게 서스펜스와 코드 분할을 함께 사용함으로써, 큰 사이즈의 프론트엔드 애플리케이션도 사용자에게 빠르게 첫 화면을 보여줄 수 있으며, 필요한 리소스만 로드하여 성능을 최적화할 수 있게 된다.

 

Data fetching 시 Suspense 사용 예 

아래는 서버 API 호출시 서스펜스가 여러 번 사용되었을 때 어떻게 사용되는지를 보여주고 있다.

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

 

<ProfileDetails>, <ProfileTimeline /> 컴포넌트 안에서는 서버 API 통신을 통해 데이터를 가져오고 있고, 그에 따른 로딩시 UI 처리를 각각의 <Suspense> 의 fallback 프로퍼티를 통해 보여주고 있다.

 

다음은 <ProfilePage> 가 렌더링 될 때 일어나는 일이다.

1.  리액트는 <ProfilePage> 를 렌더링하려고 하고, <ProfileDetails>, <ProfileTimeline> 을 children으로 리턴한다.

 

2. 리액트는 <ProfileDetails> 를 렌더링 시도하지만, 그 안에서 API 호출이 일어나고 있고, 아무 데이터도 가져오지 않은 상태이다. 그러므로 이 컴포넌트는 지연된다. 리액트는 이를 건너 뛰고, 렌더 트리 안의 다른 컴포넌트 먼저 렌더링하려고 한다.

 

3. 리액트는 <ProfileTimeline> 을 렌더링 시도하지만, 마찬가지고 API 호출이 일어나고 있고, 아무 데이터도 없다. 그러므로 이 컴포넌트 역시 지연된다. 이를 다시 건너 뛰고, 리액트는 다른 컴포넌트를 렌더링하려고 한다.

 

4. 렌더링할 것이 더 이상 없다. <ProfileDetails> 컴포넌트가 지연됐기에, 리액트는 가장 가까운 상위 서스펜스의 fallback 프로퍼티인 다음을 보여준다: <h1>Loading profile...</h1>

 

5. 여기서,

<ProfileDetails> 내부의 api 호출이 먼저 마무리되면, 상위 서스펜스의 fallback이 사라지고 <ProfileTimeline > 상위의 서스펜스, Loading posts... 가 보이게된다.

또는, <ProfileTimeline> 내부의 api 호출이 먼저 끝난다면, <ProfileDetails> 의 호출이 아직 완료되지 않은 상태이므로, 가장 바깥쪽의 서스펜스가 보이게 되고, 이 컴포넌트의 api 호출이 끝나면, <ProfileDetails> 컴포넌트가 렌더링되고, 안쪽 컴포넌트도 바로 렌더링된다.

 

이를 통해 서스펜스는 계층적으로 처리된다는 것을 알 수 있고, 이러한 방식으로 비동기 데이터 로딩을 더 사용자 친화적으로 만들어 줄 수 있다.

 

Suspense 의 사용 작동원리

그렇다면 서스펜스는 fallback을 보여줄지, children을 보여줄지 어떻게 아는 것일까? 바로 Promise 가 throw 될 때 서스펜스는 fallback 을 보여준다. 즉, 어찌보면 서스펜스는 리액트의 <ErrorBoundary> 와 비슷하게 동작한다고 할 수 있다. 서스펜스는 Promise 가 throw 된 것을 catch한다.

 

서스펜스를 사용할 컴포넌트는 비동기 상황에서 Promise 를 다뤄야 하며, Suspense를 위해 Promise를 throw 해야 한다.

 

Promise 가 pending 상태라면 → throw promise

Promise 가 success로 성공한 상태라면 return '데이타'

Promise 가 error 상태라면 throw error 

 

아래는 throw Promise 될 때의 대략적인 구현을 공식문서에서 가져온 것이다. 

function fetchUser() {
    return new Promise(resolve => {
    	setTimeout(() => {
        	resolte({name: 'david' })
        }, 1000);
    };
}

function wrapPromise(promise) {
    let status = 'pending';
    let result;    
    let suspenser = promise.then(
    	(resolve) => {
    		status = 'success';
        	result = r;
        },
        (reject) => {
        	status = 'error';
            result = e;
        }
    );    
    
    return {
    	read() {
        	if (status === 'pending') {
            	throw suspenser;
            } else if (status === 'error') {
            	throw result;
            } else if (status === 'success') {
            	return result;
            }
        }
    };
}

// 사용시
function fetchProfile() {
    let userPromise = fetchUser();
    return wrapPromise(userPromise);
}

 

작동 중인 Promise 를 직접 던지는 것이 아니라, Promise 객체의 레퍼런스(참조)를 던지고 Suspense 가 처리할 수 있도록 설계되어 있는 듯하다. 실제 Promise 는 계속 작동하게 되고, 데이터를 성공적으로 가져오면 상태를 업데이트한다.

 

 

왜 'Suspense' 를 사용하나요?

- 서스펜스를 사용하면 로딩시 보여줄 상태를 보다 선언적으로 보여줄 수 있다.

이는 개발자가 데이터 로딩과 같은 UI의 동적인 부분들을 좀 더 직관적으로 설계할 수 있도록 도와준다.

 

- 컴포넌트에서 로딩 시의 UI를 분리하여 컴포넌트 목적에 부합하는 일만 시킬 수 있게 한다. 

예를 들어, 컴포넌트에서 데이터 로딩시 아래와 같이 if 문을 이용하여 '로딩중' 상태를 체크하는 보일러플레이트를 없앨 수 있다.

function UserProfile() {
    const { data, isLoading } = fetchUser();
    ...
    if (isLoading) return ...
    ...
 }

 

직접적으로 필요한 일 이외에 부가적인 로직을 없애면서 각 컴포넌트가 로딩 상태를 직접 관리할 필요가 없게 되면서, 컴포넌트 사이에서 일관된 로딩 경험을 제공하고, 중복되는 코드를 줄일 수 있다.  

 

- 또한, 로딩 상태에 따른 디자인 변경 또한 세밀하게 설정할 수 있다. 예를 들어 <UserProfile>, <UserTimeline>이 동시에 나타내야 하는 경우, 하나씩 최대한 빨리 나타내야 하는 경우 등을 서스펜스 위치와 갯수를 조정하면서, 서로의 컴포넌트를 침범하지 않고 효과적으로 이뤄질 수 있다. 

 

- 더욱이, throw 된 프로미스에서 에러가 발생시 에러가 throw되기 때문에 서스펜스와 에러 바운더리와 결합되면 오류를 효과적으로 처리할 수 있다.

에러 바운더리는 동기적 에러에 대해서만 잡아낼 수 있지만, 서스펜스와 함께 사용되면, 서스펜스 내부에서 발생한 에러도 잡을 수 있게 된다. 서스펜스가 자식 컴포넌트로부터 throw된 프로미스를 잡아서 대기하는 동안, 프로미스 내부에서 발생할 수 있는 데이터 로드 중 발생하는 에러 등을 에러 바운더리가 캐치하고 관리할 수 있게 해주기 때문이다.

 

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <ErrorBoundary fallback={<h2>Could not fetch posts.</h2>}>
        <Suspense fallback={<h1>Loading posts...</h1>}>
          <ProfileTimeline />
        </Suspense>
      </ErrorBoundary>
    </Suspense>
  );
}

 

위와 같이 사용된다면 렌더링 에러와 서스펜스 안에서 발생한 데이터 fetching 시의 에러도 처리할 수 있게 된다.

 

 


리액트 공식문서 - https://react.dev/reference/react/Suspense#suspense

리액트 공식문서(레거시)https://17.reactjs.org/docs/concurrent-mode-suspense.html#start-fetching-early

 

A practical example of Suspense in React 18 - https://dev.to/darkmavis1980/a-practical-example-of-suspense-in-react-18-3lln

 

React Suspense for Data Fetching: A Comprehensive Guide - https://thecodeframework.com/react-suspense-for-data-fetching-a-comprehensive-guide/

 

React Suspense: Core Concept, Use Cases, and Best Practices - https://www.angularminds.com/blog/react-suspense-core-concept-use-cases-and-best-practices

 

How does react Suspense work? - https://www.youtube.com/watch?v=8YQXeqgSSeM

 

How to Use React.Suspense to wait for an image to load - https://sergiodxa.com/tutorials/react/suspense-image-loading

반응형