유명 라이브러리의 플러그인은 어떻게 구성되어 있을까? Tanstack-query의 persistQueryClient 플러그인 뜯어보기

이은지·2023년 12월 10일
1
post-thumbnail

플러그인의 공식 문서가 이해가 안됐다. 그래서 작동 방식 및 사용법을 이해하기 위해 플러그인의 코드를 일일이 뜯어보았다. React Query 플러그인 정도 되면 내가 모르는 마법 같은🧚🏻‍♀️🪄💖 코드로 작성되어 있을 줄 알았는데 들여다보니 똑같은 자바스크립트였다. 크고 복잡해보였던 코드 덩어리를 독파한 경험이 재미 있었어서 글로 남긴다.

0. persistQueryClient란?

React Query의 캐시 데이터를 로컬 저장소에 쉽게 저장할 수 있도록 도와주는 플러그인이다. 로컬 저장소의 예시로는 웹의 localStorage, 앱(React Native)의 AsyncStorage 등이 있다.

캐시 데이터를 로컬 저장소에 저장해둘 경우, 서버로부터 데이터가 도착하기 전 기존 데이터로 화면을 보여줄 수 있기 때문에 더 나은 UX를 제공할 수 있다. (나의 경우 캘린더와 채팅 기능의 UX를 개선하기 위해 활용했다. 데이터의 양이 많다는 점, 기간 단위로 조회가 이뤄지며 한 번 조회한 데이터는 변경되는 일이 드물다는 점에서 UX 개선 효과가 클 것이라 판단했다.)

1. 핵심 구현 방식

코드는 복잡해보이지만, 결국 두 가지의 핵심 동작으로 이뤄져있다. 바로 저장로드.
캐시 데이터를 로컬 저장소에 저장하고, 필요한 시점에 로컬 저장소로부터 데이터를 로드하면 된다.

도식화해보면 이렇다.

핵심 요소는 queryClient와 로컬 저장소다. 두 요소 간에 데이터의 저장과 로드가 이뤄진다. 실제로 플러그인의 API 메서드를 끝까지 타고 들어가보면, 모든 메서드는 tanstack-query-corepersister 두 개의 모듈의 조합으로 작성되어 있다는 사실을 알 수 있다.

한편 도식에 적힌 영어 동사들은 모두 공식 문서 및 코드에 사용된 단어들이다. 처음에는 ‘hydrate’, ‘dehydrate’ 두 단어가 해당 맥락에서 어떤 의미를 가지는지 잘 이해가 안됐다. 그러나 React의 hydration 개념과 단어의 사전적 의미를 통해 어느정도 감을 잡을 수 있었다. hydrate는 '수화시키다', dehydrate는 '건조시키다'라는 뜻을 가지고 있다. 즉 hydrate란 딱딱하게 굳어 있는 것을 말랑말랑하고 사용 가능한 상태로 만드는 일이고, 이를 데이터 로컬 저장에 대입해보면 hydrate란 저장소에 딱딱한 채로 저장돼있던 데이터를 꺼낸다 는 의미로 해석할 수 있다. (dehydrate는 그 반대이다.)

이제 두 모듈을 조금 더 자세히 살펴보자.

tanstack-query-core 모듈의 hydrate, dehydrate

tanstack-query-core 에는 hydrate, dehydrate 함수가 있다. hydrate의 실제 코드는 다음과 같다.

export function hydrate(
  client: QueryClient,
  dehydratedState: unknown,
  options?: HydrateOptions,
): void {
  if (typeof dehydratedState !== 'object' || dehydratedState === null) {
    return
  }

  const mutationCache = client.getMutationCache()
  const queryCache = client.getQueryCache()

  const mutations = (dehydratedState as DehydratedState).mutations || []
  const queries = (dehydratedState as DehydratedState).queries || []

	// ... 중략
  queries.forEach((dehydratedQuery) => { // 1️⃣
    const query = queryCache.get(dehydratedQuery.queryHash)

    // Reset fetch status to idle in the dehydrated state to avoid
    // query being stuck in fetching state upon hydration
    const dehydratedQueryState = {
      ...dehydratedQuery.state,
      fetchStatus: 'idle' as const,
    }

    if (query) { // 2️⃣
      if (query.state.dataUpdatedAt < dehydratedQueryState.dataUpdatedAt) {
        query.setState(dehydratedQueryState)
      }
      return
    }

    queryCache.build(
      client,
      {
        ...options?.defaultOptions?.queries,
        queryKey: dehydratedQuery.queryKey,
        queryHash: dehydratedQuery.queryHash,
      },
      dehydratedQueryState,
    )
  })
}

단순하다. dehydratedState, 즉 저장소에 저장되어 있는 쿼리들을 반복문으로 순회하면서 queryCache를 채운다.(1️⃣)

눈여겨볼 부분은 저장소의 값을 queryCache에 포함시킬지를 결정하는 로직이다.(2️⃣)
dehydratedQuery의 해시값을 이용해 현재 queryCache에 동일한 쿼리가 존재하는지 확인한다. 존재하는 경우, dehydratedQuery의 쿼리가 더 최신 데이터인 경우에만 dehydratedQuery의 값을 저장한다.

persister의 restoreClient, persistClient

persister에는 웹의 로컬스토리지, React Native의 AsyncStorage 등이 있다. 공식문서에 제공된 인터페이스를 만족한다면 직접 구현한 persister를 사용할 수도 있다.

export interface Persister {
  persistClient(persistClient: PersistedClient): Promisable<void>
  restoreClient(): Promisable<PersistedClient | undefined>
  removeClient(): Promisable<void>
}

나의 경우 persister로 React Native의 AsyncStorage를 사용했다. 따라서 각 메서드에는 AsyncStorage에 접근해 값을 저장하고 읽어오는 코드가 작성되어 있었다.

2. 추상화 Lv1: 플러그인 API

이렇게 핵심 골자만을 살펴보면 이해가 매우 쉽지만, 처음 플러그인의 공식문서를 봤을 때에는 내부 작동 방식을 이해하기가 어려웠다. 한 단계 추상화가 되어있기 때문이었다.

공식 문서에는 다음 4가지의 API가 나와있다.

  • persistQueryClientSave
  • persistQueryClientSubscribe
  • persistQueryClientRestore
  • persistQueryClient

나는 공식 문서를 볼 때 개념(및 API) 간의 위계를 파악하는데, 위 네 개를 봤을 땐 도무지 감이 안 왔다. (지금 다시 보니 Save와 Restore가 메인 API라는 게 단번에 파악되기는 한다.)

그래서 또 뜯어봤다. 각 API를 살펴본 결과 API 간의 관계를 다음과 같이 도식화할 수 있었다.

핵심 API는 Save와 Restore다. 이는 앞에서 언급했던 데이터 로컬 저장의 두 가지 기본 동작과 일치한다. 다른 모든 API는 이 두 가지를 호출하는 함수일 뿐이다.

두 함수는 1번에서 소개한 두 모듈의 함수로 작성되어 있다.

  • persistQueryClientRestore
    • tanstack-query-corehydrate: 꺼낸 데이터를 queryClient에 넣는다.
    • persisterrestoreClient: 저장소로부터 데이터를 꺼낸다.
  • persistQueryClientSave
    • dehydrate
    • persistClient

persistQueryClientSubscribe의 경우 queryClient에 이벤트 리스너를 추가하는 함수라고 보면 된다. 쿼리 데이터에 변화가 생길 때마다 Save 함수를 호출하도록 한다. 실제 코드는 다음과 같이 작성되어 있다.

export function persistQueryClientSubscribe(
  props: PersistedQueryClientSaveOptions,
) {
  const unsubscribeQueryCache = props.queryClient
    .getQueryCache()
    .subscribe((event) => {
      if (isCacheableEventType(event.type)) {
        persistQueryClientSave(props) // 데이터의 변화를 저장
      }
    })

  const unusbscribeMutationCache = props.queryClient
    .getMutationCache()
    .subscribe((event) => {
      if (isCacheableEventType(event.type)) {
        persistQueryClientSave(props)
      }
    })

  return () => {
    unsubscribeQueryCache()
    unusbscribeMutationCache()
  }
}

캐시 데이터에 이벤트가 발생하면, 즉 데이터의 변화가 발생하면 이를 저장소에 저장한다.

마지막 API인 persistQueryClient의 경우 단순히 persistQueryClientRestore와 persistQueryClientSubscribe를 호출하는 함수이다.

3. 추상화 Lv2: React에서 플러그인 활용하기

공식문서에는 React에서의 플러그인 사용을 위해서 PersistQueryClientProvider를 사용할 것을 권장하고 있다. 이는 React에 사용할 수 있도록 API를 한 단계 추상화한 것이라고 이해할 수 있다.

공식문서에 따르면 PersistQueryClientProvider는 React에서 해당 플러그인을 안전하게 사용할 수 있게 해주는 컴포넌트이다. ‘안전하다’는 건 어떤 뜻일까?

  1. restore와 data fetching 간의 순서를 보장한다. 즉 저장소로부터 데이터를 불러와 queryClient에 집어 넣는 작업이 모두 끝난 이후에 쿼리들의 data fetching이 일어나도록 한다. 만약 순서가 보장되지 않는다면 queryClient에 들어있는 최종 데이터가 (서버로부터 온) 최신 데이터임을 보장할 수 없다.

  2. 리액트 컴포넌트의 라이프사이클에 맞는 subscribe/unsubscribe를 보장한다.

실제 코드를 보면 이해가 쉽다.

'use client'
import * as React from 'react'

import {
  persistQueryClientRestore,
  persistQueryClientSubscribe,
} from '@tanstack/query-persist-client-core'
import { IsRestoringProvider, QueryClientProvider } from '@tanstack/react-query'
import type { PersistQueryClientOptions } from '@tanstack/query-persist-client-core'
import type { QueryClientProviderProps } from '@tanstack/react-query'

export type PersistQueryClientProviderProps = QueryClientProviderProps & {
  persistOptions: Omit<PersistQueryClientOptions, 'queryClient'>
  onSuccess?: () => Promise<unknown> | unknown
}

export const PersistQueryClientProvider = ({
  client,
  children,
  persistOptions,
  onSuccess,
  ...props
}: PersistQueryClientProviderProps): JSX.Element => {
  const [isRestoring, setIsRestoring] = React.useState(true)
  const refs = React.useRef({ persistOptions, onSuccess })
  const didRestore = React.useRef(false)

  React.useEffect(() => {
    refs.current = { persistOptions, onSuccess }
  })

  React.useEffect(() => {
    const options = {
      ...refs.current.persistOptions,
      queryClient: client,
    }
    if (!didRestore.current) {
      didRestore.current = true
      setIsRestoring(true)
      persistQueryClientRestore(options).then(async () => {
        try {
          await refs.current.onSuccess?.()
        } finally {
          setIsRestoring(false)
        }
      })
    }
    return isRestoring ? undefined : persistQueryClientSubscribe(options)
  }, [client, isRestoring])

  return (
    <QueryClientProvider client={client} {...props}>
      <IsRestoringProvider value={isRestoring}>{children}</IsRestoringProvider>
    </QueryClientProvider>
  )
}

PersistQueryClientProvider는 앱 최상단에 위치한다. 즉 앱 내의 모든 쿼리들은 Provider의 {children}에 렌더링된다. 따라서 Provider가 먼저 마운트되면서 restore가 이뤄지고, 그 후에 쿼리들의 마운트가 이뤄진다. 이로써 restore와 data fetching 간의 순서가 보장된다.

또한 persistQueryClientSubscribe가 반환하는 클로저 (unsubscribeMutationCache와 unsubscribeQueryCache를 호출한다.) 함수를 useEffect의 clean-up 함수로 반환함으로써 컴포넌트가 언마운트될 때 구독을 해지할 수 있다.

정리

정리하자면 이 플러그인은 총 세 가지 레벨로 구성되어 있다고 볼 수 있다.

공식문서에 나와있는 대로 PersistQueryClientProvider를 사용하고 넘어갈 수도 있었겠지만, 직접 파헤쳐봄으로써 크고 복잡해보이는 플러그인도 결국엔 단순한 모듈들의 조합으로 이뤄져 있다는 걸 알 수 있었다. (사용법을 잘 모르겠어서 뜯어본 것이긴 하지만 말이다. 😛)

한편 플러그인의 핵심 동작을 두 단어로 정리해본 것도 의미 있었다. 개발을 하다보면 코드를 어떻게 작성할지에만 정신이 쏠려 길을 잃게되는 경우가 있다. 우선 목표 기능의 핵심 동작을 정의하고, 그 후에 고도화와 추상화를 하는 식으로 접근한다면 헤매는 시간 없이 더 빠르게 개발할 수 있을 것 같다.

다음 글에서는 이 과정을 통해 터득한 플러그인의 세부 사용법에 대해 소개해보려 한다. 투비컨티뉴 🕶

0개의 댓글