React에서 Suspense가 트렌드가 된 건 아무래도 React 18버전부터 아닐까 생각됩니다. 16버전부터 존재하긴 했지만 제한적으로 사용되었고 확장된 기능이 지원되기 시작한건 18 버전부터입니다. Suspense는 현재 안쓰이는 곳이 없다고 봐도 될 정도로 많은 곳에서 사용되고 있는 거 같습니다. React-Query, SWR 뿐만 아니라 Next.js 에서도 사용되니까요.
이번 시간에는 Suspense가 무엇이고 왜 이렇게 많은 곳에서 사용되는지 알아보도록 하겠습니다. 그러기 위해서는 먼저 Race Condition에 대해 살펴보겠습니다.
공유 자원에 대해 여러 개의 프로세스가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 말한다. 동시에 접근할 때 자료의 일관성을 해치는 결과가 나타날 수 있다. - 위키피디아 참고
운영체제 시간에도 배웠던 Race Condition(경쟁 상태)에 대한 문제를 알아보겠습니다.
자바스크립트 혹은 리액트에서 경쟁상태를 적용해보면, data fetching 같은 비동기 작업이 동시에 여러번 요청을 해서 그 결과를 하나의 DOM 객체에 반영한다고 생각해보면 될 거 같습니다. 단순히 생각하기에는 앞선 요청은 무시하고 마지막 요청에 대해서 보여주면 되지 않을까요? 하지만 실제로는 그렇게 동작하지 않습니다.
반드시 A 요청이 B 요청보다 먼저 수행되었다고 해서 무조건 A 요청에 대한 응답이 먼저 도착하는건 아니기 때문입니다.
간단하게 jsonplaceholder API를 사용해서 user 정보를 호출하는 코드입니다. 이 때, 임의로 delay를 줘보도록 하겠습니다. 네트워크 지연이라고 생각해보면 될 거 같습니다.
export async function fetchUser(userId: number, delay = 0) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
const user = (await response.json()) as User;
await new Promise((resolve) => setTimeout(resolve, delay)); // 임의 딜레이
return user;
}
그리고 나머지 부분은 아래처럼 구현했습니다.
import { useEffect, useState } from "react";
import { User, fetchUser } from "../../../lib/fetch";
function UserComponent({ id }: { id: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// delay random time (0~1000ms)
fetchUser(id, Math.random() * 1000).then((user) => setUser(user));
}, [id]);
if (!user) {
return <div>Loading user...</div>;
}
return (
<div>
<p>user id: {user.id}</p>
<p>username: {user.name}</p>
<p>email: {user.email}</p>
<hr />
<PostComponent id={id} />
</div>
);
}
export default function Playground() {
const [userId, setUserId] = useState(1);
return (
<div>
<h2>Race Condition 테스트</h2>
<button onClick={() => setUserId(userId + 1)}>Next User</button>
<UserComponent id={userId} />
</div>
);
}
테스트로 3번을 연달아서 클릭을 해봤습니다. 그랬더니 네트워크 호출은 2->3->4로 되었지만, 실제 화면에 나오는 결과는 4가 아닌 3이 나왔습니다. 이것이 바로 경쟁 상태에서 발생할 수 있는 문제라고 할 수 있습니다.
분명히 userId가 바뀌면 props가 바뀌는 것이기 때문에 UserComponent도 리렌더링되는 것은 맞습니다. 그리고 useEffect를 보면 dependency에 [id]를 넣어놨기 때문에 그 때마다 fetchUser가 실행되면서 user 정보를 호출하게 됩니다.
하지만, fetchUser가 끝나는 시점은 제각각 다르고 setUser도 그 때마다 다릅니다. 그래서 userId의 마지막 번호에 해당하는 데이터가 렌더링되는것이 아닌 fetchUser가 가장 오래걸려서 setUser가 가장 늦게 호출된 user 데이터로 바뀌게 될 것입니다.
동시에 진행될 수 있는 리렌더링 프로세스... 서로 겹칠 수도 있군요... 😭 신기한건 그렇다고 하더라도 set 은 리렌더링 동일성이 보장된다는 것입니다.
Suspense를 사용하지 않았을 때 나타나는 또 다른 현상은 바로 Waterfall 현상입니다. 흔히 폭포수 현상이라고 불립니다. 주로, 개발론을 설명할 때 빠지지 않고 나오는 개념인데 앞에 업무가 끝나야 뒤에 업무를 이어서 할 수 있는 구조를 말합니다.
이런 개념을 data fetching 관점에서 보면, 이전 요청에 대한 응답이 도착해야 다음 요청을 보낼 수 있는 구조를 의미합니다.
이 내용도 실습을 통해 알아보도록 하겠습니다. 말로 설명하는것보다 보는게 이해하기 빠를테니까요.
앞서 UserComponent 하위에 PostComponet를 생성해서 Post에 대한 정보도 불러오도록 만들어보겠습니다. 그러기 위해서 fetchUser와 동일하게 fetchPost를 만들어줍니다.
export async function fetchPost(postId: number, delay = 0) {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`
);
const post = (await response.json()) as Post;
await new Promise((resolve) => setTimeout(resolve, delay)); // 임의 딜레이
return post;
}
그리고 나서 PostComponent를 만들었습니다.
import { useEffect, useState } from "react";
import { Post, User, fetchPost, fetchUser } from "../../../lib/fetch";
function PostComponent({ id }: { id: number }) {
const [post, setPost] = useState<Post | null>(null);
useEffect(() => {
// delay random time (0~2000ms)
fetchPost(id, Math.random() * 2000).then((post) => setPost(post));
}, [id]);
if (!post) {
return <div>Loading post...</div>;
}
return (
<div>
<p>post id: {post.id}</p>
<p>title: {post.title}</p>
<p>body: {post.body}</p>
</div>
);
}
function UserComponent({ id }: { id: number }) {
...
return (
<div>
...
<PostComponent id={id} />
</div>
);
}
export default function PlaygroundFive() {
...
}
새로고침해보면 User 컴포넌트가 로드 된 후 이후에 Post 컴포넌트가 로드 되는 것을 알 수 있습니다.
이런 현상에 문제점은 상위 컴포넌트에서 데이터를 요청할 때 2초가 걸린다고 가정했을때, 하위 컴포넌트는 상위 컴포넌트가 2초동안 데이터를 불러오고 렌더링할 때까지 기다린 다음, 데이터를 요청할 수 있기 때문에 더 오랜시간이 걸린다는 것입니다.
근데 무조건 waterfall 현상은 고쳐야 한다? 이건 아닌거 같습니다. 경우에 따라서는 처음에 요청한 결과 데이터가 있어야 그 다음 데이터를 요청이 가능한 경우도 있지 않을까요? 🤔
참고 : https://velog.io/@pius712/useEffect-race-condition-다루기
useEffect cleanup 으로 race condition 해결할 수 있다고 합니다.
useEffect(() => {
console.log("PostComponent effect");
let isCancelled = false
fetchPost(id, Math.random() * 2000).then((post) => {
if (!isCancelled) {
setPost(post);
}
});
return () => {
console.log("PostComponent cleanup");
isCancelled = true
}
}, [id])
...
useEffect(() => {
console.log("UserComponent effect");
let isCancelled = false
fetchUser(id, Math.random() * 1000).then((user) => {
if (!isCancelled) {
setUser(user);
}
});
return () => {
console.log("UserComponent cleanup");
isCancelled = true
}
}, [id])
진짜로 race condition 문제가 해결된것을 알 수 있습니다.
로그를 보면 버튼을 클릭할때마다 cleanup이 실행된 이후 useEffect가 실행된다는 것을 알 수 있습니다.
이 과정에서 isCancelled 라는 boolean 값을 통해 isCancelled가 true 인 경우. 즉, cleanup 되는 경우는 set 함수가 실행되지 않도록 막아주는 역할을 하게 됩니다. 다시말해 바꾸려고 봤더니 이미 cleanup 된 이후인 경우이면 무시 하도록 하는 로직입니다. 처음에는 뭔 소리인지 이해가 잘 안되었는데 알고보니까 좀 신박하더군요.
하지만 이는 직관적이지 않고 디버깅하기도 어렵습니다.
Suspense를 사용하면 component 트리의 일부가 아직 표시되지 않은 경우 로딩 상태를 선언적(declaratively)으로 지정할 수 있습니다 - React 공식문서
의미론적으로 살펴봤을 때, Suspense 뜻 중에는 긴징감이라는 뜻이 있지만, 다른 뜻에는 미결, 미정 이라는 뜻이 있습니다. 리액트에서 Suspense는 후자에 뜻이 어울리는 듯합니다.
React팀은 몇 년 전에 Suspense의 한정판(제한된 버전)을 소개했습니다. 그러나 지원되는 유일한 사용 사례는 React.lazy를 사용한 코드 분할이었고 서버에서 렌더링할 때 전혀 지원되지 않았습니다. 리액트 18에서는 서버에서 Suspense 에 대한 지원을 추가하고 동시(concurrent) 렌더링 기능을 사용하여 기능을 확장했습니다.
결론적으로 Suspense를 사용하면 앞서 2가지 문제를 해결할 수 있습니다.
비동기 네트워크 요청도 Suspense가 알아서 인지해서 로딩 화면을 띄어주면 좋겠지만 이 부분은 자동으로 이루어지지 않습니다. 데이터를 요청 중인지 완료되었는지를 Suspense가 알 수 있도록 설정을 해주어야 합니다.
찾아보니까 wrapPromise가 가장 보편적으로 많이 사용되는거 같습니다. 리액트 공식문서에서도 보이고요. 하지만 실제 프로젝트에 사용 시에는 이렇게 사용하면 안된다고 합니다. 그리고 실제로는 React-Query나 SWR, Relay 과 같이 사용하는 것을 추천합니다.
// Suspense integrations like Relay implement
// a contract like this to integrate with React.
// Real implementations can be significantly more complex.
// Don't copy-paste this into your project!
export function wrapPromise<T>(promise: Promise<T>) {
let status = 'pending'; // 최초상태
let result: T;
let suspender = promise.then(
(r) => {
status = 'success'; // 성공으로 완결시 success로
result = r;
},
(e) => {
status = 'error'; // 실패로 완결시 error로
result = e;
}
);
return {
read() {
if (status === 'pending') {
throw suspender;
} else if (status === 'error') {
throw result;
} else if (status === 'success') {
return result;
}
},
};
}
다음은 상태 객체를 반환하는 함수입니다.
// suspenseProfileData.ts
import { fetchUser } from "../../../lib/fetch";
import wrapPromise from "../../../lib/wrapPromise";
export default function suspenseProfileData(userId: number) {
const userPromise = fetchUser(userId, Math.random() * 1000);
const postPromise = fetchPost(userId, Math.random() * 2000);
return {
userId,
user: wrapPromise(userPromise),
post: wrapPromise(postPromise),
};
}
이를 최종적으로 사용하는 코드입니다.
// Playground.ts
import { Suspense, useState } from "react";
import suspenseProfileData from "./suspenseProfileData";
const initialResource = suspenseProfileData(1);
type Props = {
resource: typeof initialResource;
};
function PostComponent({ resource }: Props) {
const post = resource.post.read();
if (!post) {
return <p>Empty Post Data</p>;
}
return (
<div>
<p>post id: {post.id}</p>
<p>title: {post.title}</p>
<p>body: {post.body}</p>
</div>
);
}
function UserComponent({ resource }: Props) {
const user = resource.user.read();
if (!user) {
return <p>Empty User Data</p>;
}
return (
<div>
<p>user id: {user.id}</p>
<p>username: {user.name}</p>
<p>email: {user.email}</p>
<hr />
</div>
);
}
export default function PlaygroundSix() {
const [resource, setResource] = useState(initialResource);
return (
<div>
<h2>Race Condition 테스트</h2>
<button
onClick={() => {
const nextUserId = resource.userId + 1;
setResource(suspenseProfileData(nextUserId));
}}
>
Next User
</button>
<Suspense fallback={<p>Loading user...</p>}>
<UserComponent resource={resource} />
<Suspense fallback={<p>Loading post...</p>}>
<PostComponent resource={resource} />
</Suspense>
</Suspense>
</div>
);
}
read 메서드로부터 리턴받은 값을 보고 suspense가 판단하여 그에 맞는 UI를 보여주게 됩니다. 아래는 전체 소스코드와 실제 테스트 해볼 수 있는 URL입니다.
확인해보시면 Race condition 문제가 해결된 것을 알 수 있는데요. 그렇다면 어떻게 해결될 수 있었던 걸까요?
이 부분에 대해 찾아봤는데 "응답이 오기 전에 즉시 state 설정을 한다" 라고 합니다. 그니까 Suspense 이전에는 응답이 온 다음에 setState를 해서 문제가 되었던 것인데, 이걸 응답이 오기 전에 setState를 한다... 라고 합니다. 오 뭔가 알 거 같습니다.
그리고 나서 데이터를 얻자마자 React는 <Suspense>
컴포넌트 내에 컨텐츠를 "fills in(주입)"합니다.
네. 해결되었습니다. 아래와 같이 소스 코드를 작성했을 때, waterfall 현상이 있었다면 user는 1초, post는 3초가 걸렸을 것입니다. 하지만, 실제 결과를 봤을 때 user는 1초, post도 1초만 걸렸습니다.
이것을 통해 알 수 있는 점은 컴포넌트 렌더링 전에 data fetching을 모아서 요청한다는 사실입니다. 그리고 이것을 Concurrent하게 요청했다고 볼 수 있습니다.
export default function suspenseProfileData(userId: number) {
const userPromise = fetchUser(userId, 1000);
const postPromise = fetchPost(userId, 2000);
return {
userId,
user: wrapPromise(userPromise),
post: wrapPromise(postPromise),
};
}
반대로 user는 2초, post는 1초에 딜레이를 주면 어떨까요? 이때는 user와 post 둘 다 2초 후에 나오게 됩니다. 음... 사실 처음에는 post가 먼저 나오겠지 생각했지만 Suspense 작동원리를 보면 이렇게 동작하지는 않는거 같습니다. 🤔
export default function suspenseProfileData(userId: number) {
const userPromise = fetchUser(userId, 2000);
const postPromise = fetchPost(userId, 1000);
return {
userId,
user: wrapPromise(userPromise),
post: wrapPromise(postPromise),
};
}
"Suspense 하위에 비동기 데이터 불러오기가 여러 개 있을 경우, Suspense는 마치 Promise.all 처럼 동작합니다." - 카카오페이 tech 블로그에 이렇게 적혀있더군요. 그런 내용을 종합해서 봤을 때, 하위에 post가 먼저 fetch가 완료되었어도 상위 user가 완료되기 전에 안보이는게 맞는거 같네요.
오늘은 다소 중요한 개념에 대해 알아봤습니다. Suspense 이전에 발생했던 문제 2가지와 Suspense를 사용하면 이를 해결할 수 있다는 것을 알게 되었는데요. 내부적으로 알아서 처리해주니 코드도 명령형이 아니라 선언형으로 작성하면서 더 깔끔해진거 같고 이해하기 쉬워진거 같습니다.
Suspense는 이제 선택이 아닌 필수... 아닐까 생각합니다.