한국어

데이터 페칭을 위한 React Suspense를 마스터하세요. 선언적으로 로딩 상태를 관리하고, 트랜지션으로 UX를 개선하며, 에러 경계(Error Boundary)로 오류를 처리하는 방법을 배워보세요.

React Suspense 경계: 선언적 로딩 상태 관리에 대한 심층 분석

현대 웹 개발의 세계에서, 매끄럽고 반응성이 뛰어난 사용자 경험을 만드는 것은 무엇보다 중요합니다. 개발자들이 직면하는 가장 지속적인 과제 중 하나는 로딩 상태를 관리하는 것입니다. 사용자 프로필 데이터를 가져오는 것부터 애플리케이션의 새로운 섹션을 로드하는 것까지, 기다리는 순간은 매우 중요합니다. 역사적으로 이는 isLoading, isFetching, hasError와 같은 불리언 플래그들이 컴포넌트 전반에 흩어져 있는 복잡한 형태로 관리되었습니다. 이러한 명령형 접근 방식은 코드를 어지럽히고, 로직을 복잡하게 만들며, 경쟁 조건(race conditions)과 같은 버그의 빈번한 원인이 됩니다.

React Suspense를 만나보세요. 처음에는 React.lazy()를 사용한 코드 분할(code-splitting)을 위해 도입되었지만, React 18에서는 그 기능이 극적으로 확장되어 비동기 작업, 특히 데이터 페칭을 처리하는 강력한 일급 메커니즘이 되었습니다. Suspense는 로딩 상태를 선언적인 방식으로 관리할 수 있게 해주어, 우리가 컴포넌트를 작성하고 이해하는 방식을 근본적으로 바꿉니다. "지금 로딩 중인가?"라고 묻는 대신, 우리 컴포넌트는 간단히 "이 데이터를 렌더링해야 합니다. 기다리는 동안 이 대체 UI를 보여주세요."라고 말할 수 있습니다.

이 포괄적인 가이드는 전통적인 상태 관리 방법에서 React Suspense의 선언적 패러다임으로 여러분을 안내할 것입니다. 우리는 Suspense 경계가 무엇인지, 코드 분할과 데이터 페칭 모두에서 어떻게 작동하는지, 그리고 사용자를 좌절시키는 대신 즐겁게 하는 복잡한 로딩 UI를 어떻게 조율하는지 탐색할 것입니다.

과거의 방식: 수동 로딩 상태 관리의 번거로움

Suspense의 우아함을 완전히 이해하기 전에, 그것이 해결하는 문제를 이해하는 것이 중요합니다. useEffectuseState 훅을 사용하여 데이터를 가져오는 일반적인 컴포넌트를 살펴보겠습니다.

사용자 데이터를 가져와 표시해야 하는 컴포넌트를 상상해 보세요:


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(() => {
    // Reset state for new userId
    setIsLoading(true);
    setUser(null);
    setError(null);

    const fetchUser = async () => {
      try {
        const response = await fetch(`https://api.example.com/users/${userId}`);
        if (!response.ok) {
          throw new Error('Network response was not ok');
        }
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err);
      } finally {
        setIsLoading(false);
      }
    };

    fetchUser();
  }, [userId]); // Re-fetch when userId changes

  if (isLoading) {
    return <p>Loading profile...</p>;
  }

  if (error) {
    return <p>Error: {error.message}</p>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

이 패턴은 기능적이지만 몇 가지 단점이 있습니다:

React Suspense의 등장: 패러다임의 전환

Suspense는 이 모델을 완전히 뒤집습니다. 컴포넌트가 내부적으로 로딩 상태를 관리하는 대신, 비동기 작업에 대한 의존성을 React에 직접 전달합니다. 필요한 데이터가 아직 준비되지 않았다면, 컴포넌트는 렌더링을 "일시 중단(suspends)"합니다.

컴포넌트가 일시 중단되면, React는 컴포넌트 트리를 거슬러 올라가 가장 가까운 Suspense 경계(Suspense Boundary)를 찾습니다. Suspense 경계는 <Suspense>를 사용하여 트리에 정의하는 컴포넌트입니다. 이 경계는 그 안에 있는 모든 컴포넌트가 데이터 의존성을 해결할 때까지 대체 UI(스피너나 스켈레톤 로더 등)를 렌더링합니다.

핵심 아이디어는 데이터 의존성을 그것을 필요로 하는 컴포넌트와 함께 위치시키면서, 로딩 UI는 컴포넌트 트리에서 더 높은 수준에 중앙 집중화하는 것입니다. 이는 컴포넌트 로직을 정리하고 사용자 로딩 경험에 대한 강력한 제어권을 제공합니다.

컴포넌트는 어떻게 "일시 중단"되는가?

Suspense의 마법은 처음에는 이상하게 보일 수 있는 패턴에 있습니다: Promise 던지기(throwing a Promise). Suspense를 지원하는 데이터 소스는 다음과 같이 작동합니다:

  1. 컴포넌트가 데이터를 요청하면, 데이터 소스는 데이터가 캐시되어 있는지 확인합니다.
  2. 데이터가 사용 가능하다면, 동기적으로 반환합니다.
  3. 데이터가 사용 불가능하다면(즉, 현재 가져오는 중이라면), 데이터 소스는 진행 중인 페치 요청을 나타내는 Promise를 던집니다.

React는 이 던져진 Promise를 잡습니다. 이것이 앱을 충돌시키지는 않습니다. 대신, React는 이를 신호로 해석합니다: "이 컴포넌트는 아직 렌더링할 준비가 되지 않았습니다. 잠시 멈추고, 위에서 Suspense 경계를 찾아 대체 UI를 보여주세요." Promise가 해결되면, React는 컴포넌트 렌더링을 재시도하고, 이제 컴포넌트는 데이터를 받아 성공적으로 렌더링됩니다.

<Suspense> 경계: 당신의 로딩 UI 선언자

<Suspense> 컴포넌트는 이 패턴의 핵심입니다. 사용하기 매우 간단하며, 필수적인 프롭인 fallback 하나를 받습니다.


import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>My Application</h1>
      <Suspense fallback={<p>Loading content...</p>}>
        <SomeComponentThatFetchesData />
      </Suspense>
    </div>
  );
}

이 예제에서, 만약 SomeComponentThatFetchesData가 일시 중단되면, 사용자는 데이터가 준비될 때까지 "Loading content..." 메시지를 보게 됩니다. fallback은 간단한 문자열부터 복잡한 스켈레톤 컴포넌트까지 유효한 모든 React 노드가 될 수 있습니다.

전통적인 사용 사례: React.lazy()를 이용한 코드 분할

Suspense의 가장 잘 알려진 용도는 코드 분할입니다. 이를 통해 컴포넌트에 대한 JavaScript 로딩을 실제로 필요할 때까지 지연시킬 수 있습니다.


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

// 이 컴포넌트의 코드는 초기 번들에 포함되지 않습니다.
const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <div>
      <h2>Some content that loads immediately</h2>
      <Suspense fallback={<div>Loading component...</div>}>
        <HeavyComponent />
      </Suspense>
    </div>
  );
}

여기서 React는 HeavyComponent를 처음 렌더링하려고 할 때만 해당 JavaScript를 가져옵니다. JavaScript를 가져오고 파싱하는 동안 Suspense fallback이 표시됩니다. 이는 초기 페이지 로드 시간을 개선하는 강력한 기술입니다.

현대의 개척지: Suspense를 이용한 데이터 페칭

React는 Suspense 메커니즘을 제공하지만, 특정 데이터 페칭 클라이언트를 제공하지는 않습니다. 데이터 페칭에 Suspense를 사용하려면, Suspense와 통합되는(즉, 데이터가 보류 중일 때 Promise를 던지는) 데이터 소스가 필요합니다.

Relay나 Next.js와 같은 프레임워크는 Suspense에 대한 내장된 일급 지원을 제공합니다. TanStack Query(이전 React Query)나 SWR과 같은 인기 있는 데이터 페칭 라이브러리도 실험적이거나 완전한 지원을 제공합니다.

개념을 이해하기 위해, fetch API를 Suspense와 호환되도록 감싸는 매우 간단한 개념적 래퍼(wrapper)를 만들어 보겠습니다. 참고: 이것은 교육적 목적을 위한 단순화된 예제이며 프로덕션 환경에서 사용하기에는 부적합합니다. 적절한 캐싱 및 오류 처리의 복잡성이 부족합니다.


// data-fetcher.js
// 결과를 저장하기 위한 간단한 캐시
const cache = new Map();

export function fetchData(url) {
  if (!cache.has(url)) {
    cache.set(url, { status: 'pending', promise: fetchAndCache(url) });
  }

  const record = cache.get(url);

  if (record.status === 'pending') {
    throw record.promise; // 이것이 마법입니다!
  }
  if (record.status === 'error') {
    throw record.error;
  }
  if (record.status === 'success') {
    return record.data;
  }
}

async function fetchAndCache(url) {
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error(`Fetch failed with status ${response.status}`);
    }
    const data = await response.json();
    cache.set(url, { status: 'success', data });
  } catch (e) {
    cache.set(url, { status: 'error', error: e });
  }
}

이 래퍼는 각 URL에 대한 간단한 상태를 유지합니다. fetchData가 호출되면 상태를 확인합니다. 보류 중이면 promise를 던집니다. 성공하면 데이터를 반환합니다. 이제 이 래퍼를 사용하여 UserProfile 컴포넌트를 다시 작성해 보겠습니다.


// UserProfile.js
import React, { Suspense } from 'react';
import { fetchData } from './data-fetcher';

// 실제로 데이터를 사용하는 컴포넌트
function ProfileDetails({ userId }) {
  // 데이터를 읽으려고 시도합니다. 준비되지 않았다면, 이 컴포넌트는 일시 중단됩니다.
  const user = fetchData(`https://api.example.com/users/${userId}`);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>Email: {user.email}</p>
    </div>
  );
}

// 로딩 상태 UI를 정의하는 부모 컴포넌트
export function UserProfile({ userId }) {
  return (
    <Suspense fallback={<p>Loading profile...</p>}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

차이점을 보세요! ProfileDetails 컴포넌트는 깔끔하고 오직 데이터 렌더링에만 집중합니다. isLoading이나 error 상태가 없습니다. 그저 필요한 데이터를 요청할 뿐입니다. 로딩 인디케이터를 보여주는 책임은 부모 컴포넌트인 UserProfile로 옮겨졌고, 이 컴포넌트는 기다리는 동안 무엇을 보여줄지 선언적으로 명시합니다.

복잡한 로딩 상태 조율하기

Suspense의 진정한 힘은 여러 비동기 의존성을 가진 복잡한 UI를 구축할 때 나타납니다.

순차적 UI를 위한 중첩된 Suspense 경계

Suspense 경계를 중첩하여 더 세련된 로딩 경험을 만들 수 있습니다. 사이드바, 메인 콘텐츠 영역, 최근 활동 목록이 있는 대시보드 페이지를 상상해 보세요. 각각 자체 데이터 페치가 필요할 수 있습니다.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <div className="layout">
        <Suspense fallback={<p>Loading navigation...</p>}>
          <Sidebar />
        </Suspense>

        <main>
          <Suspense fallback={<ProfileSkeleton />}>
            <MainContent />
          </Suspense>

          <Suspense fallback={<ActivityFeedSkeleton />}>
            <ActivityFeed />
          </Suspense>
        </main>
      </div>
    </div>
  );
}

이 구조에서는:

이를 통해 사용자에게 유용한 콘텐츠를 가능한 한 빨리 보여줄 수 있어, 체감 성능을 극적으로 향상시킵니다.

UI "팝콘 현상" 피하기

때로는 순차적 접근 방식이 여러 스피너가 빠르게 나타나고 사라지는 어색한 효과를 유발할 수 있는데, 이를 종종 "팝콘 현상(popcorning)"이라고 합니다. 이 문제를 해결하기 위해 Suspense 경계를 트리 상위로 옮길 수 있습니다.


function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<DashboardSkeleton />}>
        <div className="layout">
          <Sidebar />
          <main>
            <MainContent />
            <ActivityFeed />
          </main>
        </div>
      </Suspense>
    </div>
  );
}

이 버전에서는 단일 DashboardSkeleton모든 자식 컴포넌트(Sidebar, MainContent, ActivityFeed)의 데이터가 준비될 때까지 표시됩니다. 그런 다음 전체 대시보드가 한 번에 나타납니다. 중첩된 경계와 단일 상위 수준 경계 사이의 선택은 UX 디자인 결정이며, Suspense는 이를 매우 간단하게 구현할 수 있게 해줍니다.

에러 경계(Error Boundaries)를 이용한 오류 처리

Suspense는 promise의 보류(pending) 상태를 처리하지만, 거부(rejected) 상태는 어떻게 처리할까요? 컴포넌트가 던진 promise가 거부되면(예: 네트워크 오류), React에서는 다른 렌더링 오류와 동일하게 처리됩니다.

해결책은 에러 경계(Error Boundaries)를 사용하는 것입니다. 에러 경계는 특별한 생명주기 메서드인 componentDidCatch() 또는 정적 메서드인 getDerivedStateFromError()를 정의하는 클래스 컴포넌트입니다. 이는 자식 컴포넌트 트리 어디에서든 JavaScript 오류를 포착하여 해당 오류를 기록하고 대체 UI를 표시합니다.

다음은 간단한 에러 경계 컴포넌트입니다:


import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

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

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

  render() {
    if (this.state.hasError) {
      // 사용자 지정 대체 UI를 렌더링할 수 있습니다.
      return <h1>Something went wrong. Please try again.</h1>;
    }

    return this.props.children; 
  }
}

그런 다음 에러 경계와 Suspense를 결합하여 보류, 성공, 오류의 세 가지 상태를 모두 처리하는 강력한 시스템을 만들 수 있습니다.


import { Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';
import { UserProfile } from './UserProfile';

function App() {
  return (
    <div>
      <h2>User Information</h2>
      <ErrorBoundary>
        <Suspense fallback={<p>Loading...</p>}>
          <UserProfile userId={123} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

이 패턴을 사용하면, UserProfile 내부의 데이터 페치가 성공하면 프로필이 표시됩니다. 보류 중이면 Suspense fallback이 표시됩니다. 실패하면 에러 경계의 fallback이 표시됩니다. 이 로직은 선언적이고, 조합 가능하며, 이해하기 쉽습니다.

트랜지션(Transitions): 논블로킹 UI 업데이트의 핵심

퍼즐의 마지막 조각이 하나 있습니다. 다른 사용자 프로필을 보기 위해 "다음" 버튼을 클릭하는 것과 같이 새로운 데이터 페치를 유발하는 사용자 상호작용을 생각해 보세요. 위의 설정에서는 버튼을 클릭하고 userId 프롭이 변경되는 순간, UserProfile 컴포넌트가 다시 일시 중단됩니다. 이는 현재 보이는 프로필이 사라지고 로딩 fallback으로 대체됨을 의미합니다. 이는 갑작스럽고 방해되는 느낌을 줄 수 있습니다.

이것이 바로 트랜지션(transitions)이 필요한 지점입니다. 트랜지션은 특정 상태 업데이트를 긴급하지 않은 것으로 표시할 수 있게 해주는 React 18의 새로운 기능입니다. 상태 업데이트가 트랜지션으로 감싸지면, React는 백그라운드에서 새로운 콘텐츠를 준비하는 동안 이전 UI(오래된 콘텐츠)를 계속 표시합니다. 새로운 콘텐츠가 표시될 준비가 되었을 때만 UI 업데이트를 커밋합니다.

이를 위한 기본 API는 useTransition 훅입니다.


import React, { useState, useTransition, Suspense } from 'react';
import { UserProfile } from './UserProfile';

function ProfileSwitcher() {
  const [userId, setUserId] = useState(1);
  const [isPending, startTransition] = useTransition();

  const handleNextClick = () => {
    startTransition(() => {
      setUserId(id => id + 1);
    });
  };

  return (
    <div>
      <button onClick={handleNextClick} disabled={isPending}>
        Next User
      </button>

      {isPending && <span> Loading new profile...</span>}

      <ErrorBoundary>
        <Suspense fallback={<p>Loading initial profile...</p>}>
          <UserProfile userId={userId} />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

이제 다음과 같은 일이 발생합니다:

  1. userId: 1에 대한 초기 프로필이 로드되고, Suspense fallback이 표시됩니다.
  2. 사용자가 "Next User"를 클릭합니다.
  3. setUserId 호출이 startTransition으로 감싸집니다.
  4. React는 메모리에서 새로운 userId인 2로 UserProfile을 렌더링하기 시작합니다. 이로 인해 컴포넌트가 일시 중단됩니다.
  5. 결정적으로, React는 Suspense fallback을 표시하는 대신 이전 UI(사용자 1의 프로필)를 화면에 계속 유지합니다.
  6. useTransition이 반환하는 isPending 불리언이 true가 되어, 이전 콘텐츠를 마운트 해제하지 않고도 미묘한 인라인 로딩 인디케이터를 표시할 수 있습니다.
  7. 사용자 2의 데이터가 페치되고 UserProfile이 성공적으로 렌더링될 수 있게 되면, React는 업데이트를 커밋하고 새로운 프로필이 매끄럽게 나타납니다.

트랜지션은 최종적인 제어 계층을 제공하여, 결코 거슬리지 않는 정교하고 사용자 친화적인 로딩 경험을 구축할 수 있게 해줍니다.

모범 사례 및 전반적인 고려사항

결론

React Suspense는 단순한 새로운 기능 이상을 의미합니다; 이는 React 애플리케이션에서 비동기성을 접근하는 방식의 근본적인 진화입니다. 수동적이고 명령적인 로딩 플래그에서 벗어나 선언적 모델을 채택함으로써, 우리는 더 깔끔하고, 더 탄력적이며, 조합하기 쉬운 컴포넌트를 작성할 수 있습니다.

보류 상태를 위한 <Suspense>, 실패 상태를 위한 에러 경계, 그리고 매끄러운 업데이트를 위한 useTransition을 결합함으로써, 당신은 완전하고 강력한 툴킷을 갖게 됩니다. 간단한 로딩 스피너부터 복잡하고 순차적인 대시보드 표시에 이르기까지 모든 것을 최소한의 예측 가능한 코드로 조율할 수 있습니다. 프로젝트에 Suspense를 통합하기 시작하면, 애플리케이션의 성능과 사용자 경험을 향상시킬 뿐만 아니라 상태 관리 로직을 극적으로 단순화하여 진정으로 중요한 것, 즉 훌륭한 기능을 구축하는 데 집중할 수 있게 될 것입니다.

React Suspense 경계: 선언적 로딩 상태 관리에 대한 심층 분석 | MLOG