티스토리 뷰
개요
- react suspense의 사용법에 대해 자세히 알아보자
- react suspense 이전의 코드와 현재의 코드를 비교해서 장,단점을 비교해보자
- react suspense가 나오게 된 계기는?
동시성(concurrent)
동시성모드 이전의 react는 stack 기반으로 동작 하였습니다. 태스크를 stack 담고 빌 때까지 동기적으로 처리를 하였습니다.
이러한 방법은 많은 태스크를 처리해야 할 때 문제점을 다가왔습니다.
예시로 현재 유저가 검색으로 list를 불러온다고 했을 때 당장에 생각해봐도 loading component를 보여주고 자동완성 UI도 보여주고 api 요청도 하고 유저로부터 input처리도 받게 되고 등 등 요구사항이 늘어날수록 처리해야 되는 것도 많아집니다.
그러다 보니 태스크가 지연될 경우가 발생하게 되는데 만약 타이핑 요청이 스택알고리즘에서 실행되지 않는다면 검색을 위한 타이핑이 지연된다던지 하는 현상이 발생합니다.
새롭게 나타난 React 파이버
스택알고리즘을 해결하기 위해 React 파이버라는 개념을 도입하고 이를 비동기적으로 처리하게 됩니다.
React 파이버 객체를 보면 사용한 UI태그에 따라 우선순위를 부여하게 되는데 이를 모두 실행시키는 것이 아니라 지연시키거나 폐기를 시키기도 하게 됩니다.
이러한 우선순위는 UI를 보여주는 순서를 정하게 되고 이를 기준으로 태스크를 나누어 병렬적으로 처리를 하는것처럼 보여줍니다.
Suspense
Suspense를 위해 지금까지 동시성에 대해 간단한 설명을 해보았습니다.
Suspense는 기본적으로 UI렌더링을 지연시키는 역할을 합니다. children UI를 보여줄 준비가 될 때까지 fallback UI를 먼저 보여주게 됩니다.
코드 실행원리
suspense를 사용하면 자식(children)이 로딩을 완료할 때까지 폴백을 표시할 수 있다.
begin work
react reconciler단계에서 Task를 workLoop를 돌면서 렌더링 한다.
beginwork를 진행하면서 WorkProgress의 tag (렌더링 하려는 태그, VDOM을 만들기 위한 태그)가 SuspenseComponent인 경우 updateSuspenseComponent함수를 호출 한다.
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
updateSuspenseComponent
FallbackUI를 보여줄지 ChildrenUI를 보여줄지 선택하는 부분
function updateSuspenseComponent(current, workInProgress, renderLanes) {
const nextProps = workInProgress.pendingProps;
// workINProgress는 다음에 렌더링 해서 보여 줄 VDOM을 의미한다.
let showFallback = false;
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
// didSUspend는 현재 비동기 데이터를 가져오는 동안에 발생한 Suspend를 캡쳐 했는지를 나타
// 내는 플래그, -> 현재 불러오는 중(로딩 중)인지 아닌지를 파악한다.
if (
didSuspend ||
shouldRemainOnFallback(current, workInProgress, renderLanes)
// shouldRemainOnFallback은 로딩 중에서 children으로 바뀔 수 있음에도 현재
// 우선순위에 의해서 fallback 상태를 유지해야 하는경우 flag를 true로 설
) {
showFallback = true;
workInProgress.flags &= ~DidCapture;
}
current는 현재 VDOM을 의미한다.
current === null 일경우 최초 생성, mount를 의미하고 current가 있을 경우 현재의 DOM이 있기 때문에 update를 의미한다.
if (current === null) {
// Initial mount
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.fallback;
// 위에서 정의한 flag로 children, fallback 중 어떤 것을 불러올지 선택하는 부분
if (showFallback) {
pushFallbackTreeSuspenseHandler(workInProgress);
const fallbackFragment = mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren,
renderLanes,
);
const primaryChildFragment: Fiber = (workInProgress.child: any);
primaryChildFragment.memoizedState = mountSuspenseOffscreenState(
renderLanes,
);
workInProgress.memoizedState = SUSPENDED_MARKER;
return fallbackFragment;
} else {
pushPrimaryTreeSuspenseHandler(workInProgress);
return mountSuspensePrimaryChildren(
workInProgress,
nextPrimaryChildren,
renderLanes,
);
}
}
mountSuspenseFallbackChildren
showfallback이 true일 경우에 보여준다. fallback UI를 보여주기 위해 함수를 호출하며 Fragment를 사용해 렌더링 된다 (createFiberFromFragment)
실제로는 fallbackUI와 children UI 모두 렌더링한다
childrenUI의 경우 mode가 hidden으로 설정되는데 display none !important로 설정되기 때문에 렌더링은 되나 실제로 화면에서는 보이지 않게 된다. → 백그라운드 렌더링
function mountSuspenseFallbackChildren(
workInProgress,
primaryChildren,
fallbackChildren,
renderLanes,
) {
const mode = workInProgress.mode;
const progressedPrimaryFragment: Fiber | null = workInProgress.child;
const primaryChildProps: OffscreenProps = {
mode: 'hidden',
children: primaryChildren,
};
let primaryChildFragment;
let fallbackChildFragment;
// childrenUI fiber 생성, 백그라운드 렌더링
primaryChildFragment = mountWorkInProgressOffscreenFiber(
primaryChildProps,
mode,
NoLanes,
);
// fallback fiber 생성
fallbackChildFragment = createFiberFromFragment(
fallbackChildren,
mode,
renderLanes,
null,
);
primaryChildFragment.return = workInProgress;
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
workInProgress.child = primaryChildFragment;
return fallbackChildFragment;
}
// 백그라운드 렌더링
export function hideInstance(instance: Instance): void {
instance = ((instance: any): HTMLElement);
const style = instance.style;
// $FlowFixMe[method-unbinding]
if (typeof style.setProperty === 'function') {
style.setProperty('display', 'none', 'important');
} else {
style.display = 'none';
}
}
mountSuspensePrimaryChildren
children 컴포넌트 렌더링을 위한 모든 데이터가 준비되고 우선순위를 확보하면 showFallback은 false로 변경된다,
백그라운드로 로드 했던 UI는 mode를 visible로 바꾸어 렌더링을 진행한다.
function mountSuspensePrimaryChildren(
workInProgress,
primaryChildren,
renderLanes,
) {
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;
}
function mountWorkInProgressOffscreenFiber(
offscreenProps: OffscreenProps,
mode: TypeOfMode,
renderLanes: Lanes,
) {
return createFiberFromOffscreen(offscreenProps, mode, NoLanes, null);
}
export function createFiberFromOffscreen(
pendingProps: OffscreenProps,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
) {
const fiber = createFiber(OffscreenComponent, pendingProps, key, mode);
fiber.elementType = REACT_OFFSCREEN_TYPE;
fiber.lanes = lanes;
const primaryChildInstance: OffscreenInstance = {
visibility: OffscreenVisible,
pendingMarkers: null,
retryCache: null,
transitions: null,
};
fiber.stateNode = primaryChildInstance;
return fiber;
}
Handling Promise
child component가 데이터를 가져올 때 suspense가 이를 어떻게 capture 하고 showFallback을 true로 바꾸어 보여주기 까지 어떤일들이 일어나는지 알아보자
- 먼저 promise가 발생(throw)하면 suspense에서 capture(잡는다)한다.
- 잡은 것을 resolve 한 후 제어권을 childComponent로 넘긴다.
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
switch (workInProgressSuspendedReason) {
// 1. 데이터를 불러올 때 throw error 가 발생한다.
case SuspendedOnError: {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
unwindSuspendedUnitOfWork(unitOfWork, thrownValue);
break;
}
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
const onResolution = () => {
ensureRootIsScheduled(root, now());
};
thenable.then(onResolution, onResolution);
break outer;
}
...
}
}
workLoopConcurrent();
break;
} catch (thrownValue) {
// error가 발생하면 handThrow 호출
handleThrow(root, thrownValue);
}
} while (true);
handleThrow
에러의 형태가 suspenseExcetion인 경우에 workInProgressSuspendedReason의 값을 suspendedOnData로 설정
do while loop를 돌면서 변경 되었을 때 처리할 수 있게 반복문을 돈다.
function handleThrow(root, thrownValue): void {
resetHooksAfterThrow();
resetCurrentDebugFiberInDEV();
ReactCurrentOwner.current = null;
if (thrownValue === SuspenseException) {
thrownValue = getSuspendedThenable(); // thenable한 함수로 변경.
// 2번에 해당하는 throw로 잡은 것을 resolve의 형태로 전달한다.
workInProgressSuspendedReason = shouldAttemptToSuspendUntilDataResolves()
? SuspendedOnData
: SuspendedOnImmediate;
}
...
workInProgressThrownValue = thrownValue;
...
}
SuspendedOnData
throw에서 workInProgressSuspendedReason가 SuspendOnData로 변경되었다.
이로인해 이제 제어권은 children UI로 넘어가게 된다.
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
// The data resolved. Try rendering the component again.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
const onResolution = () => {
ensureRootIsScheduled(root, now());
};
thenable.then(onResolution, onResolution);
break outer;
}
결론
.Suspense는 단순히 데이터를 불러오는 동안 UI를 보여주는 것 이상의 기능을 제공한다.
기존의 if를 이용한 렌더링 분기처리의 경우 백그라운드로 UI를 불러오는 것도 아니고 무엇을 보여줄까에 신경을 썼다면 suspense는 어떻게 적절한 UI를 보여줄까를 고민한 결과라고 생각한다.
Concurrent Mode에서의 React는 UI의 우선순위를 관리하고 적절한 시기에 갱신되는 것을 지원하기 때문이다.
예를 들어, 렌더링할 컴포넌트 A는 빠르게 갱신되어야 하지만 그 안에 있는 데이터는 로딩 시간이 걸린다고 가정해보자. Suspense를 사용하면 이러한 상황에서 React가 해당 데이터가 준비될 때까지 렌더링을 유보하고, 그 전까지는 대체 컨텐츠나 로딩 UI를 보여줄 수 있다. 이것은 사용자에게 부드러운 경험을 제공하며, 중요한 부분에 대한 갱신을 우선시하여 효율적인 UI 업데이트를 제공한다.
정리
- fallback을 보여줄지 children을 보여줄지 우선순위와 데이터유무에 따라 나뉜다.
- fallback을 보여줄 때 데이터를 요청하고 while문을 돌면서 데이터가 준비되었는지 확인한다.
- throw를 데이터가 준비된 상태로 바꾼 후 while문을 빠져나온다.
- 데이터가 준비되면 데이터를 resolve 해서 처리한다. -> children UI로 제어권을 넘겨준다.
실험하기
렌더링 속도 비교하기
위의 코드에서 suspense를 쓰면 장점이 단순히 로딩 UI를 보여주는 것이 아니라 미리 childrenUI를 renderling 해놓기 때문에 더 빠르게 화면을 보여줄 수 있을 것이다.
- suspense를 썼을 때 (선언적 UI)
Suspense를 사용한 경우 7.1밀리초의 렌더링이 걸렸다.
- if문을 이용했을 때 (명령적 UI)
사용하지 않았을 때 11.2ms가 걸렸다.
dev환경에서 실행했고 시간에 있어서 약간의 오차는 발생할 수 있으나 기본적으로 suspense를 사용할 때 더 빠르게 UI를 불러옴을 알 수 있었다.
실제로 rendering 될 때 빠른속도로 redering 됨을 확인할 수 있었다.
reference
suspense를 이해하기 위해 많은 도움을 준 아티클입니다.
https://sckimynwa.medium.com/suspense-deep-dive-code-implementation-e766582a7366
'프론트엔드' 카테고리의 다른 글
React 상태관리 패턴(MVC, MVVM, flux패턴) (0) | 2024.01.04 |
---|---|
useCallback과 useMemo (2) | 2023.11.20 |
[css] float (2) | 2023.11.01 |
- Total
- Today
- Yesterday
- 표현 가능한 이진트리
- 서비스 디자인 패턴
- nextjs 에러핸들링
- node version yarn berry
- 백준 22862
- suspense 장점
- React useMemo
- nestjs 배포하기
- 자바스크립트
- node 버전 마이그레이션
- 가장 긴 짝수 연속한 부분 수열
- serverless nestjs
- React useCallback
- nextjs errorboundary
- storybook scss import
- 미로탈출 명령어
- 1600 파이썬
- 서버사이드 error handling
- 에러핸들링
- CSS
- 불량 사용자 자바스크립트
- useCallback과 useMemo 사용
- 관심사 분리하기
- serverless 배포
- storybook react is not defiend 해결
- javascript
- react suspense
- 백준 1600번
- storybook scss이슈
- 선언적 UI
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |