Suspense 탐구하기
2025/01/27
n°57
category : React
☼
2025/01/27
n°57
category : React
최근 Next.js 15, React 19의 새로운 패턴에 적응하면서 Suspense를 자주 사용하게 됐다. 이 과정에서 Suspense가 단순히 '로딩 상태를 보여주기' 이상으로 중요한 개념으로 느껴져 궁금해졌다.
이 글은 Suspense의 기본적인 사용법에서 시작하여 점진적 렌더링, 선언적 UI, Render As You Fetch 등의 키워드로 서스팬스를 탐구해본다.
<Suspense>lets you display a fallback until its children have finished loading
서스팬스는 자식 컴포넌트의 로딩상태가 끝날 때까지 fallback을 보여주도록 합니다.
공식문서에 따르면 허무할 정도로 심플하다. 렌더링할 컨텐츠가 로딩 중일 때 fallback ui를 보여준다.
Suspense가 모든 자식 컴포넌트의 로딩 상태를 감지하는 것은 아니다. 바운더리 내부에 있는 자식 컴포넌트가 다음 세 가지 조건 일 때 동작한다:
Suspense 지원 프레임워크의 데이터 요청
Next.js의 서버 컴포넌트 데이터 페칭
Relay의 GraphQL 쿼리
이외 Suspense를 지원하도록 설계된 프레임워크의 데이터 요청
(Tanstack Query, SWR 등등)
React.lazy()를 통한 동적 임포트
use 훅을 통한 Promise 캐시 값 읽기
React.use를 통해 캐시된 Promise 값을 읽을 때
이 조건을 주의하자. Suspense를 지원하는 프레임워크나 라이브러리를 사용한다면 문제가 없겠지만, 몇년 전까지만해도 지원하지 않는 경우가 많았다. 혹은 지원하더라도 따로 옵션을 설정하고, 컴포넌트 구성을 올바르게 해야하는 등 신경을 써야 한다.
세 조건 중 하나가 성립되면, 서스팬스는 자식 컴포넌트 트리 전체에서 대기 상태를 캐치한다. 부모-서스팬스 자식-비동기 작업의 패턴을 사용해야하므로, 익숙하지 않다면 혼동하기 쉽다.
// ❌ 잘못된 예시: 서스팬스가 트리거되지 않음
async function AsyncComponent() {
const data = await fetchArtistData();
// await로 data 요청이 끝난 이후 아래 렌더링이 실행된다.
return (
<Suspense fallback={<Loading />}>
<Profile data={data} />
{/*
비동기 작업 fetchArtistData이 완료된 이후 렌더링이 실행되기에,
Suspense는 이미 완료된 data의 대기 상태를 캐치할 수 없다 */}
</Suspense>
);
}
// ❌ 잘못된 예시: 서스팬스가 트리거되지 않음
function Profile() {
const [data, setData] = useState(null);
useEffect(() => {
fetchArtistData().then(setData);
}, []);
if (!data) return null;
return <div>{data.name}</div>;
}
function Page() {
return (
<Suspense fallback={<Loading />}>
<Profile /> {/* useEffect의 fetch는 서스팬스를 트리거하지 않음 */}
</Suspense>
);
}
// ❌ 잘못된 예시: 이벤트 핸들러의 비동기 작업
function Profile() {
async function handleClick() {
const data = await fetchArtistData();
// 이벤트 핸들러 내부의 fetch는 트리거되지 않음
// ...
}
return <button onClick={handleClick}>Load Data</button>;
}
// ✅ 올바른 예시: 서스팬스가 트리거됨
function Page() {
return (
<Suspense fallback={<Loading />}>
<Profile /> {/* fetchArtistData가 트리거되면 Loading이 표시됨 */}
</Suspense>
);
}
async function Profile() {
const data = await fetchArtistData(); // Suspense-enabled 요청
return <div>{data.name}</div>;
}또한 서스팬스는 useEffect나 이벤트 핸들러의 비동기 작업은 감지하지 않는다. 이는 리액트가 의도적으로 설계한 것으로, 서스팬스는 렌더링 과정에서 발생하는 비동기 작업만을 처리한다.
앞서 서스팬스는 자식 컴포넌트 트리 전체에서 대기 상태를 캐치한다고 하였다. 공식문서에서는 이러한 점을 활용해 서스팬스를 중첩하여 점진적으로 렌더링해가는 방법을 제시한다.
function ArtistPage({ artistId }) {
return (
<article>
<Suspense fallback={<PageSkeleton />}>
{/* 페이지 레이아웃을 감싸는 외부 서스팬스 */}
<Header />
<div className="content">
<Suspense fallback={<ProfileSkeleton />}>
{/* 프로필 데이터를 로드하는 컴포넌트 */}
<ArtistProfile id={artistId} />
</Suspense>
<Suspense fallback={<AlbumsSkeleton />}>
{/* 앨범 목록을 로드하는 컴포넌트 */}
<ArtistAlbums id={artistId} />
</Suspense>
</div>
</Suspense>
</article>
);
}이렇게 중첩된 서스팬스는 컨텐츠의 점진적인 로딩을 가능하게 한다. 위 예시는 서스팬스를 중첩하여 로딩 상태를 점진적으로 노출한다.
최상단 서스팬스의 fallback ui가 렌더링
최상단 서스팬스 내부의 Header 렌더링이 완료된 이후, Profile과 Album의 스켈레톤 UI 렌더링
각 컴포넌트가 로딩 완료를 대기한 후 컴포넌트 렌더링
이 방식은 모든 작업이 완료될 때까지 단일한 fallback UI를 보여주는 대신, 계층에 따라 작업이 완료된 UI를 순차적으로 노출한다. 이를 통해 FCP(First Contentful Paint)와 LCP(Largest Contentful Paint) 같은 성능 지표, 사용자 경험과 코드 효율성 모두 개선할 수 있다.
그러나 이 코드에는문제가 있다. 중첩된 서스펜스 중 일부 UI는 로딩 상태를 반복해서 보여줄 필요가 없다. 예를 들어 Header를 감싼 최상위 서스펜스는 일반적으로 최초 렌더링 이후 다시 렌더링될 필요가 없다. 하지만 페이지 이동마다 이미 렌더링이 완료된 UI가 다시 로딩 상태로 돌아가는 문제가 발생할 수 있다.
이런 문제를 해결하기 위해 useTransition 훅을 사용한다:
// ArtistPage의 상단 컴포넌트
function App() {
const [artistId, setArtistId] = useState(null); // 현재 아티스트 ID를 저장
const [isPending, startTransition] = useTransition(); // 점진적 상태 관리
// 클릭 시 아티스트 선택 id를 새로운 상태로 변경한다.
function handleArtistSelection(newArtistId) {
startTransition(() => {
setArtistId(newArtistId); // 이전 상태를 유지하며 새로운 상태로 전환
});
}
const artistsInfo = getArtists()
return (
<div>
<nav>
{artistsInfo.map((artist, index) => (
<button onClick={() => handleArtistSelection(artist.id)}>{artist.name}</button>
))}
</nav>
{/* isPending을 사용해 로딩 상태를 시각적으로 표시할 수 있다 */}
<div className={isPending ? 'opacity-70' : ''}>
{artistId ? (
<ArtistPage artistId={artistId} />
) : (
<div>Please select an artist to view details.</div>
)}
</div>
</div>
);
}startTransition으로 감싼 상태 업데이트는 긴급하지 않은 것으로 처리된다. 이로 인해 네비게이션 중에도 이미 표시된 UI를 유지하면서, 컨텐츠가 준비되면 자연스럽게 전환할 수 있다. isPending 상태를 통해 전환 중임을 사용자에게 표시할 수도 있다.
이러한 패턴은 특히 데이터 기반 라우팅에서 유용하며, 대부분의 Suspense 지원 라우터는 이러한 동작을 기본으로 제공한다.
점진적 렌더링, 로딩 상태의 분리, 목적, 사용 방법 등등.. 그 유용함은 표면적으로 모두 이해가 간다. 그러나 아직 이것만으로는 필요성이 크게 와닿지 않는다.
가령 Suspense 없이도 '로딩' 상태는 충분히 나타낼 수 있다. 비동기 요청이라면 Tanstack Query, SWR 등 데이터 상태 관리 라이브러리나 useState, 커스텀 훅을 통해 isLoading 상태를 플래그로 로딩 상태를 분기 처리할 수 있다. 그런 상황에서 Suspense 도입을 위해 의존성을 최신으로 업데이트하고, 전체적인 컴포넌트 설계를 변경할 정도의 필요를 느끼기 힘들었다.
서스팬스 적용 이전과 이후를 간단한 코드로 비교해보자.
// 서스팬스 미적용
function DataComponent() {
const [isLoading, setIsLoading] = useState(true);
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then((res) => {
setData(res);
setIsLoading(false);
});
}, []);
if (isLoading) return <p>Loading...</p>;
return <div>{data}</div>;
}
// Suspense 사용
function DataComponent() {
const { data } = useData(); // 비동기 데이터를 가져오는 suspense-enabled custom hook
return <div>{data}</div>;
}
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<DataComponent />
</Suspense>
);
}코드가 간결해지니 왠지 '리액트'다운 것 같다. 기존 DataComponent는 로딩 상태에 따라 두 개의 UI를 표현하는 분기가 생긴다. 반면 서스팬스를 적용하면 '로딩' 상태를 서스팬스가 관리하고, DataComponent는 하나의 UI를 보여주게 된다.
'리액트답다'. 이처럼 간단한 코드에서도 느껴진다. 좋다. 좋은 것은 좋으니 알겠다.
사실 이 정도의 감상은 공식문서만 봐도 알겠다.
하지만 왜 좋은 것일까?'리액트다운 것'은 무엇이며, 왜 서스팬스를 써야 더 '좋게' 느껴지는 걸까? 솔직히 납득되지 않는다. 다시 공식문서를 뒤적이며 생각해본다.