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

notion image
요즘에는 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 라는 함수가 매 렌더링마다 호출되게끔 하여 프로미스의 상태에 따라 프로미스를 던지거나 값을 반환합니다.
notion image
이렇게 서스펜서를 통해 특정 프로미스를 Suspense 와 함께 사용할 수 있습니다.
다만 여전히 아쉬운 점은 프로미스 객체는 함수 컴포넌트 외부에서 선언되어야 한다는 점인데요, 다행히 리액트 v19에서 이를 편하게 구현할 수 있도록 새로운 훅을 만들어주었습니다.

use API

리액트 19버전 공식문서에는 use 를 다음과 같이 소개하고 있습니다. (링크)
usePromiseContext와 같은 데이터를 참조하는 React API입니다.
Promise와 함께 호출될 때 use Hook은 SuspenseError Boundary와 통합됩니다. use에 전달된 Promise가 대기(Pending)하는 동안 use를 호출하는 컴포넌트는 Suspend됩니다. use를 호출하는 컴포넌트가 Suspense 경계로 둘러싸여 있으면 Fallback이 표시됩니다. Promise가 리졸브되면 Suspense Fallback은 use Hook이 반환한 컴포넌트로 대체됩니다. use에 전달된 Promise가 Reject되면 가장 가까운 Error Boundary의 Fallback이 표시됩니다.
세상에나 마상에나, 이렇게 편리한 API를 이제서야 만들어주다니! 위에서 했던 똥꼬쑈를 단 한 줄로 대체해 버렸습니다. 😭
function Component() { const result = use(somePromise); return ...; }
이로써 Suspense 와 프로미스가 어떻게 어울려 동작하는지 가볍게 살펴보고 서스펜서를 활용하는 방법과 최신 API인 use 까지 알아보았습니다. 오랜만에 리액트 공식 문서도 살펴보았네요. 😆
독자 여러분들은 리액트의 서스펜스와 프로미스를 어떻게 활용하고 계신가요? 여러분도 저처럼 Suspense와 통합할 수 있는 커스텀 훅을 만들어보고 싶다고 생각하신 적 있으신가요? 댓글로 알려주세요!