onshore.
thumbnail
[프로젝트 회고]ssafe #4 React Query를 활용한 비동기 API 구현
프로젝트 / 프론트엔드 / React Query / Suspense / 싸페
2022.02.26.

[프로젝트 회고] #4 React Query를 활용한 비동기 API 구현

React Query는 React 앱에서 비동기 로직을 쉽게 다루도록 해주는 라이브러리입니다. 현재 대부분의 웹 어플리케이션은 비동기 로직을 사용한다고 볼 수 있습니다. 이는 유저에게 제공되는 데이터의 양과 질을 높일 수 있는 가장 기본적인 방식이기 때문입니다.

React Query는 가비지 컬랙션, 개발자 도구 등 다양한 편의 기능을 제공하고 있습니다. React Query에 대한 기본적이 내용은 저의 블로그 글에 정리해두었습니다. 아래의 내용은 프로젝트에 React Query를 사용하여 구현한 내용과 추가적인 보완사항들을 정리해봤습니다.

  • Suspense와 React Query를 통한 비동기 API
  • Infinite Queries를 활용한 무한 스크롤링 구현
  • 추가적인 보완사항

1. Suspense와 React Query를 통한 비동기 API

React Query와 Suspense

React Query는 React Suspense를 지원합니다. Suspense를 사용하기 위해 전역을 설정하거나 각 쿼리별로 react suspense의 사용여부를 설정할 수 있습니다.

//전역으로 설정
import { QueryClient, QueryClientProvider } from 'react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      suspense: true,
    },
  },
})

function Root() {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  )
}

//개별 쿼리에 대해서 설정
import { useQuery } from 'react-query'

useQuery(queryKey, queryFn, { suspense: true })

구현 방식

이처럼 react query를 사용하여 쉽게 비동기 api에 대해 suspense를 사용할 수 있게 되었습니다. 커뮤니티 게시글 목록에 suspense를 활용한 예시를 보여드리겠습니다.

Suspense

저의 경우, 커뮤니티 게시글 목록을 비동기적으로 불러오기 때문에 Fallback으로 FeedGrid에 props로 로딩상태를 전달했습니다. 또한, 비동기 로직에서 에러가 발생했을 경우, ErrorBoundary를 따로 만들어 감싸줄 수 있습니다.

import React, { Suspense } from 'react'

const CommunityPage = () => {
  .
  .
  .
  return (
    <ErrorBoundary fallback={<div />}>
      <CommunityIntro theme={theme} handleButtonClick={handleCreation} />
      <MainBox>
        . . .
        <Suspense fallback={<FeedGrid isLoading theme={theme} />}>
          <CommunityFeedGrid theme={theme} />
        </Suspense>
        . . .
      </MainBox>
    </ErrorBoundary>
  )
}

export default CommunityPage

2. Infinite Queries를 활용한 무한 스크롤링 구현

Infinite Queries

무한 쿼리는 기존 데이터 집합에 추가로 “더 많은” 데이터를 로드하거나 무한 스크롤할 수 있는 렌더링 목록도 매우 일반적인 UI 패턴입니다. React Query는 Infinite Queries를 통해 더욱 효율적으로 useQuery를 사용할 수 있도록 합니다.

const {
  data, // infinite query data를 담고 있는 객체입니다.
  error,
  fetchNextPage, // 데이터를 추가적으로 호출할 수 있도록 하는 함수입니다.
  hasNextPage, //더 호출할 데이터가 있는지 boolean 형태로 반환합니다. getNextPageParam가 undefined가 아닌 이상 true를 반환합니다.
  isFetching,
  isFetchingNextPage,
  status,
} = useInfiniteQuery('projects', fetchProjects, {
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
})

구현 방식

커뮤니티, 채용, 스터디 목록 모두 Infinite Queries를 사용하여 구현했습니다. 아래 예시는 커뮤니티 게시글 목록을 무한 스크롤로 구현한 내용입니다.

무한스크롤 구현

검색 쿼리에 대한 정보와 page에 대한 정보를 params에 담아서 보냅니다. 다만 nextPage에 대한 정보를 다시 반환했습니다.

다른 예시들을 참고했을 때, 서버에서 다음 페이지에 대한 정보를 함께 보내주는 경우도 있었습니다. 동시에 마지막 페이지일 경우에는 false를 전달해주기 위함이었습니다.

저의 경우에는, 서버쪽에서 보내주지 않아 제가 직접 다음페이지에 대한 정보를 생성해주었습니다. 또한, 마지막 페이지인지 확인하는 과정도 프론트에서 진행했습니다. 관련 내용은 바로 하단에 정리했습니다.

// getCommunityList.js
const fetchPage = async (data, pageParam) => {
  const param = {
    ...data,
    offset: pageParam,
  }
  const res = await axiosInstance({
    url: `/boards/search/`,
    params: param,
  })
  return { res: res.data, nextPage: pageParam + 1 }
}

getNextPageParam을 통해 다음 페이지에 대한 pageParam을 생성할 수 있습니다. 제가 설정한 nextPage는 prevPage.nextPage를 통해서 가져올 수 있습니다. 다만 마지막 페이지에 대한 정보는 prevPage.res.length를 통해 확인해 주었습니다.

이때, 추가적으로 호출할 데이터가 없는 경우에는, undefined를 반환해야지 getNextPageParam이 false로 반환되니 확인이 필요합니다.

// getCommunityList.js
export const GetCommunityList = data =>
  useInfiniteQuery(
    ['getCommunityList', data],
    ({ pageParam = 1 }) => fetchPage(data, pageParam),
    {
      getNextPageParam: prevPage => {
        return !!prevPage.res.length ? prevPage.nextPage : undefined
      },
    },
  )

이제 커뮤니티 게시글을 페이지 단위로 정보를 불러오기 위한 기초 쿼리는 완성이 되었습니다. 무한 스크롤의 경우, 기본적으로 2가지 방식을 통해 구현해볼 수 있습니다.

  • scroll event listener: 스크롤에 대한 조건을 만족했을 경우, 추가 데이터를 가져오는 방식
  • Intersection Observer API: 타깃으로 삼은 엘리먼트를 관찰해, 특정조건이 되면 데이터를 가져오는 방식

저의 경우 Intersection Observer을 사용했습니다. MDN에 따르면, Intersection Observer API는 타겟 요소와 상위 요소 또는 최상위 document 의 viewport 사이의 intersection 내의 변화를 비동기적으로 관찰하는 API입니다. Event listener를 등록하는 방식과 다르게, dobounce나 throttle을 적용시켜주지 않아도 되며 Reflow가 발생하지 않기 때문에 성능적인 측면에서 사용하지 않을 이유가 없습니다.

이에 useIntersectionObserver라는 hook을 만들어 필요한 곳에 사용할 수 있도록 했습니다.

  • target: useIntersectionObserver의 객체입니다.
  • onIntersect: target 엘레멘트와 교차했을 때 호출할 함수를 정의합니다.
  • enabled: useIntersectionObserver의 실행 여부를 설정합니다.
// useIntersectionObserver.js
import { useEffect } from 'react'

export const useIntersectionObserver = ({ target, onIntersect, enabled }) => {
  useEffect(() => {
    const el = target?.current

    if (!enabled || !el) {
      return
    }

    const observer = new IntersectionObserver(
      entries =>
        entries.forEach(entry => entry.isIntersecting && onIntersect()),
      {
        threshold: 1.0,
      },
    )

    observer.observe(el)
    return () => {
      observer.unobserve(el)
    }
  }, [target, enabled, onIntersect])
}

useIntersectionObserver 게시물의 가장 하단에 추가적인 로드를 위한 컴포넌트를 넣어두었습니다.

이후 Infinite Queries와 useIntersectionObserver와 연결해주었습니다.

  • target: 게시물의 가장 하단의 컴포넌트로 설정했습니다.
  • onIntersect: 교차할 경우, fetchNextPage로 추가적인 데이터를 호출하도록 했습니다.
  • enabled: hasNextPage을 통해 추가적인 데이터 여부를 판단하여, 없을 경우 추가적인 호출을 제한했습니다.
const CommunityFeedGrid = () => {

  const loader = useRef(null)
  const { data, fetchNextPage, hasNextPage } = GetCommunityList({`검색 쿼리에 대한 정보`})
  const { feedData } = CommunityFeedSelector(data)

  const onFetchNewData = () => {
    fetchNextPage()
  }

  useIntersectionObserver({
    target: loader,
    onIntersect: onFetchNewData,
    enabled: hasNextPage,
  })

  return (
    <>
      {!feedData[0] && <CommunityNoContent theme={theme} />}
      <FeedGrid data={feedData} theme={theme} />
      <FetchBox ref={loader} />
    </>
  )
}

export default CommunityFeedGrid

3. 추가적인 보완 사항

Optimistic Updates

Optimistic Updates이란 Mutation이 발생할 경우 미리 화면의 UI를 바꿔준 후, 서버와의 통신 결과에 따라 확정 / 롤백을 결정하는 방식이다. 말그대로 낙관적으로 바뀔 것이라고 예상하고 UI를 먼저 바꿔주는 것을 의미한다.

저의 경우도, 의도치 않게 좋아요 기능을 Optimistic Updates 기반으로 구현했다고 볼 수 있습니다. 좋아요를 누를 경우 UI를 변경하고, api를 다시 호출하여 데이터의 상태를 확인하는 과정을 거쳤습니다. 그러나 이렇게 되면 불필요한 api 호출이 발생합니다.

이에 React Query는 onMutate, onSettled, onError를 통해 다양한 조건을 핸들링 할 수 있도록 했습니다. 관련 내용을 아직 정확히 알지 못하여 적용하지 못했지만, 추가적으로 고민하고 구현 해볼 내용으로 기록에 남겨두기 위해 작성했습니다.

Cache, Stale

stale

아직 React Query의 Cache를 제대로 사용해보지는 못했습니다. 자주 바꾸지 않는 콘텐츠를 대상으로 캐싱을 처리하면 좋겠지만, 이번 프로젝트에는 사용해보지 못했습니다. 다만, 메인 페이지의 상단 슬라이드의 경우 캐싱을 이용해도 될 것으로 생각됩니다. 다양한 use cases들을 참고하여 공부해 볼 필요가 있는 부분이라고 생각합니다.


4. 끝맺음

React Query를 사용하게 되면서, 그 동안 어떻게 비동기 api를 처리해왔는지 막막하게 느껴질 정도로 편리했습니다. 캐싱뿐만 아니라, 반복적이 api 호출을 방지하기도 하며 Mutation이 발생했을 경우 쉽게 수정된 데이터로 업데이트 할 수도 있었습니다. 아직까진 React query의 100%를 끌어내지 못했다고 생각하며, 추가적으로 공부해볼 필요성을 느끼기도 했습니다.

참고:

Thank You for Visiting My Blog
© 2022 onshore, Powered By Gatsby.