React Suspense와 같이 쓸 수 있는 서스펜서를 만들어보기

요즘에는 React 의 Suspense 를 활용하시는 분들이 많은 것 같습니다.
저도 Suspense 를 이용해 컴포넌트 내에서 로딩 상태에 대한 맥락을 덜어내고 선언적으로 처리하는 것을 좋아합니다.
회상해보니, 본격적으로 Suspense 를 쓰게 된 계기는 아무래도 Tanstack Query 덕분이었습니다. useSuspenseQuery (당시에는 useQuery 에 속성으로 설정했었죠)를 사용하면 Suspense 를 이용해 로딩 상태를 선언적으로 처리할 수 있습니다.
이러한 패턴을 사용하다 보니, 직접 Suspense 에 걸리는 커스텀 훅을 만들어보고 싶어졌습니다.
저는 Suspense 가 ‘프로미스를 던지는 방식으로 작동한다.’ 정도만 알고 있었기에, 한번 코드를 작성해봤습니다.

단순히 프로미스 던지기

function Component() {
  throw new Promise(res => setTimeout(() => res('foo'), 3000));

  return ...;
}

function Page() {
	return (
	  <Suspense fallback={<p>loading...</p>}>
      <Component />
    </Suspense>
	);
}
Page 컴포넌트는 SuspenseComponent 컴포넌트를 감싸고 있습니다. 실제로 개발 서버로 돌려보면 로딩 UI가 잘 보이는 것을 확인할 수 있습니다.
다만, 3초가 지나도 여전히 로딩 UI가 보여집니다. 무엇이 문제일까요?

리액트 리렌더링 이해하기

아마 리액트로 개발을 하시다 보면, 이런 얘기 많이 들어보셨을 것입니다. ‘함수형 컴포넌트는 매 렌더링마다 새롭게 호출된다.’
네. 위 코드는 매 렌더링마다 새롭게 프로미스를 만들어 던지고 있기 때문에 무한히 로딩 상태에 갇혀버립니다.그러면 함수 컴포넌트 밖으로 빼볼까요?
const fooPromise = new Promise(res => setTimeout(() => res('foo'), 3000));

function Component() {
  throw fooPromise;

  return ...;
}
음.. 여전히 3초가 지나도 로딩 UI만 보여집니다. 왜 그럴까요?
프로미스 객체 자체는 컴포넌트 외부에 선언되어서 참조를 유지하지만, 함수 컴포넌트 내에서 매 렌더링마다 던지고 있기 때문입니다.
그렇다면 프로미스의 참조를 유지하면서 매 렌더링마다 프로미스의 이행 여부에 따라 프로미스를 던지거나 던지지 않게 하려면 어떻게 해야 할까요? 어떻게 해야 3초 후에 프로미스를 안 던지게끔 할 수 있을까요?

클로저를 이용하기

참조를 유지하면서 리액트 컴포넌트 렌더링 주기에 영향받지 않게끔 하려면 아무래도 함수를 활용하는 것이 좋을 것 같습니다.
function Component() {
  promiseChecker(fooPromise);
  
  return ...;
}

function PromiseChecker(promise) {
  // 프로미스가 pending 상태일 때, 프로미스를 던집니다.
	if(promise.pending) {
	  throw promise;
	}

  // 프로미스가 이행된 상태일 때
	if(promise.fulfilled) {
	  return someValue;
	}

  // 프로미스가 거부된 상태일 때
  if(promise.rejected) {
	  return someError;
  }
}
다만 현재 프로미스의 상태를 위 코드와 같이 속성 접근자를 통해 확인할 수는 없습니다. 그래서 조금 방법을 우회하여 분기처리를 해봅시다.

서스펜서 함수

다른 사람들은 뭐라고 부르는지 잘 모르지만, 제가 서스펜서라고 부르는 녀석이 있습니다. 이 녀석을 이용해서 프로미스의 상태를 분기처리할 수 있습니다.
function getSuspenser<T>(promise: Promise<T>) {
  let status: 'PENDING' | 'FULFILLED' | 'REJECTED' = 'PENDING';
  let result: T | undefined = undefined;

  const settledPromise = promise.then(
    res => {
      status = 'FULFILLED';
      result = res;
    },
    err => {
      status = 'REJECTED';
      result = err;
    }
  );

  return {
    read() {
      if (status === 'PENDING') {
        throw settledPromise;
      } else if (status === 'REJECTED') {
        throw new Error();
      } else if (status === 'FULFILLED') {
        return result;
      }
    }
  };
}

const suspenser = getSuspenser(fooPromise);

function Component() {
  const result = suspenser.read();
  
  return <p>{result}</p>;
}
간단한 코드라서 금방 이해하실 수 있으실 것입니다. getSuspenser 는 프로미스를 인자로 받고, 내부적으로 상태를 별도로 관리하며, read 라는 함수가 매 렌더링마다 호출되게끔 하여 프로미스의 상태에 따라 프로미스를 던지거나 값을 반환합니다.
3초 후 “foo” 가 보여집니다.
3초 후 “foo” 가 보여집니다.
이렇게 서스펜서를 통해 특정 프로미스를 Suspense 와 함께 사용할 수 있습니다.
다만 여전히 아쉬운 점은 프로미스 객체는 함수 컴포넌트 외부에서 선언되어야 한다는 점인데요, 다행히 리액트 v19에서 이를 편하게 구현할 수 있도록 새로운 훅을 만들어주었습니다.

use API

리액트 19버전 공식문서에는 use 를 다음과 같이 소개하고 있습니다. (링크)
세상에나 마상에나, 이렇게 편리한 API를 이제서야 만들어주다니! 위에서 했던 똥꼬쑈를 단 한 줄로 대체해 버렸습니다. 😭
function Component() {
  const result = use(somePromise);
  
  return ...;
}
이로써 Suspense 와 프로미스가 어떻게 어울려 동작하는지 가볍게 살펴보고 서스펜서를 활용하는 방법과 최신 API인 use 까지 알아보았습니다. 오랜만에 리액트 공식 문서도 살펴보았네요. 😆
독자 여러분들은 리액트의 서스펜스와 프로미스를 어떻게 활용하고 계신가요? 여러분도 저처럼 Suspense와 통합할 수 있는 커스텀 훅을 만들어보고 싶다고 생각하신 적 있으신가요? 댓글로 알려주세요!