비동기 데이터 패칭의 로딩, 에러 상태 분리하기 (with Tanstack Query, Suspense, Error Boundary)

Jihoo Kim·2023년 8월 12일
13
post-thumbnail

비동기 데이터 패칭 패턴

컴포넌트에서 외부 데이터를 패칭해 사용하는 경우 아래와 같은 패턴을 많이 사용합니다.

// HomePage.tsx
export default function HomePage() {
  return (
    <PageWrapper>
      <HomeHeader />
      <main>
        <HomeBanner />
        <PostCardList />
      </main>
      <BottomAppBar />
    </PageWrapper>
  );
}
// PostCardList.tsx
export default function PostCardList() {
  const { posts, error, isLoading } = useGetPosts();

  if (isLoading) {
    return <PostCardListSkeleton />;
  }
  if (error) {
	// 에러 처리
  }
  if (!posts) {
    return null;
  }

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <PostCard post={post} />
        </li>
      ))}
    </ul>
  );
}
// useGetPosts.tsx
export default function useGetPosts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await axios.get<GetPostsResponse>('/posts');
      return res.data;
    },
  );

  return {
    posts: data,
    isLoading,
    error,
  };
}

위와 같은 패턴은 아무 이상없이 잘 작동하지만 데이터 패칭의 에러, 로딩, 성공 상태를 모두 한 컴포넌트 안에서 다루기 때문에 코드가 복잡해지며 비즈니스 로직을 한번에 파악하기 어려워집니다.

위의 예시 코드는 단순한 예제라 문제를 못느끼겠지만 비동기 로직이 한개가 아닌 여러개가 있으며 각각의 로딩, 에러 상태에 대한 로직이 존재한다면 로직 분리의 필요성이 확실히 느껴집니다.

리액트에서 제공하는 Suspense 컴포넌트Error Boundary 패턴을 이용해 로딩과 에러 처리를 각각 위임할 수 있습니다. 따라서 PostCardList 컴포넌트에서는 데이터 패칭이 성공하는 경우에만 집중할 수 있습니다.

Suspense를 활용한 로딩 처리

Tanstack Query에서 suspense 기능을 사용하려면 useQuery 옵션에 suspense: true만 넣어주면 됩니다.(suspense를 true로 하면 useErrorBoundary도 자동으로 true가 됩니다.)

// useGetPosts.tsx
export default function useGetPosts() {
  const { data, isLoading, error } = useQuery({
    ...
	suspense: true
  });

  ...
}

PostCardList 컴포넌트를 Suspense로 감싸주고 fallback을 설정해주면 데이터가 로딩 상태일 때 fallback이 렌더링됩니다.

// HomePage.tsx
export default function HomePage() {
  
  ...

  return (
    <PageWrapper>
      <HomeHeader />
      <main>
        <HomeBanner />
        <Suspense fallback={<PostCardListSkeleton />}>
          <PostCardList />
        </Suspense>
      </main>
      <BottomAppBar />
    </PageWrapper>
  );
}

이제 데이터의 로딩 상태는 Suspense가 처리하므로 PostCardList는 다음과 같이 에러와 성공 상태만 처리하면 됩니다.

export default function PostCardList() {
  const { posts, error } = useGetPosts();

  if (error) {
    // 에러 처리
  }
  if (!posts) {
    return null;
  }

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <PostCard post={post} />
        </li>
      ))}
    </ul>
  );
}

Error Boundary 패턴을 활용한 에러 처리

Error Boundary 패턴을 사용하기 위해 리액트 공식문서에서 예시 코드를 제공하지만 react-error-boundary 패키지를 이용하면 좀더 편하게 사용할 수 있습니다.

// HomePage.tsx
import { ErrorBoundary } from 'react-error-boundary';

export default function HomePage() {
  
  ...

  return (
    <PageWrapper>
      <HomeHeader />
      <main>
        <HomeBanner />
		<ErrorBoundary FallbackComponent={Fallback} onReset={handleReset}>
	      <Suspense fallback={<PostCardListSkeleton />}>
	        <PostCardList />
	      </Suspense>
		</ErrorBoundary />
      </main>
      <BottomAppBar />
    </PageWrapper>
  );
}

이제 PostCardList 컴포넌트는 에러에 대한 로직도 책임질 필요가 없습니다.

export default function PostCardList() {
  const { posts } = useGetPosts();

  if (!posts) {
    return null;
  }

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <PostCard post={post} />
        </li>
      ))}
    </ul>
  );
}

타입 문제 해결

우리는 이제 posts가 항상 데이터를 포함한다는 것을 알고 있지만 useQuery로 부터 얻은 데이터의 타입은 TDate | undefined입니다 (관련 이슈 참고). 따라서 위와 같이 불필요하게 posts가 undefined일 때 return null을 하는 코드를 작성해야 합니다. 이에 대한 불편함을 해결하기 위해 공식 문서에서는 @suspensive/react-query 패키지 사용을 권장합니다. 해당 패키지의 useSuspenseQuery를 사용하면 기본적으로 suspense: true로 동작하며 항상 TData 타입의 data를 반환합니다.

// useGetPosts.tsx
import { useSuspenseQuery } from '@suspensive/react-query';

export default function useGetPosts() {
  const { data } = useSuspenseQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const res = await axios.get<GetPostsResponse>('/posts');
      return res.data;
    },
  });

  return {
    posts: data,
  };
}
export default function PostCardList() {
  const { posts } = useGetPosts();

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <PostCard post={post} />
        </li>
      ))}
    </ul>
  );
}

이제 PostCardList 컴포넌트는 데이터 패칭이 성공한 상태에만 집중하면서 코드를 보다 깔끔하게 관리할 수 있습니다.

REFERENCE

profile
개발자가 될래요

1개의 댓글

comment-user-thumbnail
2023년 8월 12일

많은 도움이 되었습니다, 감사합니다.

답글 달기