내가 나타나 볼게 얍!

오늘처음해요·2025년 9월 14일
post-thumbnail

안녕하세요, 오늘은 프론트엔드 비동기 처리 핵심 3가지 중 하나인 폴백 처리에 대한 글입니다. 요즘 프로젝트를 하는데 다양한 fallback 처리 방법을 고민하고, 하나씩 적용해보면서 장단점을 정리해봤습니다.

우선 fallback UI를 왜 써야하는지 간단하게 알아보겠습니다.
웹 성능 측정 지표 중 가장 중요한 CWV 중 CLS라는 항목이 있습니다.

Cumulative Layout Shift는 쉽게 설명하면 페이지 렌더링 이후 얼마나 이동하는지에 대한 측정 지표입니다.

만약에 사용자가 A라는 버튼을 누르고 싶었는데, 광고가 갑자기 렌더링되어 B라는 버튼을 누르게 된다면 UX가 크게 떨어지게 됩니다.

즉, CLS가 높으면 사용자가 의도하지 않는 동작을 유발할 가능성이 높아지게 됩니다.

이를 줄이기 위해, 네트워크 지연이 있는 데이터 UI는 바로 비워두지 않고 Fallback UI를 먼저 보여준 뒤 실제 데이터를 채워 넣는 방식을 씁니다.

이렇게 하면 화면 구조가 갑자기 변하지 않아 CLS 안정성은 물론이고 LCP와 UX도 개선됩니다.

다양한 Fallback 구현 방법들

1. isLoading 및 useEffect 사용

우선 굉장히 나이브하게 구현하는 방법부터 함께 보겠습니다.

isLoading이라는 상태를 만들고, useEffect를 활용해서 데이터를 패칭할 때는 스켈레톤을 보여주면 됩니다.

export default function UserInfo() {
  const [userInfo, setUserInfo] = useState<User[] | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    let alive = true;
    setIsLoading(true);
    fetchUsers()
      .then((u) => alive && setUserInfo(u))
      .finally(() => alive && setIsLoading(false));
    return () => {
      alive = false;
    };
  }, []);

  if (isLoading) return <div>로딩 중… (스켈레톤 UI)</div>;
  ... 에러 처리 ...

  return (
    ... UI 처리 ...
  );
}

다만 이렇게 되면, 비동기처리를 하는 다양한 컴포넌트에서 중복된 폴백 처리 코드를 작성하게 됩니다.

2. 커스텀 훅 및 DataState

데이터를 패칭하는 비즈니스 로직을 커스텀 훅으로 만들어서 분리해줍니다.
View는 View의 역할만 할 수 있게 headless 패턴을 사용합니다.

useUserInfo => 데이터 패칭 로직
DataState => 로딩/에러/정상 UI 분기
UserInfoView => View 역할

이렇게 3가지 컴포넌트를 활용하여 선언적으로 표현할 수 있습니다.

// 커스텀 훅
export function useUserInfo() {
  const [userInfo, setUserInfo] = useState<User[] | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<unknown>(null);

  useEffect(() => {
    let alive = true;
    setIsLoading(true);
    fetchUsers()
      .then((u) => alive && setUserInfo(u))
      .catch((e) => alive && setError(e))
      .finally(() => alive && setIsLoading(false));

    return () => {
      alive = false;
    };
  }, []);

  return { userInfo, isLoading, error };
}
// DataState로 로딩/에러/정상 UI 분기 처리를 캡슐화
function DataState({
  isLoading,
  error,
  loadingFallback,
  errorFallback,
  children,
}: DataStateProps) {
  if (isLoading) {
    return <>{loadingFallback}</>;
  }

  if (error) {
    if (errorFallback) {
      return <>{errorFallback(undefined, error)}</>;
    }
    return (
      <div
        role="alert"
        className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700"
      >
        데이터를 불러오는 중 오류가 발생했습니다.
      </div>
    );
  }
  return <>{children}</>;
}
export default function UserInfoView() {
  const { userInfo, isLoading, error } = useUserInfo();

  return (
    <DataState
      isLoading={isLoading}
      error={error}
      loadingFallback={<div>로딩 중… (스켈레톤 UI)</div>}
      errorFallback={(reset, err) => (
        ...errorFallback...
      )}
    >
      <ul>
          {userInfo?.map((u) => (
            <li key={u.id}>{u.name}</li>
          ))}
        </ul>
    </DataState>
  );
}

React Query + Suspense 사용

요즘 대부분 서버 상태 관리하는 도구로 React Query를 사용하는 것 같습니다. 캐싱을 적절히 사용하면 서버의 부하를 줄일 수 있다는 장점도 있지만, 프론트엔드에서의 비동기 처리를 쉽게 관리할 수 있게 해줍니다.

또한, v5부터 도입된 useSuspenseQuery와 React의 Suspense를 사용하면 쉽게 fallback을 구현할 수 있습니다.

// ReactQuery
export function useUserInfo() {
  const { data } = useSuspenseQuery({
    queryKey: ["userInfo"],
    queryFn: fetchUserInfo,
  });
  return { userInfo: data };
}
// View
export default function UserInfoView() {
  const { userInfo } = useUserInfo();

  if (userInfo.length === 0) {
    return <div>표시할 사용자 정보가 없습니다.</div>;
  }

  return (
    <ul>
      {userInfo.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}
// 상위 컴포넌트
import { Suspense } from "react";
import UserInfoView from "./UserInfoView";
import { ErrorBoundary } from "./ErrorBoundary";

export default function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={스켈레톤 UI}>
        <UserInfoView />
      </Suspense>
    </ErrorBoundary>
  );
}

맺음말

fallback을 어떤 식으로 구현하는 게 좋을까 고민하다가 가장 리액트스러운 코드를 찾으려고 했습니다. 맨 밑의 방법이 가장 선언적으로 작성되었다고 생각이 들어 현재 프로젝트에 채택하였습니다.

사실 그동안 관성적으로 useQuery만 써봤지 useSuspenseQuery가 도입되었는지도 몰랐는데 이번 기회에 알게 되어서 좋습니다.

isLoading이나 error를 받을 필요없이 자동으로 던져주니까 코드가 훨씬 깔끔해지는 것 같습니다.

0개의 댓글