React Suspense 기반 데이터 fetcher 직접 구현해보기

in-ch·2025년 8월 5일
2

꿀팁

목록 보기
15/17

들어가며


코드 스플리팅을 위한 기능이였던 Suspense는 그 기능을 넘어 컴포넌트가 필요한 데이터가 준비될 때까지 렌더링을 잠시 '중단'시키고, 로딩 상태를 선언적으로 관리할 수 있는 기능으로도 활용됩니다.

이를 통해, isLoading과 같은 상태 변수를 수동으로 관리하는 조건부 렌더링을 넘어 더 명확하고 명시적으로 상태를 관리할 수 있게 되었습니다.


다만, Suspense를 사용하기 위해서는 useSuspenseQuery를 쓰거나 SWR에서 suspense 값을 true로 줘야합니다.


과연, 어떻게 동작하는 걸까요?

직접, Suspense를 위한 데이터 fetcher를 만들어보며 동작 원리에 대해 이해해봅시다.


기존 방법들의 한계


본격적으로 들어가기에 앞서 Suspense 이전에는 어떻게 비동기 함수의 로딩 상태를 관리하였는지 확인해 봅시다.

패칭 상태를 수동 관리

React에서 비동기 데이터를 가져오기 위해 우리는 오랫동안 useStateuseEffect 훅을 조합하는 방식을 사용해왔습니다.

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

  useEffect(() => {
    fetchUser()
      .then(data => setUser(data))
      .catch(err => setError(err))
      .finally(() => setIsLoading(false));
  }, []);

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러가 발생했습니다!</div>;

  return <h1>{user.name}</h1>;
}

이 패턴은 간단히 요약하자면 몇 가지 문제점이 있습니다.

  1. 모든 비동기 요청마다 isLoading, error, data 세 가지 상태를 만들어 관리해야 합니다. 이는 코드를 길고 복잡하게 만듭니다.
  2. 데이터 패칭 로직이 뷰(View)를 담당하는 컴포넌트 내부에 깊숙이 자리 잡아, 로직의 재사용과 테스트를 어렵게 만듭니다.

특히, useEffect 안에서 데이터 패칭을 하면 안되는 이유는 제가 이전 글에 정리한 내용이 있습니다.

데이터 패칭 라이브러리를 활용한 조건부 렌더링

위의 문제점들(보일러 플레이트 등)을 해결하기 위해 TanStack QuerySWR 같은 데이터 패칭 라이브러리를 활용하는 것은 매우 효과적인 방법입니다.

예를 들어 다음과 같이 코드를 작성하여 많은 부분을 라이브러리에 의해 해결할 수 있습니다.

import { useQuery } from '@tanstack/react-query';

function UserProfile() {
  const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
  return <h1>{data.name}</h1>;
}

function App() {
  return (
    <div>
    	{isLoading ? <Loading /> : <UserInfo />}
    </div>
  );
}

대부분의 프로덕션 환경에서는 이처럼 검증된 라이브러리를 사용하는 것이 캐싱, 재요청, 에러 처리 등 부가적인 기능을 손쉽게 활용할 수 있어 가장 이상적입니다.

다만 위의 방법만으로도 한계는 분명합니다.

UserProfile 안에서는 로딩 상태나 에러 상태를 직접 처리하는 것은 컴포넌트 안에 너무 많은 책임이 부여합니다.

좀더 선언적(Declarative)이며, 조합 가능(Composable)한 방법을 써보도록 합시다.

Suspense 활용

리액트 서스펜스는 비동기 데이터를 사용하는 컴포넌트에 대해 로딩 및 에러 상태를 선언적으로 지정할 수 있게 해주는 메커니즘입니다. 로딩 및 에러 상태를 직접 추적하는 대신, 컴포넌트 트리를 <Suspense>로 감싸기만 하면 나머지는 리액트가 처리해 줍니다.

또한, 다음과 같이 데이터 패칭 라이브러리에서는 Suspense 옵션을 제공해줍니다.

import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';

const fetchUserProfile = async () => {
  // ... API 요청
};

function UserInfo() {
  const { data } = useSuspenseQuery({
    queryKey: ['user'],
    queryFn: fetchUserProfile,
  });

  return <h1>{data.name}</h1>;
}

function UserProfile() {
  return (
     <Suspense fallback={<div>로딩 중...</div>}>
       <UserInfo />
     </Suspense>
  );
}

직접 데이터패처 구현해보기


Suspense의 필요성에 대해서 이해하였으니 이제 직접 구현을 해봅시다.

라이브러리는 편리하지만, 그 내부 동작을 이해하면 Suspense를 더 깊이 있게 활용할 수 있습니다.

Suspense의 핵심 동작 원리: Promise 던지기(Throw)

Suspense가 데이터 로딩을 감지하는 핵심 원리는 의외로 간단합니다.

컴포넌트가 렌더링되는 도중, 아직 준비되지 않은 데이터에 접근하려고 할 때, Promise를 throw (던집니다).

React는 렌더링 중에 컴포넌트가 무언가를 throw 하는 것을 감지할 수 있습니다.
만약 던져진 값이 Promise라면,

"아, 이 컴포넌트는 아직 데이터를 기다리고 있구나!"라고 인식하고 렌더링을 '일시 중단'합니다.

그 후, React는 가장 가까운 상위 <Suspense> 컴포넌트를 찾아 그곳에 정의된 fallback UI를 대신 보여줍니다.

마지막으로 던져졌던 Promise가 완료되면(resolve), React는 중단했던 컴포넌트의 렌더링을 다시 시도합니다.

suspense의 동작 흐름

이 원리를 이용해, Promise의 상태를 추적하고 상태에 따라 적절한 값을 던지거나 반환하는 간단한 Wrapper 함수를 만들어 보겠습니다.

데이터 상태를 관리하는 Wrapper 구현하기

export function fetcherWrapper<T>(promise: Promise<T>) {
  let status = 'pending';
  let result: T;
  let error: unknown;

  const suspender = promise.then(
    (r) => {
      status = 'success';
      result = r;
    },
    (e) => {
      status = 'error';
      error = e;
    },
  );

  return {
    read(): T {
      if (status === 'pending') {
        throw suspender; // 렌더링을 중단시키기 위해 Promise를 던짐
      } else if (status === 'error') {
        throw error; // 에러가 발생했음을 알리기 위해 에러를 던짐
      } else if (status === 'success') {
        return result; // 데이터가 준비되었으므로 결과를 반환
      }
      throw new Error('Unreachable state');
    },
  };
}

데이터 패칭을 위한 훅 구현하기

이제 위의 fetcherWrapper를 손쉽게 쓰기 위해 커스텀 훅을 만들어 줍시다.

export function useInspense<T>(
  url: string,
  fetcher: (input: RequestInfo, init?: RequestInit) => Promise<T> = url => fetch(url).then(res => res.json())
): { fetch: () => T } {
  const resource = useMemo(() => fetcherWrapper(fetcher(url)), [url]);

  return {
    fetch: resource.fetch,
  };
}

예제 코드는 여기서 확인 가능합니다.

마치며

이번에 직접 간단한 fetcher를 만들어보며 이 원리를 직접 확인했습니다.

Suspense의 핵심은 'Promise를 throw하여 렌더링을 중단시킨다' 는 개념에 있습니다.

물론 실제 프로덕션 환경에서는 캐싱, 재요청, 상태 무효화 등 훨씬 복잡한 기능들을 제공하는 TanStack Query의 useSuspenseQuery나 SWR을 사용하는 것이 현명한 선택입니다.

끝!

profile
인치

0개의 댓글