본문 바로가기
개발

Firebase RTDB에 React-Query 적용하기

by dev-joy 2024. 7. 1.

회사에서 Firebase를 사용하고있는데 유저 특성상 모든 디바이스에서 현재 데이터에대한 실시간 연동이 중요하다보니 대부분의 데이터를 on value 이벤트로 observe해서 읽어오고있다. 일반적인 비동기 통신과 동일하게 데이터를 받아오기 전과 후에대한 처리를 해주고있는데 각 데이터마다 observe 유무에대한 상태값을 만들고 db ref에 리스너를 연결하면 해당 상태값을 true로 바꿔주는 형식으로 되어있었다.

 

기존 로직엔 여러가지 문제점이있었는데

1) 페이지마다 스토어를 생성해서 필요한 모든 데이터를 observe하는데 모든 observe 상태에대한 한 개의 getter로 페이지 전체의 로딩 상태를 표시해서 일부 컴포넌트는 병렬적으로 표시할 수 있음에도 모든 데이터를 전부 observe해야 화면을 표시해서 처음 페이지의 콘텐츠가 표시되는 시간이 길었다.

2) 콘텐츠 표시가안되면 어느 데이터가 문제인지 하나하나 따라가며 확인해봐야하는 좋지않은 디버깅 경험.

3) 하나의 데이터를 읽어오기위해 작성해야하는 코드가 너무많다. (db ref, fetch 함수, 모델 변환을 위한 set 함수, getter에 상태값을 추가했는지 확인하기, observe off 함수에도 상태값 추가했는지 확인하기)

등...

 

무엇보다 스토어 내부 대부분이 서버 상태와 관련된 로직들로 가득 차있는 상태여서 가독성이나 유지보수 측면에서 서버 상태를 분리할 필요가있다고 생각했다.

 

어떻게하면 스토어를 정리할 수 있을까 고민하던 시기에 우아한 테크 유튜브에 상태 분리에대한 영상이 올라왔었는데 마침! 상태관리 도구도 회사에서 사용하고있는 mobx였다. 팁을 얻어서 적용해보려했으나 당시엔 실시간데이터에대한 부분을 어떻게 작성해야할지 판단이 서질않아서 곧바로 적용하진 못했었고 후에 좀더 찾아보고 리액트 쿼리도 더 파악을 해본뒤에 적용해보았다.

적용하기

크게 세 부분으로 나눠서 진행했다. 각 데이터에대한 쿼리키를 어떻게 작성할것인지 정하고 실시간데이터에대한 useQuery를 작성하고 중복되는 로직들을 hook으로 묶었다.

쿼리키 관리 (query-key-factory)

리액트쿼리는 쿼리키 하나당 한 개의 쿼리로 데이터가 관리되는데 쿼리키에 세부 조건을 추가해서 새로운 쿼리키를 생성할 수 있고 생성된 쿼리키에 해당하는 새로운 데이터를 받아올수있다. 문자열이나 변수를 섞어서 키 조합을 세밀하게 만들 수있는데 같은 데이터에대한 쿼리키가 서로다른 컴포넌트들에 흩어져있으면 쿼리키를 사용해서 쿼리를 무효화시키거나 특정 쿼리를 사용하려할때 찾기가 힘들고 관리가 어려워진다. 해서 팀원분이 알려주신 query-key-factory(공식페이지에도 나와있음) 적용했다. 한 파일안에 관련있는 데이터끼리 따로따로 묶어서 작성할 수 있고 queryFn을 미리 선언할 수 잇어서 확실히 작성하기도 편하고 파악하는 것도 쉬워진다.

// 미리 선언
const users = createQueryKeys('users', {
  detail: (userKey: string) => ({
    queryKey: [userKey],
    queryFn: () => api.getUser(userKey),
  }),
  policyInfo: (policyKey: string) => ({
    queryKey: [policyKey],
    queryFn: () => api.getPolicyInfo(policyKey),
  }),
  
const todos = createQueryKeys('todos', { ...
  
export const queries = mergeQueryKeys(users, todos)
 
// 사용
const { data, isLoading } = useQuery({
   ...queries.users.policyInfo(policyKey),
   enabled: !!policyKey && !!permission,
   staleTime: 1000 * 60 * 1,
})

Real-time data hook

일반적인 쿼리는 useQuery 한 줄이면 되지만 실시간데이터는 useEffect를 이용해서 리스너가 등록되거나 데이터가 변경될 때 직접 쿼리를 업데이트해주는 방식으로 작성한다.

  const queryClient = useQueryClient()

  useEffect(() => {
    const stopUser = repository.syncUsers(userKey ,(snap) => {
      queryClient.setQueryData(['users', userKey], snap.val())
    })
    return () => stopUser()
  }, [queryClient, userKey])

  const { data, isLoading } = useQuery({
    queryKey: ['users', userKey],
    queryFn: () => new Promise(() => {}),
    enabled: !!userKey,
    cacheTime: 1000,
  })

 

한 개의 쿼리마다 저 로직을 반복적으로 작성해야하기때문에 TkDodo 블로그에 소개되어있는 글을 참고해서 실시간데이터 쿼리에대한 hook을 만들었다.

// useRealTimeQuery.ts

import { useEffect } from 'react'
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'

type Subscription<Data> = (cb: (data: Data) => void) => () => void

export default function useRealTimeQuery<Data>(
  useQueryOptions: UseQueryOptions<Data>,
  subscribe: Subscription<Data>,
  deps: readonly any[]
) {
  const queryClient = useQueryClient()

  useEffect(() => {
    const unsubscribe = subscribe((data: Data) => {
      queryClient.setQueryData(useQueryOptions.queryKey, data)
    })
    return () => unsubscribe()
  }, deps)

  return useQuery<Data, Error>({ ...useQueryOptions })
}

 

사용은 이렇게 (쿼리키 팩토리 적용)

// repository
syncUsers = (userkey: string, onUpdate: (user: User) => void) => {
    const ref = database.ref('users').child(userkey)
    ref.on('value', (snap: firebase.database.DataSnapshot) => {
      onUpdate(snapshotToObj(snap, User))
    })
    return () => ref.off()
  }

// 컴포넌트
const { data, isLoading } = useRealTimeQuery(
  {
    ...queries.users.detail(userkey),
    enabled: !!userkey,
  },
  (cb) => repository.syncUsers(userkey, cb),
  [userkey]
)

 

외에도 항상 select 옵션으로 데이터를 변환해서 사용하는 쿼리도 hook으로 만들어서 반복적으로 작성하는 로직을 줄였다.

결과

1) 스토어에서 서버 상태 관련 코드들이 사 - 악 사라졌다. 데이터 상태값을 useQuery로 바꾸고 스토어에있는 상태값과 fetch 함수들을 지울때의 쾌감이란 하핫! 기존엔 컨테이너 컴포넌트에서 observe를 거는 방식이었는데 이 부분도 데이터를 사용하는 컴포넌트에서 useQuery를 사용하도록 변경해서 로직에대한 파악도 쉬워졌다.

2) 별도의 상태값을 작성하지않아도 각 데이터마다 명확한 로딩 상태를 가진다. 로딩에대한 상태도 데이터도 한줄에 작성되어있다보니 읽고 파악하는데도 훨씬 편리하다.

3) 캐시덕분에 사용자 경험도 좋아짐. 기존에 유저플로우 앞과 끝단계에서 같은 데이터를 받아오는데 매번 새로받아와서 로딩스피너가 표시되거나 곧바로 표시되지않았는데 앞에서 받아온 데이터를 곧바로 표시해줘서 끊김없이 화면을 표시할 수 있게됐다.

 

편리한 useQuery 옵션들이나 디버깅 툴은 덤.

'개발' 카테고리의 다른 글

프론트엔드 단위테스트 작성  (0) 2024.05.31
D3.js 사용하기  (0) 2024.03.27