Suspense
: 자식 컴포넌트가 로딩을 마칠 때까지 fallback을 보여주 컴포넌트
관련 내용 : Streaming SSR
사용 예시
<Suspense fallback={<Spinner />}>
<UserList />
</Suspense>
Suspense를 사용하는 이유
-
React는 자식 컴포넌트에서 필요한 모든 코드와 데이터가 로드될 때까지 loading fallback을 보여준다.
-
Suspense 내부의 전체 트리는 하나의 요소로 취급된다. 만약 Suspense 내부의 여러 컴포넌트가 존재하고, 그 중 하나가 데이터 패칭으로 인해 렌더링이 중단되고 있다면, Suspense 내부의 모든 컴포넌트가 로딩 표시를 보여줄 것이다.
만약 Suspense를 사용하지 않고 각 컴포넌트 마다 isLoading으로 로딩 화면에 대한 분기처리를 하면 먼저 데이터 패칭을 한 컴포넌트가 먼저 렌더링 될 것이고, 해당 컴포넌트가 사용자에게 먼저 보여질 것이다. 이는 마치 폭포수처럼 순차적으로 컴포넌트가 보여질 것이며, 사용자에게 오히려 좋지 않은 경험을 제공할 수 있다.
-
컴포넌트가 중단되면(즉, 로딩 상태라면) 자신을 감싼 가장 가까운 부모 Suspense컴포넌트가 동작한다. 이는 여러 로딩 순서를 만들어 중첩된 Suspense아키텍쳐를 만들 수 있다는 것을 의미한다. 각 Suspense 경계의 fallback은 다음 단계의 컨텐츠가 이용 가능하게 되면 채워질 것이다.
-
새로운 데이터 로딩을 마치기 전까지 오래된 데이터를 보여줄 수 있다.
useDeferredValue를 이용하여 기존의 데이터를 보여줄 수 있다.
: UI의 일부 업데이트를 연기할 수 있는 훅이다.
function SearchPage() { const [query, setQuery] = useState(''); const deferredQuery = useDeferredValue(query); // ... }-
새로운 데이터를 패칭하는 동안 오래된 데이터를 보여줄 수 있다.
-
컨텐츠가 오래되었음을 보여줄 수 있다.
<div style={{ opacity: query !== deferredQuery ? 0.5 : 1, }}> <SearchResults query={deferredQuery} /> </div> -
UI에 대해 리렌더링을 연기한다.
-
-
선언적으로 코드를 작성할 수 있다.
if문으로 분기처리를 하여 ‘로딩’을 보여준다는 점에서 명령형 프로그래밍 기법에 더 가깝다. React에서 추구하는 선언형 프로그래밍에 더 가깝게 작성할 수 있다.
-
관심사를 분리할 수 있다.
데이터를 패칭하는 컴포넌트에서는, 데이터를 패칭하는 로직과 이 데이터를 직접 보여주거나, 혹은 컴포넌트에 넘겨주는 로직만 작성하면 된다. 로딩과 에러에 대한 로직을 이 컴포넌트의 바깥으로 뺼 수 있다.
또 다른 Suspense의 해결점 : Streaming SSR
Suspense가 궁극적으로 해결하고자 한 것은 바로 Streaming SSR이다.
SSR은 애플리케이션에서 상호작용이 필요없는 부분을 더 이른 시기에 볼 수 있게 해주어 유저들로 하여금 Javascript가 로드되는 동안 정적 컨텐츠를 볼 수 있도록 해준다.
하지만 이 SSR은 무언가 보여주기 전까지 워터폴 방식으로 모든 것을 다 가져와야 한다는 단점이 있다. 서버에서 데이터 가져오기 → 서버에서 HTML로 렌더링 → 코드 불러오기 → Hydrate순서대로 수행해야 한다. 이 중 어떤 단계도 이전 단계가 전체 애플리케이션에 대하여 끝나기 전까지 시작하지 못한다는 단점이 있다.
위에서 FCP는 서버가 브라우저에 HTML을 넘겨주고, 처음 사용자에게 화면을 보여준 시점이다. 그리고 TTI는 Javascript를 DOM에 Hydration하여 사용자가 브라우저에 상호작용할 수 있게 된 시점이다. 위 그림에서도 알 수 있듯이 사용자가 화면을 보기 위해서는 FCP 시간까지 기다려야하고, 웹사이트와 상호작용하기 위해서는 TTI시간까지 기다려야 한다.
HTML을 서버에서 한 번에 받아와야 하는 원인은 기존 SSR 방식에서 사용하던 ReactDOMServer.renderToString()에 있다.
💡
ReactDOMServer.renderToString(): 서버에서 React트리를 HTML문자열로 렌더링을 한다.
import { renderToString } from 'react-dom/server'; const html = renderToString(<App />);서버에서는 위와 같이 renderToString을 사용하여 React트리를 HTML문자열로 변환하고, 클라이언트(브라우저)에서는 hydrateRoot를 이용하여 서버에서 만들어진 HTML이 상호작용하도록 만든다.
: react-dom/server에 의해 생성된 HTML컨텐츠를 가져와 브라우저 돔 노드에 보여줄 수 있다.
const root = hydrateRoot(domNode, reactNode, options?)
ReactDOMServer.renderToString()은 동기 함수이기 때문에 메인 스레드에서 처리한다. 많은 요청이 몰릴 경우, 메인 스레드는 블락된다. 이러한 SSR에 대한 제한된 동시성 성능 이슈가 보고된 적이 있다.
Low Concurrent users cap with SSR
서버 렌더링 node.js의 이벤트 루프에 의해 block되기 때문에 동기적으로 처리 된다. 많은 사용자에게 제공하기 위해서는 여러 개의 next app인스턴스를 띄어야 한다. 혹은, 캐싱을 함으로써 시간을 단축시킬 수 있다.
이를 해결하기 위해 등장한 것이 renderToReadbleStream API이며, 이 API는 ReadableStream API를 기반으로 준비된 청크부터 응답을 보내줄 수 있도록 설계되었다.
정리하면, Suspense를 도입함으로써 다음과 같은 이점을 취할 수 있다.
-
모든 데이터를 가져오기 전에 HTML을 스트리밍한다.
<Layout> <NavBar /> <Sidebar /> <RightPane> <Post /> <Suspense fallback={<Spinner />}> <Comments /> </Suspense> </RightPane> </Layout>위와 같은 경우, Suspense로 감싼 컴포넌트는 Spinner placeholder로 대체되고, 나머지 부분은 HTML 스트리밍을 시작한다.
-
모든 코드가 스트리밍 되기 전에 페이지 Hydration을 수행한다.
Suspense로 래핑한 컴포넌트를 제외한 컴포넌트는 먼저 Hydation을 수행한다.
💡 Streaming SSR?
Streaming SSR은 빠르게 첫 HTML을 내보낸 다음 <img src="/_next/image?url=%2Fnotion-assets%2Fcafd959c18bb5368.webp&w=1920&q=75" srcset="/_next/image?url=%2Fnotion-assets%2Fcafd959c18bb5368.webp&w=384&q=75 384w, /_next/image?url=%2Fnotion-assets%2Fcafd959c18bb5368.webp&w=750&q=75 750w, /_next/image?url=%2Fnotion-assets%2Fcafd959c18bb5368.webp&w=1920&q=75 1920w" sizes="(max-width: 720px) 100vw, 720px" data-original-src="/notion-assets/cafd959c18bb5368.webp" alt="Suspense" width="2425" height="915" loading="lazy" decoding="async" style="width:1213px;height:auto;aspect-ratio:2425/915" />
필요한 HTML파일을 렌더링하여 먼저 보내준 후, 그 이후로는 작은 청크로 쪼게어 브라우저에 보내준다.
자세한 내용 : Streaming SSR
정리
Suspense는 선언형 프로그래밍 방식으로 로딩 화면을 보여주고, 데이터 패칭 로딩이라는 관심사를 분리할 수 있도록 한 컴포넌트이다. 뿐만 아니라 데이터 패칭을 마친 페이지를 폭포수 방식으로 브라우저에 보내주는 SSR의 문제점을 해결한 컴포넌트이다. 또한, SSR방식의 페이지에서도 데이터 패칭이 필요하지 않은 컴포넌트는 먼저 서버에서 보내주고, 그 이후 데이터 패칭이 이뤄지는 방식인 Streaming SSR을 구현할 수 있게 되었다.
주의할 점
Suspense를 사용 가능한 데이터 패칭만이 Suspense 컴포넌트를 활성화시킬 수 있다. 그 예시로는 다음과 같다.
-
Relay나 Next.js같은 Suspense가 사용 가능한 프레임 워크에서의 데이터 페칭
-
lazy를 이용한 레이지 로딩 컴포넌트
💡 lazy
처음 컴포넌트가 렌더링 된 이후, 필요할 때 컴포넌트를 불러온다.
자세한 내용 : React.lazy
-
use를 이용하여 Promise값을 반환하는 컴포넌트
💡 use
: Context를 유연하게 사용하거나, Promise객체를 RSC에서 스트림 형식으로 전달받을 수 있는 훅
자세한 내용 : React.use
Suspense 동작 원리
1. 브라우저에서 Streaming 방식 이해하기
페이지에 접속하면 Next.js서버로부터 가장 먼저 html파일이 전송된다. 이 파일을 들여다보면,
위에는 HTML로 이미 렌더링이 된 부분들이 있고, 더 내려보면
JSON형식으로 온 데이터가 있다. 이 데이터가 바로 React Server Component의 데이터를 JSON형식으로 직렬화하여 브라우저에 보내준 데이터이다. 간단히 말해, 서버에서 렌더링 된 컴포넌트들이다.
RSC에 대한 더 자세한 내용 : React Server Component
그리고 스크롤을 더 내려보면
script아래에 html파일이 더 있다.
가장 루트 노드가 <div hidden id=”S:0”>이다.
그렇다. 이 하위 노드들은 모두 Suspense컴포넌트로 둘러싸여 Suspense의 fallback컴포넌트가 먼저 보이다가, 스트리밍 방식으로 서버로부터 전달받은 후, 렌더링하여 보여주는 컴포넌트이다. 자세히 보면 데이터 패칭된 실제 데이터가 담겨있는 것을 확인할 수 있다.
그리고, 위를 다시 살펴보면 <template id=”B:0”>이 있는데, 이 부분이 바로 Suspense의 props으로 넘긴 fallback컴포넌트이다.
정리하자면, <template id=”B:0”>는, fallback 컴포넌트가 시작하는 부분이다. 또한, <!—- $? -—>와 <!—- /? -—>로 둘러쌓여 있다.
또한 <div hidden id=”S:0”>는 위 fallback을 대체하는 컴포넌트이자 Streaming방식으로 서버에서 렌더링하여 넘겨준 컴포넌트이다.
2. React 코드에서 Suspense 원리 이해하기
React에서는 Suspense를 어떤 원리로 동작할까? 궁금했다. React레포지토리에서 ‘Suspense’라는 컴포넌트를 검색했을 때 아무 것도 뜨지 않았고, 코드를 쫓아서 쫓아 가보니 Fiber로 구현되었다는 것을 알게 되었다.
다른 방식으로 접근을 해보니 Suspense의 fallback컴포넌트는 <!—- $? -—>와 <!—- /? -—>로 둘러쌓여 있다는 점이었다. 이 주석이 바로 교체할 Suspense 경계를 식별하는 데 사용되는 것이라 생각했다.
이 주석을 검색을 해보니
ReactFizzConfigDOM.js파일을 확인할 수 있었다.
-
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk( '<!--$?--><template id="', ); const startPendingSuspenseBoundary2 = stringToPrecomputedChunk('"></template>');각 값을 미리 계산된 청크로 변환하는 코드이다. Suspense 내부 컴포넌트가 렌더링된 후, 교체할 컴포넌트를 시작하는 부분이
<!--$?--><template id="이고 변수는startPendingSuspenseBoundary1에 저장한다. 또한, 교체할 컴포넌트가 끝나는 부분이"></template>로, 변수는startPendingSuspenseBoundary2이다.이제 이 두 변수를 추적해보자.
-
export function writeStartPendingSuspenseBoundary( destination: Destination, responseState: ResponseState, id: SuspenseBoundaryID, ): boolean { writeChunk(destination, startPendingSuspenseBoundary1); if (id === null) { throw new Error( 'An ID must have been assigned before we can complete the boundary.', ); } writeChunk(destination, id); return writeChunkAndReturn(destination, startPendingSuspenseBoundary2); }함수명에서 알 수 있듯이 대기 중인 Suspense의 경계의 시작을 작성하는 함수이다. writeChunk함수를 이용해서
destination에startPendingSuspenseBoundary1를 쓴다. Streaming SSR을 위해 해당 자리에 렌더링이 되는 대로 청크 단위로 전송하여 브라우저에 전송하기 위함인 듯하다.그리고, 마지막에
-
export function writeChunk( destination: Destination, chunk: Chunk | PrecomputedChunk | BinaryChunk, ): void { destination.buffer += chunk; }writeChunk함수는 destination.buffer에 청크를 추가하는 함수이다. 즉, 버퍼에 청크를 추가해서, 청크 단위로 브라우저에 전송하기 위함인 듯하다.
자세한 내용 : Streaming SSR
-
결론
Suspense는 React 18에서 동시성 개념이 추가가 되며 기존 Waterfall 방식의 단점(서버에서 데이터 fetching > html 렌더링 > 브라우저에 전송 > 하이드레이션 .. 페이지 단위로 모든 것을 가져와야 한다)을 해결하기 위해 등장한 개념이다. Suspense를 제외한 React Component를 먼저 렌더링을 해서, 브라우저에 전달하고 그 동안은 Suspense부분을 fallback 컴포넌트로 대체하여 보여준다. 그리고 Suspense 내부 컴포넌트는 Streaming방식으로 전달을 받아 채우는 방식이다.
고민
1. Suspense는 어디에 배치하는 게 좋을까?
Suspense는 Suspense안에 있는 요소들은 하나의 요소로 취급이 된다.
즉, 여러 개의 데이터를 받아오는 컴포넌트가 한 번에 보여주고 싶다면 하나의 여러개의 컴포넌트를 Suspense로 감싸면 된다.
export default function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<BigSpinner />}>
<Biography artistId={artist.id} />
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</>
);
}
export default function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<BigSpinner />}>
<Biography artistId={artist.id} />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
</Suspense>
</>
);
}
전자의 코드는 가장 바깥에서 Suspense 하나로 감싸고, 후자의 코드는 가장 바깥에 Suspense 하나, 그리고 안에 Suspense가 하나 더 있다. 각 Biography와 Panel은 모두 데이터를 fetching하는 컴포넌트이다. 이 두 코드의 차이점은 Suspense가 중간에 있냐, 없냐이다. 결과를 상상해보자. 전자는 데이터가 Biography와 Panel의 데이터를 모두 fetch했을 때 h1을 제외한 화면이 보일 것이다. 하지만 후자는 Panel의 데이터가 먼저 fetch했다면 Panel컴포넌트를, Biography의 데이터가 먼저 fetch되었다면 Biography를 먼저 보여줄 것이다.
영상으로 살펴보자.
오른쪽 영상의 화면이 확실하게 더 빠르게 느껴지지 않는가?
따라서, 데이터를 fetch하는 컴포넌트가 여러개가 있다면 Suspense를 여러 개를 감싸주고, 해당 화면에서 보여줄 UI를 fallback props로 넘겨주면 된다.
공식문서를 읽으며 들은 궁금증
❓ react에서는 Promise 상태를 어떻게 관리했을까?
react에서는 react-query를 사용하지 않고 Promise 상태값은 어떻게 관리한 걸까?
import { fetchData } from "./data.js"; export default function Albums({ artistId }) { const albums = use(fetchData(`/${artistId}/albums`)); return ( // .. ); } function use(promise) { if (promise.status === "fulfilled") { return promise.value; } else if (promise.status === "rejected") { throw promise.reason; } else if (promise.status === "pending") { throw promise; } else { promise.status = "pending"; promise.then( (result) => { promise.status = "fulfilled"; promise.value = result; }, (reason) => { promise.status = "rejected"; promise.reason = reason; } ); throw promise; } }use함수에 Promise객체를 넘긴다. 이 promise의 status에 따라 분기처리를 해주었다.
위에서 promise.status===”fulfilled”, “rejected”, “pending”을 if문으로 분기처리를 해주었다. 그런데 특이한 점은 아래 else문에서 pending, fulfilled, rejected값을 부여하며 분기처리를 한다는 점이다. 이유는, Promise가 아직 결정되지 않은 초기 상태일 때, 이후에 발생할 수 있는 상태 변경을 처리하기 위함이다.
만약 상태가 pending이라면 promise를, 상태가 fulfilled라면 인자로 전달받은 result를 promise에 설정하여 return하고, rejected라면 인자로 전달받은 reason을 promise에 설정하여 return한다.
❓ react에서는 데이터 Mocking을 어떻게 구현했을까?
let cache = new Map(); export function fetchData(url) { if (!cache.has(url)) { cache.set(url, getData(url)); } return cache.get(url); }Map으로 전역으로 관리하는 cache변수를 만들었다. 그리고 이 cache의 key값으로 url, value값으로 받은 data를 설정한다. 데이터를 getData(url)을 이용하여 데이터를 받아온다.
async function getData(url) { if (url === "/the-beatles/albums") { return await getAlbums(); } else if (url === "/the-beatles/bio") { return await getBio(); } else { throw Error("Not implemented"); } }getData함수이다. 전달받은 url로 분기처리를 하여 해당하는 데이터를 받아오는 함수를 실행하고, 그 함수의 Promise를 return한다.
async function getAlbums() { // Add a fake delay to make waiting noticeable. await new Promise((resolve) => { setTimeout(resolve, 3000); }); return [ // .. ]; }데이터를 받아오는 함수이다. Promise객체를 만든다. 이 객체는 resolve를 3초 후에 실행하므로, 3초 후에 ‘fulfilled’상태로 return될 것이다. 만약 fetch 실패를 구현하고 싶다면 resolve가 아니라, 두 번째 인자로 전달받은 reject함수를 실행하면 될 것이다.