- Published on
Next.js pages router에서 Suspense fallback의 동작에 대해
Next.js pages router 환경에서 <Suspense>
의 fallback이 의도된 대로 동작하지 않은 경우가 있었습니다. 따라서 이 글에서는 pages router에서 <Suspense>
컴포넌트가 포함된 페이지를 렌더링할 때의 동작을 Next.js 내부의 구현을 간단히 살펴보며 정리하려고 합니다.
예시
아래와 같이 /pages/gallery.tsx
에서 <List>
컴포넌트를 fallback
prop이 설정된 <Suspense>
컴포넌트로 감싼 페이지를 구성해보겠습니다. 물론 <List>
컴포넌트는 Tanstack query의 useSuspenseQuery
와 같은 Suspense와 호환 가능한(Promise
를 throw
하는) 로직이 내부에 포함되어 있다고 가정합니다.
// /pages/gallery.tsx
const Gallery = () => {
return (
<div>
<h1>Gallery</h1>
<Suspense fallback={<div>loading...</div>}>
<List />
</Suspense>
</div>
)
}
pages router에서는 특정한 페이지에 대한 요청이 있을 때 서버가 해당 페이지를 일차적으로 렌더링하여 초기 마크업을 생성한 후 클라이언트에 전달하고, 이를 전달 받은 클라이언트(브라우저)는 화면에 해당 마크업을 표시합니다. 이후 자바스크립트 번들을 실행하여 페이지를 Interactive하게 만듭니다.
그런데 여기서 착오가 있었던 부분은, /gallery
페이지에 대해 요청을 수행할 때 서버에서 일차적으로 반환되는 마크업이 다음과 같이 Suspense fallback을 포함할 것이라고 생각했던 점입니다.
...
<div>
<h1>Gallery</h1>
<div>loading...</div>
</div>
...
하지만 실제로 반환되는 마크업은 fallback이 아닌 다음과 같이 <List>
컴포넌트가 데이터를 가져오고 난 후의 모습입니다.
...
<div>
<h1>Gallery</h1>
<ul>
<li>
<span>title</title>
<img src=".." />
</li>
</ul>
</div>
...
즉, 페이지에 대한 요청을 받은 Next 서버는 일단 Suspense fallback 컴포넌트를 먼저 전달하는 것이 아니라, Suspense로 감싸진 컴포넌트 내의 모든 작업이 완료될 때까지 대기한 후 이에 대한 마크업을 클라이언트로 전달하는 형태였습니다.
사실 Suspense
를 사용하며 기대했던 것은, 처음 유저가 페이지를 방문했을 때는 일단 fallback 컴포넌트를 표시하고 이후 클라이언트 사이드에서 데이터를 가져오는 로직을 수행하는 모습이었습니다. 하지만 위와 같은 상황에서는 fallback UI를 전혀 표시할 수가 없다는 문제가 있습니다.

또한, 컴포넌트 내부에 있는 데이터 요청 로직이 완료될 때까지 전체 페이지에 대한 마크업 구성도 늦어지므로, 데이터 요청에 오랜 시간이 필요한 경우 전체 페이지에 대한 로딩 자체가 늦어지게 됩니다. 예시로, <List>
컴포넌트 내부의 데이터 요청이 10초가 걸리는 경우 /gallery
페이지의 마크업을 구성하는데도 최소 10초가 필요하기에 그 동안 유저는 아무 화면도 볼 수 없습니다. 특히, pages router에서는 이러한 렌더링을 페이지 단위로 처리하기 때문에, 위 예제에 있는 h1
요소는 Suspense
와는 상관이 없지만 모든 마크업 구성이 다 끝나기 전까지는 역시 표시되지 않습니다.
나름의 해결책
따라서, 서버에서 받아온 초기 마크업에 fallback 요소를 표시하기 위해 Suspense
로 감싸진 컴포넌트는 서버 사이드에서는 Suspense fallback만 렌더링 되게하고, 클라이언트 사이드에서만 실제 컴포넌트 내용을 렌더링하도록 약간의 수정을 가했습니다. 물론 이와 같이 서버에서의 렌더링을 스킵하는 것이 모든 경우에서의 정답은 아니긴 하지만, 당시 직면한 상황에서는 fallback UI를 먼저 표시하는 것이 더 유효한 방법이라고 판단했습니다.
첫 번째 방법
Suspense
컴포넌트를 아래와 같이 Wrapping해서 활용하는 방법입니다.
import { Suspense, useEffect, useState } from "react"
const CustomSuspense = ({ fallback, children, ...rest }) => {
const [isRenderedOnClient, setIsRenderedOnClient] = useState(false)
useEffect(() => {
setIsRenderedOnClient(true)
}, [])
if (!isRenderedOnClient) return fallback
return (
<Suspense fallback={fallback} {...rest}>
{props.children}
</Suspense>
)
}
export default CustomSuspense
isRenderedOnClient
state는 클라이언트에서 hydration이 완료된 이후에만 true
로 바뀔 것이므로, 서버에서 받아온 초기 마크업에는 무조건 fallback
이 포함되게 됩니다. 이후 클라이언트에서는 children
을 포함한 전체 Suspense를 렌더링 합니다.
두 번째 방법
Next의 dynamic
API로 컴포넌트를 import하면서, ssr
옵션을 false
로 설정하여 서버 사이드에서의 렌더링 자체를 스킵하는 방법입니다. loading
값에 Suspense의 fallback
으로 들어갈 값을 대신 넘기고, <List>
컴포넌트를 사용할 때는 <Suspense>
컴포넌트로 감싸지 않고 사용할 수 있습니다. dynamic
API가 내부적으로 <Suspense>
를 삽입하기 때문입니다. 단점은 매번 Suspense 안에 들어갈 컴포넌트를 ES import
문이 아닌 Next의 dynamic
API로 import해야 한다는 점입니다.
const List = dynamic(() => import("@/components/List"), {
ssr: false,
loading: <div>loading...</div>,
})
세 번째 방법
app router를 사용하면 위 문제를 해결할 수 있습니다. app router는 스트리밍을 지원하기 때문에, 전체 페이지 단위가 아닌 페이지 내의 개별 컴포넌트 단위로(다른 컴포넌트가 준비되었는지 여부에 상관없이) 렌더링과 스트리밍을 할 수 있습니다. 이는 하단에 후술되어 있습니다.
내부 동작 살펴보기
Next의 내부 코드를 살펴본 결과 위 현상의 원인을 찾을 수 있었습니다. pages router는 /packages/next/src/server/render.tsx
에서 renderToHTMLImpl
를 이용하여 요청을 받은 페이지를 렌더링하여 HTML을 반환하는데, 이 renderToHTMLImpl
는 renderToString
이라는 함수를 이용하여 React Element를 string으로 변환하고 있습니다.
renderToString
은 원래 react-dom/server
패키지에 포함되어 있는 API이지만, pages router에서는 이를 아래와 같이 변형하여 사용하고 있습니다.
async function renderToString(element: React.ReactElement) {
const renderStream = await ReactDOMServerPages.renderToReadableStream(element)
await renderStream.allReady
return streamToString(renderStream)
}
위 코드에서 renderToString
은 다시 react-dom/server
의 renderToReadableStream
API를 사용하고 있는데, 여기서 특이한 점은 renderToReadableStream
은 스트림 객체(ReadableStream
)를 반환하기 때문에, 응답을 생성하면서 동시에 이를 스트리밍할 수 있는 API라는 것입니다(참고로, react-dom/server
의 renderToString
은 스트리밍이 불가한 API입니다).
따라서, renderToReadableStream
을 이용하면 위에서 언급된 문제 없이 클라이언트로 전달되는 초기 응답에 일단 fallback을 포함해 스트리밍하고 이후 Suspense가 resolve되면 해당 컴포넌트 내용의 실제 내용을 추가로 스트리밍할 수 있습니다.
이
문서에서는
renderToReadableStream
와 Suspense를 같이 사용하는 상황을 집중적으로 소개하고 있습니다.
하지만 위 코드에 나와 있는 것처럼, pages router의 renderToString
은 renderToReadableStream
을 호출한
뒤 바로 다음 allReady
라는 속성을 await 합니다. 이후 이렇게 얻은 스트림 객체를 string으로 변환하여 반환합니다.
구현에 따르면, allReady
속성을 await 하는 것은 결과물의 일부분이 완성될 때마다 스트리밍으로 부분적으로
응답하는 것이 아니라 주어진 React 컴포넌트를 이용해 최종 HTML을 만들 때까지 기다린다는 것을 의미합니다.
즉, pages router에서 사용하는 renderToString
은 내부에 스트리밍을 지원하는 API를 이용해 구현되긴 했지만
이러한 스트리밍을 활용하지 않은 채로, 최종 HTML이 완성될 때까지 기다린 후(모든 Suspense가 resolve 되기를
기다린 후) 응답을 반환하도록 되어 있는 것입니다. 이것이 바로 pages router에서 페이지를 요청할 때 서버가
반환하는 초기 마크업에는 Suspense fallback이 포함되지 않았던 이유였습니다.
이렇게 renderToReadableStream
을 이용해 wrapping하지 않고, react-dom/server
의 nodeToString
을
그대로 이용하는 경우, Suspense를 기다리는 대신 fallback이 바로
반환됩니다.
app router에서의 구현
app router에서는 특정 페이지를 렌더링할 때 내부적으로 shouldWaitOnAllReady
라는 옵션을 추가로 갖게되는데, 이 옵션이 true
일 때만 모든 Suspense가 resolve될 때까지 기다리고, 이외의 경우에는 Suspense fallback을 먼저 렌더링합니다. 컴포넌트를 렌더링하는 중 Suspense를 만나게 되면, Next는 이 지점이 서버가 클라이언트에게 스트리밍 응답을 보내게 되는 일종의 경계 지점으로 삼습니다. 참고로, shouldWaitOnAllReady
는 검색 엔진 크롤러 같은 봇 요청 등의 경우에만 true
로 설정됩니다.