최근 Next.js 15, React 19의 새로운 패턴에 적응하면서 Suspense를 자주 사용하게 됐다. 이 과정에서 Suspense
가 단순히 '로딩 상태를 보여주기' 이상으로 중요한 개념으로 느껴져 궁금해졌다.
이 글은 Suspense
의 기본적인 사용법에서 시작하여 점진적 렌더링, 선언적 UI, Render As You Fetch 등의 키워드로 서스팬스를 탐구해본다.
Suspense
<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를 보여주게 된다.
'리액트답다'. 이처럼 간단한 코드에서도 느껴진다. 좋다. 좋은 것은 좋으니 알겠다.
사실 이 정도의 감상은 공식문서만 봐도 알겠다.
하지만 왜 좋은 것일까?'리액트다운 것'은 무엇이며, 왜 서스팬스를 써야 더 '좋게' 느껴지는 걸까? 솔직히 납득되지 않는다. 다시 공식문서를 뒤적이며 생각해본다.
선언적 UI: 비동기 요청도 리액트답게
tldr: 일관성 있는 컴포넌트 = 코드가 UI를 직접 표현한다
Declarative programming means describing the UI for each visual state rather than micromanaging the UI .
선언형 프로그래밍은 UI의 세부 동작을 일일이 조작하는 대신, 각 시각적 상태에 따라 UI를 묘사하는 것을 의미합니다.
리액트 공식문서에 따르면, 리액트는 UI를 선언적으로 구성한다.
'선언적 UI'란 UI가 "어떻게" 렌더링 될 지를 선언하는 것이며, 여기서 컴포넌트는 '무엇을 어떻게 렌더링할지' 정의한다. 이때 핵심은 코드는 항상 일관된 UI를 렌더링하는 것이다. 리액트 컴포넌트는 동일한 props
와 상태state
에 대해 항상 같은 UI를 보장해야한다.
이 개념을 위의 코드 조각에 대입해보자. 서스팬스 적용 이전 DataComponent
는 로딩/데이터 상태에 따라 두가지 UI를 표현한다.렌더링 과정은 다음과 같다.
DataComponent가 실행된다. isLoading의 초기값이 true이므로, 로딩 UI가 먼저 렌더링된다. 컴포넌트는 최초 실행으로 로딩 UI를 반환하고 마친다.
렌더링이 완료된 이후, useEffect가 실행된다. 내부의 fetchData가 실행되어 api를 요청한다.
비동기 요청이 완료된 이후 then 체인이 실행된다. setData, setIsLoading으로 상태가 변경된다
상태가 변경되면 리렌더링이 트리거된다. 컴포넌트는 다시 실행되어 업데이트된 새로운 상태data, isLoading를 기반으로 리렌더링한다.
어딘가 선언적이지 않다. 선언적 UI라면 코드는 "무엇이 어떻게 렌더링될지" 미리 정의한다. 리액트 컴포넌트는 결국 UI를 정의하는 함수다. 그러나 이 코드는 실행하여도 의도한 UI를 바로 보여주지 않는다. 예외 처리와 에러 UI를 추가해도 오히려 더욱 '리액트스럽지' 않아진다.
다시 DataComponent
의 목적을 생각해보자. 로딩을 나타내기 위한 것인가, 데이터를 요청하고 이를 UI로 그리는 것인가? 당연히 후자일 것이다. 로딩은 데이터 요청에서 발생하는 지연을 표시하기 위한 부수적인 상태일 뿐, 컴포넌트의 본래 목적인 의도했던 UI가 아니다.
그런 면에서DataComponent
는 본래 의도를 코드가 직접 표현하고 있는가? 아닌 것 같다. 코드가 실제로 의도하는 UI는 최초 실행 이후에 렌더링되기 때문이다. 즉 컴포넌트가 여러 번 재실행되고 나서야 본래 의도한 UI를 나타낼 수 있다. 실행만으로 의도한 UI를 바로 얻을 수 없는 것은 곧 코드가 UI를 직접 표현하지 않는 것이다. 따라서 '선언적'이지 못하다.
하지만 어쩔 수 없었다. 리액트를 처음 배울 때부터 받아들인 익숙한 사실이다. 리액트 컴포넌트의 실행 시점과 비동기 요청 시 생기는 지연으로 필연적인 시차가 발생하기 때문이다. 동기적 컴포넌트 함수 안에서 '비동기적 함수'를 실행하니 어쩌면 당연한 결과다.
리액트팀 또한 비동기 데이터를 이런 부수효과로 표현할 수 밖에 없는 게 난관이었을 것 같다. 실제로 리액트를 처음 배울 때부터 현업까지 이런 패턴에서 비롯된 까다로운 문제들을 나 또한 종종 마주쳤다. 기존 방식은 비동기 데이터를 사용해 의도한 UI를 직접 JSX로 표현할 수 없었고, 이는 리액트 설계 의도에 부합하지 않았다.
그럼 비동기 요청만이 문제일까? 조건부 렌더링같은 UI 분기는 선언적일까?
function conditionalUI ({ isOpen }) {
return (
<p>
{isOpen ? "Yes" : "No"}
</p>
)
}
이 코드는 선언적이다. 코드를 실행하면 props에 따라 Yes/No p태그를 렌더링한다. 결과가 될 UI를 코드가 명확히 직접 표현하고 있다. 조건부 렌더링은 실행 시점의 props나 state를 기반으로 렌더링이 결정된다. 선언적 UI 컴포넌트라면 주어진 상태 그대로 UI를 표현한다. useEffect 같은 부수 효과가 없는 일반적인 조건부 렌더링은 컴포넌트 실행과 함께 주어진 props, state를 기반으로 코드에서 정의한 그대로 UI를 그려낸다.
function eventHandleUI () {
const [count, setCount] = useState(0)
const handleCount = (num:number) => {
setCount((prev) => prev + num)
}
return (
<div>
<p>{count}</p>
<button onClick={() => handleCount(-1)}>-1</button>
<button onClick={() => handleCount(1)}>+1</button>
</div>
)
}
일반적인 이벤트 핸들링도 선언적이다. 최초 렌더링 이후, 이벤트 핸들링으로 인한 업데이트는 자연스럽다. 의도한 UI 결과가 이벤트에 따라 업데이트되어야 함을 코드로 미리 결정하여 나타내고 있기 때문이다. 버튼을 누르면 새로운 상태 count를 기반으로 UI가 다시 렌더링될 것이란 걸 코드만 보고 알 수 있다. 사용자 이벤트로 컴포넌트 함수가 재실행되어 UI가 업데이트되는 것은 자연스럽고, 의도한 동작이다. 코드는 이러한 UI 의도를 선언적으로 표현하고 있다.
정리하자면, 리액트스럽지 않은 것은:
여러 번의 실행(리렌더링)을 통해 컴포넌트가 본래 의도한 UI를 만든다.
이를 위해 컴포넌트 최초 실행 후 발생하는 부수 효과 useEffect를 전제한다
이러한 방식은 컴포넌트의 본래 의도를 모호하게 만든다. 그래서 서스팬스는 렌더링 과정에서 발생하는 비동기 작업만을 처리한다.
리액트스러운 것과 아닌 것을 '선언적 UI'라는 키워드로 알아보았다. 이제 납득이 간다. 서스팬스로 UI 컴포넌트를 리액트 원칙에 맞게 표현할 수 있다. 비동기 요청이 있는 컴포넌트는 목적 그대로 UI를 직접 표현하고, 로딩 상태는 Suspense 바운더리와 로딩 UI로 분리한다. 비동기 데이터, 로딩 모두 본래 의도에 맞게 컴포넌트로 분리하여 UI로 표현할 수 있다.
숨겨진 부수 효과
사실 기존 방식의 문제는 간단한 코드 조각에서만 재현되는 건 아니다. 부수 효과를 커스텀 훅이나 데이터 페칭 라이브러리로 숨기더라도 문제는 남는다:
// ❌ useQuery로 숨겨진 부수 효과
function Comments() {
const { data: comments, isLoading } = useQuery({
queryKey: ['comments'],
queryFn: fetchComments
});
// Comments는 실행 시 동일한 UI 결과를 보장할 수 없다.
// 부수효과에 의존하기 때문에 컴포넌트 첫 실행과 비동기 요청 완료 이후 UI가 다르다.
return (
<div>
{comments?.map(...)}
{isLoading && <Loading />}
</div>
);
}
// tanstack/react-query 최신 버전의 useQuery 내부에서 실행되는 useBaseQuery
// 옵저버를 통한 상태 관리
const [observer] = React.useState(...)
...
// Suspense 모드가 아닐 때
// shouldSuspend(defaultedOptions, result) === false
// 부수 효과를 통한 데이터 구독
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: noop
...
},
[observer, shouldSubscribe],
),
...
)
// 옵션 변경을 위한 부수 효과
React.useEffect(() => {
observer.setOptions(defaultedOptions, { listeners: false })
}, [defaultedOptions, observer])
// Suspense 모드가 아닐 때 (shouldSuspend(defaultedOptions, result)===false), 컴포넌트는 여전히 초기 렌더링 → 데이터 페칭 → 상태 업데이트 → 리렌더링의 흐름을 따른다.
Suspense 모드가 아닐 때 컴포넌트 렌더링 과정은 여전히 다음과 같다:
초기 렌더링 → 데이터 페칭 → 상태 업데이트 → 리렌더링
Comments
는 매번 동일한 UI 결과를 보장할 수 없다. isLoading
과 로딩 UI를 없애도 문제는 여전하다. 라이브러리 내부에서 비동기 요청이 부수효과에 의존하기 때문에, Comments
의 첫 실행과 비동기 요청 완료 이후 UI는 다를 수 밖에 없다. 이처럼 Suspense가 적용되지 않은 useQuery
는 기존 방식의 문제를 해결하지 않는다. 라이브러리로 추상화하여도, 선언적이지 않은 UI 표현이라는 근본적인 문제는 동일하다.
서스팬스는 선언적 UI 원칙을 비동기 상황에서도 지킬 수 있게 해준다. 비동기 요청도 리액트답게.
설계 의도
서스팬스도 벌써 수년 전에 도입됐다. 당시에 어떤 배경이 있었고, 의도가 어땠을 지 궁금했다. 이와 관련해 4년 전에 작성된 글을 읽어봤다.
"You can fetch the data for a route, start streaming the shell, and then fill in the data as it arrives."
데이터를 가져오는 동시에 UI의 초기 구조를 스트리밍하고, 데이터가 준비되는 대로 채울 수 있다.
이 말이 중요한 의도로 느껴진다. 핵심 문제는 병목이고, 이를 병렬적으로 해결한다. 공식문서는 Suspense를 "로딩 상태의 UI를 보여준다"고만 간략히 설명한다. 이 단순한 설명 뒤에 '데이터를 가져오는 동안 렌더링을 멈추지 않는다'는 의도가 있다고 생각하게 된다.
원문에서 작성자 @Dan은 SSR
과정에서 발생하는 병목이라는, 더 큰 맥락에서 해결 방향을 제시한다. 이제는 익숙한 내용이지만, 이 글은 당시 맥락(특히 SSR
)에 대한 깊은 이해를 제공한다. 설명도 쉽고 친절해서 읽기 편하다.
글쓴이가 구체적으로 지적하는 문제는 기존 SSR
의 'All or Nothing' 방식이다. 서버 사이드에서 모든 비동기 데이터가 준비되어야 HTML을 보낼 수 있고, 모든 자바스크립트가 브라우저에 로드되어야 하이드레이션을 시작할 수 있었다.
서버에서 모든 데이터를 페치
HTML 렌더링
클라이언트에서 자바스크립트 로드
전체 앱 하이드레이션
이 과정이 순차적으로 진행되며 병목이 발생한다. 데이터 요청 컴포넌트의 선언적인 작성과 함께, 렌더링과 네트워크 요청 병목이라는 다층적인 문제를 해결할 필요가 생겼다.
서스팬스는 SSR
에서 이를 해결하기 위해 제시된다. 서스팬스를 통해 이러한 동기적인 과정을 컴포넌트 단위로 쪼갠다. 데이터가 준비된 컴포넌트부터 보여주고, 하이드레이션할 수 있게 된다. 즉 컴포넌트 단위로 점진적으로 렌더링 해간다.
Render-as-You-Fetch
말 그대로, 데이터 요청을 렌더링 시작과 동시에 진행하는 패턴이다. 컴포넌트가 마운트되기를 기다리지 않고, 렌더링 시작과 동시에 데이터 페칭을 시작한다. 그럼 서스팬스 사용이 Render-as-You-Fetch를 보장하나? 무조건 그렇진 않다. Render-as-You-Fetch가 제대로 작동하려면, 데이터 요청과 컴포넌트 렌더링 로직이 연계되어야 한다. 이때 Suspense의 사용 방식과 컴포넌트 실행 환경(서버/브라우저)에 따라 동작이 달라질 수 있다.
Streaming SSR
원문에서 언급된 SSR의 render-as-you-fetch은 Suspense를 사용해 구현할 수 있다.
import React from "react";
async function ServerComponent() {
const data = await fetchData();
// suspense를 지원하는 데이터 요청 방식
return <div>{data}</div>;
}
export default function Page() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<ServerComponent />
</React.Suspense>
);
}
이 코드에서 Suspense는 데이터 요청(fetchData
)과 렌더링을 병렬로 진행해 병목 문제를 해결한다. 서버는 먼저 가벼운 fallback ui를 렌더링해 브라우저로 보내고, fetchData 요청이 끝나면 해당 데이터를 포함한 ServerComponent
를 렌더링해 브라우저에 전달한다.
이 접근법은 SSR
에서 발생할 수 있는 초기 로딩 지연을 개선한다. SSR
에서 페이지로 이동했지만 첫 화면을 보기까지 오랜 시간이 걸리는 문제를 해결한다.
이같은 서스팬스 바운더리를 활용한 SSR 방식은 스트리밍 Streaming SSR이라고 한다. 이 방식에서는 서스팬스 내의 각 컴포넌트가 데이터가 준비되는 즉시 독립적으로 렌더링되어 브라우저에 전달된다. '스트리밍'은 React가 서버에서 데이터를 처리한 뒤, 준비된 내용을 조금씩 클라이언트로 보내는 방식을 말한다.
사실 '스트리밍'이라는 단어가 생소했다. 맥락을 이해하는데 MDN의 Streams API의 설명이 도움이 됐다.
스트리밍은 네트워크를 통해 받으려는 리소스를 작은 청크(chunk)로 나누어, 이를 조금씩 처리하는 방식을 의미합니다. ... Streams API를 사용하면..., JavaScript로 원시 데이터를 청크 단위로 점진적으로 처리할 수 있습니다
MDN - Streams API
Streaming SSR은 로딩 상태 UI를 관리하는 서스팬스를 넘어 '서버에서 실행되는 React'와 밀접하게 연관된다. '스트리밍' 구현에는 HTTP(Transfer-Encoding:chunked
), JavaScript Streams API(ReadableStream)
, React 서버 DOM의 readPipeableStream
가 연관이 있다.
더 자세한 내용은 관련 문서를 참고.
Streams API - MDN
renderToPipeableStream - React Docs
그러고 보니, renderToPipeableStream에 대해 예전에 써둔 글이 있었다.
최근에 번역된 전문가를 위한 리액트(Fluent React)를 읽고 작성했다.
CSR에서의 병목
비슷한 문제는 서버 사이드 뿐만 아니라 브라우저 환경에서도 발생한다. Suspense 없는 비동기 요청은 렌더링 과정으로 병목이 생긴다. 초기 렌더링이 완료된 이후, useEffect
를 통해 비로소 요청이 발생하기 때문이다.
function App () {
<Parent />
}
// useQuery suspense: false 설정 시
function Parent() {
const { data: parentData, isLoading } = useQuery('parent', fetchParent);
return
<article>
<ArticleHeader />
{isLoading && <Loading />}
{parentData && <Child parentId={parentData.id} /> }
</article>
}
function Child({parentId}) {
const { data: childData, isLoading } = useQuery(['child', parentId], () => fetchChild(id));
return(
<div>
{childData && <p>childData.name</p>}
{isLoading && <Loading />}
</div>;
)
}
실제로 이러한 코드는 불필요한 과정을 통해 완성된다.
Parent 컴포넌트 최초 렌더링
렌더링 완료 후 fetchParent 요청 시작
요청 완료 후 Parent 리렌더링
Child 컴포넌트 마운트 및 렌더링
Child 렌더링 완료 후 fetchChild 요청 시작
fetchChild 완료 후 Child 리렌더링
전체 UI 완성
fetchChild
의 요청이 parent
결과에 의존하기 때문에 요청 워터폴은 근본적으로 해결할 수 없다. 하지만 렌더링으로 인해 추가적인 병목이 발생한다.
이 과정에서 문제는
네트워크 요청의 지연
리액트 렌더링 과정이 요청 타이밍을 더 지연시킨다. Parent의 첫 렌더링이 완료된 후에야 fetchParent가 시작되고, 이 요청이 완료되어 Parent가 리렌더링된 후에야 Child가 마운트된다. Child의 첫 렌더링이 끝난 후에야 fetchChild가 시작되는 연쇄적인 지연이 발생한다.불필요한 리렌더링
최소 4번의 렌더링을 전제한다. Parent는 fetchParent 완료 시 리렌더링되고, Child는 fetchChild 완료 시 리렌더링된다. (이또한 리액트 컴파일러, 메모이제이션이 적용되지 않은 경우를 전제로) Parent의 리렌더링은 모든 자식 컴포넌트의 리마운트를 유발하기 때문에 Parent에 Child와 같은 비동기 요청 컴포넌트가 늘어난다면, 코드의 오류 가능성이 더 높아진다. 즉, 컴포넌트 구조가 복잡해질 수록, '불필요한 렌더링'이 일으킬 에러의 가능성이 높아진다.
이제 서스팬스와 Render-as-You-Fetch 패턴을 적용해 클라이언트 사이드 렌더링(CSR
)에서 이를 해결해보자. 브라우저에서 실행되는 클라이언트 컴포넌트는 비동기 요청이 React의 렌더링 단계에서 직접 이루어질 수 없다. 이를 해결하기 위해 비동기 요청의 시점을 바꾸고, Suspense를 활용할 수 있다. 데이터 요청을 컴포넌트 렌더링 전에 시작하고 Suspense로 로딩 상태를 처리하는 방식으로 Render-as-You-Fetch를 구현할 수 있다.
여러가지 방법으로 구현할 수 있지만, 이번에는 REST API, Suspense와 함께 Tanstack Query, Tanstack Router의 loader
를 사용해봤다.
import { createRootRoute, createRouter } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
// 루트 경로의 Route 설정과 서스팬스 바운더리 적용
// loader와 각 컴포넌트 useSuspenseQuery에서 동일한 쿼리 키 사용
const rootRoute = createRootRoute({
loader: async () => {
// parent 데이터를 fetchQuery로 가져와서 캐시에 저장
const parentData = await queryClient.fetchQuery({
queryKey: ['parent'],
queryFn: fetchParent
});
// parent 데이터를 이용해 child 데이터 prefetch 시작
queryClient.prefetchQuery({
queryKey: ['child', parentData.id],
queryFn: () => fetchChild(parentData.id)
});
return parentData;
},
component: () => (
<QueryClientProvider client={queryClient}>
<Suspense fallback={<Loading />}>
<Parent />
</Suspense>
</QueryClientProvider>
)
});
// Router 설정
const router = createRouter({ routeTree: rootRoute });
// App 컴포넌트
function App() {
return <RouterProvider router={router} />;
}
function Parent() {
const initialData = useLoaderData<typeof parentRoute>();
const { data: parentData } = useSuspenseQuery({
queryKey: ['parent'],
queryFn: fetchParent,
initialData
});
return (
<article>
<h1>{parentData.title}</h1>
<Suspense fallback={<Loading />}>
<Child parentId={parentData.id} />
</Suspense>
</article>
);
}
렌더링의 과정을 요약하면:
before: 컴포넌트 초기 렌더링 → 요청 시작 → 완료 → 리렌더링
after: 요청 동시에 fallback UI 렌더링 -> 완료후 컴포넌트 렌더링
Parent 컴포넌트의 Suspense 처리
route 진입 시 loader에서 Parent 데이터를 fetchQuery로 가져와 캐시에 저장한다.
최상위 Suspense는 loader에서 fetchParent가 완료될 때까지 Parent의 fallback을 보여준다.
완료가 끝나면, Parent 컴포넌트는 loader가 반환하는 데이터로 즉시 렌더링된다.
loader가 완료되면 캐시된 데이터로 한 번에 렌더링되어 Parent에 불필요한 리렌더링이 발생하지 않는다.
Child 컴포넌트의 요청과 렌더링
loader에서 prefetchQuery로 Child 데이터 요청이 미리 시작된다. Parent가 최종 렌더링되기 전에 fetchChild 요청을 시작한다.
Child 컴포넌트가 마운트될 때 캐시된 데이터가 있거나 진행 중인 요청을 재사용한다.
Child의 Suspense는 데이터가 준비될 때까지 fallback을 보여준다.
loader
를 통해 라우트 진입 시점에 미리 데이터 요청을 시작하고, Suspense로 컴포넌트의 렌더링을 데이터 준비 상태와 동기화한다. 비동기 요청 컴포넌트는 최초 렌더링으로 데이터를 UI로 표현하고, 이로써 렌더링 과정이 요청 지연을 방지한다.