한국어

코드 스플리팅을 넘어선 데이터 페칭을 위한 React Suspense를 탐색하세요. Fetch-As-You-Render, 에러 핸들링, 글로벌 애플리케이션을 위한 미래 지향적 패턴을 이해합니다.

React Suspense 리소스 로딩: 최신 데이터 페칭 패턴 마스터하기

역동적인 웹 개발의 세계에서는 사용자 경험(UX)이 무엇보다 중요합니다. 애플리케이션은 네트워크 상태나 기기 성능에 관계없이 빠르고, 반응성이 뛰어나며, 즐거운 경험을 제공해야 합니다. React 개발자에게 이는 종종 복잡한 상태 관리, 복잡한 로딩 표시기, 그리고 데이터 페칭 워터폴과의 끊임없는 싸움을 의미합니다. 바로 이때 React Suspense가 등장합니다. Suspense는 강력하지만 종종 오해받는 기능으로, 비동기 작업, 특히 데이터 페칭을 처리하는 방식을 근본적으로 바꾸기 위해 설계되었습니다.

처음에는 React.lazy()를 사용한 코드 스플리팅을 위해 소개되었지만, Suspense의 진정한 잠재력은 API 데이터 등 *모든* 비동기 리소스의 로딩을 조율하는 능력에 있습니다. 이 종합 가이드에서는 리소스 로딩을 위한 React Suspense를 깊이 파고들어, 핵심 개념, 기본적인 데이터 페칭 패턴, 그리고 성능이 뛰어나고 복원력 있는 글로벌 애플리케이션을 구축하기 위한 실용적인 고려 사항들을 탐구할 것입니다.

React 데이터 페칭의 진화: 명령형에서 선언형으로

수년 동안 React 컴포넌트에서의 데이터 페칭은 주로 공통된 패턴에 의존했습니다: useEffect 훅을 사용하여 API 호출을 시작하고, useState로 로딩 및 에러 상태를 관리하며, 이러한 상태에 따라 조건부 렌더링을 하는 방식입니다. 이 접근 방식은 기능적이긴 하지만 종종 여러 가지 문제점을 야기했습니다:

Suspense가 없는 일반적인 데이터 페칭 시나리오를 생각해 봅시다:

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setIsLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const data = await response.json();
        setUser(data);
      } catch (e) {
        setError(e);
      } finally {
        setIsLoading(false);
      }
    };
    fetchUser();
  }, [userId]);

  if (isLoading) {
    return <p>사용자 프로필 로딩 중...</p>;
  }

  if (error) {
    return <p style={"color: red;"}>에러: {error.message}</p>;
  }

  if (!user) {
    return <p>사용자 데이터가 없습니다.</p>;
  }

  return (
    <div>
      <h2>사용자: {user.name}</h2>
      <p>이메일: {user.email}</p>
      <!-- 더 많은 사용자 정보 -->
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>애플리케이션에 오신 것을 환영합니다</h1>
      <UserProfile userId={"123"} />
    </div>
  );
}

이 패턴은 어디에서나 볼 수 있지만, 컴포넌트가 자체 비동기 상태를 관리하도록 강요하며, 이는 종종 UI와 데이터 페칭 로직 간의 강한 결합으로 이어집니다. Suspense는 더 선언적이고 간소화된 대안을 제공합니다.

코드 스플리팅을 넘어서 React Suspense 이해하기

대부분의 개발자들은 코드 스플리팅을 위해 React.lazy()를 통해 Suspense를 처음 접합니다. Suspense는 컴포넌트의 코드가 필요해질 때까지 로딩을 지연시킬 수 있게 해줍니다. 예를 들어:

import React, { Suspense, lazy } from 'react';

const LazyComponent = lazy(() => import('./MyHeavyComponent'));

function App() {
  return (
    <Suspense fallback={<div>컴포넌트 로딩 중...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

이 시나리오에서 MyHeavyComponent가 아직 로드되지 않았다면, <Suspense> 경계는 lazy()가 던진 프로미스를 잡아내고 컴포넌트 코드가 준비될 때까지 fallback을 표시합니다. 여기서 핵심은 Suspense가 렌더링 중에 던져진 프로미스를 잡아내는 방식으로 작동한다는 것입니다.

이 메커니즘은 코드 로딩에만 국한되지 않습니다. 렌더링 중에 호출되어 프로미스를 던지는 모든 함수(예: 리소스가 아직 사용 가능하지 않기 때문에)는 컴포넌트 트리 상위의 Suspense 경계에 의해 잡힐 수 있습니다. 프로미스가 해결되면 React는 컴포넌트를 다시 렌더링하려고 시도하고, 이제 리소스를 사용할 수 있게 되면 fallback이 숨겨지고 실제 콘텐츠가 표시됩니다.

데이터 페칭을 위한 Suspense의 핵심 개념

데이터 페칭에 Suspense를 활용하려면 몇 가지 핵심 원칙을 이해해야 합니다:

1. 프로미스 던지기(Throwing a Promise)

프로미스를 해결하기 위해 async/await를 사용하는 전통적인 비동기 코드와 달리, Suspense는 데이터가 준비되지 않았을 때 프로미스를 *던지는* 함수에 의존합니다. React가 그런 함수를 호출하는 컴포넌트를 렌더링하려고 할 때 데이터가 아직 보류 중이면 프로미스가 던져집니다. 그러면 React는 해당 컴포넌트와 그 자식들의 렌더링을 '일시 중지'하고 가장 가까운 <Suspense> 경계를 찾습니다.

2. Suspense 경계(The Suspense Boundary)

<Suspense> 컴포넌트는 프로미스를 위한 에러 경계처럼 작동합니다. 이 컴포넌트는 fallback prop을 받으며, 이는 자식(또는 그 후손) 중 어느 것이든 일시 중단(suspending, 즉 프로미스를 던지는) 상태일 때 렌더링할 UI입니다. 하위 트리 내에서 던져진 모든 프로미스가 해결되면 fallback은 실제 콘텐츠로 대체됩니다.

하나의 Suspense 경계는 여러 비동기 작업을 관리할 수 있습니다. 예를 들어, 동일한 <Suspense> 경계 내에 두 개의 컴포넌트가 있고 각각 데이터를 페칭해야 하는 경우, *두* 데이터 페칭이 모두 완료될 때까지 fallback이 표시됩니다. 이는 부분적인 UI가 표시되는 것을 방지하고 더 조화로운 로딩 경험을 제공합니다.

3. 캐시/리소스 관리자 (사용자 영역의 책임)

중요하게도, Suspense 자체는 데이터 페칭이나 캐싱을 처리하지 않습니다. 그것은 단지 조정 메커니즘일 뿐입니다. 데이터 페칭에 Suspense를 사용하려면 다음을 수행하는 계층이 필요합니다:

이 '리소스 관리자'는 일반적으로 각 리소스의 상태(보류, 해결 또는 에러)를 저장하기 위해 간단한 캐시(예: Map 또는 객체)를 사용하여 구현됩니다. 데모 목적으로 수동으로 구축할 수도 있지만, 실제 애플리케이션에서는 Suspense와 통합되는 강력한 데이터 페칭 라이브러리를 사용하게 될 것입니다.

4. 동시성 모드 (React 18의 개선 사항)

Suspense는 이전 버전의 React에서도 사용할 수 있지만, 그 완전한 힘은 Concurrent React(React 18에서 createRoot로 기본 활성화됨)와 함께 발휘됩니다. 동시성 모드를 사용하면 React는 렌더링 작업을 중단, 일시 중지 및 재개할 수 있습니다. 이는 다음을 의미합니다:

Suspense를 사용한 데이터 페칭 패턴

Suspense의 등장과 함께 데이터 페칭 패턴이 어떻게 진화했는지 살펴보겠습니다.

패턴 1: Fetch-Then-Render (전통적인 방식에 Suspense 래핑)

이것은 데이터를 페칭한 다음, 컴포넌트를 렌더링하는 고전적인 접근 방식입니다. 데이터를 위해 '프로미스 던지기' 메커니즘을 직접 활용하지는 않지만, *결과적으로* 데이터를 렌더링하는 컴포넌트를 Suspense 경계로 감싸 fallback을 제공할 수 있습니다. 이것은 내부 데이터 페칭이 여전히 전통적인 useEffect 기반이더라도, 결국 준비되는 컴포넌트를 위해 Suspense를 일반적인 로딩 UI 조정자로 사용하는 것에 가깝습니다.

import React, { Suspense, useState, useEffect } from 'react';

function UserDetails({ userId }) {
  const [user, setUser] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchUserData = async () => {
      setIsLoading(true);
      const res = await fetch(`/api/users/${userId}`);
      const data = await res.json();
      setUser(data);
      setIsLoading(false);
    };
    fetchUserData();
  }, [userId]);

  if (isLoading) {
    return <p>사용자 정보 로딩 중...</p>;
  }

  return (
    <div>
      <h3>사용자: {user.name}</h3>
      <p>이메일: {user.email}</p>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Fetch-Then-Render 예제</h1>
      <Suspense fallback={<div>전체 페이지 로딩 중...</div>}>
        <UserDetails userId={"1"} />
      </Suspense>
    </div>
  );
}

장점: 이해하기 쉽고, 이전 버전과 호환됩니다. 전역 로딩 상태를 추가하는 빠른 방법으로 사용할 수 있습니다.

단점: UserDetails 내부의 보일러플레이트를 제거하지 않습니다. 컴포넌트가 순차적으로 데이터를 페칭할 경우 여전히 워터폴에 취약합니다. 데이터 자체에 대해 Suspense의 '던지고-잡기' 메커니즘을 진정으로 활용하지 않습니다.

패턴 2: Render-Then-Fetch (렌더링 중 페칭, 프로덕션용 아님)

이 패턴은 주로 Suspense로 직접 무엇을 하지 말아야 하는지를 설명하기 위한 것입니다. 꼼꼼하게 처리하지 않으면 무한 루프나 성능 문제를 일으킬 수 있기 때문입니다. 이는 컴포넌트의 렌더링 단계에서 *적절한 캐싱 메커니즘 없이* 데이터를 페칭하거나 일시 중단 함수를 직접 호출하려는 시도를 포함합니다.

// 적절한 캐싱 레이어 없이 프로덕션에서 사용하지 마세요
// 이것은 직접적인 'throw'가 개념적으로 어떻게 작동하는지 설명하기 위한 것입니다.

let fetchedData = null;
let dataPromise = null;

function fetchDataSynchronously(url) {
  if (fetchedData) {
    return fetchedData;
  }

  if (!dataPromise) {
    dataPromise = fetch(url)
      .then(res => res.json())
      .then(data => { fetchedData = data; dataPromise = null; return data; })
      .catch(err => { dataPromise = null; throw err; });
  }
  throw dataPromise; // Suspense가 작동하는 지점입니다
}

function UserDetailsBadExample({ userId }) {
  const user = fetchDataSynchronously(`/api/users/${userId}`);
  return (
    <div>
      <h3>사용자: {user.name}</h3>
      <p>이메일: {user.email}</p>
    </div>
  );
}

function App() {
  return (
    <div>
      <h1>Render-Then-Fetch (설명용, 직접 사용 비권장)</h1>
      <Suspense fallback={<div>사용자 로딩 중...</div>}>
        <UserDetailsBadExample userId={"2"} />
      </Suspense>
    </div>
  );
}

장점: 컴포넌트가 어떻게 직접 데이터를 '요청'하고 준비되지 않았을 때 일시 중단될 수 있는지 보여줍니다.

단점: 프로덕션 환경에서 사용하기에 매우 문제가 많습니다. 이 수동적이고 전역적인 fetchedDatadataPromise 시스템은 단순하며, 여러 요청, 무효화 또는 에러 상태를 견고하게 처리하지 못합니다. 이것은 '프로미스 던지기' 개념의 원시적인 설명일 뿐, 채택할 패턴은 아닙니다.

패턴 3: Fetch-As-You-Render (이상적인 Suspense 패턴)

이것이 Suspense가 데이터 페칭에 진정으로 가능하게 하는 패러다임 전환입니다. 컴포넌트가 렌더링되기를 기다렸다가 데이터를 페칭하거나 모든 데이터를 미리 페칭하는 대신, Fetch-As-You-Render는 데이터를 *가능한 한 빨리* 페칭하기 시작하는 것을 의미합니다. 이는 종종 렌더링 프로세스 *전* 또는 *동시에* 일어납니다. 그런 다음 컴포넌트는 캐시에서 데이터를 '읽고', 데이터가 준비되지 않았다면 일시 중단됩니다. 핵심 아이디어는 데이터 페칭 로직을 컴포넌트의 렌더링 로직에서 분리하는 것입니다.

Fetch-As-You-Render를 구현하려면 다음을 수행하는 메커니즘이 필요합니다:

  1. 컴포넌트의 렌더링 함수 외부에서 데이터 페칭을 시작합니다 (예: 경로에 진입할 때 또는 버튼을 클릭할 때).
  2. 프로미스 또는 해결된 데이터를 캐시에 저장합니다.
  3. 컴포넌트가 이 캐시에서 '읽을' 수 있는 방법을 제공합니다. 데이터가 아직 사용 가능하지 않은 경우, 읽기 함수는 보류 중인 프로미스를 던집니다.

이 패턴은 워터폴 문제를 해결합니다. 두 개의 다른 컴포넌트가 데이터를 필요로 하는 경우, 그들의 요청은 병렬로 시작될 수 있으며, UI는 단일 Suspense 경계에 의해 조정되어 *둘 다* 준비되었을 때만 나타납니다.

수동 구현 (이해를 위해)

기본 메커니즘을 파악하기 위해 간단한 수동 리소스 관리자를 만들어 보겠습니다. 실제 애플리케이션에서는 전용 라이브러리를 사용해야 합니다.

import React, { Suspense } from 'react';

// --- 간단한 캐시/리소스 관리자 --- //
const cache = new Map();

function createResource(promise) {
  let status = 'pending';
  let result;
  let suspender = promise.then(
    (r) => {
      status = 'success';
      result = r;
    },
    (e) => {
      status = 'error';
      result = e;
    }
  );

  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    },
  };
}

function fetchData(key, fetcher) {
  if (!cache.has(key)) {
    cache.set(key, createResource(fetcher()));
  }
  return cache.get(key);
}

// --- 데이터 페칭 함수 --- //
const fetchUserById = (id) => {
  console.log(`사용자 ${id} 페칭 중...`);
  return new Promise(resolve => setTimeout(() => {
    const users = {
      '1': { id: '1', name: 'Alice Smith', email: 'alice@example.com' },
      '2': { id: '2', name: 'Bob Johnson', email: 'bob@example.com' },
      '3': { id: '3', name: 'Charlie Brown', email: 'charlie@example.com' }
    };
    resolve(users[id]);
  }, 1500));
};

const fetchPostsByUserId = (userId) => {
  console.log(`사용자 ${userId}의 포스트 페칭 중...`);
  return new Promise(resolve => setTimeout(() => {
    const posts = {
      '1': [{ id: 'p1', title: '나의 첫 포스트' }, { id: 'p2', title: '여행 모험' }],
      '2': [{ id: 'p3', title: '코딩 인사이트' }],
      '3': [{ id: 'p4', title: '글로벌 트렌드' }, { id: 'p5', title: '현지 요리' }]
    };
    resolve(posts[userId] || []);
  }, 2000));
};

// --- 컴포넌트 --- //
function UserProfile({ userId }) {
  const userResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
  const user = userResource.read(); // 사용자 데이터가 준비되지 않았다면 일시 중단됩니다

  return (
    <div>
      <h3>사용자: {user.name}</h3>
      <p>이메일: {user.email}</p>
    </div>
  );
}

function UserPosts({ userId }) {
  const postsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
  const posts = postsResource.read(); // 포스트 데이터가 준비되지 않았다면 일시 중단됩니다

  return (
    <div>
      <h4>{userId}의 포스트:</h4>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
        {posts.length === 0 && <li>포스트를 찾을 수 없습니다.</li>}
      </ul>
    </div>
  );
}

// --- 애플리케이션 --- //
let initialUserResource = null;
let initialPostsResource = null;

function prefetchDataForUser(userId) {
  initialUserResource = fetchData(`user-${userId}`, () => fetchUserById(userId));
  initialPostsResource = fetchData(`posts-${userId}`, () => fetchPostsByUserId(userId));
}

// App 컴포넌트가 렌더링되기 전에 일부 데이터를 미리 페칭합니다
prefetchDataForUser('1');

function App() {
  return (
    <div>
      <h1>Suspense를 사용한 Fetch-As-You-Render</h1>
      <p>이 예제는 데이터 페칭이 Suspense에 의해 조정되어 병렬로 어떻게 일어나는지 보여줍니다.</p>

      <Suspense fallback={<div>사용자 프로필과 포스트 로딩 중...</div>}>
        <UserProfile userId={"1"} />
        <UserPosts userId={"1"} />
      </Suspense>

      <h2>다른 섹션</h2>
      <Suspense fallback={<div>다른 사용자 로딩 중...</div>}>
        <UserProfile userId={"2"} />
      </Suspense>
    </div>
  );
}

이 예제에서:

Fetch-As-You-Render를 위한 라이브러리

견고한 리소스 관리자를 수동으로 구축하고 유지하는 것은 복잡합니다. 다행히도, 여러 성숙한 데이터 페칭 라이브러리들이 Suspense를 채택했거나 채택하고 있으며, 실전에서 검증된 솔루션을 제공합니다:

이러한 라이브러리들은 리소스 생성 및 관리, 캐싱, 재검증, 낙관적 업데이트, 에러 핸들링의 복잡성을 추상화하여 Fetch-As-You-Render를 훨씬 쉽게 구현할 수 있게 해줍니다.

패턴 4: Suspense-Aware 라이브러리를 사용한 프리페칭

프리페칭은 사용자가 가까운 미래에 필요할 가능성이 있는 데이터를 명시적으로 요청하기 전에 선제적으로 페칭하는 강력한 최적화 기법입니다. 이는 체감 성능을 극적으로 향상시킬 수 있습니다.

Suspense를 지원하는 라이브러리를 사용하면 프리페칭이 매끄러워집니다. 링크에 마우스를 올리거나 버튼 위에 마우스를 가져가는 등 즉시 UI를 변경하지 않는 사용자 상호 작용에 데이터 페칭을 트리거할 수 있습니다.

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

// API 호출이라고 가정합니다
const fetchProductById = async (id) => {
  console.log(`상품 ${id} 페칭 중...`);
  return new Promise(resolve => setTimeout(() => {
    const products = {
      'A001': { id: 'A001', name: '글로벌 위젯 X', price: 29.99, description: '국제적으로 사용 가능한 다용도 위젯입니다.' },
      'B002': { id: 'B002', name: '유니버설 가젯 Y', price: 149.99, description: '전 세계적으로 사랑받는 최첨단 가젯입니다.' },
    };
    resolve(products[id]);
  }, 1000));
};

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true, // 모든 쿼리에 대해 기본적으로 Suspense 활성화
    },
  },
});

function ProductDetails({ productId }) {
  const { data: product } = useQuery({
    queryKey: ['product', productId],
    queryFn: () => fetchProductById(productId),
  });

  return (
    <div style={{"border": "1px solid #ccc", "padding": "15px", "margin": "10px 0"}}>
      <h3>{product.name}</h3>
      <p>가격: ${product.price.toFixed(2)}</p>
      <p>{product.description}</p>
    </div>
  );
}

function ProductList() {
  const handleProductHover = (productId) => {
    // 사용자가 상품 링크에 마우스를 올리면 데이터를 프리페칭합니다
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProductById(productId),
    });
    console.log(`상품 ${productId} 프리페칭 중`);
  };

  return (
    <div>
      <h2>사용 가능한 상품:</h2>
      <ul>
        <li>
          <a href="#" onMouseEnter={() => handleProductHover('A001')}
             onClick={(e) => { e.preventDefault(); /* 탐색 또는 상세 정보 표시 */ }}
          >글로벌 위젯 X (A001)</a>
        </li>
        <li>
          <a href="#" onMouseEnter={() => handleProductHover('B002')}
             onClick={(e) => { e.preventDefault(); /* 탐색 또는 상세 정보 표시 */ }}
          >유니버설 가젯 Y (B002)</a>
        </li>
      </ul>
      <p>프리페칭 동작을 보려면 상품 링크에 마우스를 올려보세요. 네트워크 탭을 열어 관찰하세요.</p>
    </div>
  );
}

function App() {
  const [showProductA, setShowProductA] = React.useState(false);
  const [showProductB, setShowProductB] = React.useState(false);

  return (
    <QueryClientProvider client={queryClient}>
      <h1>React Suspense를 사용한 프리페칭 (React Query)</h1>
      <ProductList />

      <button onClick={() => setShowProductA(true)}>글로벌 위젯 X 표시</button>
      <button onClick={() => setShowProductB(true)}>유니버설 가젯 Y 표시</button>

      {showProductA && (
        <Suspense fallback={<p>글로벌 위젯 X 로딩 중...</p>}>
          <ProductDetails productId="A001" />
        </Suspense>
      )}
      {showProductB && (
        <Suspense fallback={<p>유니버설 가젯 Y 로딩 중...</p>}>
          <ProductDetails productId="B002" />
        </Suspense>
      )}
    </QueryClientProvider>
  );
}

이 예제에서 상품 링크 위에 마우스를 올리면 `queryClient.prefetchQuery`가 트리거되어 백그라운드에서 데이터 페칭을 시작합니다. 그런 다음 사용자가 버튼을 클릭하여 상품 상세 정보를 표시할 때, 프리페칭으로 인해 데이터가 이미 캐시에 있다면 컴포넌트는 일시 중단 없이 즉시 렌더링됩니다. 프리페칭이 아직 진행 중이거나 시작되지 않았다면, Suspense는 데이터가 준비될 때까지 fallback을 표시합니다.

Suspense와 에러 바운더리를 사용한 에러 핸들링

Suspense는 fallback을 표시하여 '로딩' 상태를 처리하지만, '에러' 상태를 직접 처리하지는 않습니다. 일시 중단된 컴포넌트가 던진 프로미스가 거부되면(즉, 데이터 페칭 실패), 이 에러는 컴포넌트 트리를 따라 위로 전파됩니다. 이러한 에러를 우아하게 처리하고 적절한 UI를 표시하려면 에러 바운더리(Error Boundaries)를 사용해야 합니다.

에러 바운더리는 componentDidCatch 또는 static getDerivedStateFromError 생명주기 메서드 중 하나를 구현하는 React 컴포넌트입니다. 이것은 자식 컴포넌트 트리 어디에서든 자바스크립트 에러를 잡아내며, Suspense가 보류 중일 때 잡았을 프로미스에서 발생한 에러도 포함합니다.

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

// --- 에러 바운더리 컴포넌트 --- //
class MyErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    // 다음 렌더링에서 fallback UI가 표시되도록 상태를 업데이트합니다.
    return { hasError: true, error };
  }

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스에 에러를 기록할 수도 있습니다.
    console.error("에러를 잡았습니다:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      // 어떤 커스텀 fallback UI든 렌더링할 수 있습니다.
      return (
        <div style={{"border": "2px solid red", "padding": "20px", "margin": "20px 0", "background": "#ffe0e0"}}>
          <h2>문제가 발생했습니다!</h2>
          <p>{this.state.error && this.state.error.message}</p>
          <p>페이지를 새로고침하거나 고객 지원에 문의해주세요.</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>다시 시도</button>
        </div>
      );
    }
    return this.props.children;
  }
}

// --- 데이터 페칭 (에러 가능성 있음) --- //
const fetchItemById = async (id) => {
  console.log(`아이템 ${id} 페칭 시도 중...`);
  return new Promise((resolve, reject) => setTimeout(() => {
    if (id === 'error-item') {
      reject(new Error('아이템 로드 실패: 네트워크에 연결할 수 없거나 아이템을 찾을 수 없습니다.'));
    } else if (id === 'slow-item') {
      resolve({ id: 'slow-item', name: '느리게 전달됨', data: '이 아이템은 시간이 좀 걸렸지만 도착했습니다!', status: 'success' });
    } else {
      resolve({ id, name: `아이템 ${id}`, data: `아이템 ${id}에 대한 데이터` });
    }
  }, id === 'slow-item' ? 3000 : 800));
};

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
      retry: false, // 데모를 위해 재시도를 비활성화하여 에러가 즉시 발생하도록 함
    },
  },
});

function DisplayItem({ itemId }) {
  const { data: item } = useQuery({
    queryKey: ['item', itemId],
    queryFn: () => fetchItemById(itemId),
  });

  return (
    <div>
      <h3>아이템 상세정보:</h3>
      <p>ID: {item.id}</p>
      <p>이름: {item.name}</p>
      <p>데이터: {item.data}</p>
    </div>
  );
}

function App() {
  const [fetchType, setFetchType] = useState('normal-item');

  return (
    <QueryClientProvider client={queryClient}>
      <h1>Suspense와 에러 바운더리</h1>

      <div>
        <button onClick={() => setFetchType('normal-item')}>일반 아이템 페칭</button>
        <button onClick={() => setFetchType('slow-item')}>느린 아이템 페칭</button>
        <button onClick={() => setFetchType('error-item')}>에러 아이템 페칭</button>
      </div>

      <MyErrorBoundary>
        <Suspense fallback={<p>Suspense를 통해 아이템 로딩 중...</p>}>
          <DisplayItem itemId={fetchType} />
        </Suspense>
      </MyErrorBoundary>
    </QueryClientProvider>
  );
}

Suspense 경계(또는 일시 중단될 수 있는 컴포넌트들)를 에러 바운더리로 감싸면, 데이터 페칭 중 네트워크 실패나 서버 에러가 발생했을 때 이를 잡아내고 우아하게 처리하여 전체 애플리케이션이 충돌하는 것을 방지할 수 있습니다. 이는 사용자가 문제를 이해하고 잠재적으로 재시도할 수 있도록 하여 견고하고 사용자 친화적인 경험을 제공합니다.

Suspense를 사용한 상태 관리 및 데이터 무효화

React Suspense는 주로 비동기 리소스의 초기 로딩 상태를 다룬다는 점을 명확히 하는 것이 중요합니다. Suspense는 본질적으로 클라이언트 사이드 캐시를 관리하거나, 데이터 무효화를 처리하거나, 뮤테이션(생성, 업데이트, 삭제 작업)과 그에 따른 UI 업데이트를 조율하지 않습니다.

이 지점에서 Suspense를 지원하는 데이터 페칭 라이브러리(React Query, SWR, Apollo Client, Relay)가 필수 불가결해집니다. 이들은 Suspense를 보완하여 다음을 제공합니다:

견고한 데이터 페칭 라이브러리 없이는 수동 Suspense 리소스 관리자 위에 이러한 기능들을 구현하는 것이 상당한 작업이 될 것이며, 본질적으로 자신만의 데이터 페칭 프레임워크를 구축해야 할 것입니다.

실용적인 고려 사항 및 모범 사례

데이터 페칭에 Suspense를 채택하는 것은 중요한 아키텍처 결정입니다. 글로벌 애플리케이션을 위한 몇 가지 실용적인 고려 사항은 다음과 같습니다:

1. 모든 데이터에 Suspense가 필요한 것은 아닙니다

Suspense는 컴포넌트의 초기 렌더링에 직접적인 영향을 미치는 중요한 데이터에 이상적입니다. 중요하지 않은 데이터, 백그라운드 페치, 또는 강력한 시각적 영향 없이 지연 로드될 수 있는 데이터의 경우, 전통적인 useEffect나 사전 렌더링이 여전히 적합할 수 있습니다. Suspense를 과도하게 사용하면 단일 Suspense 경계가 *모든* 자식이 해결될 때까지 기다리므로 덜 세분화된 로딩 경험으로 이어질 수 있습니다.

2. Suspense 경계의 세분성

<Suspense> 경계를 신중하게 배치하세요. 애플리케이션 최상단에 있는 하나의 큰 경계는 전체 페이지를 스피너 뒤에 숨길 수 있어 답답할 수 있습니다. 더 작고 세분화된 경계를 사용하면 페이지의 다른 부분들이 독립적으로 로드되어 더 점진적이고 반응적인 경험을 제공할 수 있습니다. 예를 들어, 사용자 프로필 컴포넌트 주위에 하나의 경계를 두고, 추천 상품 목록 주위에 또 다른 경계를 두는 것입니다.

<div>
  <h1>상품 페이지</h1>
  <Suspense fallback={<p>주요 상품 정보 로딩 중...</p>}>
    <ProductDetails id="prod123" />
  </Suspense>

  <hr />

  <h2>관련 상품</h2>
  <Suspense fallback={<p>관련 상품 로딩 중...</p>}>
    <RelatedProducts category="electronics" />
  </Suspense>
</div>

이 접근 방식은 관련 상품이 아직 로딩 중이더라도 사용자가 주요 상품 정보를 볼 수 있음을 의미합니다.

3. 서버 사이드 렌더링(SSR) 및 스트리밍 HTML

React 18의 새로운 스트리밍 SSR API(renderToPipeableStream)는 Suspense와 완벽하게 통합됩니다. 이를 통해 서버는 페이지의 일부(데이터 의존적인 컴포넌트 등)가 아직 로딩 중이더라도 준비된 HTML을 즉시 보낼 수 있습니다. 서버는 플레이스홀더(Suspense fallback에서)를 스트리밍한 다음, 데이터가 해결되면 전체 클라이언트 사이드 리렌더링 없이 실제 콘텐츠를 스트리밍할 수 있습니다. 이는 다양한 네트워크 조건에 있는 글로벌 사용자의 체감 로딩 성능을 크게 향상시킵니다.

4. 점진적 도입

Suspense를 사용하기 위해 전체 애플리케이션을 다시 작성할 필요는 없습니다. 선언적 로딩 패턴의 이점을 가장 많이 얻을 수 있는 새로운 기능이나 컴포넌트부터 시작하여 점진적으로 도입할 수 있습니다.

5. 툴링 및 디버깅

Suspense가 컴포넌트 로직을 단순화하지만, 디버깅은 다를 수 있습니다. React DevTools는 Suspense 경계와 그 상태에 대한 통찰력을 제공합니다. 선택한 데이터 페칭 라이브러리가 내부 상태를 어떻게 노출하는지(예: React Query Devtools)에 익숙해지세요.

6. Suspense Fallback에 대한 타임아웃

매우 긴 로딩 시간의 경우, Suspense fallback에 타임아웃을 도입하거나 일정 시간이 지난 후 더 상세한 로딩 표시기로 전환하고 싶을 수 있습니다. React 18의 useDeferredValueuseTransition 훅은 이러한 더 미묘한 로딩 상태를 관리하는 데 도움이 될 수 있으며, 새로운 데이터가 페칭되는 동안 UI의 '이전' 버전을 보여주거나 긴급하지 않은 업데이트를 지연시킬 수 있습니다.

React 데이터 페칭의 미래: React 서버 컴포넌트와 그 너머

React에서 데이터 페칭의 여정은 클라이언트 사이드 Suspense에서 멈추지 않습니다. React 서버 컴포넌트(RSC)는 클라이언트와 서버 간의 경계를 모호하게 하고 데이터 페칭을 더욱 최적화할 것을 약속하는 중요한 진화를 나타냅니다.

React가 계속 성숙해짐에 따라, Suspense는 고성능의 사용자 친화적이며 유지보수 가능한 애플리케이션을 구축하는 데 있어 점점 더 중심적인 부분이 될 것입니다. 이는 개발자들이 비동기 작업을 처리하는 더 선언적이고 복원력 있는 방식으로 나아가도록 유도하며, 복잡성을 개별 컴포넌트에서 잘 관리되는 데이터 레이어로 이동시킵니다.

결론

처음에는 코드 스플리팅 기능이었던 React Suspense는 데이터 페칭을 위한 혁신적인 도구로 꽃피웠습니다. Fetch-As-You-Render 패턴을 채택하고 Suspense를 지원하는 라이브러리를 활용함으로써 개발자는 애플리케이션의 사용자 경험을 크게 향상시키고, 로딩 워터폴을 제거하며, 컴포넌트 로직을 단순화하고, 부드럽고 조화로운 로딩 상태를 제공할 수 있습니다. 견고한 에러 핸들링을 위한 에러 바운더리와 React 서버 컴포넌트의 미래 약속과 결합된 Suspense는 성능이 뛰어나고 복원력이 있을 뿐만 아니라 전 세계 사용자에게 본질적으로 더 즐거운 애플리케이션을 구축할 수 있도록 힘을 실어줍니다. Suspense 기반의 데이터 페칭 패러다임으로의 전환은 개념적인 조정이 필요하지만, 코드 명확성, 성능 및 사용자 만족도 측면에서의 이점은 상당하며 투자할 가치가 충분합니다.