웹 서비스에서 데이터를 fetch 하는 데이는 어느정도의 시간이 걸릴 수 밖에 없고, 사용자들은 이를 기다린 후에 UI에서 데이터를 확인할 수 있다.
이 시간동안 화면에서 아무것도 볼 수 없다면 사용자들은 답답함을 느끼게 될 것이다.
해당 시간동안 스켈레톤 컴포넌트를 보여줌으로써 데이터를 불러오고 있다는 것을 UI 상으로 보여줄 수 있다.
그럼 데이터를 불러오고 있는 중이라는 것을 상태로 나타내야 한다.
fetch 메소드를 사용하고 있다면 useState로 로딩 상태를 관리해야 하고, tanstack query같은 라이브러리를 사용한다면, 이가 제공해주는 isLoading 상태로 관리한다.
이도 문제는 없지만 삼항 연산자나 if 문으로 로딩상태를 관리해야 하므로 코드가 좀 지저분해 질 수 있다.
리액트는 로딩 상태를 간단히 관리할 수 있게 Suspense를 제공해준다.
Suspense로 로딩 상태 UI에 보여주기
리액트로 프론트 작업을 하면 컴포넌트 단위 작업을 많이 하게 된다.
Suspense를 사용하면 평소 하는 컴포넌트 단위 작업 처럼 비동기 작업의 로딩 처리를 처리할 수 있다.
사용법은 매우 간단하다.
<Suspense fallback={loading UI}>
...
</Suspense>
... 부분에는 데이터를 fetch하는 동작과 관련된 코드가 들어가게 된다.
데이터를 파일 내의 Suspense 외부에서 호출하고 해당 데이터를 사용해야 하는 코드가 들어갈 수도 있지만, 아예 데이터를 외부 컴포넌트에서 호출하고 해당 외부 컴포넌트를 선언해주어도 된다.
Suspense의 children이 성공적으로 rendering되기 위해 비동기 작업이 완료되기 전에 가장 가까운 Suspense boundary를 찾게 된다. 그럼 Promise를 catch한 Suspense가 자동으로 해당 children코드가 렌더링 될 자리에 fallback 속성에 정의되어 있는 로딩 UI를 보여주게 된다.
가독성이 매우 좋아질 뿐더러, 자주 사용하는 부모/자식 형태로 로딩 상태를 구현할 수 있어 매우 편리하다.
서버 컴포넌트에서의 Suspense 동작(NEXT 프레임워크를 사용하고 있다면)
NEXT 프레임워크는 기본적으로 Suspense를 지원하고 있다.
만약 서버 컴포넌트에서 데이터를 호출하고 있다면, 다른 조건 없이 위 설명대로만 해주면 된다.
나는 기록 페이지에서 현재까지 유저가 스크랩한 데이터를 가져온다.
<Suspense fallback={<PlaceScrappedSkeleton />}>
<PlaceScrapList />
</Suspense>
PlaceScrapList라는 서버 컴포넌트에서 데이터를 불러오고 있고, 해당 컴포넌트 전체를 Suspense를 감싸주었다.
export default async function PlaceScrapList() {
const scrappedPlace = await GetPlaceScrapped();
return (
...
위와 같이 비동기로 데이터를 불러오는 동안 해당 컴포넌트는 가장 가까운 Suspense boundary 즉, 기록 페이지에 선언한 Suspense를 찾고, fallback에 선언해 준 스켈레톤 컴포넌트를 띄우게 된다.
위와 같이 데이터 fetch가 완료 되기 전에 내가 만들어준 스켈레톤 컴포넌트가 나타나는 것을 볼 수 있다.
일반적인 React (혹은 NEXT의 클라이언트 컴포넌트)에서의 Suspense 동작
클라이언트 컴포넌트에서도 크게 다를거는 없다.
다만 위와 똑같이 코드를 짜주면 fallback UI가 뜨지 않을 것이다.
SSR동작 방식이 아닌 일반적인 React, 혹은 클라이언트 컴포넌트 내에서 데이터를 fetch 하고 있다면(테스트 결과 Route Handler도 마찬가지였다) 데이터를 이용해 띄울 컴포넌트는 lazy-loading 처리 되어있어야 한다.
나는 NEXT 프로젝트의 검색 기능을 클라이언트 컴포넌트에서 검색 결과를 Route Handler를 통해 fetch한 후 UI에 띄워주도록 구현하였다.
<Suspense fallback={<HomeSearchSkeleton />}>
...
{textSearchPlaceData.spaceList.map((place) => (
<div key={place.id + place.type} className="mb-[4rem]">
<PlaceInfoCard
{...place}
...
</Suspense>
위는 검색 페이지 코드의 일부이다.
클라이언트 컴포넌트에서 Route Handler를 통해 textSearchPlaceData를 fetch하고 있다.
해당 결과를 이용하여 PlaceInfoCard 컴포넌트를 UI에 띄워야 한다.
이 때, 데이터가 클라이언트 컴포넌트(혹은 일반 React 서비스) 에서 fetch되고 있으므로 다음과 같이 PlaceInfoCard 컴포넌트를 lazy-loading 처리 해줘야 한다.
const PlaceInfoCard = lazy(() => import("@feature/place/components/PlaceInfoCard/PlaceInfoCard"));
그러면 위와 같이 마찬가지로 데이터 fetch가 완료되기 전, Suspense의 fallback이 잘 나타나는 것을 확인할 수 있다.
지양해야 하는 Suspense pattern
이처럼 Suspense는 비동기 동작의 로딩 상태를 매우 편하게 관리할 수 있다.
다만, 무분별하게 사용하다가 오히려 서비스의 성능 저하를 불러올 수 있다.
지양해야 하는 패턴: 1개의 Suspense 내 1개의 컴포넌트에서 여러 api를 호출하는 pattern
이와 같은 패턴으로 Suspense를 사용하면 network waterfall 로 인해 병목 현상이 발생할 수 있다.
Suspense를 효율적으로 사용하기 위해서는 1개의 컴포넌트에서는 무조건 하나의 api만, 혹은 여러개의 네트워크를 다음과 같은 방식으로 병렬적으로 호출해야한다.
const [record, member] = await Promise.all([recordData, memberData]);
나는 Promise.all 을 통해 2개의 api를 병렬적으로 호출한 후, 하나의 Suspense로 처리를 하고 있다.
서비스에 따라 Suspense를 2개로 분리하여 먼저 호출이 완료된 데이터부터 띄우는 식으로 구현을 할 수도 있지만, 내 서비스에서는 한번에 띄우는 것이 더 자연스러워 보였기 때문에 Suspense를 1개만 사용하였다.
비동기 처리에서 무조건 fallback을 띄우는게 사용자 경험에 좋을까에 대한 고민
로딩 UI를 사용자에게 보여주는 것은 사용자 경험에 있어서 매우 중요한 부분이다.
하지만 사용자의 네트워크 상황 등 여러 변수로 인해 데이터가 호출 완료되는 시간이 모두 제각각일 것이다.
만약 매우 짧은 시간에 데이터 호출이 완료되는 상황에서 fallback을 보여준다면 오히려 부자연스러운 현상이 발생할 수도 있다.
이에 대해 고민하던 와중, 카카오페이 기술 블로그에서 아주 좋은 블로그를 발견했다.
참고
이 블로그에서도 역시 해당 상황에 대해 부자연스러운 점을 지적하고 있고, 비동기 작업의 완료가 특정 시간 이상 걸릴때만 fallback UI를 사용자에게 보여줄 수 있도록 처리하고 있었다.
나도 해당 블로그를 참고하여 setTimeout 메소드를 통해 200ms가 지날 때 까지도 데이터 fetch가 완료되지 않았다면 Suspense의 fallback을 띄우고, 200ms 만큼 fallback이 지연될 때 동안 데이터 fetch가 완료되었다면 fallback에 null을 전달하도록 구현하였다.
"use client";
import { ReactNode, useEffect, useState } from "react";
export default function UseDeferredComponent({
children,
}: {
children: ReactNode;
}) {
const [isDeferred, setIsDeferred] = useState(false);
useEffect(() => {
// 200ms 지난 후 children Render
const timeoutId = setTimeout(() => {
setIsDeferred(true);
}, 200);
return () => clearTimeout(timeoutId);
}, []);
if (!isDeferred) {
return null;
}
return children;
}
fallback 요소를 위의 컴포넌트로 감싸줄 것이다.
children prop에 fallback 요소가 들어오게 되고, 만약 데이터 호출이 200ms 전에 완료되었다면 children이 return되기 전에 데이터가 보여질 것이다. 당연히 이후 fallback에는 null이 들어가게 되는 방식이다.
<Suspense
fallback={
<UseDeferredComponent>
<PlaceScrappedSkeleton />
</UseDeferredComponent>
}
>
<PlaceScrapList />
</Suspense>
그 후 위와 같이 Suspense의 fallback 요소를 위의 컴포넌트로 감싸주었다.
물론 위의 방식도 한계는 있다.
만약 특정 사용자의 환경에서는 해당 데이터 호출이 200ms 보다 아주 살짝 오래걸린다면, 마찬가지로 매우 짧은 시간 fallback UI를 보여주며 부자연스러운 동작을 실행할 것이다.
각각 서비스의 성능 지표를 파악하여 가장 효율적으로 로딩 상태를 UI에 띄워주는 방법에 대해 고민해보는 것이 필수적일 것 같다!
'FrontEnd > React' 카테고리의 다른 글
React 에서 Context API 효율적으로 사용하기 (0) | 2024.04.04 |
---|---|
프론트엔드(React) 클린 코드로 나아가기 (0) | 2024.03.29 |
서비스 내에 나만의 지도 띄우기 (Feat : NAVER MAP) (4) | 2024.02.27 |
React-Transition-Group 으로 애니메이션 주입하기 (0) | 2024.02.18 |
ref 속성 파헤치기 (4) | 2024.02.10 |