React의 Suspense가 Next.js에서 어떻게 활용되는지, 그리고 서버 컴포넌트에서 스트리밍 렌더링이 어떻게 동작하는지 깊이 있게 알아보겠습니다.
Next.js에서 Suspense를 사용할 때는 일반적인 React 앱과 몇 가지 중요한 차이점이 있습니다.
App Router (권장)
Pages Router
// app/page.tsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'
export default function Page() {
return (
<div>
<h1>대시보드</h1>
<Suspense fallback={<div>사용자 정보 로딩 중...</div>}>
<UserProfile />
</Suspense>
</div>
)
}
// UserProfile.tsx (서버 컴포넌트)
async function UserProfile() {
const user = await fetch('https://api.example.com/user').then(res => res.json())
return <div>{user.name}</div>
}
처음에는 의문이 들 수 있습니다. "서버에서 이미 데이터를 가져와서 내려주는 컴포넌트인데 로딩이 필요한가?"
하지만 스트리밍 렌더링과 함께 사용될 때 진짜 효과를 발휘합니다.
export default async function Page() {
const user = await fetchUser() // 3초 대기
const posts = await fetchPosts() // 2초 대기
const comments = await fetchComments() // 1초 대기
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
<CommentList comments={comments} />
</div>
)
}
// 총 6초 후에 완성된 페이지가 한 번에 전송
export default function Page() {
return (
<div>
<h1>대시보드</h1> {/* 즉시 보임 */}
<Suspense fallback={<UserSkeleton />}>
<UserProfile /> {/* 3초 후 교체 */}
</Suspense>
<Suspense fallback={<PostSkeleton />}>
<PostList /> {/* 2초 후 교체 */}
</Suspense>
<Suspense fallback={<CommentSkeleton />}>
<CommentList /> {/* 1초 후 교체 */}
</Suspense>
</div>
)
}
실제 사용자 경험:
스트리밍과 PPR은 다른 개념입니다.
차이점 요약: 동적/정적 미리 빌드된 걸 보여주냐 안 보여주냐의 차이입니다.
스트리밍: 모든 걸 런타임에 서버에서 만들어서 조각조각 보내줌
PPR: 미리 만들어놓은 정적 부분은 바로 보여주고, 동적 부분만 나중에 채워넣음
HTTP/1.1의 Transfer-Encoding: chunked를 사용합니다:
HTTP/1.1 200 OK
Content-Type: text/html
Transfer-Encoding: chunked
4
<htm
6
l><hea
3
d>
...
0
// 이 컴포넌트가 있다고 가정
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
<Footer />
</div>
)
}
실제 전송되는 청크들:
// 청크 1: 초기 HTML 청크 (즉시 전송)
`<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div>
<header>헤더 내용</header>
<div>로딩 중...</div> <!-- fallback -->
<footer>푸터 내용</footer>
</div>
</body>
</html>`
// 청크 2: React 하이드레이션 스크립트
`<script>/* React 초기화 코드 */</script>`
// 청크 3: SlowComponent 준비 완료 후 교체 청크
`<script>
$RC = function(id, html) {
const element = document.getElementById(id);
element.innerHTML = html;
};
$RC("suspense-boundary-1", "<div>실제 컴포넌트 내용</div>");
</script>`
TTFB는 사용자가 요청을 보낸 후 서버로부터 첫 번째 바이트를 받을 때까지 걸리는 시간입니다.
renderToString:
function handleRequest(req, res) {
const html = renderToString(<App />) // 5초 소요
res.send(html) // 5초 후에 첫 바이트 전송
}
// TTFB: 5초 + 네트워크 지연
renderToStream:
function handleRequest(req, res) {
const stream = renderToPipeableStream(<App />, {
onShellReady() {
stream.pipe(res) // 0.1초 후에 첫 바이트 전송
}
})
}
// TTFB: 0.1초 + 네트워크 지연
import { renderToString } from 'react-dom/server'
function handleRequest(req, res) {
const App = <MyApp />
const html = renderToString(App) // 동기적 처리
res.send(`<!DOCTYPE html><html><body><div id="root">${html}</div></body></html>`)
}
특징:
import { renderToPipeableStream } from 'react-dom/server'
function handleRequest(req, res) {
const stream = renderToPipeableStream(<App />, {
onShellReady() {
res.setHeader('Content-Type', 'text/html')
stream.pipe(res)
}
})
}
특징:
// React 내부 Suspense 구현 (단순화)
function Suspense({ children, fallback }) {
try {
return children;
} catch (promise) {
if (isPromise(promise)) {
// Promise를 throw하면 Suspense가 catch
return fallback;
}
throw promise;
}
}
// async 컴포넌트에서 사용
function AsyncComponent() {
const data = use(fetchData()); // use hook이 Promise를 throw
return <div>{data}</div>;
}
// React의 use hook 내부
function use(promise) {
if (promise.status === 'pending') {
throw promise; // 핵심: Promise 자체를 throw!
} else if (promise.status === 'fulfilled') {
return promise.value;
} else {
throw promise.reason;
}
}
// React 서버 렌더러 내부 구현 (의사코드)
async function renderElement(element, controller, suspenseMap) {
if (element.type === Suspense) {
const boundaryId = generateId();
try {
const content = await renderElement(element.props.children);
return content;
} catch (promise) {
if (isPromise(promise)) {
// 1. 즉시 fallback을 스트림에 전송
const fallbackHtml = renderSuspenseBoundary(boundaryId, element.props.fallback);
controller.enqueue(fallbackHtml);
// 2. Promise 완료를 기다린 후 교체 스크립트 전송
promise.then(async () => {
const resolvedContent = await renderElement(element.props.children);
const replacementScript = createReplacementScript(boundaryId, resolvedContent);
controller.enqueue(replacementScript);
});
return '';
}
}
}
}
renderToStream은 React Fiber 노드를 생성해야 하므로 서버 메모리 부하가 클 수 있습니다.
1. Server Components의 경량화된 구조:
// 일반 클라이언트 컴포넌트 (무거운 Fiber)
function ClientComponent() {
const [state, setState] = useState(0); // Fiber에 상태 저장
useEffect(() => { /* 이펙트 저장 */ });
return <div>{state}</div>
}
// 서버 컴포넌트 (경량화된 구조)
async function ServerComponent() {
const data = await fetch('/api/data'); // 상태 없음
return <div>{data}</div> // 단순 JSX 변환
}
2. 단계적 메모리 해제:
function renderToStream(element) {
return new ReadableStream({
async start(controller) {
const workStack = [element];
while (workStack.length > 0) {
const currentWork = workStack.pop();
const result = await processWork(currentWork);
if (result.isComplete) {
controller.enqueue(result.html);
// 완료된 작업은 즉시 메모리에서 해제
currentWork = null;
}
}
}
});
}
export default function Page() {
return (
<Suspense fallback={<div>전체 페이지 로딩...</div>}>
<Header />
<main>
<Suspense fallback={<div>사이드바 로딩...</div>}>
<Sidebar />
</Suspense>
<Suspense fallback={<div>콘텐츠 로딩...</div>}>
<MainContent />
</Suspense>
</main>
</Suspense>
)
}
export default function Dashboard() {
return (
<div>
<QuickStats /> {/* 빠른 데이터 */}
<Suspense fallback={<ChartSkeleton />}>
<ExpensiveChart /> {/* 느린 데이터 */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<HeavyDataTable /> {/* 또 다른 느린 데이터 */}
</Suspense>
</div>
)
}
Next.js App Router에서 Suspense와 스트리밍 렌더링은 다음과 같은 이점을 제공합니다:
서버 컴포넌트에서 Suspense는 스트리밍과 함께 사용될 때 진짜 효과를 발휘하며, 이는 현대 웹 개발에서 필수적인 성능 최적화 기법이 되었습니다.
메모리 사용량이나 복잡성 측면에서 고려사항이 있지만, React 팀의 지속적인 최적화를 통해 이런 문제들이 점차 해결되고 있습니다. Next.js 14+에서는 이러한 기능들이 기본적으로 제공되므로, 적극적으로 활용해보시기 바랍니다.