플러그인의 공식 문서가 이해가 안됐다. 그래서 작동 방식 및 사용법을 이해하기 위해 플러그인의 코드를 일일이 뜯어보았다. React Query 플러그인 정도 되면 내가 모르는 마법 같은🧚🏻♀️🪄💖 코드로 작성되어 있을 줄 알았는데 들여다보니 똑같은 자바스크립트였다. 크고 복잡해보였던 코드 덩어리를 독파한 경험이 재미 있었어서 글로 남긴다.
React Query의 캐시 데이터를 로컬 저장소에 쉽게 저장할 수 있도록 도와주는 플러그인이다. 로컬 저장소의 예시로는 웹의 localStorage, 앱(React Native)의 AsyncStorage 등이 있다.
캐시 데이터를 로컬 저장소에 저장해둘 경우, 서버로부터 데이터가 도착하기 전 기존 데이터로 화면을 보여줄 수 있기 때문에 더 나은 UX를 제공할 수 있다. (나의 경우 캘린더와 채팅 기능의 UX를 개선하기 위해 활용했다. 데이터의 양이 많다는 점, 기간 단위로 조회가 이뤄지며 한 번 조회한 데이터는 변경되는 일이 드물다는 점에서 UX 개선 효과가 클 것이라 판단했다.)
코드는 복잡해보이지만, 결국 두 가지의 핵심 동작으로 이뤄져있다. 바로 저장과 로드.
캐시 데이터를 로컬 저장소에 저장하고, 필요한 시점에 로컬 저장소로부터 데이터를 로드하면 된다.
도식화해보면 이렇다.
핵심 요소는 queryClient와 로컬 저장소다. 두 요소 간에 데이터의 저장과 로드가 이뤄진다. 실제로 플러그인의 API 메서드를 끝까지 타고 들어가보면, 모든 메서드는 tanstack-query-core
와 persister
두 개의 모듈의 조합으로 작성되어 있다는 사실을 알 수 있다.
한편 도식에 적힌 영어 동사들은 모두 공식 문서 및 코드에 사용된 단어들이다. 처음에는 ‘hydrate’, ‘dehydrate’ 두 단어가 해당 맥락에서 어떤 의미를 가지는지 잘 이해가 안됐다. 그러나 React의 hydration 개념과 단어의 사전적 의미를 통해 어느정도 감을 잡을 수 있었다. hydrate는 '수화시키다', dehydrate는 '건조시키다'라는 뜻을 가지고 있다. 즉 hydrate란 딱딱하게 굳어 있는 것을 말랑말랑하고 사용 가능한 상태로 만드는 일이고, 이를 데이터 로컬 저장에 대입해보면 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에는 웹의 로컬스토리지, React Native의 AsyncStorage 등이 있다. 공식문서에 제공된 인터페이스를 만족한다면 직접 구현한 persister를 사용할 수도 있다.
export interface Persister {
persistClient(persistClient: PersistedClient): Promisable<void>
restoreClient(): Promisable<PersistedClient | undefined>
removeClient(): Promisable<void>
}
나의 경우 persister로 React Native의 AsyncStorage를 사용했다. 따라서 각 메서드에는 AsyncStorage에 접근해 값을 저장하고 읽어오는 코드가 작성되어 있었다.
이렇게 핵심 골자만을 살펴보면 이해가 매우 쉽지만, 처음 플러그인의 공식문서를 봤을 때에는 내부 작동 방식을 이해하기가 어려웠다. 한 단계 추상화가 되어있기 때문이었다.
공식 문서에는 다음 4가지의 API가 나와있다.
나는 공식 문서를 볼 때 개념(및 API) 간의 위계를 파악하는데, 위 네 개를 봤을 땐 도무지 감이 안 왔다. (지금 다시 보니 Save와 Restore가 메인 API라는 게 단번에 파악되기는 한다.)
그래서 또 뜯어봤다. 각 API를 살펴본 결과 API 간의 관계를 다음과 같이 도식화할 수 있었다.
핵심 API는 Save와 Restore다. 이는 앞에서 언급했던 데이터 로컬 저장의 두 가지 기본 동작과 일치한다. 다른 모든 API는 이 두 가지를 호출하는 함수일 뿐이다.
두 함수는 1번에서 소개한 두 모듈의 함수로 작성되어 있다.
tanstack-query-core
의 hydrate
: 꺼낸 데이터를 queryClient에 넣는다.persister
의 restoreClient
: 저장소로부터 데이터를 꺼낸다.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를 호출하는 함수이다.
공식문서에는 React에서의 플러그인 사용을 위해서 PersistQueryClientProvider를 사용할 것을 권장하고 있다. 이는 React에 사용할 수 있도록 API를 한 단계 추상화한 것이라고 이해할 수 있다.
공식문서에 따르면 PersistQueryClientProvider는 React에서 해당 플러그인을 안전하게 사용할 수 있게 해주는 컴포넌트이다. ‘안전하다’는 건 어떤 뜻일까?
restore와 data fetching 간의 순서를 보장한다. 즉 저장소로부터 데이터를 불러와 queryClient에 집어 넣는 작업이 모두 끝난 이후에 쿼리들의 data fetching이 일어나도록 한다. 만약 순서가 보장되지 않는다면 queryClient에 들어있는 최종 데이터가 (서버로부터 온) 최신 데이터임을 보장할 수 없다.
리액트 컴포넌트의 라이프사이클에 맞는 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를 사용하고 넘어갈 수도 있었겠지만, 직접 파헤쳐봄으로써 크고 복잡해보이는 플러그인도 결국엔 단순한 모듈들의 조합으로 이뤄져 있다는 걸 알 수 있었다. (사용법을 잘 모르겠어서 뜯어본 것이긴 하지만 말이다. 😛)
한편 플러그인의 핵심 동작을 두 단어로 정리해본 것도 의미 있었다. 개발을 하다보면 코드를 어떻게 작성할지에만 정신이 쏠려 길을 잃게되는 경우가 있다. 우선 목표 기능의 핵심 동작을 정의하고, 그 후에 고도화와 추상화를 하는 식으로 접근한다면 헤매는 시간 없이 더 빠르게 개발할 수 있을 것 같다.
다음 글에서는 이 과정을 통해 터득한 플러그인의 세부 사용법에 대해 소개해보려 한다. 투비컨티뉴 🕶