들어가며
안녕하세요, pxd xe그룹에서 프론트엔드 개발을 하고 있는 김동규입니다.데이터 요청 상태에 따라 다르게 노출되는 UX/UI 설계는 많은 고민을 필요로 하는데요, 다행히 React는 이러한 고민과 문제들을 해결하기 위한 다양한 방법을 제공합니다. 오늘은 그중에서도 Suspense와 ErrorBoundary를 활용한 선언적 데이터 패칭에 대한 방법을 알아보고자 합니다.
지금부터 Suspense와 ErrorBoundary를 활용한 선언적 데이터 패칭이 어떻게 효과적인지 함께 알아보겠습니다.
전통적인 데이터 패칭 처리
전통적으로(?) 데이터 패칭은 컴포넌트 내부에서 data, loading, error의 상태들을 사용하여 반환되는 컴포넌트에 대한 분기 처리로 이루어졌습니다. 아래의 예시 코드는 비동기 요청으로 데이터를 받아와 사용자에게 전달하는 간단한 컴포넌트입니다.
const SampleContents = () => {
const [sampleDatas, setSampleDatas] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState();
useEffect(() => {
(async () => {
try {
setIsLoading(true); // isLoading 상태변경
const { data } = await queryFn(); // axios get 요청 예시 함수
setSampleDatas(data);
} catch (e) {
setError(e);
} finally {
setIsLoading(false); // 성공이든 실패이든 isLoading은 false
}
})();
}, []);
if (isLoading) <Loader />;
if (error) <Error error={error} />;
return (
<div>
{sampleDatas.map((data) => (
<Content data={data} />
))}
</div>
);
};
export default SampleContents;
비동기 함수 queryFn가 데이터 요청 동안 isLoading, error 상태에 따라 로딩 중일 땐 <Loader />를 반환하고 에러가 발생했을땐 <Error />를 분기처리하여 반환하여 사용자에게 정확한 정보를 전달합니다.
Tanstack React Query
React-Query는 서버 상태를 효과적으로 관리할 수 있는 캐싱 라이브러리입니다. React-Query API들이 제공하는 프로퍼티를 활용하면 좀 더 간편하게 비동기 로직을 처리할 수 있습니다.
const SampleContents = () => {
const {
data: sampleDatas,
isLoading,
error,
} = useQuery({ queryKey, queryFn });
if (isLoading) <Loader />;
if (error) <Error error={error} />;
return (
<div>
{sampleDatas.map(data => (
<Content data={data} />
))}
</div>
);
};
export default SampleContents;
React Query의 API useQuery가 반환하는 isLoading, error 통해 useEffect를 제거하고 useState 줄이는 등 컴포넌트 내부 로직을 좀 더 간소화하여 첫번째 예시와 마찬가지로 데이터 요청 상태에 따라 반환되는 컴포넌트 랜더링 여부를 작성할 수 있습니다.
다만, 간소화 한다하더라도 컴포넌트 상태 관련 로직이나 비동기 로직들 늘어나다보면 복잡해지는건 마찬가지입니다.
선언적 데이터 패칭
선언적 데이터 패칭은 React의 주요 개념인 '선언형 프로그래밍'에 기반합니다. 이는 상태의 변화에 따라 UI를 직접 조작하지 않고, 어떤 상태에 따라 어떤 UI가 보여져야 하는지만 집중할 수 있습니다. 이러한 접근법은 비동기 작업의 복잡성을 크게 줄여주며 코드의 가독성과 유지보수성을 향상시킵니다. Suspense와 ErrorBoundary는 이러한 선언형 프로그래밍 방식으로 비동기 작업과 에러 처리를 담당합니다. 우선 Suspense와 ErrorBoundary에 대해 간단하게 살펴보겠습니다.
Suspense
Suspense를 사용하면 자식이 로딩을 완료할 때까지 fallback을 표시할 수 있습니다. React는 자식에게 필요한 모든 코드와 데이터가 로드될 때까지 로딩 fallback을 표시합니다.
<Suspense fallback={<Loading />}>
<SampleContents />
</Suspense>
ErrorBoundary
ErrorBoundary는 하위 컴포넌트 트리에서 발생하는 에러를 캡처하여 fallback를 보여주거나 에러 리포팅 등의 작업을 수행합니다. 필요에 따라 커스터마이징을 할 수 있는데, ErrorBoundary의 커스터마이징에 대한 내용은 다음 기회에 또다른 포스팅으로 진행해보겠습니다.
추천 라이브러리
React-Query 와 Suspense, Error Boundary 사용하기
useQuery를 Suspense, ErrorBoundary와 함께 사용하려면 suspense: true 옵션을 넣어주면 됩니다.
const SampleContents = () => {
const { data: sampleDatas } = useQuery({ queryKey, queryFn, suspense: true }); // suspense 옵션
return (
<div>
{sampleDatas.map(data => (
<Content data={data} />
))}
</div>
);
};
const SampleWrapper = () => {
// ...
return (
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Loader />}>
<SampleContents />
</Suspense>
</ErrorBoundary>
);
};
Suspense는 컴포넌트가 준비될 때까지 대기하고 fallback을 보여주는 역할을 합니다. 이로 인해 로딩 상태 관리 로직이 컴포넌트 외부로 추출되어 각 컴포넌트에서 로딩 상태를 확인하고 처리할 필요가 없어집니다.
ErrorBoundary는 하위 컴포넌트 트리에서 발생하는 에러를 캡처하여 대체 UI를 보여주거나 에러 리포팅 등의 작업을 수행합니다. 이로 인해 각각의 컴포넌트에서 에러 처리 로직이 분산되지 않고 한 곳에서 관리됩니다.
위의 코드에서 볼 수 있듯이, Suspense와 ErrorBoundary를 사용하면 로딩 상태와 에러 처리 로직을 컴포넌트 외부로 분리하여 관리할 수 있습니다. 즉, 항상 데이터가 있다고 생각하고 컴포넌트를 구성할 수 있습니다. 이는 코드의 가독성을 높이고 유지보수를 용이하게 합니다.
만약, 모든 쿼리문에서 Suspense와 ErrorBoundary를사용할 것 이라면 QueryClient를 생성할때 기본 옵션으로 지정해 줄 수 있습니다.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
staleTime: 3000,
// ...
},
},
});
또한 ErrorBoundary는 하위 컴포넌트들의 에러들을 공통으로 처리할 수 있습니다.
const ChildComponent = () => {
// ...
return (
<Suspense fallback={<Loader />}>
<SampleContents />
</Suspense>
);
};
const ParentComponent = () => {
// ...
// 하위 컴포넌트에서 발생하는 error를 모두 캐치
return (
<ErrorBoundary fallback={<Error />}>
<ChildComponent />
<ChildComponent2 />
<ChildComponent3 />
</ErrorBoundary>
);
};
선언적 데이터 패칭을 할 때 의 주의사항
선언적 데이터 패칭은 많은 장점을 가지고 있지만, 몇 가지 주의사항도 존재합니다.
No Silver Bullet, 개발의 복잡성을 한 번에 해소할 수 있는 마법과 같은 '은탄환'은 존재하지 않는다.
1. 비동기 요청 Waterfall
Suspense로 감싼 컴포넌트 내에서 여러개의 useQuery를 사용하는 경우 비동기 요청 Waterfall이 발생합니다.
<Suspense fallback={<Loading />}>
<SampleContents /> // 3개의 query문이 있는 컴포넌트
</Suspense>
이에 대한 해결책으로 컴포넌트를 분리하여 하나의 컴포넌트에 하나의 쿼리를 사용하는 방법이 있으며
<Suspense fallback={<Loading />}>
<SampleContent1 /> // 1개의 query문 있는 컴포넌트
<SampleContent2 /> // 1개의 query문 있는 컴포넌트
<SampleContent3 /> // 1개의 query문 있는 컴포넌트
</Suspense>
최신 버전의 React Query를 사용한다면 useQuery대신 Promise.all을 반환하는 useQueries의 사용을 고려해 볼 만 합니다. useQueries는 비동기를 병렬로 처리해 줍니다.
2. Loader와 Skeleton의 사용자 경험에 대한 고민필요
데이터 패칭 시간이 짧은 경우 Loader나 Skeleton UI 같은 로딩 표시는 깜빡이는 현상처럼 보일 수 있어 오히려 부정확한 정보 전달 및 사용자 경험 저하로 이어질 수 있으므로 신중하게 고려해야 합니다.
const DeferredLoader = () => {
const [isDeferred, setIsDeferred] = useState(false);
useEffect(() => {
const id = setTimeout(() => {
setIsDeferred(true);
}, 200);
return () => clearTimeout(id);
}, []);
if (!isDeferred) {
return null;
}
return <Loader />;
};
짧은 시간(예시는 200ms) 내에 데이터가 들어오면 Loader나 Skeleton UI 대신 null을 반환하여 대체 화면을 그리지 않고 그 이상의 시간이 소요될 시 Loader나 Skeleton UI를 노출하는 위와 같은 방식도 고려할 만합니다.
결론
지금까지 Suspense와 ErrorBoundary를 활용한 선언적 데이터 패칭에 대해 알아봤습니다.
Suspense와 ErrorBoundary를 활용한 비동기 처리는 컴포넌트의 가독성, 유지보수 측면, 비즈니스 로직 분리 측면에서 상당한 이점이 있지만, 무분별한 Suspense, ErrorBoundary는 웹 성능 저하, 사용자 경험 저하를 일으킬 수 있으므로 장단점을 좀 더 고민하고 동작 원리에 기반한 적절한 사용 여부에 대한 탐구가 필요할 것 같습니다.
도움이 됐길 바라며, 긴 글 읽어주셔서 감사합니다!