Suspense를 사용하면 컴포넌트의 렌더링을 어떤 작업이 끝날 때까지 잠시 중단시키고 다른 컴포넌트를 먼저 렌더링시킬 수 있습니다. Rest API를 호출하여 네트워크를 통해 비동기로 데이터를 가져오는 작업을 할 때 사용할 수 있습니다.
비동기로 데이터를 읽어오는 것은 React로 직접 구현하기에는 까다로운 면이 있어 데이터 로딩을 전문으로 하는 라이브러리나 프레임워크에서 제공하는 데이터 로더에 의존하는 경우가 많습니다.
Suspense는 어떤 컴포넌트가 읽어야 하는 데이터가 아직 준비가 되지 않았다고 리액트에게 알려주는 새로운 매커니즘입니다. Suspense를 통해 컴포넌트가 비동기 데이터를 읽어오는 방법을 표준화하고자 리액트 팀의 장기적인 계획도 엿볼 수 있습니다. Suspense는 얼핏 보기에는 작은 아이디어처럼 보이지만 개인적으로 앞으로 리액트 개발 패러다임을 바꿀 수 있을 정도로 파급력이 큰 기능이라고 생각합니다.
기본적으로 리액트는 JSX 코드 안에 들어있는 모든 컴포넌트를 즉시 호출하여 바로 렌더링을 진행합니다.
예를 들어, 리액트는 다음과 같이 <UserList />
컴포넌트가 포함된 JSX 코드를 렌더링할 때, UserList
함수를 바로 호출할 것 입니다.
<UserList />
하지만 컴포넌트를 아래와 같이 Suspense로 감싸주면 컴포넌트의 렌더링을 특정 작업 이후로 미루고, 그 작업이 끝날 때까지는 fallback
속성으로 넘긴 컴포넌트를 대신 보여줄 수 있습니다.
<Suspense fallback={<Spinner />}>
<UserList />
</Suspense>
물론 컴포넌트가 렌더링 되기 전에 구체적으로 어떤 작업이 일어나야 하는지는 UserList
함수 안에 명시가 되어 있을 것입니다.
먼저 그 동안 우리가 리액트에서 비동기 데이터를 읽어와야 하는 컴포넌트를 어떻게 작성해왔는지 되돌아보겠습니다.
함수형 컴포넌트를 사용할 때 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;
부모 컴포넌트인 <User />
는 API를 호출하여 가져온 데이터에서 사용자 이름과 이메일을 추출하여 보여주고 있습니다.
// src/before/User.jsx
import { userState, 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>
)
}
자식 컴포넌트인 도 역시 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.title}
</li>
})}
</ul>
)
}
export default Posts
이 두개의 컴포넌트는 공통적으로 크게 두 가지 역할을 담당하고 있는데요. 첫 번째는 비동기로 API를 호출하여 원격에 있는 데이터를 가져오는 것이고, 두 번째는 데이터 수신 상태에 따라 알맞은 UI를 제공하는 것입니다.
React에서 이처럼 비동기 데이터를 읽어오는 컴포넌트를 작성하면 몇가지 고질적인 문자가 발생하는 것으로 알려져있는데요.
우선 최종 사용자(end user) 경험 측면에서 UI가 마치 폭포처럼 순차적으로 나타나는 현상이 나타날 수 있습니다. 이 waterfall 현상은 특히 한 페이지 상의 여러 컴포넌트에서 동시에 비동기 데이터를 읽어오는 경우 자주 발생할 수 있는데요. 상위 컴포넌트의 데이터 로딩이 끝나야지만 하위 컴포넌트의 데이터 로딩이 시작될 수 있기 때문에 주로 발생하게 됩니다.
뿐만 아니라 이렇게 초기 렌더딩 후에 데이터 로딩 후 다시 렌더링을 수행하는 방법은 경쟁 상태에도 취약한 것으로 알려져있는데요. 비동기 통신은 반드시 요청한 순서대로 데이터가 응답된다는 보장이 없기 때문에 의도치 않게 싱크가 맞지 않은 데이터를 제공할 수도 있습니다.
마지막으로 개발 측면에서도 이렇게 if
조건문을 사용하여 어떤 컴포넌트를 보여줄지를 제어하는 것은 명령형 코드에 가깝기 때문에 선언적 코드를 지향하는 React의 기본 방향성과 맞지 않게 느껴지고요. 기본적으로 데이터 로딩과 UI 렌더링이라는 두 가지 전혀 다른 목표가 하나의 컴포넌트 안에 커플링되어 코드가 읽기가 어려워지고 테스트를 작성하기도 힘들어집니다.
동일한 코드를 이번에는 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;
})
});
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()
함수는 데이터 수신 중에는 suspenser
변수에 저장되어 있는 API를 호출하는 코드를 반환하고, 데이터 수신이 완료되면 데이터를 반환합니다.
이제 <Main />
컴포넌트 안에서 <User />
컴포넌트를 <Suspense />
컴포넌트로 감싸주겠습니다. 기존에 <User />
컴포넌트 안에 있던 로딩 시 보여줄 컴포넌트가 fallback
속성으로 넘어감니다. 그리고 <User />
컴포넌트에는 prop으로 사용자 아이디 대신에 데이터를 가져오기 위한 함수의 호출이 사용됩니다.
//scr/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 />
로 감싸줍니다.
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>}>
<Post resource={resource} />
</Suspense>
</div>
);
}
export default User;
<Post />
컴포넌트 안에서도 마찬가지로 resource
로 부터 글목록 데이터를 읽어올 수 있습니다.
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;