
요즘에는 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
컴포넌트는 Suspense
로 Component
컴포넌트를 감싸고 있습니다. 실제로 개발 서버로 돌려보면 로딩 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
라는 함수가 매 렌더링마다 호출되게끔 하여 프로미스의 상태에 따라 프로미스를 던지거나 값을 반환합니다.
이렇게 서스펜서를 통해 특정 프로미스를
Suspense
와 함께 사용할 수 있습니다.다만 여전히 아쉬운 점은 프로미스 객체는 함수 컴포넌트 외부에서 선언되어야 한다는 점인데요, 다행히 리액트 v19에서 이를 편하게 구현할 수 있도록 새로운 훅을 만들어주었습니다.
use
API
리액트 19버전 공식문서에는
use
를 다음과 같이 소개하고 있습니다. (링크)Promise와 함께 호출될 때use
Hook은Suspense
및 Error 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와 통합할 수 있는 커스텀 훅을 만들어보고 싶다고 생각하신 적 있으신가요? 댓글로 알려주세요!