지난 시간에는 Server Component에서 데이터를 가져오는 단순한 패턴을 다뤘습니다. 그런데 지금까지 우리가 만든 페이지는 모든 데이터가 다 도착할 때까지 화면이 안 보입니다. 페이지 안에서 빠른 데이터와 느린 데이터가 섞여 있어도, 가장 느린 쪽에 맞춰서 모두가 기다리는 거죠. 이번 글에서는 그 문제를 푸는 도구들 — Suspense, loading.js, use() 훅을 다룹니다.
문제 — 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을 대신 보여주고, 준비되면 교체해줘"라고 React에 알려주는 표시입니다.
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로 단축됩니다. 데이터가 도착하는 데 걸리는 시간 자체는 변하지 않지만 체감 속도는 극적으로 좋아져요.
loading.js — 페이지 전체 fallback
페이지 전체를 한 단위의 Suspense로 감싸고 싶을 때를 위한 단축 문법이 있습니다. 폴더에 loading.js 파일을 두면 됩니다.
src/app/
├── layout.js
└── posts/
├── loading.js ← 자동으로 Suspense fallback이 됨
└── page.jssrc/app/posts/loading.js:
export default function Loading() {
return (
<div style={{ padding: '24px' }}>
<p>포스트 페이지 불러오는 중...</p>
</div>
);
}/posts로 이동하면 page.js의 데이터가 준비되는 동안 이 화면이 보이고, 준비되면 자동 교체됩니다. <Suspense>로 페이지를 감싼 것과 같은 효과입니다.
이건 페이지 전체가 한 단위로 로딩될 때 편리한 방식이고, 더 세밀한 streaming(빠른 부분은 먼저 보여주고 느린 부분만 fallback)을 원하면 페이지 안에서 직접 <Suspense>를 쓰면 됩니다.
Suspense 경계를 어디에 둘지
Suspense를 효과적으로 쓰려면 경계를 어디에 그어야 하는지 감이 있어야 합니다. 가이드라인:
- 빠른 데이터와 느린 데이터를 다른 Suspense에 둔다 — 느린 쪽이 빠른 쪽을 가리지 않게
- 사용자 경험상 같이 보여야 자연스러운 부분은 같은 Suspense에 — 예: 글 제목과 작성자
- 너무 잘게 쪼개지 않는다 — 모든 작은 부분에 fallback을 두면 화면이 깜빡임의 패치워크가 됨
Skeleton fallback
"로딩 중..." 텍스트보다는 실제 콘텐츠와 비슷한 모양의 skeleton이 사용자 경험에 좋습니다. 화면 레이아웃이 미리 잡혀 있으면 진짜 콘텐츠가 도착했을 때 점핑이 없거든요.
function Skeleton({ width, height }) {
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를 컴포넌트에서 직접 풀기
React 19에서 새로 안정화된 훅 use는 Promise를 받아서 그 결과 값을 반환합니다. 다음 두 가지 시나리오에서 의미가 큽니다.
시나리오 1. Server Component에서 Client Component로 Promise를 넘기기
데이터 페칭은 Server에서 시작하고 싶지만, 그 결과를 사용하는 컴포넌트는 인터랙션이 필요해 Client여야 한다고 합시다.
import PostList from './PostList';
export default function Page() {
const postsPromise = 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';
export default function PostList({ postsPromise }) {
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가 도착하길 기다리고, 도착하면 React가 Suspense로 처리해 fallback ↔ 콘텐츠 교체를 자동 처리합니다.
이 패턴의 장점은 서버에서 fetch를 즉시 시작할 수 있다는 점입니다. await을 한 다음 prop을 넘기면 그 await 동안 Suspense fallback이 안 보이지만, Promise를 그대로 넘기면 클라이언트가 Suspense 경계 안에서 그것을 풀려고 시도하는 순간 fallback이 즉시 표시됩니다.
시나리오 2. Context를 조건부로 사용
useContext는 함수의 최상위에서만 호출 가능했지만 (#13의 훅 규칙), use는 조건문 안에서도 호출 가능합니다.
'use client';
import { use } from 'react';
import { ThemeContext } from './ThemeContext';
function Card({ showTheme }) {
if (showTheme) {
const theme = use(ThemeContext); // 조건문 안에서 OK
return <div className={theme}>...</div>;
}
return <div>...</div>;
}이게 가능한 이유는 use가 일반 훅과 다른 메커니즘으로 동작하기 때문이에요. 일상적으로 자주 쓰는 패턴은 아니지만 알아두면 유용할 때가 있습니다.
use는 React 19에서 정식 안정화된 비교적 새 훅입니다. 기존 useContext/useState 같은 훅을 대체하는 건 아니고, Promise나 Context를 더 유연하게 다루는 추가 도구로 보시면 됩니다. 일반 데이터 페칭은 그냥 Server Component에서 await 하는 게 가장 간단합니다.
동작 확인 — 점진적 로딩 사이트
점진적 로딩의 차이를 직접 느껴보는 예제를 만들어봅시다.
src/app/dashboard/page.js:
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 }) {
return (
<div style={{ padding: '12px', background: '#f4f4f4', color: '#888', borderRadius: '4px' }}>
{text}
</div>
);
}
async function delay(ms) {
return new Promise(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으로 감싸 buffer 효과 없애기
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이 더 단순하고 명확해요.
마무리
이번 글에서는 점진적 로딩을 만드는 도구들을 다뤘습니다.
- Suspense — fallback ↔ 콘텐츠를 자동 교체하는 경계
- Server Components + Suspense = streaming (준비된 부분부터 보내기)
loading.js— 페이지 단위 자동 Suspense- Skeleton fallback — 점핑 없는 부드러운 전환
use()훅 — Promise/Context를 더 유연하게 다루는 새 도구
지금까지 우리는 데이터를 읽기만 했습니다. 사용자가 폼을 제출하거나 버튼을 눌러서 서버 데이터를 변경하는 일은 어떻게 할까요? 다음 글이자 시리즈의 마지막인 "모던 리액트 + Next.js #6 Server Actions와 폼"에서는 Next.js의 가장 새롭고 강력한 도구인 Server Actions를 다룹니다. 그동안 배운 모든 걸 합친 작은 미니 프로젝트로 시리즈를 마무리할게요.