모던 리액트 + Next.js #5 Suspense와 use()로 로딩 처리
지난 시간에는 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를 다루겠습니다. 그동안 배운 모든 걸 합친 작은 미니 프로젝트로 시리즈를 마무리하겠습니다.