한 10일 전쯤에 TodoList 과제를 끝낸 적이 있었습니다. 기간에 맞춰 기능 구현을 완료하긴 했지만, 추가적으로 Skeleton 구현을 하려고 했지만 구현하지 못했었습니다. 해야 하고 싶었던 기능을 구현하지 못해 찜찜한 기분을 가지고 있다가 그 기분을 털어내기 위해 이번에 구현을 완료했습니다.
Skeleton UI란 데이터를 가져오는 동안에 데이터의 위치를 나타내는 컴포넌트입니다. Skeleton UI를 사용함으로써 사용자는 응용 프로그램의 응답성이 더 빠르다고 느끼게 되고 스피너를 로드할 때 보다 더 빠르고 친화적이라고 느끼게 됩니다. 단, 앱의 크기가 더 커진다는 단점이 있습니다.
보통 Skeleton은 데이터를 불러오는 경우에 사용이 됩니다. React에서 fetch된 데이터를 기반으로 component가 생성된다고 했을 때, Skeleton을 나타내는 경우는 데이터가 fetch 되는 중 (loading)이라고 볼 수 있습니다.
React 공식문서에 보면 데이터를 가져오기 위한 기능에 대해 다룬 자료가 있고 Suspsnse 컴포넌트를 통해 데이터 처리가 하위 컴포넌트가 render 되지 않는다면 fallback 실행을 통해 loading 기능을 외부로 일임할 수 있습니다.
// 최대한 일찍 불러오기를 발동시킵니다
const promise = fetchProfileData();
function ProfilePage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState(null);
useEffect(() => {
promise.then(data => {
setUser(data.user);
setPosts(data.posts);
});
}, []);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<>
<h1>{user.name}</h1>
<ProfileTimeline posts={posts} />
</>
);
}
// 자식 컴포넌트들은 더 이상 불러오기를 발동시키지 않습니다
function ProfileTimeline({ posts }) {
if (posts === null) {
return <h2>Loading posts...</h2>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
Suspense를 사용하지 않았을 때 useEffect를 통해 데이터 fetch를 side Effect로 처리하고, 현재 컴포넌트에서 user의 state에 따라 반환하는 값을 다르게 설정했습니다.
하지만 위와 같은 방식은 data를 전부 받아올 때까지 대기해야 하고 프로젝트의 덩치가 커짐에 따라 컴포넌트 트리의 복잡도가 훨씬 커진다는 것입니다. 따라서 화면의 데이터에 사용될 데이터를 모두 불러온 다음에 렌더링 하는 것이 더욱 효과적인 선택지입니다.
Suspense를 사용하지 않았을 때 렌더링 되는 단계는 다음과 같습니다.
1. 불러오기 시작
2. 불러오기 완료
3. 렌더링 시작
Suspense를 사용하면, 불러오기를 먼저 시작하면서도 아래와 같이 마지막 두 단계의 순서를 바꿔줄 수 있습니다.
1. 불러오기 시작
2. 렌더링 시작
3. 불러오기 완료
const resource = fetchProfileData();
function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
// 아직 로딩이 완료되지 않았더라도, 사용자 정보 읽기를 시도합니다
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
function ProfileTimeline() {
// 아직 로딩이 완료되지 않았더라도, 게시글 읽기를 시도합니다
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
Suspense에 할당한 fallback의 실행은 하위 컴포넌트의 데이터들을 불러올 때. 즉 하위 컴포넌트가 렌더링 되는 도중에 fallback이 보이는 것입니다.
만약에 loading을 구현하고 싶다면 이 fallback에 들어갈 컴포넌트로
loading 컴포넌트를, Skeleton을 구현하고 싶다면 Skeleton 컴포넌트를 할당해 주면 됩니다.
React-Query 공식문서에 보면 Suspense를 사용하는 방법에 대해 잘 나와있습니다.
// Configure for all queries
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
})
function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
}
Query Client에서 Suspense 옵션을 true로 설정합니다. 그리고 suspense를 사용할 query 코드에서도 suspense 옵션을 true로 설정하면 사용이 가능합니다.
import { useQuery } from '@tanstack/react-query'
// Enable for an individual query
useQuery(queryKey, queryFn, { suspense: true })
그리고 useQuery를 통해 데이터를 가져오는 컴포넌트의 부모 컴포넌트를 Suspense로 감싼다면 데이터를 fetching 하는 동안에 fallback 화면에 render 될 것입니다.
저의 가장 큰 실책은 데이터를 받아오는 useQuery에 기본 옵션 즉 초깃값을 설정해 놓은 것이 가장 큰 실책이었습니다. 앞에서 언급했듯이 Suspense는 data를 fetching 하는 와중 즉, 데이터가 없는 경우에 fallback이 실행되는 것이데, 저는 useQuery에 초깃값을 설정했기 때문에 데이터의 값이 존재하게 되어 fallback이 실행되지 않고 데이터의 초깃값들이(빈 값) 화면에 렌더링 되는 문제가 있었습니다.
function useGetTodoDetail(id: string, token: string) {
return useQuery(["todos", id], () => getTodosDetail({ token, id }), {
initialData: initialResultData,
suspense: true,
});
}
즉, 기본 값 설정 -> data가 존재 -> Suspense에서 불러오기 완료가 바로 실행이 되어서 설정한 Skeleton UI가 생기지 않았던 것입니다.
initialData 옵션을 제거한 뒤 Suspense의 하위 컴포넌트로 TodoList를 설정했습니다. 하지만, 라우팅 되는 과정에서 렌더링 돼야 하는 페이지가 제대로 렌더링 되지 않고 계속해서 중첩적으로 페이지가 렌더링 되는 문제가 발생했습니다.
도저히 문제가 뭔지 몰라서 코드를 여러 번 끄적일 때, 한 가지 문제를 알았습니다. 렌더링 되는 페이지에 useQuery 코드가 존재하고, 반환되는 컴포넌트로 Suspense가 있었습니다. 즉, Suspense의 하위 컴포넌트에서 data의 fetching이 일어났어야 하는 것인데, data의 fetching이 이루어지고, 그 결괏값으로 Suspense의 tsx의 값이 반환된 것이었습니다.
function Todos() {
...
const { data } = useGetTodos();
useEffect(() => {
if (data) {
if (data.details) throw console.error(data.details);
else setTodos(data.data);
}
}, [data]);
...
return (
<Suspense fallback={Skeleton}>
<Title title="Tasks" size={"4.6rem"} />
<button onClick={logoutHandler}>로그아웃</button>
<CreateTodo token={token} />
<hr />
<TodoList todos={todos} />
<hr />
{id && <TodoDetail />}
</Suspense>
);
즉, 이미 Todos라는 페이지가 렌더링 되고, 렌더링의 결괏값으로 Suspense와 그 하위 컴포넌트들이 실행되어야 하는 것인데, 렌더링의 과정에서 이미 suspense 옵션이 적용된 useQuery가 실행되기 때문에 라우팅이 제대로 되지 않았던 것입니다.
따라서 Home이라는 페이지로 파일을 분리한 뒤, TodoList라는 컴포넌트를 리팩토링 하는 형식으로 관심사를 분리하고 Suspense를 적용했더니 제대로 작동했습니다.
function Home() {
const navigation = useNavigate();
const token = useGetToken();
const location = useLocation();
const id = splitPathName(location.pathname)[2];
const logoutHandler = () => {
localStorage.removeItem("token");
navigation("/auth/login");
};
return (
<>
<Title title="Tasks" size={"4.6rem"} />
<DefaultButton onClick={logoutHandler}>로그아웃</DefaultButton>
<CreateTodo token={token} />
<hr />
<Suspense fallback={<TodoListSkeleton />}>
<TodoList />
</Suspense>
<Suspense fallback={<DetailSkeleton />}>{id && <TodoDetail />}</Suspense>
</>
);
}
이다음에는 Modal 창 적용과 ErrorHandling, React-Query에서 caching 적용을 해봐야겠습니다.