
Suspense는 v16에서 실험적인 기능으로 있다가 v18에서 정식 릴리즈 되었습니다. 리액트를 사용 한다면 이제 Suspense는 필수라는 생각이 듭니다. v16에서는 lazy 로딩에 대해서만 완벽히 지원을 했지만 v18부터는 데이터를 로딩 할 때도 사용할 수 있게 되었습니다.
왜 일까요?
공식 문서에서는 이렇게 설명하고 있습니다.
Suspense 는 자식 요소가 로드되기 전까지 화면에 대체 UI를 보여줍니다.
<Suspense fallback={<Loading />}> <SomeComponent /> </Suspense>
위 예시는 SomeComponent가 현재 로드되고 있는 상태라면 Suspense의fallback 으로 내려준 Loading 컴포넌트가 표시되는 예시입니다.
이 Suspense는 리액트 v18부터 등장한 동시성(Concurrency) 과 깊은 관계가 있습니다. 동시성은 한번의 둘 이상의 작업이 진행되고 더 긴급한 작업이 우선 처리되는 개념이며, 리액트 개발자는 동시성을 Transitions과 Suspense를 사용해 처리할 수 있습니다.
이번 글에서는 다루지 않도록 하겠습니다.
Suspense의 children이 loading중이면 fallback을 표시하는건 알겠는데 그럼 어떻게 로딩중이란걸 판단할까요?
제일먼저 공식 문서를 찾아봤습니다.
그리고 아래의 코드를 발견할 수 있었습니다.
데이터를 가져오는 API가 포함된 예제에서는 use 함수를 사용하는데 이는 v19에 포함될 예정인 use훅의 핵심 개념만 뽑아내 간단하게 구현한 함수로 보입니다.
import { fetchData } from './data.js';
// Note: this component is written using an experimental API
// that's not yet available in stable versions of React.
// For a realistic example you can follow today, try a framework
// that's integrated with Suspense, like Relay or Next.js.
export default function Albums({ artistId }) {
const albums = use(fetchData(`/${artistId}/albums`));
return (
<ul>
{albums.map(album => (
<li key={album.id}>
{album.title} ({album.year})
</li>
))}
</ul>
);
}
// This is a workaround for a bug to get the demo running.
// TODO: replace with real implementation when the bug is fixed.
function use(promise) {
// fulfilled 즉, 정상적으로 성공 되었을 때 값을 리턴
if (promise.status === 'fulfilled') {
return promise.value;
}
// promise가 실패 했을 때 에러를 위쪽 Suspense로 던져줌
else if (promise.status === 'rejected') {
throw promise.reason;
}
// promise가 진행중일 때 에러를 위쪽 Suspense로 던져줌
else if (promise.status === 'pending') {
throw promise;
}
// 최초로 이 함수를 실행 했을 때
// status를 바꾸고, 핸들러를 등록 후 promise를 리턴함
else {
promise.status = 'pending';
promise.then(
result => {
promise.status = 'fulfilled';
promise.value = result;
},
reason => {
promise.status = 'rejected';
promise.reason = reason;
},
);
throw promise;
}
}
이제 저희는 아래와 같이 생각 해 볼 수 있을것 같습니다!
function render() {
try {
// 여기서 자식 컴포넌트를 실행해본다.
} catch(e) {
// 만약 자식 컴포넌트가 에러를 throw하면 여기서 처리해준다.
// Suspense의 경우 promise를 throw 하기에 분기에 따라 이를 처리해준다.
}
}
실제로 리액트의 코드를 읽어보면 trackUsedThenable 함수에 위 use 훅의 구현체가 있는데 거의 비슷하게 작동합니다.
ReactFiberHooks 파일의 use 함수에서 useThenable 함수를 호출하고 그 후 trackUsedThenable가 호출됩니다.
참고로 이러한 try-catch문은 대수적 효과와 관련이 있는데 궁금하다면!
아래 블로그를 한번 읽어보면 좋을것 같습니다.
Algebraic Effects for the Rest of Us
이제 리액트가 어떻게 Suspense 아래의 컴포넌트들의 로딩 상태를 알고있는지 우리도 알게 됐습니다. 바로! 하위 컴포넌트가 promise를 throw하고, 상위의 컴포넌트가 이를 감지하는 방식이죠.
리액트 v19에서 나올 예정인 use훅에서 promise의 상태에 따라 throw를 합니다.
그리고 리액트가 render phase를 수행하며 아래에서 던진 throw를 처리해주고, 이전의 작업을 이어서 하게 됩니다.
이 과정을 코드를 보며 따라가 보겠습니다.
현재 스케줄된 작업(workInProgress)이 존재한다면 performUnitOfWork 함수가 실행되는데 이는 작업 단위를 의미합니다.그 후 그 안에서 beginWork 함수가 실행됩니다.
// react-reconciler/src/ReactFiberWorkLoop.js
/** @noinline */
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
performUnitOfWork(workInProgress);
}
}
// react-reconciler/src/ReactFiberWorkLoop.js
function performUnitOfWork(unitOfWork: Fiber): void {
...
const current = unitOfWork.alternate;
...
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, entangledRenderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, entangledRenderLanes);
}
...
}
beginWork 함수에서 만약 workInProgress.tag가 SuspenseComponent라면 updateSuspenseComponent를 호출하게 됩니다.
// react-reconciler/src/ReactFiberBeginWork.js
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
switch (workInProgress.tag) {
...
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
...
}
...
}
updateSuspenseComponent 함수는 fallback을 보여줄지 아니면 자식 컴포넌트를 보여줄지를 결정합니다. 이는 showFallback에 의해 결정되는데 이는 이진수 플래그로 이뤄져 있습니다.
살펴보면 & 연산자로 workInProgress.flags & DidCapture 이렇게 현재 workInProgress의 flag가 DidCapture인지 확인하고 있습니다.
현재 DidCapture 상태면 fallback을 보여주는데 DidCapture는 Suspense 컴포넌트가 첫번째 Promise를 캡쳐했는지(찾았는지)를 나타내는 플래그입니다.
혹은 shouldRemainOnFallback함수가 true여도 fallback을 보여줍니다. 이 함수는 현재 이미 fallback을 보여주고 있는 상태거나, 다른 우선순위 높은 작업이 없으면 true를 리턴합니다. 이 때 다른 작업이 있는지 판단하기 위해 ReactFiberSuspenseContext를 참조하게 됩니다.
// react-reconciler/src/ReactFiberBeginWork.js
function updateSuspenseComponent(
current: null | Fiber,
workInProgress: Fiber,
renderLanes: Lanes,
) {
const nextProps = workInProgress.pendingProps;
...
let showFallback = false;
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
if (
didSuspend ||
shouldRemainOnFallback(current, workInProgress, renderLanes)
) {
// Something in this boundary's subtree already suspended. Switch to
// rendering the fallback children.
showFallback = true;
workInProgress.flags &= ~DidCapture;
}
...
💡 아래에 나오는 렌더링은 모두 리액트의 렌더 페이즈를 의미합니다.
만약 showFallback 상태라면 mountSuspenseFallbackChildren 를 호출해 fallback을 마운트 하고 아니라면 mountSuspensePrimaryChildren을 호출해 컴포넌트를 마운트 하는 모습을 볼 수 있습니다.
참고로 showFallback 상태에서 mountSuspenseFallbackChildren 함수는 원래 자식도 함께 렌더링 합니다. 이 때 함수 내부에서 자식 컴포넌트는 display:none!important 을 적용 해줍니다. 이로인해 렌더트리를 생성할 때 fallback만 화면에 보여지며, 자식 컴포넌트는 화면에 보이지 않지만, 리액트가 쭈욱 아래로 재귀적으로 탐색하며 처리할 수 있게 됩니다.
// react-reconciler/src/ReactFiberBeginWork.js
if (current === null) {
// Initial mount
// 지금 중요한 내용은 아니지만
// 서버 컴포넌트가 아직 Hydrating 상태일 때도
// 여기서 같이 처리되는걸 확인할 수 있습니다.
if (getIsHydrating()) {
...
}
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
if (showFallback) {
pushFallbackTreeSuspenseHandler(workInProgress);
// fallback을 마운트 합니다
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState =
mountSuspenseOffscreenState(renderLanes);
primaryChildFragment.childLanes = getRemainingWorkInPrimaryTree(
current,
didPrimaryChildrenDefer,
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
...
return fallbackFragment;
} else {
pushPrimaryTreeSuspenseHandler(workInProgress);
// 자식(기본) 컴포넌트를 마운트 합니다.
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
renderLanes,
);
}
}
위에서 mountSuspenseFallbackChildren 함수로 인해 바뀌어 있던 display를 다시 원래대로 돌려주고, Fiber를 생성 후 화면에 자식 컴포넌트를 마운트 해주게 됩니다.
// react-reconciler/src/ReactFiberBeginWork.js
function mountSuspensePrimaryChildren(
workInProgress: Fiber,
primaryChildren: $FlowFixMe,
renderLanes: Lanes,
) {
const mode = workInProgress.mode;
const primaryChildProps: OffscreenProps = {
mode: 'visible',
children: primaryChildren,
};
const primaryChildFragment = mountWorkInProgressOffscreenFiber(
primaryChildProps,
mode,
renderLanes,
);
primaryChildFragment.return = workInProgress;
workInProgress.child = primaryChildFragment;
return primaryChildFragment;
}
위에서는 Suspens 컴포넌트가 어떤 과정을 거쳐 Fallback혹은 자식 컴포넌트를 화면에 보여주는지에 대해 다뤘습니다. 위의 try-cath문을 다시 가져와보면 아래와 같습니다.
function render() {
try {
// 여기서 렌더링을 진행한다.
} catch(e) {
// 만약 자식 컴포넌트가 에러를 throw하면 여기서 처리해준다.
// Suspense도 여기서 Promise를 처리해준다.
}
}
이 부분에 대한 자세한 로직은 renderRootConcurrent 함수가 다루고 있습니다. do while문 안의 try문 안에서 workLoopConcurrent 함수를 호출함으로써 현재 처리해야할 작업을 처리합니다. 그러다 여기서 promise등이 throw되면 catch문 안의 handleThrow 함수가 처리 해주게 됩니다.
// react-reconciler/src/ReactFiberWorkLoop.js
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
...
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
resumeOrUnwind: switch (workInProgressSuspendedReason) {
case SuspendedOnError: {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(root, unitOfWork, thrownValue);
break;
}
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// 데이터가 resolved 되었으면 컴포넌트를 다시 렌더링 합니다.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
const onResolution = () => {
if (
workInProgressSuspendedReason === SuspendedOnData &&
workInProgressRoot === root
) {
workInProgressSuspendedReason = SuspendedAndReadyToContinue;
}
// 루트가 예약되도록 보장합니다.
// 현재 다른 루트에서 작업 중이더라도 이 작업을 수행하여
// 나중에 렌더링을 계속할 수 있도록 합니다.
ensureRootIsScheduled(root);
};
thenable.then(onResolution, onResolution);
break outer;
}
...
default: {
throw new Error(
'Unexpected SuspendedReason. This is a bug in React.',
);
}
}
}
...
// 이 함수를 호출함으로써 계속해서 현재 작업을 진행합니다.
workLoopConcurrent();
break;
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
...
}
이렇게 handleThrow에서 workInProgressSuspendedReason를 SuspendedOnData로 바꿔주게 됩니다.
// react-reconciler/src/ReactFiberWorkLoop.js
function handleThrow(root: FiberRoot, thrownValue: any): void {
resetHooksAfterThrow();
resetCurrentDebugFiberInDEV();
ReactCurrentOwner.current = null;
if (thrownValue === SuspenseException) {
workInProgressSuspendedReason =
shouldRemainOnPreviousScreen() &&
// 이 컴포넌트가 일시 중단되어 있는지 확인할 수 있는 다른 보류 중인 업데이트가 있는지 확인합니다.
!includesNonIdleWork(workInProgressRootSkippedLanes) &&
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
? // 데이터가 해결될 때까지 작업 루프를 일시 중단합니다.
SuspendedOnData
: // 데이터가 즉시 해결되었는지 확인하기 위해 작업 루프를 일시 중단하지 않습니다.
// 그렇지 않으면 가장 가까운 Suspense를 트리거합니다.
SuspendedOnImmediate;
}
workInProgressThrownValue = thrownValue;
...
}
그 후 위의 loop에서 아래에서 던진 값을 thenable로 만들어주고, 스케줄러에 등록 해 줍니다. 그러면 자식 컴포넌트부터 스케줄링을 다시 시작 할 수 있게 됩니다.
즉, Suspense는 계속 while문을 돌면서 promise가 처리 됐는지 확인하고 처리 됐다면 렌더링 해주는 식으로 작동합니다.
이렇게 어려운 코드들을 읽고 해석하시는 게, 대단하다고 생각합니다. 민형님께서 작성해주신 글 덕분에 쉽게 흐름을 파악할 수 있네요. 한번 열심히 공부해보겠습니다.