Suspense와 Skeleton UI 제대로 이해하고 적용하기

Suspense의 작동 원리부터 클라이언트 컴포넌트에서의 올바른 스켈레톤 UI 구현까지

Frontend
React, Next.js, Suspense, UI/UX

Before Skeleton UI

스켈레톤 UI가 없는 웹사이트는 마치 응답 없는 엘리베이터 버튼과 같습니다. 버튼을 눌렀는데 아무 반응이 없으면 “고장났나?” 싶죠. 하지만 버튼에 불이 들어오면 “아, 오고 있구나” 하고 기다릴 수 있습니다.

스켈레톤 UI도 마찬가지입니다. 컨텐츠가 로딩되는 동안 “여기에 뭔가 나올 거야” 라고 알려주는 역할을 합니다.

1. Suspense란?

레스토랑으로 이해하는 Suspense

레스토랑에서 음식을 주문했다고 생각해보세요.

Suspense가 없는 레스토랑

주문 후 아무것도 없는 빈 테이블만 보고 기다림.
”내 주문이 들어간 거 맞아? 주방이 돌아가고 있긴 한 거야?”

Suspense가 있는 레스토랑

주문 후 빈 접시와 수저가 세팅됨.
”아, 음식이 오고 있구나. 곧 나오겠네.”

React의 <Suspense>는 바로 이 빈 접시와 수저를 세팅해주는 역할입니다.

<Suspense fallback={<빈접시와수저 />}>
  <음식 />
</Suspense>

음식(데이터)이 준비되기 전까지 빈 접시(스켈레톤)를 보여주고, 음식이 도착하면 자동으로 교체해줍니다.

핵심 원리: Promise를 던진다.

Suspense의 동작 원리를 좀 더 기술적으로 설명하면,

  1. 컴포넌트가 “나 아직 준비 안 됐어!” 라고 신호를 보냄 (Promise를 throw)
  2. Suspense가 이 신호를 받아서 fallback UI를 대신 보여줌
  3. 준비가 완료되면 (Promise가 resolve되면) 실제 컴포넌트로 교체
[컴포넌트]  →  "아직이야!" (throw Promise)  →  [Suspense가 감지]

                                              fallback 표시

                                              Promise 완료!

                                              실제 컴포넌트로 교체

2. Next.js에서 Suspense가 특별한 이유

여기서 많은 분들이 헷갈려하는 부분이 있습니다.

“클라이언트 컴포넌트는 Promise를 throw하지 않는데, Suspense로 감싸면 스켈레톤이 보일까?”

정답은 “Next.js에서는 보인다” 입니다. 이유를 설명해드릴게요.

서버와 클라이언트, 두 개의 세계

Next.js App Router는 서버에서 먼저 HTML을 만들어서 보내줍니다.

useSearchParams()가 특별한 이유

useSearchParams()는 URL의 쿼리 파라미터를 읽는 훅입니다.

https://bbinya1224.github.io/blog?category=Frontend&tag=React

                            이 부분을 읽음

하지만 서버에서는 이 값을 모릅니다.

  • 서버는 빌드 시점에 HTML을 미리 만들어둠 (Static Rendering)
  • ?category=Frontend는 사용자가 접속할 때마다 다를 수 있음
  • 그래서 서버는 “이건 클라이언트에서 처리해야 해”라고 판단

이때 Next.js가 똑똑하게 행동합니다.

<Suspense fallback={<TagSkeleton />}>
  <TagList />  {/* useSearchParams() 사용 */}
</Suspense>

→ 서버: “TagList는 클라이언트에서 처리해야 하니까, 일단 TagSkeleton을 HTML로 보내줄게”
→ 클라이언트: “JavaScript 로드 완료! 이제 진짜 TagList 보여줄게”

3. react-loading-skeleton 적용하기

이제 Suspense의 fallback으로 보여줄 스켈레톤 UI를 만들어봅시다.

설치

npm install react-loading-skeleton

스타일 import

최상단 레이아웃에서 CSS를 import합니다.

// app/layout.tsx
import 'react-loading-skeleton/dist/skeleton.css'

스켈레톤 컴포넌트 만들기

import Skeleton from "react-loading-skeleton";
 
const PostListSkeleton = () => {
  return (
    <div className="space-y-6">
      {Array.from({ length: 3 }).map((_, i) => (
        <article key={i} className="flex gap-4">
          {/* 텍스트 영역 */}
          <div className="flex-1">
            <Skeleton width="60%" height={24} />      {/* 제목 */}
            <Skeleton count={2} height={16} />         {/* 설명 2줄 */}
            <Skeleton width={100} height={14} />       {/* 날짜 */}
          </div>
          {/* 썸네일 */}
          <Skeleton width={112} height={112} borderRadius={8} />
        </article>
      ))}
    </div>
  );
};

4. 실제 적용

이제 모든 준비가 끝났습니다. 적용은 정말 간단합니다.

// page.tsx (서버 컴포넌트)
import { Suspense } from "react";
 
const MainPage = () => {
  return (
    <Suspense fallback={<PostListSkeleton />}>
      <PostList />
    </Suspense>
  );
};
// PostList.tsx (클라이언트 컴포넌트)
"use client";
 
const PostList = () => {
  const searchParams = useSearchParams();
  const [posts] = useAtom(postsAtom);
 
  return (
    <section>
      {posts.map((post) => (
        <PostWidget key={post.slug} {...post} />
      ))}
    </section>
  );
};

끝입니다! mounted 상태 같은 추가 처리가 필요 없습니다.

5. Suspense 경계 설계하기

나쁜 예: 모든 곳에 Suspense

<Suspense fallback={<Skeleton />}><Header /></Suspense>
<Suspense fallback={<Skeleton />}><Sidebar /></Suspense>
<Suspense fallback={<Skeleton />}><MainContent /></Suspense>
<Suspense fallback={<Skeleton />}><Footer /></Suspense>

Suspense Bad Example

화면 전체가 깜빡깜빡. 어떤게 주요 컨텐츠인지, 어떤게 로딩 중인지 알 수가 없습니다.

좋은 예: 실제로 데이터가 필요한 곳만

<Header />  {/* 정적 컨텐츠 - 바로 보여줌 */}
 
<Suspense fallback={<ContentSkeleton />}>
  <Sidebar />      {/* 데이터 필요 */}
  <MainContent />  {/* 데이터 필요 */}
</Suspense>
 
<Footer />  {/* 정적 컨텐츠 - 바로 보여줌 */}

Suspense Good Example

Header와 Footer는 즉시 보이고, 데이터가 필요한 부분만 스켈레톤으로 표시됩니다.

독립적으로 로딩하고 싶다면?

각 영역이 준비되는 대로 보여주고 싶다면 Suspense를 분리합니다.

<Header />
 
<Suspense fallback={<SidebarSkeleton />}>
  <Sidebar />  {/* 1초 후 완료 */}
</Suspense>
 
<Suspense fallback={<MainSkeleton />}>
  <MainContent />  {/* 2초 후 완료 */}
</Suspense>
 
<Footer />

Next.js는 각 Suspense를 병렬로 처리합니다.
Sidebar가 먼저 준비되면 먼저 보여주고, MainContent는 아직 스켈레톤으로 남아있습니다.

6. 정리

상황해결 방법
useSearchParams() 사용하는 클라이언트 컴포넌트Suspense만 사용
async 서버 컴포넌트 (데이터 fetching)Suspense만 사용
큰 컴포넌트 코드 스플리팅React.lazy() + Suspense

스켈레톤 UI 적용 후

이렇게 스켈레톤 UI를 적용하고 나니, 처음 의도했던 대로 잘 작동하게 되었습니다!

처음에는 “클라이언트 컴포넌트를 Suspense로 감싸면 안 되겠지?”라고 생각했는데, Next.js App Router에서는 서버에서 fallback을 렌더링해주기 때문에 잘 작동합니다. 프레임워크가 어떻게 동작하는지 이해하는 게 중요하다는 걸 다시 한번 느꼈네요.


참고 자료

관련 글