Suspense와 use()로 로딩 처리
Suspense의 경계 모델로 streaming 렌더링을 만들고, `loading.tsx` · skeleton fallback · 리액트 19에서 안정화된 `use()` 훅까지 정리합니다.
25장에서 Server Component의 데이터 페칭이 단순해진 것을 봤습니다. 그런데 지금까지 만든 페이지는 모든 데이터가 다 도착할 때까지 화면이 안 보이는 문제가 남아 있습니다. 페이지 안에서 빠른 데이터와 느린 데이터가 섞여 있어도 가장 느린 쪽에 맞춰서 모두가 기다리는 형태입니다.
본 챕터에서는 그 문제를 푸는 도구들 — <Suspense>, loading.tsx, 그리고 리액트 19에서 정식 안정화된 use() 훅을 정리합니다. 본 챕터의 streaming 모델은 31장 (Web Vitals와 성능)에서 LCP / TTFB와의 관계로 다시 한번 만나게 됩니다. use()는 28장 (리액트 19 신기능 정리)의 빌딩 블록 중 하나이기도 합니다.
문제 — All-or-Nothing #
다음 페이지를 상상해 보세요.
export default async function Page() {
const profile = await getProfile(); // 100ms (빠름)
const posts = await getPosts(); // 2000ms (느림)
const stats = await getStats(); // 3000ms (가장 느림)
return (
<div>
<Profile data={profile} />
<Posts data={posts} />
<Stats data={stats} />
</div>
);
}이 페이지는 3초 동안 흰 화면입니다. profile은 100ms만에 준비됐는데도 stats가 끝날 때까지 화면에 나오지 못합니다.
병렬화 (Promise.all)는 도움이 되지만 본질을 풀진 못합니다. 어차피 가장 느린 쪽이 끝날 때까지 화면 전체가 기다려야 하니까요.
진짜 해결은 준비된 부분부터 보여주고, 나머지는 준비되는 대로 나중에 채우는 것입니다. 이걸 가능하게 하는 게 Suspense와 streaming입니다.
Suspense의 기본 개념 #
<Suspense>는 “이 안의 컴포넌트가 아직 준비 안 됐으면 fallback을 대신 보여 주고, 준비되면 교체해 줘"라고 리액트에 알려 주는 표시입니다.
import { Suspense } from 'react';
<Suspense fallback={<p>로딩 중...</p>}>
<SlowComponent />
</Suspense>SlowComponent가 데이터 페칭 등으로 시간이 걸리면 그동안 <p>로딩 중...</p>이 보이고, 준비가 끝나면 자동으로 교체됩니다.
이게 그 자체로 강력한 건, Suspense 안과 밖이 독립적으로 동작 한다는 점입니다. 위 예에서 페이지의 다른 부분은 SlowComponent를 기다리지 않고 바로 그려질 수 있습니다.
Server Components + Suspense = Streaming #
Server Component에서 Suspense를 쓰면 정말 강력해집니다. 위의 문제 코드를 이렇게 바꿉시다.
import { Suspense } from 'react';
export default async function Page() {
const profile = await getProfile(); // 100ms는 기다려도 OK
return (
<div>
<Profile data={profile} />
<Suspense fallback={<p>포스트 불러오는 중...</p>}>
<PostsSection />
</Suspense>
<Suspense fallback={<p>통계 불러오는 중...</p>}>
<StatsSection />
</Suspense>
</div>
);
}
async function PostsSection() {
const posts = await getPosts(); // 2000ms
return <Posts data={posts} />;
}
async function StatsSection() {
const stats = await getStats(); // 3000ms
return <Stats data={stats} />;
}이제 일어나는 일.
0ms 서버가 페이지 렌더 시작
100ms profile 도착 → Profile 부분 + Suspense fallback 들이 클라이언트로 전송
(사용자: profile은 보임, 나머지는 "로딩 중..." 표시)
2000ms posts 도착 → 서버가 Posts HTML을 추가로 클라이언트에 보냄
(사용자: Posts 영역이 자동으로 fallback에서 실제 내용으로 교체됨)
3000ms stats 도착 → 같은 식으로 Stats 영역도 교체페이지가 점진적으로 채워지는 것입니다. 빠른 부분은 빠르게, 느린 부분은 자기 페이스대로. 이걸 streaming이라고 부릅니다.
사용자 입장에선 흰 화면이 사라지는 시간이 3초 → 100ms로 단축됩니다. 데이터가 도착하는 데 걸리는 시간 자체는 변하지 않지만 체감 속도는 극적으로 좋아집니다. 31장 (Web Vitals)에서 보면 LCP (Largest Contentful Paint)와 TTFB (Time To First Byte)가 동시에 개선되는 구조입니다.
loading.tsx — 페이지 전체 fallback #
페이지 전체를 한 단위의 Suspense로 감싸고 싶을 때를 위한 단축 문법이 있습니다. 폴더에 loading.tsx 파일을 두면 됩니다.
src/app/
├── layout.tsx
└── posts/
├── loading.tsx ← 자동으로 Suspense fallback이 됨
└── page.tsxsrc/app/posts/loading.tsx:
export default function Loading() {
return (
<div style={{ padding: '24px' }}>
<p>포스트 페이지 불러오는 중...</p>
</div>
);
}/posts로 이동하면 page.tsx의 데이터가 준비되는 동안 이 화면이 보이고, 준비되면 자동 교체됩니다. <Suspense>로 페이지를 감싼 것과 같은 효과입니다.
이건 페이지 전체가 한 단위로 로딩될 때 편리한 방식이고, 더 세밀한 streaming (빠른 부분은 먼저 보여 주고 느린 부분만 fallback)을 원하면 페이지 안에서 직접 <Suspense>를 쓰면 됩니다.
Suspense 경계를 어디에 둘지 #
Suspense를 효과적으로 쓰려면 경계를 어디에 그어야 하는지 감이 있어야 합니다. 의사결정 가이드.
- 빠른 데이터와 느린 데이터를 다른 Suspense에 둔다. 느린 쪽이 빠른 쪽을 가리지 않게.
- 사용자 경험상 같이 보여야 자연스러운 부분은 같은 Suspense에. 예: 글 제목과 작성자.
- 너무 잘게 쪼개지 않는다. 모든 작은 부분에 fallback을 두면 화면이 깜빡임의 패치워크가 됩니다.
- 인터랙션의 단위로 자른다. 사용자가 한 덩어리로 인식하는 카드 / 섹션은 한 Suspense 안에. 화면을 채우는 큰 영역들 (사이드바, 메인, 푸터)은 보통 별도 Suspense.
Skeleton fallback #
“로딩 중…” 텍스트보다는 실제 콘텐츠와 비슷한 모양의 skeleton이 사용자 경험에 좋습니다. 화면 레이아웃이 미리 잡혀 있으면 진짜 콘텐츠가 도착했을 때 점핑이 없기 때문입니다.
function Skeleton({ width, height }: { width: string; height: string }) {
return (
<div style={{
width,
height,
background: '#eee',
borderRadius: '4px',
animation: 'pulse 1.5s ease-in-out infinite',
}} />
);
}
export default function Loading() {
return (
<div style={{ padding: '24px' }}>
<Skeleton width="60%" height="32px" />
<div style={{ marginTop: '16px' }}>
<Skeleton width="100%" height="60px" />
<Skeleton width="100%" height="60px" />
<Skeleton width="100%" height="60px" />
</div>
</div>
);
}(globals.css에 @keyframes pulse { ... } 정의가 필요한 점 참고)
콘텐츠와 같은 위치에 같은 크기로 placeholder를 놓아 두면 콘텐츠 도착 시 자연스럽게 교체됩니다. 사용자는 흰 깜빡임 없는 부드러운 전환을 보게 됩니다.
use() 훅 — Promise를 컴포넌트에서 직접 풀기 #
리액트 19에서 새로 안정화된 훅 use는 Promise를 받아서 그 결과 값을 반환합니다. 다음 두 가지 시나리오에서 의미가 큽니다.
시나리오 1. Server Component에서 Client Component로 Promise를 넘기기 #
데이터 페칭은 Server에서 시작하고 싶지만, 그 결과를 사용하는 컴포넌트는 인터랙션이 필요해 Client 여야 한다고 합시다.
import { Suspense } from 'react';
import PostList from './PostList';
type Post = { id: number; title: string };
export default function Page() {
const postsPromise: Promise<Post[]> = fetch('https://api.example.com/posts')
.then(r => r.json());
return (
<Suspense fallback={<p>불러오는 중...</p>}>
<PostList postsPromise={postsPromise} />
</Suspense>
);
}'use client';
import { use } from 'react';
type Post = { id: number; title: string };
export default function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
const posts = use(postsPromise);
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}서버에서 Promise를 만들어 클라이언트로 넘기고, 클라이언트에서 use(promise)로 풀어 쓰는 패턴입니다. await을 안 쓰고 Promise 자체를 prop으로 넘기는 게 핵심입니다. fetch는 서버에서 시작되어 클라이언트에서는 Promise가 도착하길 기다리고, 도착하면 리액트가 Suspense로 처리해 fallback ↔ 콘텐츠 교체를 자동 처리합니다.
이 패턴이 24장에서 봤던 **“props는 직렬화 가능해야 한다”**의 한 예시입니다 — Promise는 직렬화 가능한 값입니다. 24장의 표에서 “Promise (26장 use()와 짝)” 줄을 기억하고 있다면 본 챕터에서 그 의미가 풀린 것입니다.
이 패턴의 장점은 서버에서 fetch를 즉시 시작 할 수 있다는 점입니다. await을 한 다음 prop을 넘기면 그 await 동안 Suspense fallback이 안 보이지만, Promise를 그대로 넘기면 클라이언트가 Suspense 경계 안에서 그것을 풀려고 시도하는 순간 fallback이 즉시 표시됩니다.
시나리오 2. Context를 조건부로 사용 #
useContext는 함수의 최상위에서만 호출 가능했지만 (13장 hooks 타이핑의 훅 규칙), use는 조건문 안에서도 호출 가능 합니다.
'use client';
import { use } from 'react';
import { ThemeContext } from './ThemeContext';
function Card({ showTheme }: { showTheme: boolean }) {
if (showTheme) {
const theme = use(ThemeContext); // 조건문 안에서 OK
return <div className={theme}>...</div>;
}
return <div>...</div>;
}이게 가능한 이유는 use가 일반 훅과 다른 메커니즘으로 동작하기 때문입니다. 일상적으로 자주 쓰는 패턴은 아니지만 알아 두면 유용할 때가 있습니다.
use는 리액트 19에서 정식 안정화된 비교적 새 훅입니다. 기존 useContext / useState 같은 훅을 대체하는 건 아니고, Promise나 Context를 더 유연하게 다루는 추가 도구로 보시면 됩니다. 일반 데이터 페칭은 그냥 Server Component에서 await 하는 게 가장 간단합니다.직접 해보기 — 점진적 로딩 사이트 #
점진적 로딩의 차이를 직접 느껴 보는 예제를 만들어 봅시다.
src/app/dashboard/page.tsx:
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div style={{ padding: '24px' }}>
<h1>대시보드</h1>
<p>이 페이지는 여러 부분이 각자의 속도로 로드됩니다.</p>
<section style={{ marginTop: '24px' }}>
<h2>프로필 (빠름)</h2>
<Suspense fallback={<Skeleton text="프로필 불러오는 중..." />}>
<Profile />
</Suspense>
</section>
<section style={{ marginTop: '24px' }}>
<h2>알림 (보통)</h2>
<Suspense fallback={<Skeleton text="알림 불러오는 중..." />}>
<Notifications />
</Suspense>
</section>
<section style={{ marginTop: '24px' }}>
<h2>활동 기록 (느림)</h2>
<Suspense fallback={<Skeleton text="활동 기록 불러오는 중..." />}>
<Activity />
</Suspense>
</section>
</div>
);
}
function Skeleton({ text }: { text: string }) {
return (
<div style={{ padding: '12px', background: '#f4f4f4', color: '#888', borderRadius: '4px' }}>
{text}
</div>
);
}
function delay(ms: number) {
return new Promise<void>(resolve => setTimeout(resolve, ms));
}
async function Profile() {
await delay(500);
return (
<div style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px' }}>
<strong>철수</strong> · cheolsu@example.com
</div>
);
}
async function Notifications() {
await delay(2000);
return (
<ul style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px', listStyle: 'disc inside' }}>
<li>새 메시지 3건</li>
<li>친구 요청 1건</li>
</ul>
);
}
async function Activity() {
await delay(4000);
return (
<ul style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px', listStyle: 'decimal inside' }}>
<li>10분 전 - 새 글 작성</li>
<li>1시간 전 - 댓글 달기</li>
<li>3시간 전 - 좋아요 누르기</li>
</ul>
);
}/dashboard로 이동해 보세요.
- 0.5초 후 프로필이 표시됨 (다른 두 영역은 여전히 로딩 중)
- 2초 후 알림이 표시됨
- 4초 후 활동 기록이 표시됨
각 섹션이 자기 속도대로 화면에 나타납니다. 한 영역이 느리다고 해서 다른 영역까지 기다리지 않습니다. 이게 streaming의 실제 모습입니다.
브라우저의 네트워크 탭에서 페이지 요청을 보면 응답이 한 번에 끝나지 않고 chunk 단위로 점진적으로 도착 하는 것도 확인할 수 있습니다. 서버가 준비된 부분부터 보내고 있는 것입니다.
흔한 함정 #
1. 페이지 전체를 await으로 감싸 streaming 효과 없애기 #
export default async function Page() {
const profile = await getProfile();
const posts = await getPosts(); // 여기서 모두 기다림
const stats = await getStats();
return (
<>
<Profile data={profile} />
<Suspense fallback={<p>로딩...</p>}>
<PostsSection data={posts} /> {/* 이미 await 끝나서 fallback 안 보임 */}
</Suspense>
</>
);
}페이지 함수에서 모든 데이터를 await으로 가져온 후 자식에 props로 넘기면, 페이지 함수가 끝날 때까지 클라이언트에 아무것도 안 가서 Suspense의 효과가 안 나옵니다. await은 자식 컴포넌트 안으로 옮기세요.
2. Suspense가 너무 작은 단위에 적용 #
{posts.map(post => (
<Suspense key={post.id} fallback={<p>로딩...</p>}>
<PostItem postId={post.id} />
</Suspense>
))}목록의 각 항목마다 별도 Suspense 경계를 두면 항목들이 따로따로 깜빡깜빡 나타납니다. 보통은 목록 전체를 하나의 Suspense에 두는 쪽이 자연스럽습니다.
3. Server Component 안에서 use(Promise) 호출 — 보통은 그냥 await #
use(Promise)는 주로 Promise를 prop으로 받은 Client Component에서 의미 있는 패턴입니다. Server Component 에선 그냥 await이 더 단순하고 명확합니다.
연습문제 #
- streaming 직접 관찰. 위 대시보드 예제에서 dev tools의 네트워크 탭을 열고
/dashboard응답을 살펴보세요. 응답 헤더의Transfer-Encoding: chunked와 응답 본문이 시간에 따라 추가되는 것을 확인합니다. 그 후 페이지 함수의 첫 줄에const all = await Promise.all([profile(), notifications(), activity()])같이 모든 데이터를 미리 await 하도록 바꿔서, streaming이 사라지고 응답 자체가 늦어지는 것을 비교해 보세요. use()패턴 직접 만들기. Server Component 페이지에서Promise<Post[]>를 만들어 Client Component 자식에게 prop으로 넘기고, 자식에서use(postsPromise)로 풀어 렌더링하세요. 이때 페이지에서await postsPromise를 먼저 한 뒤 결과만 넘기는 버전과 비교해, Suspense fallback이 보이는 시점이 어떻게 달라지는지 관찰합니다.- Suspense 경계 설계. 한 페이지에 (a) 사용자 헤더 (50ms), (b) 추천 카드 3개 (각 200ms), (c) 활동 피드 (1500ms)를 표시해야 합니다. 어떤 부분을 같은
<Suspense>에 두고 어떤 부분을 분리할지 본문의 4가지 가이드라인에 비추어 설계하고, 그렇게 결정한 이유를 한 단락으로 적어 보세요. 정답은 하나가 아닙니다. 트레이드오프를 설명할 수 있으면 됩니다.
한 줄 요약:
<Suspense>는 fallback ↔ 콘텐츠를 자동 교체하는 경계이고, Server Component의async함수와 결합하면 준비된 부분부터 보내는 streaming이 된다. 페이지 단위 fallback은loading.tsx한 파일로 해결하고, 정교한 분리는<Suspense>를 직접 둔다. skeleton fallback으로 점핑 없는 전환을, 리액트 19의use()훅으로 Server가 만든 Promise를 Client에서 풀어 쓴다. await을 페이지 함수 최상위에서 하면 streaming이 사라지니, await은 자식 컴포넌트 안으로.
다음 챕터 #
지금까지 우리는 데이터를 읽기만 했습니다. 다음 27장 Server Actions와 폼에서는 사용자가 폼을 제출하거나 버튼을 눌러 서버 데이터를 변경 하는 mutation의 새 패러다임을 다룹니다. API 라우트를 한 줄도 만들지 않고 서버 함수를 그대로 호출하는 Server Actions, 리액트 19의 useActionState / useFormStatus, 그리고 본 챕터의 Suspense + 27장의 Server Actions를 결합한 작은 미니 프로젝트로 4부를 마무리합니다.