thumbnail

React Suspense + React-Query 으로 로딩 처리하기

생성일2023. 12. 6.
태그
작성자지한솔

눈감고 물드세요..
눈감고 물드세요..
프론트엔드 개발자로써 일을하다보면 이쁜 UI개발을 자주하게 됩니다. 저도 아무래도 유저에게 보여주는 화면이다 보니 이쁜 UI를 제공하고자 많은 고민을 하게 됩니다.
하지만 이쁜 UI가 좋은 UX일까? 라는 내용에 대해서는 “아니다” 라고 말할 수 있습니다. 위에 짤에서도 볼 수 있는 정말 이쁜 컵인데, 물을 마시려면 눈을 포기해야 하는 상황이 생길수도 있습니다. 나에게 만약 저런 컵이 있다고 가정할때 모르고 마셨던 처음 이외에 또 저 컵을 사용할 일이 있을까요? 그만큼 “경험”은 정말 중요합니다.
내가 웹사이트에서 좋은 경험을 갖고있다면 다시 방문할 확률이 높아지고, 그것은 직관적으로 일방문자수, 월방문자수 에 반영될 것입니다. 만약 그 웹사이트가 커머스라면 재방문은 곧바로 매출에 영향을 주게 될 것입니다.

React-Query 를 사용해 로딩 핸들링 하기

React-Query 는 우리에게 data fetch 라이브러리로 따로 설정하지 않아도 isLoading 과 isError등을 통해 핸들링 할 수 있게 도와주는 라이브러리로 친숙할겁니다.
기존에 있던 코드에서는 API의 Fetch를 유저가 인식하기 어려울 정도로 빨랐기 때문에 별도의 로딩처리를 진행하지 않고 있었습니다. 하지만 복잡한 쿼리를 내는 (ex. 매출) 같은 페이지에서 쿼리의 시간이 점차 소요되는 현상이 발생했고, 별다른 로딩이 없는 화면이라 유저가 지금 로딩상태인지를 파악하기 어려워지게 되었습니다.
따라서 React-Query 에서 제공해주는 suspense 옵션을 사용해 콘텐츠가 로드되는 동안 대체로 로딩 아이콘을 보여주는 작업을 진행하게 되었습니다.
해당 포스팅에서는 React-Query의 v4와 v5를 비교하면서 왜 v5로 버전업을 진행했고, 어떻게 사용했는지에 대해 작성해볼 생각입니다. (따로 Suspense를 다루지 않습니다. React Suspense 공식문서)

v4로 Suspense 구현하기

현재 사용하는 React-Query의 버전이 4.0.10 버전으로 4버전 중에서도 낮은 버전이였습니다. (4의 마지막 버전은 v4.35.6)
v4의 React-Query에서는 에서는 suspense의 옵션을 제공하고 있습니다. (suspense 공식문서)
suspense에서는 로딩 상태를 처리하기 때문에 사실상 QueryFn의 return data는 undefined 의 값을 갖고있지 않다고 확신할 수 있습니다.
따라서 쿼리의 데이터를 커스텀훅으로 분리해서 suspense옵션을 준 QueryFn의 return 을 타입 단언(as) 를 통해 확실한 데이터를 전달해주는 구조로 구현을 하였습니다.
예시는 wait을 props로 받아 wait 초 뒤에 return 하는 간단한 쿼리를 구현하였습니다.
import { UseQueryResult, useQuery } from "@tanstack/react-query"; export const UseV4SuspenseQuery = (wait: number): [string, UseQueryResult] => { const query = useQuery({ queryKey: ["wait", "suspense", wait], queryFn: async () => { await new Promise((resolve) => { setTimeout(resolve, wait); }); return `${wait}초 경과.`; }, suspense: true, // suspense 옵션 }); return [query.data as string, query]; // 타입 단언을 통해 string으로 고정 };
이렇게 작업한 query를 받아오는 컴포넌트를 Suspense로 감싸주면 query가 완료될때까지 Fallback 설정한 컴포넌트로 대체할 수 있습니다.
import { Suspense } from "react"; import { ReactNode, Suspense } from "react"; import { UseV4SuspenseQuery } from "/hook/v4/useV4SuspenseQuery"; const WaitQueryComponent = ({ wait, children }: { wait: number; children?: ReactNode }) => { const [data] = UseV4SuspenseQuery(wait); return ( <> <div>경과 시간: {data}</div> </> ); }; const Loading = () => <div>loading...</div> const QueryV4Suspense = () => { return ( <div> <Suspense fallback={<Loading />}> <WaitQueryComponent wait={2000} /> </Suspense> <Suspense fallback={<Loading />}> <WaitQueryComponent wait={1000} /> </Suspense> <Suspense fallback={<Loading />}> <WaitQueryComponent wait={5000} /> </Suspense> </div> ); }; export default QueryV4Suspense;
컴포넌트 랜더 결과
컴포넌트 랜더 결과
이렇게 원하는 대로 API가 완료하기 전까지 loading… 라는 내용을 화면에 보여주고 있습니다. 이렇게 우리는 유저에게 데이터가 아직 완료되지 않았다면 완료되지 않았다는 로딩 상태를 보여줄 수 있는 것입니다.
이러한 로딩 처리는 유저에게 좋은 사용자 경험을 줄 수 있을 것입니다.
그렇다면 왜 저희는 React-Query v5로 버전업을 진행했을까요? 위 내용을 그대로 사용해도 문제가 없을 것 같긴 했지만, 2가지가 마음에 걸렸습니다.

1. 타입 단언(as)를 통한 타입 지정

return [query.data as string, query]; // 타입 단언을 통해 string으로 고정
저희팀은 suspense를 통해 완료 상태를 체크하면 ‘당연히 QueryFn 에서 return 해준 string의 타입만 나오겠지?’ 라는 생각을 하였지만 useQuery 에서의 QueryFn의 return은 항상 undefined 를 포함하고 있었습니다. 이 문제를 해결하기 위해 타입 단언(as)를 사용했지만 무언가 깔끔하지 않은 코드형태가 마음에 걸렸습니다.

2. React-Query v4 docs의 경고

NOTE: Suspense mode for React Query is experimental, same as Suspense for data fetching itself. These APIs WILL change and should not be used in production unless you lock both your React and React Query versions to patch-level versions that are compatible with each other.
React-Qeury v4에서 suspense탭에서는 위와같은 NOTE가 최상단에 나와있습니다. “실험적” 이란말과 프로덕트에서 사용하지 말라는 내용이 너무 마음에 걸렸습니다.
그렇게 저희는 v4에서 v5로 버전업을 진행하게 되었습니다.

v5에서 suspense 사용하기

v5에서는 useQuery에서 suspense 옵션이 사라지고 useSuspenseQuery 라는 훅이 새로 생겼습니다. 사용법은 useQuery와 동일하지만 useSuspenseQuery 훅은 QueryFn의 return값에 undefined가 없습니다.
import { UseQueryResult, useQuery } from "@tanstack/react-query"; export const UseV5SuspenseQuery = (wait: number): [string, UseQueryResult] => { const query = useSuspenseQuery({ queryKey: ["wait", "suspense", wait], queryFn: async () => { await new Promise((resolve) => { setTimeout(resolve, wait); }); return `${wait}초 경과.`; }, }); return [query.data, query]; // undefined 타입 x };
타입 단언을 사용하지 않아서 코드가 깔끔해보이는 것이 딱 필요한 내용이 추가된 것 같습니다.
버전을 올리면서 조금 더 깔끔한 코드가 되었지만, 아직 한가지 불편함점이 있습니다. suspense를 사용하다보니 fetch 가 완료되지 않는 모든 상태일때 로딩을 보여주게됩니다.
300ms처럼 빠른 API에도 loading 상태를 보여줘야 할까요? 관련 아티클 에서도 1초 미만의 API는 로딩효과를 주지 않는게 좋다고 합니다.
// 카카오페이 아티클 https://tech.kakaopay.com/post/skeleton-ui-idea/ import { PropsWithChildren, useEffect, useState } from "react"; const DeferredComponent = ({ children }: PropsWithChildren) => { const [isDeferred, setIsDeferred] = useState(false); useEffect(() => { const timeoutId = setTimeout(() => { setIsDeferred(true); }, 300); return () => clearTimeout(timeoutId); }, []); if (!isDeferred) { return null; } return <>{children}</>; }; export default DeferredComponent;
위 코드를 사용해 Loading 컴포넌트를 보여주기 전 300ms 이내에는 보여주지 않는 상태를 구현할 수 있습니다. 이렇게 구현한 로딩 컴포넌트는 유저에게 좋은 웹사이트 경험을 주게될 것입니다.
저희도 관리자 페이지를 만들면서 테이블 형태의 UI가 늘어나고 있는데요, 테이블에 들어갈 데이터가 많아지면서 API 시간이 조금씩 늘어나고 있습니다. 그렇기 때문에 기존에 못느꼈던 유저의 불편한 경험을 하나씩 느끼면서 고치고 있는데, 이미 React-Query 라는 상태 관리 패턴 라이브러리를 사용하고 있기도 하니 제공해주는 메소드나, 옵션들을 통해서 로딩을 처리해 보는것도 좋겠다는 생각에서 작업을 진행하게 되었습니다.
결과적으론 React-Query 라이브러리와 React.Suspense 를 사용한 간단한 로딩 컴포넌트 구현이였지만, 유저의 경험적 측면에서 큰 도움이 될 수 있을 거라 생각한 작업이였습니다.
많은 분들이 useQuery의 isLoading 등과 같은 상태값을 토대로 아래와 같이 로딩을 처리하곤 합니다.
const TestComponent = () => { const { isLoading } = useQuery({ queryKey: ["wait", "suspense", wait], queryFn: async () => { await new Promise((resolve) => { setTimeout(resolve, wait); }); return `${wait}초 경과.`; }, }); if(isLoading) return <>loading</> return <>TestComponent</> }
위 방법이 틀린건 아니지만, v4 useQuery의 suspense 옵션, v5 useSuspenseQuery 등의 기능이 있고, React.Suspense와 같이 사용하면 컴포넌트 단위의 로딩 처리를 유연하게 할 수 있다는 정보 정도는 알고 있다면 도움이 될 것이라 생각합니다.
감사합니다.