React 에서 화면을 개발하면서 필연적으로 네트워크 요청 등의 비동기 데이터를 처리 하게 된다.
이렇게 비동기 요청을 통해 받아온 데이터를 바탕으로 상태(state)를 변경하여 화면에 보여지게 하는 구현이 필요했다.
const App = () => {
const [data, setData] = useState(null);
useEffect(()=>{
fetchData().then(res => setData(res))
},[]);
if(!data){
return <div>로딩 중...<div/>
}
return <div>{data}<div/>
}
위와 같이 데이터를 받아오면 수동으로 State에 추가를 하고, 조건부 렌더링으로 “로딩 중” 이라는 UI가 보여지도록 하였다.
위와 같은 작업은 프로젝트가 커질수록 코드의 복잡도가 증가하고, 각 컴포넌트마다 비슷한 패턴의 코드가 반복되기도 하였다. 이런 방법은 직관적이지 않아 DX를 떨어트리는 단점을 보이기도 했다.
React 팀에서는 이러한 비동기 데이터의 로딩 상태, 에러 처리, 렌더링 중단을 더 일관적이고 선언적(declarative)으로 처리하는 방법이 필요하다고 판단했고, Suspense를 도입하게 되었다.
“비동기 데이터 로딩”
React Suspense 컴포넌트는 16.6 버전에서 실험적인 기능으로 먼저 소개가 되었고, 18버전에서 정식으로 출시가 된 기능이다.
children
- 렌더링 하려고 하는 실제 UI
- 비동기 로딩 (렌더링 지연)이 발생하는 컴포넌트
fallback
- 실제UI가 로딩되기 전까지 대신 렌더링 되는 “대체 UI”
- ReactNode 형식이면 무엇이든 가능
const App = ({children})=> {
return (
<Suspense fallback={<Spinner />}>
{children}
</Suspense>
)
}
Suspense는 children의 렌더링이 지연이 발생하면, 자동으로 fallback UI로 전환하고, 데이터가 준비가 되면 다시 children으로 전환한다.
만약 여기서 fallback UI 에서 지연이 발생하면, 가장 가까운 부모 Suspense가 활성화 된다.
여기서 주의 해야 하는 점은 Suspense는 Effect 또는 이벤트 핸들러 내부에서 가져오는 데이터를 감지하지 않는다. 그렇기 때문에 Suspense를 사용할 때는 어떤 데이터를 감지하고 활성화 하는지 알고 사용해야한다.
Suspense가 가능한 데이터
React.lazy를 활용한 지연 로딩 컴포넌트- 비동기 데이터 호출 (18 버전 추가)
use를 사용하여 Promise 값 읽기 (19 버전에서 추가)- Suspense가 가능한 프레임워크(SWR, Tanstack Query 등)를 사용한 데이터 가져오기
import {use} from 'react';
import { fetchData } from './data.js';
export default function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<Loading />}>
<Biography artistId={artist.id} />
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</>
);
}
// 데이터를 비동기로 불러오는 컴포넌트
function Albums({ artistId }) {
const albums = use(fetchData(`/${artistId}/albums`));
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
기본적으로 Suspense 내부의 전체 트리는 “하나의 단위”로 취급된다. 그렇기 때문에 구성 요소 중 어떤 데이터에 의해 지연이 되더라도, “모든 구성 요소”가 함께 Fallback UI 로 대체된다.
<Suspense fallback={<Loading />}>
<ChildrenComponent1 /> // 비동기 호출 1
<ChildrenComponent2 /> // 비동기 호출 2
<ChildrenComponent3 /> // 비동기 호출 X
</Suspense>
Suspense 컴포넌트가 묶고 있는 아래 자식에서 2개 이상의 “비동기 호출”이 있다고 할 때 서로 각각 호출이 발생하지만, Suspense 아래에 “하나의 단위”로 취급되기 때문에, 가장 지연이 오래 걸리는 데이터의 로딩이 끝날 때 비로서 모든 컴포넌트가 함께 그려지게 된다.
즉, 하나의 Suspense로 묶여 있는 친구들은 언제나 항상 함께 등장하고, 사라진다는 것이다.
Suspense 컴포넌트는 일반적인 컴포넌트와 동일하게 중첩되어 사용할 수 있다. 이때 비동기 로딩이 발생하는 콘텐츠에서 가장 가까운 Suspense의 영향을 받게 된다.
예를 들어 아래와 같이 중첩되는 Suspense가 있다고 해보자.
<Suspense fallback={<BigSpinner />}> // 1번
<Biography /> // 비동기 호출
<Suspense fallback={<AlbumsGlimmer />}> // 2번
<Panel>
<Albums /> // 비동기 호출
</Panel>
</Suspense>
</Suspense>
이때 가장 안쪽에 있는 <Albums /> 에서 비동기 호출이 가장 빠르게 종료 된다고 한다고 하면 가장 가까운 2번 Suspense의 영향을 받게 된다.
그래서 <Biography /> 가 오래 지연이 된다고 하더라도, 내부에서 Fallback UI 상태로 계속 기다리지 않고, <Albums /> 이 바로 화면에 그려지게 된다.
즉, <Albums /> 은 데이터 로딩이 발생하면 가장 가까운 Suspense의 Fallback UI 인 <AlbumsGlimmer /> 으로 대체가 되고,
<Biography /> 은 가장 가까운 Suspense의 Fallback UI 인 <BigSpinner /> 로 대체가 되게 된다.
이렇게 Suspense를 중첩하여 사용하면, UI 어떤 부분이 항상 동시에 그려져야 하는지, 어떤 부분이 로딩 순서에 점진적으로 더 많은 콘텐츠를 보여주어야 하는지 조정할 수 있다.
앞서 Suspense를 만들기 위한 목적 중 한가지가 데이터 로딩 처리를 “선언적(declarative)”으로 처리하기 위함에 있었다.
즉, 비동기 처리에 대한 책임을 Suspense에 위임함으로써 컴포넌트 내부에서는 본 로직에만 집중할 수 있게 되었다.
그렇기 때문에 기존에 useEffect과 조건부 렌더링을 사용하던 방식을 벗어나 조금 더 가독성 좋은 코드를 작성할 수 있게 되었다.
그렇기 때문에 최근 React 19에서 나온 use 함수를 활용하거나, Suspense를 지원하는 Tanstack query, SWR 등을 라이브러를 활용하여 데이터를 받아올 수 있을 것이다.
뿐만 아니라, Streaming SSR 을 지원하면서 Next.js 와 같은 프레임워크를 사용할 때 조금 더 로딩 처리를 쉽게 할 수 있다.
Streaming SSR과 Suspense의 관계, 그리고 동작원리에 대해서는 다음 글에서 자세히 알아보자.