안녕하세요. 마이다스인에서 인재 채용 플랫폼 잡다의 웹 프론트엔드 개발을 담당하고 있는 배준형입니다.
잡다에서는 React-Query
라이브러리를 사용하여 Data Fetching을 처리하고 있는데요. 로딩 상태는 useQuery
에서 반환하는 isLoading
, isFetching
값을 활용하여 관리하고 있습니다.
이를 Suspense
를 이용해 간단하게 처리하려 했으나 의도한 대로 동작하지 않았고, suspense 옵션을 true로 설정한 useQuery
(v5에서는 useSuspenseQuery
)가 여러 개 존재하면 waterfall 방식으로 순차 실행되는 문제가 있었습니다.
useQueries
를 사용하거나 기존처럼 isLoading
, isFetching
으로 처리하면서 suspense 옵션을 사용하지 않으면 query들이 병렬로 실행되는 것처럼 보이는데요. 왜 suspense를 사용하면 순차 실행될까요? 그리고 Suspense는 어떻게 자식 요소들의 Data Fetching 완료를 알 수 있을까요?
의도한 대로 동작하지 않는 코드들을 통해 Suspense의 작동 원리가 궁금해졌고, 이에 대해 알아본 내용을 공유하고자 합니다.
Suspense
컴포넌트를 사용하면 하위 항목이 로딩을 완료할 때까지 대체 fallback을 표시할 수 있습니다.
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
여기서 로딩이 완료되었다(finished loading)
는 것은 정확히 무엇을 의미할까요?
React 공식 문서에 따르면 Suspense는 다음 3가지 경우에 대해서만 활성화됩니다.
lazy
를 사용한 지연 로딩 컴포넌트use
를 통해 읽은 Promise value반면 Effect나 Event Handler에서 발생하는 Data Fetching은 감지하지 못합니다.
로딩이 완료되었다(finished loading)
는 의미를 추론해보면,
lazy
지연 로딩 컴포넌트의 경우 컴포넌트 로딩이 완료되었을 때use
를 사용한 Promise value의 경우 Promise가 Settled 되었을 때를 의미하는 것으로 보입니다.
만약 하나의 Suspense로 여러 요소를 감싸고 있다면, Suspense 내부는 단일 단위로 취급됩니다. 아래 코드처럼 2개의 자식 요소 중 하나라도 로딩 중이라면 fallback이 표시되고, 모든 요소가 준비되면 한번에 렌더링됩니다.
<Suspense fallback={<Loading />}>
<Biography />
<Panel>
<Albums />
</Panel>
</Suspense>
Suspense가 중첩되어 있으면 어떨까요? 컴포넌트가 Suspends 된다면 가장 가까운 상위 Suspense 컴포넌트의 fallback이 표시됩니다.
<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>
만약 Biography
, Albums
둘 다 Suspend된다면,
Suspense
컴포넌트에 의해 Biography
가 먼저 로딩을 시도합니다.Albums
는 Data Fetching을 시도하지 않고, 렌더링도 이루어지지 않습니다.Biography
의 렌더링 준비가 완료되면 BigSpinner 대신 Biography
와 Albums
를 감싸고 있는 Suspense
가 렌더링됩니다.Biography
가 렌더링 되어있는 상태에서 Albums
가 로딩을 시도하고, 완료되면 AlbumsGlimmer
를 Albums
가 대체합니다.순서로 컴포넌트가 표시됩니다. 이 때 Suspense 하나로 모든 요소를 감쌀 때와 중첩되게 Suspense를 사용하는 경우 동작이 다른데요.
<Suspense fallback={<Loading />}>
<Biography />
<Panel>
<Albums />
</Panel>
</Suspense>
<Suspense fallback={<BigSpinner />}>
<Biography />
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums />
</Panel>
</Suspense>
</Suspense>
children을 한 번에 보여주느냐, 준비된 요소부터 바로 보여주느냐 차이는 있지만, Data Fetching은 여전히 순차적으로 이루어지는 것을 확인할 수 있습니다.
<Suspense fallback={<BigSpinner />}>
<h1>{artist.name}</h1>
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
<Biography artistId={artist.id} />
<Biography2 artistId={artist.id} />
</Suspense>
Biography2
컴포넌트를 추가했고, Albums는 3초, Biography는 1.5초, Biography2는 2.5초 소요되는 것으로 가정했습니다.결과는 어떨까요?
Albums
와 Biography
는 동시에 로딩됩니다.Biography
와 Biography2
는 순차 실행됩니다.Albums
로딩이 완료되어도 Biography2
로딩이 끝나지 않아 화면에 표시되지 않습니다.Albums와 Biography가 동시에 실행된 건 서로 다른 Suspense 경계에 있기 때문이고, Biography와 Biography2가 순차 실행된 건 같은 Suspense 경계 안에 있기 때문입니다.
Biography2는 Biography와 같은 Suspense 경계에 있어, Biography 로딩이 끝나고 데이터가 표시될 때까지 렌더링되지 않습니다.
그럼 각 컴포넌트를 별도 Suspense로 감싸면 어떻게 될까요?
export default function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<BigSpinner />}>
<BiographyAndAlbums artist={artist} />
</Suspense>
</>
);
}
function BiographyAndAlbums({ artist }) {
return (
<>
<Suspense fallback={<BigSpinner />}>
<Biography artistId={artist.id} />
</Suspense>
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
<Suspense fallback={<BigSpinner />}>
<Biography2 artistId={artist.id} />
</Suspense>
</>
);
}
우선 가장 상위의 부모 역할 Suspense 경계의 fallback은 동작하지 않았습니다. 이는 컴포넌트가 Suspends되면 가장 가까운 상위 Suspense 컴포넌트의 fallback이 표시되기 때문인데, 위 경우 모든 컴포넌트가 Suspense 경계를 가지고 있으므로 가장 상위의 Suspense 컴포넌트의 fallback은 표시되지 않습니다.
결과적으로 각 요소들이 모두 동시에 로딩되는데요. 이 경우 각 컴포넌트가 각자의 Suspense 경계를 갖기 때문에 동시에 로딩되는 것으로 보입니다. 이에 따라 준비가 완료된 요소부터 개별적으로 렌더링 되는 것을 확인할 수 있습니다.
Suspense 컴포넌트를 어떻게 쓰는지에 따라 결과가 조금씩 달라지는 것을 확인했는데요. 왜 이런 차이가 발생하는 것일까요?
여기부터는 React 코드를 보면서 추론한 내용이며, 실제 동작과는 다를 수 있습니다.
React 16부터 Fiber Reconciler
가 도입되었고, Fiber
는 가상 DOM과 실제 DOM을 연결하는 데이터 구조를 갖습니다. Fiber
는 React가 UI를 업데이트하는 데 필요한 정보를 담은 객체이며, Fiber Reconciler
는 컴포넌트 변경 사항을 파악하고 DOM을 업데이트하는 역할을 합니다.
Fiber Reconciler
가 컴포넌트의 변경 사항을 파악하고 DOM을 업데이트할 때 하나의 작업 단위(unitOfWork
)를 기준으로 작업을 수행하며, 한 번에 하나의 Fiber
를 처리합니다. 그리고 하나의 작업은 beginWork
, completeWork
함수에 의해 처리됩니다.
beginWork
는 unitOfWork
를 기준으로 재귀적으로 호출하여 Fiber 트리를 순회하며 Fiber
노드를 생성하거나 업데이트하고, completeWork
는 beginWork
이후에 호출되어 unitOfWork
의 작업을 완료하는 역할을 합니다.
function performUnitOfWork(unitOfWork: Fiber): void {
// ...
const current = unitOfWork.alternate;
setCurrentDebugFiberInDEV(unitOfWork);
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, entangledRenderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, entangledRenderLanes);
}
resetCurrentDebugFiberInDEV();
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete the current work.
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
ReactCurrentOwner.current = null;
}
performUnitOfWork
함수는 주어진 unitOfWork
를 처리합니다.
startProfilerTimer
/ setCurrentDebugFiberInDEV
같은 함수들이 있지만, 핵심은 beginWork
함수를 호출하고, beginWork
함수의 결과에 따라 다음 작업 단위를 결정하는 것입니다.
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
if (__DEV__) {
// ...
}
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
if (
oldProps !== newProps ||
hasLegacyContextChanged() ||
// ...
) {
// ...
} else {
// ...
}
// ...
workInProgress.lanes = NoLanes;
switch (workInProgress.tag) {
case IndeterminateComponent: { ... }
case LazyComponent: { ... }
case FunctionComponent: { ... }
case ClassComponent: { ... }
// ...
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
// ...
}
throw new Error(
`Unknown unit of work tag (${workInProgress.tag}). This error is likely caused by a bug in ` +
'React. Please file an issue.',
);
}
current
Fiber Node
가 null
이 아닌 경우 이미 마운트된 상태인 경우를 처리합니다.
이후 Fiber Node
의 tag
값에 따라 다양한 컴포넌트 타입을 처리하고, SuspenseComponent
의 경우 updateSuspenseComponent
함수를 호출합니다.
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)
) {
// ...
showFallback = true;
}
// ...
if (current === null) {
// Initial mount
// ...
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
if (showFallback) {
pushFallbackTreeSuspenseHandler(workInProgress);
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
// ...
return fallbackFragment;
} else if ( ... ) {
} else {
pushPrimaryTreeSuspenseHandler(workInProgress);
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
renderLanes,
);
}
} else {
// This is an update.
// ...
}
}
updateSuspenseComponent
는 beginWork
에서 Fiber Node
의 tag
가 SuspenseComponent
일 때 컴포넌트를 처리합니다.
showFallback
을 false로 둔 뒤 조건에 따라 true로 변경하면서 fallback
컴포넌트 표시 여부를 결정합니다.
nextPrimaryChildren
은 주 컴포넌트를, nextFallbackChildren
은 fallback 컴포넌트를 나타내는데,
showFallback
이 true이면
pushFallbackTreeSuspenseHandler
함수를 호출하여 fallback 트리를 처리mountSuspenseFallbackChildren
함수를 호출하여 fallback 컴포넌트를 마운트showFallback
이 false이면
pushPrimaryTreeSuspenseHandler
함수를 호출하여 주 컴포넌트 트리를 처리mountSuspensePrimaryChildren
함수를 호출하여 주 컴포넌트를 마운트처리 됩니다.
그럼 언제 showFallback
이 true가 될까요?
showFallback
이 true가 될까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;
}
didSuspend
: 현재 컴포넌트가 suspend 상태인지 확인합니다.workInProgress.flags & DidCapture
가 NoFlags
와 다르다는 건 workInProgress.flags
에 DidCapture
플래그가 설정되어 있다는 의미로, 이는 컴포넌트가 suspend 상태임을 나타냅니다.shouldRemainOnFallback
: 현재 컴포넌트가 fallback UI를 계속 보여줘야 하는지 결정합니다. 이미 fallback을 보여주고 있다면 fallback 상태를 유지해야 할 수 있습니다.현재 작업 중인 workInProgress Fiber의 flags에 DidCapture
가 설정되어 있으면 fallback UI를 보여주게 됩니다.
그럼 DidCapture Flag는 언제 설정될까요?
markSuspenseBoundaryShouldCapture
function markSuspenseBoundaryShouldCapture(
suspenseBoundary: Fiber,
returnFiber: Fiber,
sourceFiber: Fiber,
root: FiberRoot,
rootRenderLanes: Lanes,
): Fiber | null {
// ...
if ((suspenseBoundary.mode & ConcurrentMode) === NoMode) {
// Legacy Mode일 때
if (suspenseBoundary === returnFiber) {
suspenseBoundary.flags |= ShouldCapture;
} else {
suspenseBoundary.flags |= DidCapture;
sourceFiber.flags |= ForceUpdateForLegacySuspense;
// ...
return suspenseBoundary;
}
}
// Concurrent Mode일 때
suspenseBoundary.flags |= ShouldCapture;
suspenseBoundary.lanes = rootRenderLanes;
return suspenseBoundary;
}
이 함수는 Suspense 경계(boundary)가 일시 중단된 예외를 캡처해야 하는지 판단합니다. Legacy Mode와 Concurrent Mode에서 다르게 동작하는데요.
ReactDom.render
를 통해 React App을 구성한다면 Legacy Mode입니다.ReactDOM.createRoot
를 통해 React App을 구성한다면 Concurrent Mode입니다.결국 markSuspenseBoundaryShouldCapture
함수는 조건에 따라 DidCapture
또는 ShouldCapture
flag를 설정하고, Suspense 경계 Fiber를 반환합니다.
beginWork()
에서 SuspenseComponent
일 때 workInProgress Fiber
의 flag가 DidCapture
인 경우 fallback을 보여주기로 했습니다. 그 조건에 ShouldCapture
flag는 사용되지 않았는데요.
Concurrent Mode일 때는 DidCapture
대신 ShouldCapture
flag만 설정하고 있습니다. 코드를 더 살펴보겠지만, 이는 ShouldCapture
flag가 활성화 되어 있을 때 어딘가에서 DidCapture
flag를 활성화 시켜줄 것으로 추정됩니다.
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: { ... }
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
// ...
const onResolution = () => {
if (
workInProgressSuspendedReason === SuspendedOnData &&
workInProgressRoot === root
) {
// Mark the root as ready to continue rendering.
workInProgressSuspendedReason = SuspendedAndReadyToContinue;
}
ensureRootIsScheduled(root);
};
thenable.then(onResolution, onResolution);
break outer;
}
case SuspendedOnImmediate: { ... }
case SuspendedOnInstance: { ... }
case SuspendedAndReadyToContinue: { ... }
// ...
}
if (__DEV__ && ReactCurrentActQueue.current !== null) {
// ...
} else {
workLoopConcurrent();
}
break;
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
// ...
}
renderRootConcurrent
함수는 Concurrent Mode에서 루트를 렌더링하는 역할을 수행합니다.
SuspendedOnData
: 작업 단위가 데이터를 기다리는 동안 일시 중단된 경우입니다. 데이터가 해결되면 작업 단위를 재생(replay)하고, 그렇지 않으면 작업 루프가 데이터가 해결될 때까지 기다립니다.SuspendedAndReadyToContinue
: 데이터가 해결되면 작업 단위를 재생하고, 그렇지 않으면 작업 단위를 풀어내고 일반 작업 루프로 돌아갑니다.switch 문 조건 중 SuspendedOnData
부분에선 작업 단위가 데이터를 기다리는 동안 일시 중단된 경우를 처리합니다. thenable.then(onResolution, onResolution);
부분에서 onResolution
콜백이 전달되는데, 이 콜백은 데이터가 준비되었을 때 호출됩니다.
thenable.then
에서 호출된 onResolution
콜백에선 workInProgressSuspendedReason
값을 SuspendedAndReadyToContinue
로 설정하며, 데이터가 준비되었으므로 작업을 계속할 준비가 되었음을 나타냅니다.
이렇게 되면 다음 렌더링 주기에 작업 루프가 재개되면서 SuspendedAndReadyToContinue
case로 실행되며, 아래 로직들이 실행됩니다.
case SuspendedAndReadyToContinue: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
} else {
// Otherwise, unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
}
break;
}
주석의 설명에 따라 데이터가 준비된 경우라면 replaySuspendedUnitOfWork
함수를 호출하여 중단된 작업을 다시 시도하여 렌더링을 재개하고, 데이터가 준비되지 않은 경우라면 throwAndUnwindWorkLoop
함수를 호출하여 스택을 풀어내고(unwind) 정상적인 작업 루프로 돌아갑니다.
데이터가 아직 준비되지 않아 fallback을 보여줘야되는 경우는 else 문에 해당합니다. 그리고 이 throwAndUnwindWorkLoop
함수에서 조건에 따라 예외를 처리하고 스택을 풀어내는 과정에서 ShouldCapture
flag가 설정되어 있을 때 이를 DidCapture
로 변경하는 작업이 이루어집니다.
function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed) {
// ...
try {
// Find and mark the nearest Suspense or error boundary that can handle
// this "exception".
throwException(
workInProgressRoot,
returnFiber,
unitOfWork,
thrownValue,
workInProgressRootRenderLanes,
);
} catch (error) {
workInProgress = returnFiber;
throw error;
}
if (unitOfWork.flags & Incomplete) {
unwindUnitOfWork(unitOfWork); // 아직 완료되지 않은 Fiber에 대해 여기서 unwindUnitOfWork 호출
} else {
completeUnitOfWork(unitOfWork);
}
}
function unwindUnitOfWork(unitOfWork: Fiber): void {
let incompleteWork: Fiber = unitOfWork;
do {
// ...
const next = unwindWork(current, incompleteWork, entangledRenderLanes);
// ...
} while (incompleteWork !== null);
// ...
}
function unwindWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
switch (workInProgress.tag) {
case ClassComponent: { ... }
case HostRoot: { ... }
case HostHoistable
case HostSingleton:
case HostComponent: { ... }
case SuspenseComponent: {
// ...
const flags = workInProgress.flags;
if (flags & ShouldCapture) { // flag에 ShouldCapture가 설정되어 있으면
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture; // ShouldCapture flag를 제거하고, DidCapture flag를 설정합니다.
// Captured a suspense effect. Re-render the boundary.
if (
enableProfilerTimer &&
(workInProgress.mode & ProfileMode) !== NoMode
) {
transferActualDuration(workInProgress);
}
return workInProgress;
}
return null;
}
// ...
default:
return null;
}
}
renderRootConcurrent
함수에서 호출된 throwAndUnwindWorkLoop
함수 내부에서 unwindUnitOfWork
→ unwindWork
함수가 호출되는데요. unwindWork
함수에서 ShouldCapture
flag가 설정되어 있는 경우 ShouldCapture
flag를 제거하고 DidCapture
flag를 설정하게 됩니다.
이런 과정에 의해 beginWork()
함수에서 호출된 updateSuspenseComponent()
함수에서 flag의 DidCapture
활성화 여부를 확인하여 showFallback 값을 true로 설정한 다음 fallback을 보여주는 것이죠.
지금까지의 흐름을 정리하자면
renderRootConcurrent
함수를 호출하여 Concurrent 렌더링을 수행SuspendedOnData
인 경우 액션을 수행한 다음 onResolution
함수에 의해 값을 SuspendedAndReadyToContinue
로 변경renderRootConcurrent
함수에 의해 다시 do while 문 작업을 수행하고, 이번엔 SuspendedAndReadyToContinue
case로 동작throwAndUnwindWorkLoop
함수를 호출throwAndUnwindWorkLoop
함수에서 throwException
함수를 통해 가장 가까운 Suspense
또는 ErrorBoundary
를 찾고, 예외를 처리throwException
내부에서 markSuspenseBoundaryShouldCapture
함수를 호출하고, 조건에 따라 ShouldCapture
| DidCapture
flag를 설정unwindUnitOfWork
함수를 호출하여 완료되지 않았거나 오류가 발생된 Fiber에 대해 상태를 업데이트하거나 예외 처리를 수행unwindWork
함수를 호출하고, 여기서 ShouldCapture
flag가 설정되어 있는 Fiber라면 DidCapture
flag로 설정Fiber
트리를 순회하면서 Fiber Node
를 생성 또는 업데이트 할 때 performUnitOfWork
함수 호출, beginWork
함수 호출이 이루어지며 여기서 Fiber Node
의 tag값이SuspenseComponent
인 경우 fallback UI를 표시할지 children을 표시할지 결정됩니다.DidCapture
가 설정되어 있다면 fallback UI를 보여줌어느 흐름으로 fallback 또는 children을 보여주는지 알게 되었습니다. 그러면, 조건에 따라 Suspense가 동작할 수 있는 컴포넌트들을 Suspense 컴포넌트로 감싸면 왜 순차적으로 호출되는지도 알아보겠습니다.
일련의 과정에 의해 unwindUnitOfWork
함수가 호출되는데, 이 함수는 Fiber 작업 단위를 처리하는 데 사용되는 함수로 throwAndUnwindWorkLoop
내부에서 호출됩니다.
function unwindUnitOfWork(unitOfWork: Fiber): void {
let incompleteWork: Fiber = unitOfWork;
do {
const current = incompleteWork.alternate;
const next = unwindWork(current, incompleteWork, entangledRenderLanes);
if (next !== null) {
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
// ...
} while (incompleteWork !== null);
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
}
do while 문에서 next 변수에 unwindWork
반환된 값을 저장하고 있습니다. 이 값이 null이 아닌 경우 해당 Fiber
에 HostEffectMask
flag를 설정하고 return 하여 함수를 종료합니다. unwindWork
함수의 반환값은 다음과 같은데요.
function unwindWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
switch (workInProgress.tag) {
case ClassComponent: { ... }
case HostRoot: { ... }
case HostHoistable
case HostSingleton:
case HostComponent: { ... }
case SuspenseComponent: {
// ...
const flags = workInProgress.flags;
if (flags & ShouldCapture) {
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
// Captured a suspense effect. Re-render the boundary.
if (
enableProfilerTimer &&
(workInProgress.mode & ProfileMode) !== NoMode
) {
transferActualDuration(workInProgress);
}
return workInProgress;
}
return null;
}
// ...
default:
return null;
}
}
작업이 진행 중인 Fiber의 tag가 SuspenseComponent
일 때, flag에 ShouldCapture
가 활성화 되어 있다면 그 flag에 ShouldCapture
를 제거하고 DidCapture
를 활성화 하면서 해당 Fiber를 반환합니다.
결국, ShouldCapture
flag가 활성화 되어 있으면 unwindUnitOfWork
함수에서의 Fiber
tree를 순회하면서 작업하던 do while 문을 멈추고 return 하면서 작업을 중지하는 것인데요.
Suspense
컴포넌트로 조건에 해당하는 하위 컴포넌트를 감싸고 있고, Data를 로딩중인 경우 하위 컴포넌트의 Fiber Node
를 생성, 업데이트 하는 과정에서 flag가 ShouldCapture
로 설정되고, 이후 수행되는 unwindUnitOfWork
함수가 return 되면서 다음 작업이 진행되지 않기에 순차적으로 Data fetching이 이루어지는 것처럼 보이게 됩니다.
Suspense를 1개만 쓸 때
<Suspense fallback={<Loading />}>
<Biography />
<Panel>
<Albums />
</Panel>
</Suspense>
Biography
의 Fiber Node
생성을 시도할 때 Data가 준비되지 않았다면 ShouldCapture
flag가 활성화되면서 이후의 작업은 이루어지지 않습니다.ShouldCapture
flag에 의해 해당 flag는 제거되고 DidCapture
flag가 활성화 되면서 Fiber tree를 순회하며 Fiber Node
의 생성/업데이트 처리를 실행하던 함수가 중단됩니다.Fiber Node
의 업데이트를 시도하게 되고, 그 과정에선 do while 문을 멈추지 않고 다음 Fiber Node
로 넘어갑니다.Albums
Fiber Node
를 생성하면서 해당 컴포넌트의 Data fetching을 시도합니다.Fiber Node
에 대한 생성/업데이트는 이루어지지 않습니다.Albums
의 Data가 준비되었다면 Fiber Node
를 업데이트 하면서 렌더링이 진행 됩니다.위의 흐름으로 진행되니 Biography
컴포넌트가 준비되지 않은 상태라면 Albums
의 작업을 진행하지 않기에 초기 렌더링 시에는 Albums
의 Data fetching이 이루어지지 않습니다.
각 요소에 Suspense를 감싸서 사용하는 경우
export default function ArtistPage({ artist }) {
return (
<>
<h1>{artist.name}</h1>
<Suspense fallback={<BigSpinner />}>
<BiographyAndAlbums artist={artist} />
</Suspense>
</>
);
}
function BiographyAndAlbums({ artist }) {
return (
<>
<Suspense fallback={<BigSpinner />}>
<Biography artistId={artist.id} />
</Suspense>
<Suspense fallback={<AlbumsGlimmer />}>
<Panel>
<Albums artistId={artist.id} />
</Panel>
</Suspense>
<Suspense fallback={<BigSpinner />}>
<Biography2 artistId={artist.id} />
</Suspense>
</>
);
}
이 경우엔 동시에 처리되는 것처럼 동작하는 것을 확인했었죠. Suspense
컴포넌트로 각 요소를 감싸면 각각의 컴포넌트가 개별 Suspense Boundary
를 갖게 됩니다.
이에 따라 각자 독립적으로 Data fetching을 시도하고 준비가 완료되는대로 개별적으로 렌더링됩니다.
최상단에 Suspense 컴포넌트로 하위 요소들을 감싸고 있긴 하지만, 하위 요소에서 발생하는 Suspend 상태는 가장 가까운 Suspense 경계를 찾으므로 개별적으로 동작하게 됩니다.
Suspense를 사용할 수 있는 경우
Suspense를 사용할 수 없는 경우
Suspense를 사용할 수 있는 여러 개의 컴포넌트를 하나의 Suspense 컴포넌트로 감싸는 경우
Fiber Node
생성/업데이트를 시도Suspense를 사용할 수 있는 여러 개의 컴포넌트를 각각 Suspense 컴포넌트로 감싸는 경우
위의 흐름에 따라 Suspense가 사용됩니다. 잡다 코드에는 Suspense
를 사용하고 있지 않은데, 기획 요구사항에 따라 한 페이지에서 발생하는 모든 Loading이 마무리된 후에 화면을 보여주는 것으로 작업을 해야해서 부분적으로 Suspense
도입을 시도했었습니다.
그러나, Suspense
가 아예 동작하지 않거나, 적절히 수정하여 동작하게 됐더라도 API 요청이 waterfall 방식으로 호출되어 느리게 동작하는 것처럼 느껴졌습니다. 이에 더 학습한 후에 적절히 Suspense
를 사용하여(또는 사용하지 않고) 이를 구현하기 위해 Suspense
의 동작과 코드를 확인해 보았습니다.
그러나 아직 useQueries
를 사용한다거나 useQuery
의 suspense
옵션을 true로 주는 경우(useSuspenseQuery
) 어떤 일이 일어나는지 아직 모릅니다. 이에 대한 포스팅도 이어서 진행해보면 Suspense
사용에 도움이 될 것 같습니다.