children을 위한 Suspending
팀 프로젝트를 하면서 Suspense
를 처음 써보았는데, 따로 연결이나 등록하는 과정 없이 suspense 옵션만 useQuery
에 설정해주면 알아서 로딩을 감지한다는 게 신기해서 알아보고자 했다.
Suspense는 컴포넌트의 렌더링에 필요한 무언가를 대기할 수 있도록 하는 기능을 제공한다. 대기하는 동안 다른 컴포넌트를 렌더링할 수 있기 때문에 기존보다 유연하게 렌더링을 처리할 수 있다. 여기서 렌더링에 필요한 무언가란 코드(Lazy Loading)나 데이터(Fetching)를 의미한다.
기존에 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 />
</>
);
}
user
데이터가 없을 때는 조건문을 통해 일단 로딩 컴포넌트를 반환한다. 데이터 요청은 렌더링 과정의 마지막에 수행되는 useEffect
에서 이루어진다. 요청이 완료되면 setState
을 실행함으로써 재 렌더링을 유발하고 기존에 목적으로 하던 컴포넌트를 반환시킨다.
이렇게 렌더링 중에 데이터를 요청하는 방식은 waterfall 문제를 야기한다. 이는 원치 않게 데이터 간 의존 관계가 설정되는 것을 말한다. 위 예제에서 ProfilePage
의 하위 컴포넌트인 ProfileTimeline
은 상위 컴포넌트의 데이터 요청과 재 렌더링이 완료된 이후에야 렌더링을 시도할 수 있다. 만약 하위 컴포넌트도 데이터 요청을 수행해야 한다면 화면에 모든 정보가 완전하게 표시되는 시간은 두 배로 지연된다. 이는 컴포넌트가 더 중첩될수록 심각해지며, 하위 요소로 계속 전파되는 로딩은 UX 측면에서 안 좋을 수 밖에 없다.
개발 측면에서의 단점도 있는데 구현 방식이 명령적 이다. 컴포넌트 내부에서 데이터가 로딩 중인지, 받아왔는지, 오류가 났는지 모두 확인하는 로직을 작성해야 하고 모든 조건에 맞는 반환값도 따로 설정해주어야 한다.
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>
);
}
위 코드에 나온 resource
는 Suspense를 적용할 수 있도록 만들어진 데이터 Fetching 구현체를 개념적으로 나타낸 것으로 react-query
같은 라이브러리를 생각하면 된다.
이런 식으로 Suspense를 사용하면 데이터가 없거나 로딩 중일 때는 해당 컴포넌트의 렌더링을 건너뛰고 다른 컴포넌트를 먼저 렌더링한다. 그리고 가장 가까운 부모 Suspense
의 fallback
으로 지정된 컴포넌트를 화면에 대신 보여준다. fallback
은 보이는 것을 대신할 뿐 백그라운드에서 suspense된 컴포넌트들의 렌더링은 시도된다. 예제에서 ProfileDetail
이 로딩 중이더라도 ProfileTimeline
에게 렌더링 순서가 올 수 있으므로 두 데이터 요청은 병렬적으로 수행될 수 있다.
컴포넌트는 Suspense를 통해 로딩에 관한 책임도 위임할 수 있다. Suspense와 비슷하게 에러를 처리하는 ErrorBoundary
도 있는데, 둘을 같이 사용하면 컴포넌트는 오직 데이터가 성공적으로 받아와진 상황만을 고려해서 구현될 수 있다. 즉, 컴포넌트를 선언적으로 나타낼 수 있다.
Suspense는 아무 상황에서나 적용할 수 있는 것은 아니다. 위 예제에서 데이터 Fetching 역할로 갑자기 resource
라는 구현체를 사용한 것처럼 데이터 요청이 Suspense에 맞는 방식 으로 구현되어 있어야 한다. 그 방식은 바로 Promise
를 throw
하는 것이다.
throw
는 Exception 던지는 역할 아니었나?
throw
는 try...catch
구문과 결합해서 에러를 잡는 용도로 쓰인다. throw
이후 코드는 실행되지 않고 콜 스택에서 가장 가까운 catch
구문으로 제어가 이동되어 던져진 Exception
객체를 받아 처리할 수 있다.
여기서 인상 깊게 볼 특성은 제어의 이동 과 객체 전달 이다. 사실 throw
는 Exception
이 아니라도 Object 기반의 객체면 모두 던질 수 있다. React는 이를 활용해서 Suspense를 구현했다. 컴포넌트의 렌더링 과정에서 데이터의 로딩이 필요한 경우 Fetching을 수행하는 Promise
를 throw해서 제어권을 상위로 넘겨준다. 던져진 Promise
는 resolve 됐을 때 스케쥴러에 다시 등록되도록 Work Loop에서 처리한다.
Suspense
가 try...catch
기반으로 구현되어 있으므로 데이터 Fetching도 Promise
를 throw
하는 방식으로 구현되어야 작동하는 것이다.
outer: do {
try {
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
// The work loop is suspended. We need to either unwind the stack or
// replay the suspended component.
const unitOfWork = workInProgress;
const thrownValue = workInProgressThrownValue;
resumeOrUnwind: switch (workInProgressSuspendedReason) {
case SuspendedOnError: {
// Unwind then continue with the normal work loop.
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
throwAndUnwindWorkLoop(unitOfWork, thrownValue);
break;
}
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;
}
// The work loop is suspended on data. We should wait for it to
// resolve before continuing to render.
const onResolution = () => {
// Check if the root is still suspended on this promise.
if (
workInProgressSuspendedReason === SuspendedOnData &&
workInProgressRoot === root
) {
// Mark the root as ready to continue rendering.
workInProgressSuspendedReason = SuspendedAndReadyToContinue;
}
// Ensure the root is scheduled. We should do this even if we're
// currently working on a different root, so that we resume
// rendering later.
ensureRootIsScheduled(root);
};
thenable.then(onResolution, onResolution);
break outer;
}
/*** 생략 ***/
} else {
workLoopConcurrent();
}
break;
} catch (thrownValue) {
handleThrow(root, thrownValue);
}
} while (true);
do...while
문 내부에서 try...catch
로 감싸져 Work Loop를 수행하고 있다. 내부에서 throw가 이루어진 경우 catch
문의 handleThrow
함수를 통해 던져진 값을 처리하도록 되어있다.
function handleThrow(root: FiberRoot, thrownValue: any): void {
// A component threw an exception. Usually this is because it suspended, but
// it also includes regular program errors.
//
// We're either going to unwind the stack to show a Suspense or error
// boundary, or we're going to replay the component again. Like after a
// promise resolves.
//
// Until we decide whether we're going to unwind or replay, we should preserve
// the current state of the work loop without resetting anything.
//
// If we do decide to unwind the stack, module-level variables will be reset
// in resetSuspendedWorkLoopOnUnwind.
/*** 생략 ***/
if (thrownValue === SuspenseException) {
// This is a special type of exception used for Suspense. For historical
// reasons, the rest of the Suspense implementation expects the thrown value
// to be a thenable, because before `use` existed that was the (unstable)
// API for suspending. This implementation detail can change later, once we
// deprecate the old API in favor of `use`.
thrownValue = getSuspendedThenable();
workInProgressSuspendedReason =
shouldRemainOnPreviousScreen() &&
// Check if there are other pending updates that might possibly unblock this
// component from suspending. This mirrors the check in
// renderDidSuspendDelayIfPossible. We should attempt to unify them somehow.
!includesNonIdleWork(workInProgressRootSkippedLanes) &&
!includesNonIdleWork(workInProgressRootInterleavedUpdatedLanes)
? // Suspend work loop until data resolves
SuspendedOnData
: // Don't suspend work loop, except to check if the data has
// immediately resolved (i.e. in a microtask). Otherwise, trigger the
// nearest Suspense fallback.
SuspendedOnImmediate;
throw된 값인 thrownValue
가 SuspenseException
이라는 Suspense를 위한 특별한 Exception일 경우 workInProgressSuspendedReason
이라는 flag를 SuspendedOnData
로 설정해서 Promise가 resolve될 때까지 work loop가 suspend될 수 있도록 처리한다.
Thenable
then
메서드를 구현한 객체들을 의미한다. 이는 JS에promise
가 완전히 정립되기 전 여러 버전의 구현체들이 존재했고 이들을 호환시키기 위해 thenable 인터페이스를 정의했다고 한다.
// Handle suspense
if (shouldSuspend(defaultedOptions, result, isRestoring)) {
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}
// Handle error boundary
if (
getHasError({
result,
errorResetBoundary,
useErrorBoundary: defaultedOptions.useErrorBoundary,
query: observer.getCurrentQuery(),
})
) {
throw result.error
}
Suspense와 Error Boundary를 처리하는 부분에서 throw
를 사용하고 있다. fetchOptimistic
은 Promise 객체를 반환하므로 Suspense의 경우 Promise를 throw 하도록 구현되어 있다.
export const fetchOptimistic = (
/*** 생략 ***/
) =>
observer
.fetchOptimistic(defaultedOptions)
.then(({ data }) => {
defaultedOptions.onSuccess?.(data as TData)
defaultedOptions.onSettled?.(data, null)
})
.catch((error) => {
errorResetBoundary.clearReset()
defaultedOptions.onError?.(error)
defaultedOptions.onSettled?.(undefined, error)
})
function lazyInitializer<T>(payload: Payload<T>): T {
if (payload._status === Uninitialized) {
const ctor = payload._result;
const thenable = ctor();
// Transition to the next state.
// This might throw either because it's missing or throws. If so, we treat it
// as still uninitialized and try again next time. Which is the same as what
// happens if the ctor or any wrappers processing the ctor throws. This might
// end up fixing it if the resolution was a concurrency bug.
thenable.then(
moduleObject => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const resolved: ResolvedPayload<T> = (payload: any);
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
error => {
if (payload._status === Pending || payload._status === Uninitialized) {
// Transition to the next state.
const rejected: RejectedPayload = (payload: any);
rejected._status = Rejected;
rejected._result = error;
}
},
);
if (payload._status === Uninitialized) {
// In case, we're still uninitialized, then we're waiting for the thenable
// to resolve. Set it as pending in the meantime.
const pending: PendingPayload = (payload: any);
pending._status = Pending;
pending._result = thenable;
}
}
if (payload._status === Resolved) {
const moduleObject = payload._result;
/*** 생략 ***/
return moduleObject.default;
} else {
throw payload._result;
}
}
Suspense의 대상이 될 수 있는 Lazy Loading 역시 throw
를 사용한다. payload
의 상태가 Uninitialized
도 Resolved
도 아닌 경우 thenable
이 들어가 있을 payload._result
를 throw한다.
뭔가 대단한 방법을 쓰고 있는 건가! 했는데 결론은 역시 JS였다.
그동안 우리가
throw
를 Exception 전달용으로만 배워왔다는 건 다른 데이터 전달용으로 쓰는 게 바람직하지 않다는 합의가 암묵적으로든 있던 거라고 생각한다. 그럼에도 Promise를 throw해서 Suspense를 구현할 발상을 한 사람이 있었다는 게 대단하다.