-
React 18 - Suspense 분석하기Study/개발 2024. 2. 21. 22:26
TL;DR
- Suspense는 내부에서 throw된 Promise를 감지할 수 있음.
- 이를 통해 data loading 로직과 ui 로직을 분리할 수 있음.
- Tanstack Query나 Suspend React 등을 통해 간단한 예를 만들어 볼 수 있다.
소개
기존의 Suspense
이전의
Suspense
는lazy
로드된 코드를 불러올 때 fallback UI를 노출하는데에 사용되었다.
이를 통해 code splitting을 할 수 있었다.const LazyComponent = lazy(() => import('path/to/component')); <Suspense fallback={<Spinner />}> <LazyComponent /> </Suspense>
React 18 버전
React 18부터는 Data fetch에 대해서도 Suspense UI를 노출할 수 있도록 하였다. 공식 문서
공식 문서를 참고하면 다음과 같은 사용성을 가지는 것을 알 수 있다.
<Suspense fallback={<Loading />}> <Albums /> </Suspense>
위와 비슷하지만,
Albums
는lazy
로드된 컴포넌트가 아니다.대신 내부적으로
Suspense
의 fallback을 트리거할 수 있는 data fetching을 사용한다.이런 data fetching 방식은 프레임워크들을 통해 사용할 수 있다.
(직접 만들 수 있지만, 권장되지 않는다.)Suspense를 활용할 수 있는 데이터 소스
- Next
- Relay
- Tanstack Query
use
를 통해 Promise 읽기 등
구현
Children 내부의 data fetching에 대해
Suspense
는 어떻게 알 수 있을까?여기에 대해 몇가지 힌트를 찾을 수 있다.
1. RFC
React 18의 RFC를 보면 이런 내용이 있다. RFC
Conceptually, you can think of Suspense as being similar to a catch block. However, instead of catching errors, it catches components "suspending"
In JavaScript, when you
throw
, the closestcatch
above "wins", even if it's several function calls higher. Although Suspense works differently under the hood, the mental model is similar: if a component suspends, the closestSuspense
component above the suspending component "catches" it, no matter how many components that are in between.요약하자면,
- Suspense는
try/catch
의catch
블록과 유사하게 동작한다.
- 아래에서의throw
를 가장 가까운 상위의catch
가 받아서 처리한다.
- 하위의 컴포넌트가 Suspend하면, 가장 가까운Suspense
가 이를 캐치한다.정확히 일치하지는 않지만 리액트는 이런 방식으로 Suspense에서 Data를 "Catch"하고 있음을 알 수 있다.
2. React Lazy
그럼 실제로 Suspense의 코드를 살펴볼 수 있다. Suspense 사용의 가장 대표적인 예인 React Lazy와 Suspense 찾아봤다. lazy
lazy
는Thenable
을 리턴하는 함수를 인자로 받는다.export function lazy<T>( ctor: () => Thenable<{default: T, ...}>, ): LazyComponent<T, Payload<T>> { const payload: Payload<T> = { // We use these fields to store the result. _status: Uninitialized, _result: ctor, }; const lazyType: LazyComponent<T, Payload<T>> = { $$typeof: REACT_LAZY_TYPE, _payload: payload, // Promise가 완료되면 then에서 payload의 값을 바꿔준다. _init: lazyInitializer, }; return lazyType; }
이 인자는 내부에서
lazyInitializer
로 전달된다.layzyInitializer
를 간추려보면 다음과 같다. 상세는 생략했다.type Payload = { _status: Uninitialized | Pending | Rejected | Resolved, _result: ..., }; function lazyInitializer<T>(payload: Payload<T>): T { // 초기 상태 if (payload._status === Uninitialized) { const ctor = payload._result; // lazy의 인자로 받은 함수 // ctor은 Promise같은 Thenable을 리턴. 간단히 Promise라고 생각해도 됨. const thenable = ctor(); thenable.then( // Promise resolve 케이스 moduleObject => { if (payload._status === Pending || payload._status === Uninitialized) { const resolved: ResolvedPayload<T> = (payload: any); resolved._status = Resolved; resolved._result = moduleObject; } }, // Promise reject 케이스 error => { if (payload._status === Pending || payload._status === Uninitialized) { const rejected: RejectedPayload = (payload: any); rejected._status = Rejected; rejected._result = error; } }, ); // 아직 초기상태인 케이스 if (payload._status === Uninitialized) { const pending: PendingPayload = (payload: any); pending._status = Pending; pending._result = thenable; } } if (payload._status === Resolved) { const moduleObject = payload._result; return moduleObject.default; } else { // ** Important! Pending, Rejected일 경우 throw ** throw payload._result; } }
여기서 마지막
else
구문을 보면throw Promise
로 구현한 것을 알 수 있다.payload
의 상태가Pending
일 경우에_result
는Promise
이다. 이 때 Promise를 throw한다.사실 좀 어색하긴 하지만 JS는
Promise
도throw
할 수 있다. 관련 글이번엔 throw된 부분을 catch하는 방법을 찾아보면 아래와 같다.
throw 된 값은 워크루프에서 처리한다. catch를 통해 throw을 잡아내고, Suspense의 fallback이 렌더될 수 있도록 적절히 처리한다.
// ReactFiberWorkLoop.new.js // work loop function renderRootConcurrent(...) { ... do { try { workLoopConcurrent(); break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true); } // handle thrown value function handleError(root, thrownValue) { ... throwException( root, erroredWork.return, erroredWork, thrownValue, workInProgressRootRenderLanes, ); completeUnitOfWork(erroredWork); }
// ReactFiberThrow.new.js function throwException(...) { if ( value !== null && typeof value === 'object' && typeof value.then === 'function' ) { // This is a wakeable. The component suspended. const wakeable: Wakeable = (value: any); resetSuspendedComponent(sourceFiber, rootRenderLanes); // Schedule the nearest Suspense to re-render the timed out view. const suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); if (suspenseBoundary !== null) { suspenseBoundary.flags &= ~ForceClientRender; markSuspenseBoundaryShouldCapture( suspenseBoundary, returnFiber, sourceFiber, root, rootRenderLanes, ); attachRetryListener(suspenseBoundary, root, wakeable, rootRenderLanes); return; } else { // No boundary was found. Unless this is a sync update, this is OK. // We can suspend and wait for more data to arrive. ... } } }
이 방법을 통해
Suspense
는 상위에서 pending 상태를 캐치할 수 있다.이런 방식을 React에서는 Algebraic Effects라고 말한다. (상세 설명은 더 보기에)
cf) Algebraic Effects
Algebraic Effect는 함수형 언어에서 사용되는 용어로, 현재 진행하던 작업을 상위 환경으로 위임 했다가, 완료되면 다시 돌아와서 작업을 이어나갈 수 있는 연산을 말한다.
Dan Abramov의 글 - https://overreacted.io/algebraic-effects-for-the-rest-of-us/
관련 블로그 글(한글) - https://blog.mathpresso.com/algebraic-effects-of-react-suspense-157b49807ea0더보기Note)
"Algebraic Effects"의 번역인 "대수적 효과"라는 단어는 나에겐 직관적으로 이해되지 않았다.
"숫자를 기호로 대신한다"는 뜻의 "대수"는 Algebraic Effects가 나타내는 것과 큰 관련이 없어보인다.
Algebraic이라는 단어에 대해 찾아본 후 뜻이 조금 명확해졌다.
Algebra는 아랍어 al-jabr 에서 나온 말이다.
이 단어는 "Reunion of broken parts"라는 뜻을 가지고 있다.
함수가 여러 부분으로 나눠져 상위에 전달 되었다가, 다시 원래 함수로 돌아와서 진행한다는 의미와 꽤 잘 들어맞는다.
이런 어원이 있다는걸 알게된 후 저 단어가 조금 덜 낯설게 느껴졌다.
물론 Algebraic Effects는 해당 어원에서 직접적으로 파생된 단어는 아니다.
다만 내가 그랬던 것 처럼, 의미를 이해하는 데에 도움이 되길 바라며 첨언했다.3. Suspend-React
구현을 살펴보았으니 data fetching과 함께 사용하는 예를 알아보자.
suspend react
라는 간단한 라이브러리를 사용할 것이다.이 라이브러리에서도 내부에서
Promise
를throw
하는 방식을 볼 수 있다.이 방식을 통해 다른 프레임워크들 처럼
React
의Suspense
를 사용할 수 있다.코드를 작성해보자.
fetch
가 성공하는 케이스, 실패하는 케이스에 대해 간단한 예시를 만들 수 있다.- 데이터 로딩 성공
데이터를 1초간 가져올 때 로딩을 노출한다.
성공 시에는 화면을 띄우는 것을 볼 수 있다.
const fetchData = (): Promise<Data> => { return new Promise((resolve) => { setTimeout(() => { resolve({ title: "TITLE", by: "ME" }); }, 1000); }); }; const SuspenseLoad: React.FC = () => { const data = suspend(fetchData); // suspend를 사용하면 여기서 로딩 관련 처리가 필요없다 // if (loading) { ... } // 에러 처리 필요 // ex) if (error) { ... } return ( <div> {data.title} by {data.by} </div> ); }; function App() { return ( <div className="card"> <Suspense fallback={<div>Loading...</div>}> <SuspenseLoad /> </Suspense> </div> ); }
- 데이터 로딩 실패
실패 케이스에는
ErrorBoundary
도 넣어서 대응해볼것이다.이를 통해서 데이터의 로딩, 실패 처리를 모두 컴포넌트와 분리할 수 있다.
const fetchError = (): Promise<Data> => { return new Promise((_, reject) => { setTimeout(() => { reject("ERROR"); }, 1000); }); }; const SuspenseError: React.FC = () => { const data = suspend(fetchError); // if (loading) { ... } // error 처리도 필요없다. // if (error) { ... } return ( <div> {data.title} by {data.by} </div> ); }; function App() { return ( <div className="card"> <ErrorBoundary fallback={<div>Something went wrong</div>}> <Suspense fallback={<div>Loading...</div>}> <SuspenseError /> </Suspense> </ErrorBoundary> </div> ); }
장점 및 한계
이 방식을 통해서 데이터 로딩 및 에러에 관련된 로직과 UI 로직을 분리할 수 있다. 이로 인해 컴포넌트는 더 간결해지고, 명확한 로직을 가질 수 있다.
또한 에러와 서스펜스의 바운더리가 명확하기 때문에 일관된 에러 및 로딩 처리를 할 수 있다는 장점도 있다.
하지만 공식 문서에서도 언급하고 있듯이,
Suspense
내에서 직접적으로Promise
를 던지는 방식은 production에서는 조심할 필요가 있다.대신에
ReactQuery
등Suspense
를 지원하는 라이브러리가 등장했기 때문에 그런 걸 사용하는 것이 더 좋다.
728x90'Study > 개발' 카테고리의 다른 글
Tmux 마우스 드래그 오류 (0) 2024.07.01 Regular Expression 종류, 동작 방식, 성능 (2) 2024.03.23 Neovim (2) 2023.12.28 React 18 - Transition과 외부 상태 관리 도구 (2) 2023.12.10 React 18 - Transition (0) 2023.12.09