서버 컴포넌트에서 데이터를 가져오는 것까지 배웠습니다. 그런데 데이터를 가져오는 데 시간이 오래 걸리면 어떻게 될까요?
예를 들어, 블로그 페이지에서 글 목록은 빠르게 가져올 수 있지만 추천 글 데이터는 외부 API에서 가져오느라 3초가 걸린다고 가정해 보겠습니다:
// app/blog/page.tsx
import { SearchablePostList } from '../components/SearchablePostList';
export default async function Blog() {
const res = await fetch('http://localhost:4000/posts');
const posts = await res.json();
// 이 요청이 3초 걸린다면?
const recRes = await fetch('http://localhost:4000/recommendations');
const recommendations = await recRes.json();
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
<h2>추천 글</h2>
<ul>
{recommendations.map((rec: { id: number; title: string }) => (
<li key={rec.id}>{rec.title}</li>
))}
</ul>
</div>
);
}
두 번째 await가 3초 걸리면, 글 목록이 이미 준비되었어도 사용자는 페이지 전체가 완성될 때까지 아무것도 볼 수 없습니다:
[요청] ─── 글 목록 fetch (0.1초) ─── 추천 글 fetch (3초) ─── HTML 전송
↑
이 시점에서 사용자가 화면을 봄
글 목록은 0.1초 만에 준비되었는데, 추천 글 때문에 전체가 3초나 기다리는 셈입니다.
가장 간단한 해결책은 loading.tsx 파일을 추가하는 것입니다. page.tsx와 같은 폴더에 이 파일을 만들면, 데이터를 가져오는 동안 자동으로 로딩 UI를 보여줍니다:
// app/blog/loading.tsx
export default function Loading() {
return <p>글 목록을 불러오는 중...</p>;
}
이제 사용자는 빈 화면 대신 "글 목록을 불러오는 중..." 메시지를 즉시 볼 수 있습니다. 데이터가 준비되면 loading.tsx가 page.tsx의 결과로 자동 교체됩니다.
[요청] ─── loading.tsx 즉시 표시
─── 데이터 fetch (3초) ─── page.tsx로 교체
loading.tsx는 내부적으로 React의 Suspense를 사용합니다. Next.js가 page.tsx를 <Suspense fallback={<Loading />}>로 자동 감싸주는 것과 같습니다.
loading.tsx에는 한계가 있습니다. 페이지 전체를 로딩 UI로 교체하기 때문에, 이미 준비된 글 목록까지 숨겨 버립니다. 글 목록은 바로 보여주고, 느린 추천 글 부분만 로딩 표시를 하고 싶다면 어떻게 해야 할까요?
React의 <Suspense>를 사용하면 느린 부분만 골라서 로딩 처리를 할 수 있습니다. 핵심은 느린 데이터를 가져오는 부분을 별도의 async 서버 컴포넌트로 분리하는 것입니다:
// app/blog/components/Recommendations.tsx
export default async function Recommendations() {
const res = await fetch('http://localhost:4000/recommendations');
const recommendations = await res.json();
return (
<ul>
{recommendations.map((rec: { id: number; title: string }) => (
<li key={rec.id}>{rec.title}</li>
))}
</ul>
);
}
이제 페이지에서 이 컴포넌트를 Suspense로 감쌉니다:
// app/blog/page.tsx
import { Suspense } from 'react';
import { SearchablePostList } from '../components/SearchablePostList';
import Recommendations from './components/Recommendations';
export default async function Blog() {
const res = await fetch('http://localhost:4000/posts');
const posts = await res.json();
return (
<div>
<h1>글 목록</h1>
<SearchablePostList posts={posts} />
<h2>추천 글</h2>
<Suspense fallback={<p>추천 글 로딩 중...</p>}>
<Recommendations />
</Suspense>
</div>
);
}
이제 글 목록은 즉시 표시되고, 추천 글 영역만 "추천 글 로딩 중..."으로 표시됩니다. 추천 데이터가 도착하면 해당 부분만 교체됩니다:
[요청] ─── 글 목록 (0.1초) ─── 글 목록 + "추천 글 로딩 중..." 즉시 전송
─── 추천 글 (3초) ─── 추천 영역만 교체
이것이 Streaming입니다. Suspense를 사용하면 Next.js가 완성된 부분부터 브라우저로 전송하고, 나머지는 준비되는 대로 이어서 보냅니다. 사용자는 빈 화면을 기다리는 대신 점진적으로 콘텐츠를 볼 수 있습니다.
Suspense가 로딩 경계를 만들려면, 느린 await가 별도의 async 서버 컴포넌트 안에 있어야 합니다. 같은 컴포넌트 안에서 await한 뒤 Suspense로 감싸는 것은 효과가 없습니다 - 이미 기다린 뒤이므로 감쌀 것이 없기 때문입니다.
Suspense와 별개로, 서버 컴포넌트 안에서 여러 데이터를 가져올 때 불필요한 순차 실행을 피하는 것도 중요합니다.
export default async function Blog() {
// 1번이 끝나야 2번이 시작됨
const posts = await fetch('http://localhost:4000/posts')
.then(r => r.json());
const categories = await fetch('http://localhost:4000/categories')
.then(r => r.json());
// 총 시간: posts 시간 + categories 시간
// ...
}
export default async function Blog() {
// 동시에 시작
const [posts, categories] = await Promise.all([
fetch('http://localhost:4000/posts').then(r => r.json()),
fetch('http://localhost:4000/categories').then(r => r.json()),
]);
// 총 시간: 둘 중 더 오래 걸리는 쪽
// ...
}
Promise.all은 모든 요청을 동시에 시작하고, 전부 완료되면 결과를 반환합니다. 서로 의존성이 없는 데이터라면 항상 병렬로 가져오는 것이 좋습니다.
| 개념 | 핵심 내용 |
|---|---|
loading.tsx | 페이지 전체의 로딩 UI. 파일 하나로 간단하게 적용 |
Suspense | 컴포넌트 단위 로딩. 느린 부분만 골라서 로딩 처리 |
| Streaming | Suspense 경계를 기준으로 완성된 부분부터 브라우저로 전송 |
| 병렬 fetching | Promise.all로 독립적인 데이터를 동시에 요청 |
다음 세션에서는 캐싱을 다룹니다. 같은 블로그 글 목록을 100명이 요청하면 매번 데이터를 새로 가져와야 할까요? 한 번 가져온 결과를 재사용하는 전략을 배웁니다.