Suspense를 사용하면 컴포넌트가 렌더링하기 전에 다른 작업이 먼저 이루어지도록 “대기" 해주는 기능을 제공합니다. 즉 Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을때 로딩 화면을 보여주고 로딩이 완료되면 해당 컴포넌트를 보여주는 React에 내장되어 있는 기능입니다.
기존에 Suspense 없이 로딩을 처리하던 방식을 Fetch-on-Render라고 합니다.
사용자 정보를 가져와 화면에 표시하는 간단한 예제입니다.
function ProfilePage() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(u => setUser(u));
}, []);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<>
<h1>{user.name}</h1>
<ProfileTimeline />
</>
);
}
fetchUser 함수를 통해 데이터를 가져올 때 데이터가 불러와지는 동안 "Loading profile.." 이라는 UI가 표시되고, 데이터가 성공적으로 불러와지면 해당 데이터가 setUser 통해 상태에 저장되어 화면에 렌더링 됩니다.
이 과정에서 생기는 단점이 존재합니다.
1. 컴포넌트가 마운트 된 이후에 data fetching이 시작됩니다. 만약 위와 같은 컴포넌트 내부에 같은 형태의 컴포넌트가 존재한다면, 데이터를 불러오는 시간이 그만큼 더 길어지게 됩니다.
2.코드 가독성이 좋지 않다.
코드의 가독성이 떨어집니다. 중간에 if (user === null) 와 같은 불필요한 코드가 포함되어 있습니다. React의 Suspense를 활용하면 코드를 더 직관적으로 작성할 수 있으며, Loading과 같은 상태를 신경쓰지 않고 비즈니스 로직에 집중할 수 있습니다.
React 18 Suspense의 공식 예제를 보겠습니다. Suspense를 사용하면 데이터 로딩 완료 후 본 렌더링을 시작하는 게 아니라 데이터 로딩과 함께 본 렌더링을 시작할 수 있습니다.
// This is not a Promise. It's a special object from our Suspense integration.
const resource = fetchProfileData();
function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
// Try to read user info, although it might not have loaded yet
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
function ProfileTimeline() {
// Try to read posts, although they might not have loaded yet
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
Suspense를 사용하면 데이터가 없거나 로딩 중일 때는 해당 컴포넌트의 렌더링을 건너뛰고 다른 컴포넌트를 먼저 렌더링합니다. 그리고 가장 가까운 부모 Suspense의 fallback으로 지정된 컴포넌트를 화면에 대신 보여주게 됩니다.예제에서 ProfileDetail이 로딩 중이더라도 ProfileTimeline에게 렌더링 순서가 올 수 있으므로 두 데이터 요청은 병렬적으로 수행될 수 있게 됩니다.
컴포넌트는 Suspense를 통해 로딩에 관한 책임도 위임할 수 있습니다. Suspense와 비슷하게 에러를 처리하는 ErrorBoundary도 있는데, 둘을 같이 사용하면 컴포넌트는 오직 데이터가 성공적으로 받아와진 상황만을 고려해서 구현될 수 있습니다.
해당 컴포넌트에서 리소스를 불러오기 위해 사용하는 fetchProfileData라는 함수는 데이터 로딩시에 보여주어야 할 UI 로직을 Suspense로 넘기기 위해 다음과 같이 algebraic effect가 적용되어 있습니다.즉, 데이터가 로딩 중일때는 Suspense로 Promise를 throw 하도록 구현되어 있다는 의미입니다.
algebraic effect란?
대수적 효과 (Algebraic effects)는 프로그래밍 언어에서 효과적으로 부수 효과를 다루는 방법을 나타내는 현대적인 프로그래밍 개념입니다.
우선, '효과 (effects)'란 프로그램의 실행 중에 발생하는 외부와의 상호 작용을 의미합니다. 예를 들어 파일을 읽거나 쓰는 작업, 네트워크 통신, 예외 처리 등이 효과적인 작업에 해당합니다.
대수적 효과는 이러한 효과를 '대수적'으로 조작하는 방법을 제공합니다. 여기서 '대수적'은 수학적인 대수학의 개념에서 유래했습니다. 이 방법은 효과를 조합하고 구성하여 새로운 효과를 만들어내는 것을 중심으로 합니다.
흔히 사용되는 예로는 throw와 catch를 사용한 예외 처리입니다. 예외를 던지고 그에 대한 처리를 하는 것이 대수적 효과의 한 예입니다.
이러한 대수적 효과는 코드를 더 모듈화하고 재사용 가능하게 만들어주며, 복잡한 프로그램을 더 쉽게 구성하고 이해할 수 있게 도와줍니다. 또한, 테스트와 디버깅을 용이하게 만들어주는 장점이 있습니다
위에서 다뤘던 예제의 resource.posts.read()
메소드의 내부 구현은 다음과 같습니다.
export function fetchProfileData() {
let userPromise = fetchUser(); // 프로미스를 리턴
let postsPromise = fetchPosts();
return {
user: wrapPromise(userPromise),
posts: wrapPromise(postsPromise),
};
}
function wrapPromise(promise) {
let status = 'pending'; // 최초의 상태
let result;
// 프로미스 객체 자체
let suspender = promise.then(
(r) => {
status = 'success'; // 성공으로 완결시 success로
result = r;
},
(e) => {
status = 'error'; // 실패로 완결시 error로
result = e;
}
);
// 위의 Suspense For Data Fetching 예제에서의 read() 메소드입니다.
// 위 함수의 로직을 클로저삼아, 함수 밖에서 프로미스의 진행 상황을 읽는 인터페이스가 된다
return {
read() {
if (status === 'pending') {
throw suspender; // 펜딩 프로미스를 throw 하면 Suspense의 Fallback UI를 보여준다
} else if (status === 'error') {
throw result; // Error을 throw하는 경우 ErrorBoundary의 Fallback UI를 보여준다
} else if (status === 'success') {
return result; // 결과값을 리턴하는 경우 성공 UI를 보여준다
}
},
};
}
API 호출이 존재하는 컴포넌트는 렌더링이 매번 시도될때 마다 read()를 통해 결과값을 읽으려는 시도를 합니다. 그리고 읽어온 결과값이 throw된 Error나 pending 상태의 Promise, 혹은 정상적인 결과값이냐에 따라 어떤 UI를 보여줄지가 달라집니다. throw가 된 경우 컴포넌트에서는 상위 Suspense, ErrorBoundary의 fallback UI를 찾아 보여줍니다.
비동기 요청을 하는 컴포넌트는 read() 메소드가 리턴하거나 throw하는 값들을 통해 Supense, ErrorBoundary 컴포넌트와 상호작용하고 있음을 알 수 있는 예제였습니다.
1. Fetch-on-Render
2. Render-as-You-Fetch
결론 - Suspense를 사용하면 응답이 돌아올 때까지 기다렸다가 렌더링을 시작하지 않습니다.
read()와 Suspense, ErrorBoundary 컴포넌트가 어떻게 상호작용하는지를 d알아보았습니다.그러면 React는 어떻게 특정 컴포넌트 비동기 로직의 상태값을 계속 구독하면서 매번 렌더링 시도를 하는 걸까요?
우선 Concurrent React를 위한 Lane Model을 개발한 Andrew Clark의 Suspense에 대한 트윗을 확인해보면 Suspenserk 동작하는 방식을 다음과 같이 소개하고 있습니다.
Suspense는 개발자가 Data Loading에 대한 상태를 선언적으로 관리할 수 있는 방식을 제공하며, 이로 인해 메인 컴포넌트가 “데이터가 성공적으로 로드되었을 때”에 대한 상태만 신경 쓸 수 있게 도와주는 역할을 한다는 것을 살펴보았습니다.
Suspense의 로직을 크게 두가지로 부분으로 나누어 설명합니다.
첫 번째는 Scheduler에 의해 스케줄된 Task를 workLoop에서 처리할 때, Suspense와 Fallback Component, Child Component를 렌더링하는 부분이고, 두번째는 Data Fetching 시에 throw한 Promise를 처리하는 부분입니다.
workLoopConcurrent
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
// $FlowFixMe[incompatible-call] found when upgrading Flow
performUnitOfWork(workInProgress);
}
}
workLoopConcurrent() 함수의 역할은 '아직 처리해야 할 작업이 남아있고 스케줄러로부터 양보받을 필요가 없다면 계속해서 작업을 수행하라'입니다.
workInProgress가 null이 아닌 경우: 이 변수에는 현재 처리 중인 작업 유닛(또는 태스크)가 저장되어 있습니다. 이 변수가 null이 아니라면, 아직 처리해야 할 작업이 남아 있다는 의미입니다.
shouldYield() 함수가 false를 반환하는 경우: 이 함수는 스케줄러로부터 양보할 시간인지 여부를 판단합니다. 즉, 다른 우선 순위의 태스크에게 CPU 제어권을 넘겨줄 필요가 있는지 확인합니다.
workLoopConcurrent 함수는 Fiber 아키텍처에서의 비동기적인(concurrent) 워크 루프를 나타냅니다.
performUnitOfWork 함수를 호출하여 현재 유닛의 작업을 수행합니다.
performUnitOfWork와 beginWork
function performUnitOfWork(unitOfWork: Fiber): void {
... 생략
let next;
if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, renderLanes);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
} else {
next = beginWork(current, unitOfWork, renderLanes);
}
... 생략
ReactCurrentOwner.current = null;
}
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
... 생략
// Fall through
case SuspenseComponent:
return updateSuspenseComponent(current, workInProgress, renderLanes);
... 생략
}
React Reconciler에서는 스케줄러에 의해 스케줄된 Task를 workLoop를 돌면서 렌더링합니다. 이때 매 루프마다 작업되는 단위가 performUnitOfWork로 감싸진 beginWork인데, beginWork로 넘겨진 workInProgress의 tag가 "SuspenseComponent"인 경우, Suspense Component를 처리하기 위한 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)
) {
// Something in this boundary's subtree already suspended. Switch to
// rendering the fallback children.
showFallback = true;
workInProgress.flags &= ~DidCapture;
}
if (current === null) {
//아래 코드에서 설명 계속
}
React에서 Suspense 컴포넌트가 업데이트될 때 호출되는 함수인 updateSuspenseComponent를 보여줍니다. Suspense 컴포넌트는 리액트에서 비동기적인 작업을 수행할 때 로딩 상태를 관리하는데 사용됩니다.
argument
1.pendingProps 가져오기
workInProgress의 pendingProps 값을 가져와 nextProps에 저장합니다. pendingProps는 다음 렌더링 때 사용할 프로퍼티 값들이 담겨있습니다.
2.중단 여부 확인
(workInProgress.flags & DidCapture) !== NoFlags;
표현식을 통해 현재 작업이 suspend 상태인지 확인합니다.
3.fallback 모드 결정
여기서 showFallback은 fallback UI를 보여줄지 말지 결정하는 불린 값입니다. 초기값은 false
로 설정됩니다.
이렇게 코드는 다음 렌더링 때 사용할 프로퍼티 값을 가져오고, 컴포넌트가 이미 중단되었는지 여부를 확인하며, fallback 모드로 전환해야 하는지 결정합니다. Suspense 컴포넌트의 업데이트 시 중요한 역할을 합니다.
if (current === null) {
const nextPrimaryChildren = nextProps.children;
const nextFallbackChildren = nextProps.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,
);
}
}
React에서 Suspense 컴포넌트를 마운트하거나 업데이트할 때의 동작을 담고 있습니다. 여기서 Suspense 컴포넌트는 로딩 중이거나 실패 시 대체 콘텐츠를 처리할 때 사용됩니다.
cucurrent가 null인 경우
current가 null인 경우는 초기 마운트(initial mount)를 의미하며, 그 외의 경우는 업데이트 상황입니다.
초기 마운트 처리
showFallback이 true인 경우
pushFallbackTreeSuspenseHandler 또는 pushPrimaryTreeSuspenseHandler를 호출하여 적절한 Suspense 핸들러를 스택에 추가합니다.
로딩 상태 또는 에러 상태에 따라 적절한 처리를 수행하고 대체 콘텐츠를 렌더링합니다.
초기 마운트에서는 현재 상태가 없으므로 pushFallbackTreeSuspenseHandler(workInProgress);
를 호출하여 Suspense 처리 관련 핸들러를 설정하고, mountSuspenseFallbackChildren()
함수를 통해 Fallback 자식들을 마운트합니다.
그리고 현재 작업의 자식에 대한 참조(workInProgress.child)
를 가져와 그 메모이즈된 상태(memoizedState)
에 offscreen 상태를 설정합니다. (Offscreen이란 사용자에게 보이지 않는 상태에서 미리 처리하는 것을 의미합니다. 이로써 사용자에게 눈에 보이지 않으면서도 필요한 작업을 수행할 수 있어 성능을 향상시킬 수 있습니다.)
마지막으로 현재 작업의 메모이즈된 상태에 SUSPENDED_MARKER(일시 중단 마커)를 설정하고 Fallback 프래그먼트(Fragment)를 반환합니다.
showFallback이 false인 경우
pushPrimaryTreeSuspenseHandler(workInProgress)
를 호출 뒤,
mountSuspensePrimaryChildren
를 호출하여 컴포넌트를 렌더링 하도록 합니다.
요약
여기서 중요한 포인트는 showFallback이 true인 경우,
mountSuspenseFallbackChildren
을 호출하고, false인 경우 mountSuspensePrimaryChildren
을 호출합니다.
mountSuspenseFallbackChildren
function mountSuspenseFallbackChildren(
workInProgress: Fiber,
primaryChildren: $FlowFixMe,
fallbackChildren: $FlowFixMe,
renderLanes: Lanes,
) {
const mode = workInProgress.mode;
const progressedPrimaryFragment: Fiber | null = workInProgress.child;
const primaryChildProps: OffscreenProps = {
mode: 'hidden',
children: primaryChildren,
};
let primaryChildFragment;
let fallbackChildFragment;
primaryChildFragment = mountWorkInProgressOffscreenFiber(
primaryChildProps,
mode,
NoLanes,
);
fallbackChildFragment = createFiberFromFragment(
fallbackChildren,
mode,
renderLanes,
null,
);
primaryChildFragment.return = workInProgress;
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
workInProgress.child = primaryChildFragment;
return fallbackChildFragment;
}
mountSuspenseFallbackChildren의 역할은 실제로 Fallback UI와 Child Component를 모두 렌더링하는 역할을 합니다.
이때 Fallback UI는 createFiberFromFragment를 사용해서 렌더링하고, Child Component는 mountWorkInProgressOffscreenFiber를 사용해서 렌더링하는데, 이때 들어가는 primaryChildProps의 mode 속성이 'hidden'으로 설정됩니다. 이는 Suspense 렌더링에서 핵심적인 부분을 담당하는데, DOM에는 반영되지 않지만(실제로 해당 노드는 display:none!important가 걸린 div로 업데이트되어 DOM에 나타나지 않습니다.), 백그라운드에서 해당 컴포넌트가 렌더링되어 실제 Data fetching과 reconcilation 대상이 되는 것입니다.
mountSuspensePrimaryChildren
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;
}
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,
): Fiber {
const fiber = createFiber(OffscreenComponent, pendingProps, key, mode);
fiber.elementType = REACT_OFFSCREEN_TYPE;
fiber.lanes = lanes;
const primaryChildInstance: OffscreenInstance = {
_visibility: OffscreenVisible,
_pendingVisibility: OffscreenVisible,
_pendingMarkers: null,
_retryCache: null,
_transitions: null,
_current: null,
detach: () => detachOffscreenInstance(primaryChildInstance),
attach: () => attachOffscreenInstance(primaryChildInstance),
};
fiber.stateNode = primaryChildInstance;
return fiber;
}
mountSuspensePrimaryChildren는 주요 콘텐츠(primaryChildren)를 Offscreen으로 처리하여 Fiber 트리에 마운트하는 역할을 합니다. (Offscreen이란 사용자에게 보이지 않는 상태에서 미리 처리하는 것을 의미합니다. 이로써 사용자에게 눈에 보이지 않으면서도 필요한 작업을 수행할 수 있어 성능을 향상시킬 수 있습니다.)
주요 콘텐츠를 Offscreen으로 처리하므로써, primaryChildProps 객체를 생성하고, 그 안에 주요 콘텐츠를 담습니다.컴포넌트 렌더링을 위한 모든 데이터가 준비되고 충분한 렌더링 우선순위를 확보하게 되면, 이때, 모드를 'visible'로 설정하여 다시 mountWorkInProgressOffscreenFiber를 통해 ChildComponent를 렌더링합니다. (사용자에게 보여짐)
ChildComponent에서 데이터를 가져오는 Promise가 throw되면 Suspense가 이를 감지하고 resolve한 후, 제어권을 다시 ChildComponent로 넘기는 방식으로 동작합니다. 이 중간 과정인 throw된 Promise를 처리하는 부분을 아래에서 설명합니다.
renderRootSync 함수 안에 코드를 보게 되면,
do / wihle 문이 있습니다.
renderRootConcurrent
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
resumeOrUnwind: switch (workInProgressSuspendedReason) {
case SuspendedOnError: {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
break;
}
case SuspendedOnData: {
const thenable: Thenable<mixed> = (thrownValue: any);
if (isThenableResolved(thenable)) {
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
replaySuspendedUnitOfWork(unitOfWork);
break;
}
//생략 ...
if (__DEV__ && ReactCurrentActQueue.current !== null)
workLoopSync();
} else {
workLoopConcurrent();
}
break;
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
각 Fiber를 돌면서 렌더링을 처리하는 workLoopConcurrent라는 로직의 호출부를 try / catch로 한번 감싸고, do / while loop으로 한번 더 감싼 것을 확인할 수 있습니다.
workInProgressSuspendedReason !== NotSuspended( 현재 진행 중인 작업의 일시 중단 이유(workInProgressSuspendedReason)가 '중단되지 않음'(NotSuspended)) 과 workInProgress !== null 인 경우(현재 처리해야 할 작업이 실제로 존재하는지)
SuspendedOnError: 에러로 인해 suspend된 경우입니다.
SuspendedOnData: 데이터 대기로 인해 suspend된 경우입니다. 이때는 thenable 객체(즉, Promise와 같은 비동기 객체)가 resolve되어 있는지 확인하고, 만약 resolve되어 있다면 해당 유닛의 작업을 재실행 합니다.
개발 모드가 아니거나 Act Queue가 비어있는 경우: workLoopConcurrent() 함수를 호출하여 작업을 비동기적으로 처리합니다 workLoopConcurrent은 처음에 설명한 부분 입니다.
catch : handleThrow 함수를 실행합니다.
Suspense의 동작원리에 대해서 알아보았습니다.이제 Suspense가 데이터를 가져오는 중에 어떻게 동작하고, 데이터가 준비되면 어떻게 처리하는지에 대한 이해를 갖게 되었습니다.
React Suspense는 컴포넌트가 무언가를 렌더하기 위해 “wait”할 수 있도록 도와주며, 여기서의 “wait”은 데이터 로딩을 포함한 모든 비동기 요청이 해당될 수 있습니다.
(Error Boundary와 함께) Suspense를 사용하면 개발자는 컴포넌트에서 사용할 데이터가 “정상적으로 로드되었을 때”의 UI만 고려하면 되고, 로딩 중이나 에러의 상태에서의 UI는 위임하면 됩니다. (하나의 컴포넌트에서 각각의 상태에 대한 관리를 명령적으로 관리할 필요가 없다)
Suspense는 Fallback UI를 보여주는 동안에도 Child Component를 렌더한다(단지 보이지 않을 뿐.) 따라서 보다 Concurrent하게 데이터를 요청할 수 있고, 결과적으로 그렇지 않을 때 보다 더 나은 사용자 경험을 제공합니다.
Suspense가 Fallback UI를 보여주기 위해 사용하는 개념적인 방식은 “promise를 Throw하는 것이다.” promise가 throw되면 이 promise가 resolve되기 전까지 Fallback UI를 보여주고 resolve되면, hidden 상태였던 Child Component를 “보여준다”.
https://blog.mathpresso.com/conceptual-model-of-react-suspense-a7454273f82e
https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md