Suspense란?
Suspense라는 React의 신기술을 사용하면 컴포넌트의 랜더링을 어떤 작업이 끝날 때까지 잠시 중단 시키고 다른 컴포넌트를 먼저 랜더링할 수 있습니다. 이 작업이 꼭 어떠한 작업이 되어야 한다는 특별한 제약 사항은 없지만 아무래도 REST API나 GraphQL을 호출하여 네트워크를 통해 비동기로 데이터를 가져오는 작업을 가장 먼저 떠오르게 됩니다.
비동기로 데이터를 읽어오는 것은 예전에 클래스로 컴포넌트를 작성하던 시절부터 훅을 사용하는 요즘까지도 항상 필요한 일이지만 React로 직접 구현하기에는 까다로운 면이 있습니다. 그래서 일반적으로 데이터 로딩을 전문적으로 하는 라이브러리나 프레임워크에서 제공하는 로더에 의존하는 경우가 많습니다.
Suspense는 어떤 컴포넌트가 읽어야 하는 데이터가 아직 준비가 되지 않았다고 리액트에게 알려주는 새로운 매커니즘입니다. Suspense를 통해 컴포넌트가 비동기 데이터를 읽어오는 방법을 표준화하고자 리액트 팀의 장기적인 계획도 엿볼 수 있습니다. Suspense는 얼핏 보기에는 작은 아이디어처럼 보이지만 리액트 개발 패러다임을 바꿀 정도로 파급력이 큰 기능이라고 생각합니다.
기본 분법
<UserList />
기본적으로는 리액트는 JSX코드 안에 들어있는 모든 컴포넌트를 즉시 호출하여 바로 랜더링을 진행합니다. 위 코드의 경우도 UserList함수를 바로 호출할 것입니다.
<Suspense fallback={<Spinner />}>
<UserList />
</Suspense>
하지만 위와같이 Suspense로 컴포넌트를 감싸주면, 컴포넌트의 랜더링을 특정 작업 이후로 미루고, 그 작업이 끝날 때까지는 fallback속성으로 넘긴 컴포넌트를 대신 보여줄 수 있습니다.
Suspense사용 전
아마도 현재 가장 흔하게 사용되는 방법은 클래스 기반 컴포넌트를 사용할 때는 생명주기함수인 componentDidMount()구현하는 것이고, 함수형 컴포넌트를 사용할 때는 useEffect()훅 함수를 호출하는 것일 것입니다. API호출하여 네트워크를 통해 데이터를 가져오는 처리는 컴포넌트에서 발생할 수 있는 대표적인 Side Effect이기 때문입니다.
/src/before/Main.jsx
import User from "./User";
function Main() {
return (
<main>
<h2>Suspense 미사용</h2>
<User userId="1" />
</main>
);
}
export default Main;
/src/before/User.jsx
import { useState, useEffect } from "react";
import Posts from "./Posts";
function User({ userId }) {
const [loading, setLoading] = useState(true);
const [user, setUser] = useState([]);
useEffect(() => {
fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
.then((response) => response.json())
.then((user) => {
setTimeout(() => {
setUser(user);
setLoading(false);
}, 3000);
});
});
if (loading) return <p>사용자 정보 로딩중...</p>;
return (
<div>
<p>{user.name} 님이 작성한 글</p>
<Posts userId={userId} />
</div>
);
}
export default User;
먼저 최상위 컴포넌트인 <Main />은 단순히 제목을 표시해주고 <User />컴포넌트에 userId props을 "1"을 넘겨주고 있습니다.
부모 컴포넌트인 <User />는 API를 호출하여 가져온 데이터에서 사용자 이름과 이메일을 추출하여 보여주고 있습니다
/src/before/Posts.jsx
import { useState, useEffect } from "react";
function Posts({ userId }) {
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`)
.then((response) => response.json())
.then((posts) => {
setTimeout(() => {
setPosts(posts);
setLoading(false);
}, 3000);
});
});
if (loading) return <p>글목록 로딩중...</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.id}. {post.title}
</li>
))}
</ul>
);
}
export default Posts;
자식 컴포넌트인 <Posts />도 역시 API를 호출하여 가져온 데이터에서 글 아이디와 글 제목을 추출하여 보여주고 있습니다.
이 두개의 컴포넌트는 공통적으로 크게 두 가지 역할을 담당하고 있습니다. 첫 번째는 비동기로 API를 호출하여 원격에 있는 데이터를 가져오는 것입니다, 두 번째는 데이터 수신 상태에 따라 알맞는 UI를 제공하는 것입니다.
React에서 이처럼 비동기 데이터를 읽어오는 컴포넌트를 작성하면 몇가지 고질적인 문제가 발생하는 것으로 알려져 있습니다.
우선 최종 사용자(end user) 경험 측면에서 UI마치 폭포(waterfall)처럼 순차적으로 나타나는 현상이 나타날 수 있습니다. 이 waterfall현상은 특시 한 페이지 상의 여러 컴포넌트에서 동시에 비동기 데이터를 읽어오는 경우 자주 발생하게 됩니다. 상위 컴포넌트의 데이터 로딩이 끝나야지만 하위 컴포넌트의 데이터 로딩이 시작될 수 있기 때문에 주로 발생합니다.
뿐만 아니라 이렇게 초기 랜더링 후에 데이터 로딩 후 다시 랜더링을 수행하는 방법은 경쟁 상태(race conditions)에도 취약한 것으로 알려져 있습니다. 비동기 통신은 반드시 요청한 순서대로 데이터가 응답된다는 보장이 없기 때문에 의도치 않게 싱크가 맞지 않는 데이터를 제공할 수도 있습니다.
마지막으로 개발 측면에서도 이렇게 if조건문을 사용하여 어떤 컴포넌트를 보여줄지를 제어하는 명령형(imperative)코드에 가깝기 때문에 선언적(declarative)코드를 지향하는 React의 기본 방향성과 맞지 않게 느껴지게 됩니다. 기본적으로 데이터 로딩과 UI랜더링이라는 두 가지 전혀 다른 목표가 하나의 컴포넌트 안에 커플링(coupling)되어 코드가 읽기 어려워지고 테스트를 작성하기도 힘들어집니다.
Suspense 사용 후
먼저 API를 호출하여 비동기로 데이터를 가져오는 코드를 별도의 함수로 빼내겠습니다.
/src/after/fetchData.js
function fetchUser(userId) {
let user = null;
const suspender = fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
)
.then((response) => response.json())
.then((data) => {
setTimeout(() => {
user = data;
}, 3000);
});
return {
read() {
if (user === null) {
throw suspender;
} else {
return user;
}
}
};
}
function fetchPosts(userId) {
let posts = null;
const suspender = fetch(
`https://jsonplaceholder.typicode.com/posts?userId=${userId}`
)
.then((response) => response.json())
.then((data) => {
setTimeout(() => {
posts = data;
}, 3000);
});
return {
read() {
if (posts === null) {
throw suspender;
} else {
return posts;
}
}
};
}
function fetchData(userId) {
return {
user: fetchUser(userId),
posts: fetchPosts(userId)
};
}
export default fetchData;
이 함수는 컴포넌트에서 필요한 데이터를 제공하는 user와 posts속성을 담고있는 객체를 반환합니다. read()함수는 데이터 수신 중에는 suspender변수에 저장되어 있는 API를 호출하는 코드를 반환하고, 데이터 수신이 완료되면 데이터를 반환합니다.
이제 <Main />컴포넌트 안에서 <User />컴포넌트를 <Suspense />컴포넌트로 감싸주겠습니다. 기존에 <User />컴포넌트 안에 있던 로딩 시 보여줄 컴포넌트가 fallback속성으로 넘어갑니다. 그리고 <User />컴포넌트에는 prop으로 사용자 아이디 대신 데이터를 가져오기 위한 함수의 호출이 사용됩니다.
/src/after/Main.jsx
import { Suspense } from "react";
import User from "./User";
import fetchData from "./fetchData";
function Main() {
return (
<main>
<h2>Suspense 사용</h2>
<Suspense fallback={<p>사용자 정보 로딩중...</p>}>
<User resource={fetchData("1")} />
</Suspense>
</main>
);
}
export default Main;
이제 <User />컴포넌트 안에서는 prop으로 넘어온 resource로 부터 사용자 데이터를 읽어올 수 있습니다. 그리고 <Posts />컴포넌트를 사용할 때 마찬가지로 <Suspense />로 감싸줍니다.
/src/after/User.jsx
import React, { Suspense } from "react";
import Posts from "./Posts";
function User({ resource }) {
const user = resource.user.read();
return (
<div>
<p>
{user.name}({user.email}) 님이 작성한 글
</p>
<Suspense fallback={<p>글목록 로딩중...</p>}>
<Posts resource={resource} />
</Suspense>
</div>
);
}
export default User;
<Posts />컴포넌트 안에서도 마찬가지로 마찬가지로 resource로 부터 글목록 데이터를 읽어올 수 있습니다.
/src/after/Posts.jsx
function Posts({ resource }) {
const posts = resource.posts.read();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.id}. {post.title}
</li>
))}
</ul>
);
}
export default Posts;
이렇게 코드를 변경해주면 <User />컴포넌트와 <Posts />컴포넌트 간의 waterfall현상이 사라지고 거의 동시에 화면에 나타나는 것을 확인하실 수 있으실 겁니다.
코드 측면에서도 데이터 로딩과 UI랜더링이 완전히 분리되어 코드 가독성과 유지 보수성이 향상 될겁니다.
'Web > NextJs' 카테고리의 다른 글
[ Next.js - DeepDive ] - REACT18 - Sever Components - Parallelism (0) | 2022.08.13 |
---|---|
[ Next.js - DeepDive ] - REACT18 - Sever Components 2 (0) | 2022.08.13 |
[ Next.js - DeepDive ] - REACT18 - Sever Components (0) | 2022.08.13 |
[ Next.js - DeepDive ] - REACT18 - Suspense (0) | 2022.08.12 |
[ Next.js - DeepDive ] - Data Fetching Reca (0) | 2022.08.12 |