모던 리액트 + Next.js #5 Suspense와 use()로 로딩 처리

16 분 소요

지난 시간에는 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)는 도움이 되지만 본질을 풀진 못합니다. 어차피 가장 느린 쪽이 끝날 때까지 화면 전체가 기다려야 하니까요.

진짜 해결은 준비된 부분부터 보여주고, 나머지는 준비되는 대로 나중에 채우는 것입니다. 이걸 가능하게 하는 게 Suspensestreaming이에요.

Suspense의 기본 개념

<Suspense>는 "이 안의 컴포넌트가 아직 준비 안 됐으면 fallback을 대신 보여주고, 준비되면 교체해줘"라고 React에 알려주는 표시입니다.

Suspense 기본
import { Suspense } from 'react';
 
<Suspense fallback={<p>로딩 중...</p>}>
  <SlowComponent />
</Suspense>

SlowComponent가 데이터 페칭 등으로 시간이 걸리면 그동안 <p>로딩 중...</p>이 보이고, 준비가 끝나면 자동으로 교체됩니다.

이게 그 자체로 강력한 건, Suspense 안과 밖이 독립적으로 동작한다는 점입니다. 위 예에서 페이지의 다른 부분은 SlowComponent를 기다리지 않고 바로 그려질 수 있어요.

Server Components + Suspense = Streaming

Server Component에서 Suspense를 쓰면 정말 강력해집니다. 위의 문제 코드를 이렇게 바꿉시다.

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.js

src/app/posts/loading.js:

src/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를 효과적으로 쓰려면 경계를 어디에 그어야 하는지 감이 있어야 합니다. 가이드라인:

  1. 빠른 데이터와 느린 데이터를 다른 Suspense에 둔다 — 느린 쪽이 빠른 쪽을 가리지 않게
  2. 사용자 경험상 같이 보여야 자연스러운 부분은 같은 Suspense에 — 예: 글 제목과 작성자
  3. 너무 잘게 쪼개지 않는다 — 모든 작은 부분에 fallback을 두면 화면이 깜빡임의 패치워크가 됨

Skeleton fallback

"로딩 중..." 텍스트보다는 실제 콘텐츠와 비슷한 모양의 skeleton이 사용자 경험에 좋습니다. 화면 레이아웃이 미리 잡혀 있으면 진짜 콘텐츠가 도착했을 때 점핑이 없거든요.

src/app/posts/loading.js (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여야 한다고 합시다.

src/app/posts/page.js (Server)
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>
  );
}
src/app/posts/PostList.jsx (Client)
'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조건문 안에서도 호출 가능합니다.

조건부 context 사용
'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:

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 효과 없애기

🚫 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이 더 단순하고 명확해요.

마무리

이번 글에서는 점진적 로딩을 만드는 도구들을 다뤘습니다.

  • Suspense — fallback ↔ 콘텐츠를 자동 교체하는 경계
  • Server Components + Suspense = streaming (준비된 부분부터 보내기)
  • loading.js — 페이지 단위 자동 Suspense
  • Skeleton fallback — 점핑 없는 부드러운 전환
  • use() 훅 — Promise/Context를 더 유연하게 다루는 새 도구

지금까지 우리는 데이터를 읽기만 했습니다. 사용자가 폼을 제출하거나 버튼을 눌러서 서버 데이터를 변경하는 일은 어떻게 할까요? 다음 글이자 시리즈의 마지막인 "모던 리액트 + Next.js #6 Server Actions와 폼"에서는 Next.js의 가장 새롭고 강력한 도구인 Server Actions를 다룹니다. 그동안 배운 모든 걸 합친 작은 미니 프로젝트로 시리즈를 마무리할게요.