이번 포스팅에서는 React v18에서 정식 기능으로 릴리즈 된 Suspense에 대해 알아보자.
React v18이 3월 말에 릴리즈가 되었으니 포스팅이 조금 늦은 감이 있지만.. 안하는것 보다는 낫지 뭐 ^0^
Suspense는 React v16에 처음 등장해서 v18 정식 릴리즈 전까지 실험적인(experimental) 기능으로서 존재했다.
18버전 이전의 Suspense는 서브 트리의 일부 구성 요소가 아직 렌더링할 준비가 되지 않은 경우 fallback UI를 지정할 수 있었다. 이러한 특성 때문에 code spliting에 많이 적용되었다.
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
// 해당 번들 파일이 다운로드 되어 로딩이 완료될때까지 Spinner 노출
<React.Suspense fallback={<Spinner />}>
<div>
<OtherComponent />
</div>
</React.Suspense>
);
}
위와 같이 import에 React.lazy와 Suspense를 함께 적용하면, 번들 파일을 페이지 단위로 나누어 필요한 코드만 내려받을 수 있다.
기존의 Suspense는 코드를 Lazy Loading 하는데에 목적이 있었다.
18버전 부터는 이러한 기능이 data fetching에도 확대 적용되었다. 이제는 코드 스플리팅 뿐만 아니라 API의 결과값을 기다리는데도 사용할 수 있다.
코드를 통해 바로 살펴보자.
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>
);
}
출처 : react 공식 문서
Suspense로 비동기 통신이 있는 컴포넌트로 감싸주고, data fetching이 완료되기 전에 보여줄 fallback UI를 전달한다.
children 컴포넌트인 ProfileDetails, ProfileTimeLine는 항상 데이터가 준비된 시점에 렌더되기 때문에 컴포넌트 내부에서 비동기 통신의 완료 여부를 체크하지 않아도 된다.
이렇게 비동기 처리에 대한 책임을 Suspense에 위임함으로써 컴포넌트 내부에서는 본 로직에만 집중할 수 있다. 로딩 상태를 분기하는 코드가 빠진만큼 코드도 더 짧아지고, 코드를 더 선언적으로 작성할 수 있다는 장점도 있다.
지금까지 Suspense의 추가된 기능과 적용하는 방법에 대해 살펴보았으니, 이제부터는 내부 동작 Suspense의 원리에 대해 더 파헤쳐보자.
앞서 이야기했듯 Suspense에서는 비동기 상태에 따라 fallback UI 혹은 children 컴포넌트를 띄워준다.
이는 곧 Suspense는 하위 children 컴포넌트들의 비동기 상태를 감지할 수 있다는 소리인데, 어떻게 알 수 있는걸까?
핵심은 하위 컴포넌트에서 Promise를 throw 해주는 것이다.
아래 코드는 React 코어 팀에서 Suspense로 비동기를 감지하는 과정 설명을 위해 작성한 컨셉 코드이다.
컨셉 코드이므로 실제의 구현에서 바로 쓰기에는 무리가 있지만, 아래의 wrapPromise 함수를 보면 children 컴포넌트에서 어떻게 parent 컴포넌트인 Suspense와 소통하는지에 대한 힌트를 얻을 수 있다.
function wrapPromise(promise) {
let status = "pending";
let response;
const suspender = promise.then(
(res) => {
status = "success";
response = res;
},
(err) => {
status = "error";
response = err;
}
);
const read = () => {
switch (status) {
case "pending":
throw suspender;
case "error":
throw response;
default:
return response;
}
};
return { read };
}
export default wrapPromise;
wrapPromise는 promise를 한번 감싸서 promise가 "pending" 혹은 "error" 상태이면 상위로 throw하고, 데이터가 준비된 시점에는 response를 return한다.
이렇게 promise 상태에 따라 상위로 throw함으로써 상위에 존재하는 Suspense, ErrorBoundary 컴포넌트와 커뮤니케이션 할 수 있는 것이다.
promise를 throw하다니 정말 엄청난 발상의 전환인 것 같다.
그러면 실제 비동기 통신 코드에 wrapPromise 함수를 적용해보자.
import React from "react";
import wrapPromise from "./wrapPromise";
const fetchData = (url) => {
const promise = fetch(url)
.then((res) => res.json())
.then((res) => res.data);
return wrapPromise(promise);
}
const resource = fetchData(
"https://run.mocky.io/v3/d6ac91ac-6dab-4ff0-a08e-9348d7deed51"
);
const UserWelcome = () => {
const userDetails = resource.read();
return (
<div>
<div>Fetch completed.</div>
<div>
Welcome <span className="user-name">{userDetails.name}</span>
</div>
</div>
);
};
export default UserWelcome;
UserWelcome 컴포넌트는 매번 렌더될때 마다 read 함수를 통해 결과값을 읽으려고 시도한다.
그럼 wrapPromise 함수에서는 "pending" 혹은 "error" 상태인 경우 상위의 Suspense 혹은 ErrorBoundary로 해당 promise를 throw하고, 만약 정상 종료된 "fulfilled" 상태인 경우 정상 UI를 보여준다.
이제 하위 컴포넌트와 상위 컴포넌트가 어떻게 의사소통을 하는지는 알아냈다. 마지막으로 Suspense는 어떻게 throw 상태에 따라 UI를 결정해서 내려줄 수 있는지 살펴보면 모든 비밀이 풀릴 것 같다.
Suspense 코드를 살펴보자. 구현을 위해서는 throw된 promise를 Suspense 내에서 catch해야 하기 때문에 class 컴포넌트를 사용했다.
import React from "react";
export interface SuspenseProps {
fallback: React.ReactNode;
}
interface SuspenseState {
pending: boolean;
error?: any;
}
function isPromise(i: any): i is Promise<any> {
return i && typeof i.then === "function";
}
export default class Suspense extends React.Component<
SuspenseProps,
SuspenseState
> {
public state: SuspenseState = {
pending: false
};
public componentDidCatch(catchedPromise: any) {
if (isPromise(catchedPromise)) {
this.setState({ pending: true });
catchedPromise
.then(() => {
this.setState({ pending: false });
})
.catch((err) => {
this.setState({ error: err || new Error("Suspense Error") });
});
} else {
throw catchedPromise;
}
}
public componentDidUpdate() {
if (this.state.pending && this.state.error) {
throw this.state.error;
}
}
public render() {
return this.state.pending ? this.props.fallback : this.props.children;
}
}
Suspense 컴포넌트는 내부적으로 pending 여부를 판단하는 state를 갖는다.
이후 componentDidCatch 메서드를 통해 throw된 promise가 error 상태면 상위 ErrorBoundary 컴포넌트로 다시 throw하고, pending의 경우 fallback UI를 렌더하고, fulfilled 상태가 되면 children 컴포넌트를 렌더한다.
전체 코드는 여기서 확인하실 수 있습니다 :)
이렇게 Suspense의 비밀에 대해서 알아보았다. 항상(사실 항상은 아님) Suspense를 사용하며 어떻게 내부 컴포넌트들의 promise 상태를 감지하는건지 궁금했었는데 이번 포스팅을 계기로 또 조금 더 알게 되었다.
요즘 프레임워크나 라이브러리들이 제공해주는 magical한 기능 뒤편에 숨어있는 로직들을 살펴보는데 흥미가 있다. 추상화되어 마법처럼 제공되는 멋진 기능들의 뒷 단에 짜여진 복잡한 로직들을 살펴보다보면 오히려 더 마법처럼 느껴진다. 결국 자바스크립트를 더 잘해야겠다는 생각을 많이 하게 된다.
언젠가 이런 멋진 로직들을 직접 구현하는 사람이 될 수 있을까? 잠시 생각해 봤는데 안될 것 같아서 적어도 원리를 알고 쓰는 사람은 되어야겠다 생각하며 포스팅을 마친다. 끝!
이 포스팅은 해당 글을 참고하여 작성하였습니다.
https://blog.logrocket.com/react-suspense-data-fetching/
https://tech.kakaopay.com/post/react-query-2/
https://maxkim-j.github.io/posts/suspense-argibraic-effect/
궁금해서 글 남겨요 +_+..
저런 경우, wrapPromise 가 페칭이 이루어지는 동안 계속 여러번 호출이 되지 않나요??