원티드 프리온보딩 강의에서 오픈소스(react-query와 redux)를 뜯어보며 옵저버 패턴을 깨달았다는 얘기를 하길래 나도 직접 한번 뜯어보기로 했다.
React-query는 오픈소스 라이브러리로 누구나 구현된 소스코드를 볼 수 있다.
Tanstack에서는 여러 라이브러리를 관리하고 있고 이중에 query라이브러리를 보면 된다.
이중에 나는 react-query를 살펴볼 것이다.
가장 흔하게 사용되는 useQuery
로 코드를 한번 분석해보았다.
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}
useQuery
는 매우 간단하게 구현이되어 있는데 useBaseQuery
에 params
를 넘겨서 return해주는 것 뿐이다. 그럼 useBaseQuery
를 보자
export function useBaseQuery<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey extends QueryKey,
>(
options: UseBaseQueryOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
Observer: typeof QueryObserver,
queryClient?: QueryClient,
) {
const client = useQueryClient(queryClient)
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
client,
defaultedOptions,
),
)
const result = observer.getOptimisticResult(defaultedOptions)
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = isRestoring
? () => undefined
: observer.subscribe(notifyManager.batchCalls(onStoreChange))
// Update result to make sure we did not miss any query updates
// between creating the observer and subscribing to it.
observer.updateResult()
return unsubscribe
},
[observer, isRestoring],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)
// ...
// Handle result property usage tracking
return !defaultedOptions.notifyOnChangeProps
? observer.trackResult(result)
: result
}
코드가 복잡하지만 결국 return 하는 값은 result
이다.
그럼 result
는 무엇인가?
result
는 observer라는 state에서 getOptimisticResult
로 값을 받아온 값이다.
const result = observer.getOptimisticResult(defaultedOptions)
그럼 다시 observer
는 무엇인지 살펴봐야 한다.
const client = useQueryClient(queryClient)
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
client,
defaultedOptions,
),
)
상태로 정의한 observer
는 useBaseQuery
에서 받은 Observer를 인스턴스로 생성해서 저장해놓은 값이다. Params로 받은 Observer
는 useQuery
에서 넘겼던 query-core에 있는 QueryObserver
여서 결국엔 핵심 로직은 여기에 담겨져 있는 것이다.
그럼 queryObserver
를 살펴보도록 하자
result에서 getOptimisticResult
를 사용해서 값을 가져왔는데 이는 단순히 공문에서 쓰여있듯 useQuery
에서 제공해주는 값들을 그대로 리턴해준다.
export class QueryObserver<
TQueryFnData = unknown,
TError = DefaultError,
TData = TQueryFnData,
TQueryData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey,
> extends Subscribable<QueryObserverListener<TData, TError>> {
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>
// ...
getOptimisticResult(
options: DefaultedQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): QueryObserverResult<TData, TError> {
const query = this.#client.getQueryCache().build(this.#client, options)
const result = this.createResult(query, options)
// this function would decide if we will update the observer's 'current'
// properties after an optimistic reading via getOptimisticResult
// 현재 properties를 업데이트 할지 말지 결정해주는 함수이다.
if (shouldAssignObserverCurrentProperties(this, result)) {
// this assigns the optimistic result to the current Observer
// because if the query function changes, useQuery will be performing
// an effect where it would fetch again.
// When the fetch finishes, we perform a deep data cloning in order
// to reuse objects references. This deep data clone is performed against
// the `observer.currentResult.data` property
// When QueryKey changes, we refresh the query and get new `optimistic`
// result, while we leave the `observer.currentResult`, so when new data
// arrives, it finds the old `observer.currentResult` which is related
// to the old QueryKey. Which means that currentResult and selectData are
// out of sync already.
// To solve this, we move the cursor of the currentResult everytime
// an observer reads an optimistic value.
// When keeping the previous data, the result doesn't change until new
// data arrives.
this.#currentResult = result
this.#currentResultOptions = this.options
this.#currentResultState = this.#currentQuery.state
}
return result
}
// ...
protected createResult(
query: Query<TQueryFnData, TError, TQueryData, TQueryKey>,
options: QueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): QueryObserverResult<TData, TError> {
// 생략 ..
const result: QueryObserverBaseResult<TData, TError> = {
status,
fetchStatus,
isPending,
isSuccess: status === 'success',
isError,
isInitialLoading: isLoading,
isLoading,
data,
dataUpdatedAt: state.dataUpdatedAt,
error,
errorUpdatedAt,
failureCount: state.fetchFailureCount,
failureReason: state.fetchFailureReason,
errorUpdateCount: state.errorUpdateCount,
isFetched: state.dataUpdateCount > 0 || state.errorUpdateCount > 0,
isFetchedAfterMount:
state.dataUpdateCount > queryInitialState.dataUpdateCount ||
state.errorUpdateCount > queryInitialState.errorUpdateCount,
isFetching,
isRefetching: isFetching && !isPending,
isLoadingError: isError && state.dataUpdatedAt === 0,
isPaused: fetchStatus === 'paused',
isPlaceholderData,
isRefetchError: isError && state.dataUpdatedAt !== 0,
isStale: isStale(query, options),
refetch: this.refetch,
}
return result as QueryObserverResult<TData, TError>
}
}
중간에 현재 properties를 업데이트 할지, 아니면 그대로 내려줄지 하는 로직들이 있고 결국엔 result를 내려주게 된다. result에 쓰이는 query
는 밑에서 다시 다루겠다.
여기서 눈여겨봐야 할 것은 Subscribable
를 상속받고 있다는 것이다.
다시 observer
상태를 선언할때 쓰였던 client
는 queryClient를 리엑트의 context API
를 사용해서 만든 훅으로 정의되어 있다.
const client = useQueryClient(queryClient)
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
client,
defaultedOptions,
),
)
export const QueryClientContext = React.createContext<QueryClient | undefined>(
undefined,
)
export const useQueryClient = (queryClient?: QueryClient) => {
const client = React.useContext(QueryClientContext)
if (queryClient) {
return queryClient
}
if (!client) {
throw new Error('No QueryClient set, use QueryClientProvider to set one')
}
return client
}
queryClient
의 대한 구현도 같이 살펴보면 아까 봤던 클래스(Subscribable
)를 다시 상속받는것을 알수 있게 된다.
export class QueryClient {
#queryCache: QueryCache
#mutationCache: MutationCache
#defaultOptions: DefaultOptions
#queryDefaults: Map<string, QueryDefaults>
#mutationDefaults: Map<string, MutationDefaults>
#mountCount: number
#unsubscribeFocus?: () => void
#unsubscribeOnline?: () => void
constructor(config: QueryClientConfig = {}) {
this.#queryCache = config.queryCache || new QueryCache()
this.#mutationCache = config.mutationCache || new MutationCache()
this.#defaultOptions = config.defaultOptions || {}
this.#queryDefaults = new Map()
this.#mutationDefaults = new Map()
this.#mountCount = 0
}
// ...
getQueryCache(): QueryCache {
return this.#queryCache
}
// ...
}
생성자를 보면 우리가 흔히 사용할때 공식문서에 쓰여있는 option들, 즉 config를 받는 것을 볼 수 있고, queryCahce
또한 받는 부분이 보인다. 아까 위에서 useQuery
의 result를 리턴하기 전에 query를 선언 하는 부분이 잠깐 보였다. 코드로 보자면 다음과 같다.
getOptimisticResult(
options: DefaultedQueryObserverOptions<
TQueryFnData,
TError,
TData,
TQueryData,
TQueryKey
>,
): QueryObserverResult<TData, TError> {
const query = this.#client.getQueryCache().build(this.#client, options)
const result = this.createResult(query, options)
// ...
return result
}
queryClient에서 쿼리 캐시를 가져와 해당 queryClient
로 build
해서 query
에 넣고 이를 result를 만드는데 사용한다.
export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore
constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}
build<TQueryFnData, TError, TData, TQueryKey extends QueryKey>(
client: QueryClient,
options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
state?: QueryState<TData, TError>,
): Query<TQueryFnData, TError, TData, TQueryKey> {
const queryKey = options.queryKey!
const queryHash =
options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)
if (!query) {
query = new Query({
cache: this,
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey),
})
this.add(query)
}
return query
}
// ...
}
build
함수는 말그대로 해당 쿼리를 만드는 역할을 해준다. 세부적인 구현사항은 Query
도 자세히 살펴보아야 알겠지만 지금 봐야할 것은 QueryCache
또한 Subscribable
class를 상속받는다는 것이다.
그럼 결국 Subscribable
이 최상위 클래스라고 보이는데 무엇을 하는 역할인지 보도록 하자
type Listener = () => void
export class Subscribable<TListener extends Function = Listener> {
protected listeners: Set<TListener>
constructor() {
this.listeners = new Set()
this.subscribe = this.subscribe.bind(this)
}
subscribe(listener: TListener): () => void {
this.listeners.add(listener)
this.onSubscribe()
return () => {
this.listeners.delete(listener)
this.onUnsubscribe()
}
}
hasListeners(): boolean {
return this.listeners.size > 0
}
protected onSubscribe(): void {
// Do nothing
}
protected onUnsubscribe(): void {
// Do nothing
}
}
생각보다 짧은 코드로 구현이 되어있다.
listeneres
라는 변수를 Set 자료형으로 선언하고 생성자에서는 listeners
와 subscribe
를 초기화 해준다. 각각의 메서드들은 listener
를 받아서 listeners
에 추가해서 subscribe
하는 메서드, listener
가 있는지 확인하는 메서드로 구성되어 있다.
이는 observer 패턴의 전형적인 코드의 형태이다.
옵저버 패턴이란?
옵저버 패턴(observer pattern)은 관찰자들이 관찰하고 있는 대상자의 상태가 변화가 있을 때마다 대상자는 직접 목록의 각 관찰자들에게 통지하고, 관찰자들은 알림(Notification)을 받아 조치를 취하는 행동 패턴이다.
옵저버 패턴 흐름
react-query
에서도 당연히 subscribe와 unsubscribe 그리고 notify하는 코드가 들어가 있다.
// ...
protected onSubscribe(): void {
if (this.listeners.size === 1) {
this.#currentQuery.addObserver(this)
if (shouldFetchOnMount(this.#currentQuery, this.options)) {
this.#executeFetch()
} else {
this.updateResult()
}
this.#updateTimers()
}
}
protected onUnsubscribe(): void {
if (!this.hasListeners()) {
this.destroy()
}
}
// ...
#notify(notifyOptions: NotifyOptions): void {
notifyManager.batch(() => {
// First, trigger the listeners
if (notifyOptions.listeners) {
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
}
// Then the cache listeners
this.#client.getQueryCache().notify({
query: this.#currentQuery,
type: 'observerResultsUpdated',
})
})
}
참고 자료: