본문 바로가기

React

React Suspense로 데이터 로딩 경험 개선하기

로딩 스피너 하나로는 부족합니다. React Suspense는 대기 중인 비동기 작업을 컴포넌트 트리 바깥으로 던지고, 가장 가까운 경계에서 적절한 대체 UI를 보여줍니다. 코드 스플리팅부터 데이터 로딩까지, 경계를 잘 배치하면 지연을 사용자 경험으로 전환할 수 있습니다.

1. Suspense는 무엇이고 언제 쓰나요

Suspense는 "대기"를 UI로 모델링합니다. Promise를 던지는 컴포넌트를 경계로 감싸면, 준비되기 전까지 fallback UI를 렌더링합니다. 실무에서는 다음에 유용합니다.

- 코드 스플리팅된 청크 로드 대기

- 데이터 요청 중 스켈레톤 보여주기

- Next.js App Router에서 서버 컴포넌트 스트리밍

2. 가장 쉬운 진입: 코드 스플리팅 + 스켈레톤

큰 위젯은 lazy 로드하고 Suspense로 스켈레톤을 보여줍니다.

import React, { Suspense } from 'react';

const Chart = React.lazy(() => import('./Chart'));

function ChartSkeleton() {
  return <div style={{ height: 240, background: '#f3f4f6' }} />;
}

export default function Dashboard() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <Chart />
    </Suspense>
  );
}

- 스켈레톤은 실제 위젯과 높이/구조를 맞춰 레이아웃 점프를 막습니다.

3. 데이터 로딩에 적용하는 3가지 패턴

- 라이브러리 기반(Client): React Query/SWR/Relay처럼 Suspense를 지원하는 클라이언트 데이터 라이브러리를 사용합니다.

- 서버 컴포넌트(Next.js App Router): 서버에서 데이터를 기다리는 동안 UI를 스트리밍하고, 클라이언트에는 경계만 남깁니다.

- 직접 구현은 지양: 학습용 wrapPromise 패턴은 개념 이해에는 좋지만, 캐시/동기화/취소가 없어 실무에는 부적합합니다.

4. React Query와 Suspense로 스켈레톤 렌더링

import React, { Suspense } from 'react';
import { QueryClient, QueryClientProvider, useQuery, useQueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,           // pending 상태에서 Suspense로 위임
      staleTime: 30 * 1000,     // 캐시된 데이터 유지 시간
      retry: 1,                 // 과도한 재시도 방지
      refetchOnWindowFocus: false,
    },
  },
});

async function fetchUser(id) {
  const r = await fetch(`/api/users/${id}`);
  if (!r.ok) throw new Error('Network error');
  return r.json();
}

function UserProfile({ id }) {
  const { data } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
  });
  return (
    <div>
      <h2>{data.name}</h2>
      <p>Email: {data.email}</p>
    </div>
  );
}

function UserSkeleton() {
  return <div className="skeleton" style={{ height: 80, background: '#f3f4f6' }} />;
}

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { error: null };
  }
  static getDerivedStateFromError(error) {
    return { error };
  }
  render() {
    if (this.state.error) return this.props.fallback;
    return this.props.children;
  }
}

function ErrorBox() {
  return <div style={{ color: 'crimson' }}>불러오기에 실패했습니다. 새로고침 해주세요.</div>;
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Suspense fallback={<UserSkeleton />}>
        <ErrorBoundary fallback={<ErrorBox />}>
          <UserProfile id="42" />
        </ErrorBoundary>
      </Suspense>
    </QueryClientProvider>
  );
}

// 사전 가져오기로 체감 속도 향상
function UserLink({ id, name }) {
  const qc = useQueryClient();
  return (
    <a
      href={`/users/${id}`}
      onMouseEnter={() => qc.prefetchQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })}
    >
      {name}
    </a>
  );
}

- suspense: true를 쓰면 pending 시 스켈레톤, 실패 시 ErrorBoundary가 처리합니다. 배경 리패치에는 보통 Suspense를 쓰지 않고, 작은 인라인 로더나 아이콘으로 처리합니다.

5. Next.js App Router에서 스트리밍과 함께 쓰기

서버 컴포넌트는 fetch가 완료될 때까지 자동으로 서스펜드됩니다. 경계로 감싸면 빠른 영역부터 스트리밍됩니다.

// app/page.tsx (Server Component)
import { Suspense } from 'react';
import PostList from './PostList';
import PostListSkeleton from './PostListSkeleton';

export default function Page() {
  return (
    <>
      <h1>피드</h1>
      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
      </Suspense>
    </>
  );
}

// app/PostList.tsx (Server Component)
export default async function PostList() {
  const res = await fetch('https://api.example.com/posts', { cache: 'no-store' });
  if (!res.ok) throw new Error('Failed to load');
  const posts = await res.json();
  return (
    <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
  );
}

- 서버에서 느린 부분만 경계로 감싸 Above-the-fold를 먼저 렌더링합니다. 클라이언트 컴포넌트는 필요한 곳에만 도입하세요.

6. startTransition으로 전환 중 끊김 줄이기

입력 반응성은 유지하고, 느린 필터 변경은 전환으로 표시해 Suspense가 처리하게 합니다.

import React, { useState, startTransition, Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';

function Results({ filter }) {
  const { data } = useQuery({
    queryKey: ['results', filter],
    queryFn: () => fetch(`/api/search?q=${encodeURIComponent(filter)}`).then(r => r.json()),
    suspense: true,
  });
  return <ul>{data.items.map(i => <li key={i.id}>{i.name}</li>)}</ul>;
}

function Search() {
  const [q, setQ] = useState('');
  const [filter, setFilter] = useState('');

  function onChange(e) {
    const value = e.target.value;
    setQ(value); // 입력은 즉시 반응
    startTransition(() => {
      setFilter(value); // 결과 영역만 Suspense로 전환
    });
  }

  return (
    <>
      <input value={q} onChange={onChange} placeholder="검색" />
      <Suspense fallback={<div style={{ height: 120, background: '#f3f4f6' }} />}>
        <Results filter={filter} />
      </Suspense>
    </>
  );
}

- 전환 중에는 기존 결과가 유지되고, 준비되면 새 결과로 교체되어 깜빡임이 줄어듭니다.

7. ErrorBoundary와 함께 실패 경험 통제

- Suspense 옆에 ErrorBoundary를 둬서 영역별로 실패를 격리합니다.

- 실패 메시지는 간결하게, 재시도 버튼을 제공하세요. React Query의 queryClient.invalidateQueries로 재시도 트리거가 간단합니다.

8. 경계 배치와 UX 체크리스트

- 경계는 작게, 필요 영역만 감싸기: 리스트/상세, 사이드바/메인 등으로 분리합니다.

- 스켈레톤은 실제 콘텐츠 레이아웃을 흉내 내 CLS를 방지합니다.

- 캐시를 적극 활용: staleTime, prefetch로 체감 지연을 줄입니다.

- 경계에 key를 부여해 명시적으로 재로딩을 트리거할 수 있습니다.

- 너무 짧은 대기에는 스피너를 숨겨 깜빡임을 줄입니다(예: 300ms 지연 후 표시).

9. 마무리

Suspense의 핵심은 “대기를 설계”하는 것입니다. 경계를 적절히 나누고, 스켈레톤/에러/전환 흐름을 분리하면 응답성이 크게 좋아집니다. 새로운 기능을 도입할 때는 코드 스플리팅부터 시작해, 데이터 경계, 서버 스트리밍까지 단계적으로 확장해보시기 바랍니다.