프로젝트에서 서버 상태를 효율적으로 관리하기 위해 TanStack Query를 자주 활용한다. 특히, 네트워크 요청이 많은 환경에서 캐싱 기능은 성능 최적화와 사용자 경험 개선에 중요한 역할을 한다.
하지만 단순히 라이브러리를 사용하기만 할 뿐, 내부에서 어떻게 캐싱이 동작하는지 깊이 이해하지 못하고 있었다. 이번 기회를 통해 캐싱 관련 개념을 정리하고, 코드를 통해 TanStack Query의 내부 동작 방식을 분석해보고자 한다 🔥
캐싱(Cache)이란 자주 사용하는 데이터나 결과를 저장하여 빠르게 접근할 수 있도록 하는 기술이다.
서버에서 데이터를 매번 가져오는 대신, 한 번 가져온 데이터를 일정 시간 동안 보관하여 불필요한 네트워크 요청을 줄이고 응답 속도를 개선하는 데 활용된다.
SWR은 HTTP 캐싱 표준 중 하나로, 서버가 응답 헤더를 통해 브라우저 캐시 또는 프록시 서버가 먼저 캐시된 데이터를 제공하도록 한 후, 백그라운드에서 최신 데이터를 요청해 자동으로 갱신하는 방식이다.
SWR을 적용하려면 HTTP의 Cache-Control
헤더에 stale-while-revalidate
지시어를 추가하면 된다.
Cache-Control: max-age=60, stale-while-revalidate=120
이 설정은 다음과 같은 의미를 가진다.
max-age=60
: 데이터를 캐시에 저장한 후 60초 동안 신선한 데이터로 간주한다. stale-while-revalidate=120
: 60초가 지나면 최대 120초 동안 기존 캐시 데이터를 그대로 사용하면서, 동시에 백그라운드에서 최신 데이터를 요청하여 갱신한다. 이 방식은 사용자가 오래된 데이터를 보지 않도록 하면서도 불필요한 네트워크 요청을 줄이고, 응답 속도를 높이는 효과를 가져온다.
SWR 개념을 활용하는 대표적인 라이브러리 중 하나가 TanStack Query다. TanStack Query는 React 애플리케이션에서 서버 상태를 효율적으로 관리할 수 있도록 설계된 라이브러리로, 기본적으로 SWR과 유사한 캐싱 전략을 채택하고 있다.
참고로 이번 글은 TanStack Query v5를 기준으로 알아본다.
useQuery
훅은 서버에서 데이터를 가져오고, 이를 캐싱하여 불필요한 네트워크 요청을 줄이는 역할을 한다.
const { data, isLoading } = useQuery({
queryKey: ['todos'], // 요청을 식별하는 키
queryFn: fetchTodos, // 데이터를 가져오는 함수
staleTime: 1000 * 60, // 데이터가 신선한 상태로 유지되는 시간 (1분)
gcTime: 1000 * 60 * 5, // 캐시가 유지되는 시간 (5분)
});
옵션 | 설명 |
---|---|
queryKey | 요청을 식별하는 키 (같은 키를 가진 요청은 같은 캐시에 저장됨) |
queryFn | 데이터를 가져오는 비동기 함수 |
staleTime | 데이터를 신선한 상태로 유지하는 시간 (이 시간이 지나면 데이터가 "오래됨"으로 간주됨, 기본값 0분) |
gcTime | 오래된 데이터를 캐시에서 제거하는 시간 (이 시간이 지나면 캐시에서 삭제됨, 기본값 5분) |
useMutation
은 데이터를 변경(생성, 수정, 삭제)하는 API 요청을 처리하는 데 사용된다.
const mutation = useMutation({
mutationFn: createTodo, // 데이터를 변경하는 함수
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['todos']}); // todos 캐시 무효화 후 자동 새로고침
},
});
옵션 | 설명 |
---|---|
mutationFn | 데이터를 변경하는 비동기 함수 (예: POST, PUT, DELETE 요청) |
onSuccess | 요청이 성공하면 실행할 콜백 함수 |
invalidateQueries(['todos'])
를 사용하면 queryKey: ['todos']
캐시를 무효화하고 데이터를 다시 불러옴.useMutation
으로 데이터를 변경한 후, 캐싱된 데이터를 최신 상태로 갱신하는 역할을 한다.컴포넌트 A - 최초 캐싱
const { data, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1분 동안 신선한 데이터 유지
gcTime: 1000 * 60 * 5, // 5분 동안 캐시에 유지
});
useQuery(['todos'])
가 실행
캐시 컨텍스트에 queryKey가 "todos"인 데이터를 요청한다. 하지만 최초 상태이므로 캐시된 데이터가 없어 data는 undefined, isLoading: true가 반환된다.
네트워크 요청 실행
비동기적으로 fetchTodos()
실행되고 API 요청 발생한다.
요청이 끝나기 전까지 data: undefined 상태가 유지된다.
응답 도착 후 데이터 캐싱
fetchTodos()
가 응답을 받으면 데이터를 캐시에 저장한다.
이제 data가 실제 데이터를 가지게 되고, isLoading: false로 변경된다.
이때 쿼리 상태가 변경되었으므로 컴포넌트가 리렌더링 된다.
useQuery 재실행 및 캐싱된 데이터 반환
다시 queryKey가 "todos"인 데이터를 요청하고 캐싱된 데이터를 반환받는다.
컴포넌트 B
const { data, isFetching } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
useQuery(['todos'])
가 실행되어 캐시된 데이터를 반환 받는다.
이때
staleTime
이내면 캐시를 사용하고,staleTime
초과 시에는 백그라운드 refetch,gcTime
초과 시에는 네트워크 요청이 다시 발생한다. 📌 백그라운드 refetch VS 네트워크 요청
- 백그라운드 refetch는 UI를 방해하지 않고 데이터를 갱신하며, 새로운 데이터가 준비되면 UI가 업데이트
- 네트워크 요청은 캐시된 데이터가 없거나 만료되었을 때 발생하며, 로딩 상태를 보여주고 요청이 완료되면 UI가 업데이트
컴포넌트 C - 데이터 변경
const mutation = useMutation(updateTodo, {
onSuccess: () => {
queryClient.invalidateQueries({queryKey: ['todos']});
},
});
updateTodo()
이 실행된다.onSuccess()
실행된다.queryClient.invalidateQueries(['todos'])
→ ['todos']
데이터가 무효화되고 A와 B에서 자동으로 최신 데이터 요청(refetch) 발생한다. 출처: 우아한테크세미나
A->B->C 시나리오의 흐름을 위 생명주기로 정리해 볼 수 있다.
상태 | 설명 |
---|---|
fresh | 데이터가 최신 상태로, 새로 패칭할 필요가 없는 상태. staleTime 내에서 캐시된 데이터 사용 가능. |
stale | 데이터가 최신 상태가 아니어서 새로 패칭해야 하는 상태. staleTime 을 초과한 데이터. |
active | 현재 컴포넌트에서 사용 중인 쿼리 상태. 컴포넌트가 마운트되어 쿼리를 사용하고 있을 때. |
inactive | 더 이상 사용되지 않는 쿼리 상태. 컴포넌트가 언마운트되었거나 해당 쿼리를 더 이상 사용하지 않을 때. |
deleted | 캐시에서 제거된 쿼리 상태. gcTime 이 지나서 삭제된 데이터. |
fetching | 데이터를 서버에서 가져오고 있는 상태. 백그라운드에서 데이터를 요청하는 중. |
이제 실제 TanStack Query의 core 코드를 보며 어떻게 동작하는지 알아보자!
우리는 TanStack Query를 사용하기 위해 QueryClientProvider
를 최상단에서 감싸주고 QueryClient
인스턴스를 client props로 넣어 애플리케이션에 연결해준다.
QueryClientProvider
는 내부적으로 Context API를 사용하므로 모든 자식 컴포넌트들은 QueryClient
에 접근이 가능해진다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function App() {
return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}
QueryClient
클래스 내부에서 QueryCache
를 사용하는 것을 확인할 수 있다.
export class QueryClient {
#queryCache: QueryCache
...
constructor(config: QueryClientConfig = {}) {
this.#queryCache = config.queryCache || new QueryCache()
...
}
...
}
다음으로 QueryCache
는 내부적으로 #queries
라는 Map<string, Query>
형태로 쿼리 데이터를 저장한다.
export class QueryCache extends Subscribable {
#queries: QueryStore
constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}
...
}
내부 메서드 build
는 주어진 QueryKey
와 옵션을 바탕으로 기존에 존재하는 쿼리를 찾아 반환한다. 만약 해당 쿼리가 존재하지 않으면, 새로운 Query
객체를 생성하여 캐시에 추가한다.
export class QueryCache extends Subscribable {
...
build(
client: QueryClient,
options: WithRequired<
QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
'queryKey'
>,
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)
// 새로운 Query 객체 생성
if (!query) {
query = new Query({
client,
queryKey,
queryHash,
options: client.defaultQueryOptions(options),
state,
defaultOptions: client.getQueryDefaults(queryKey),
})
this.add(query)
}
return query
}
}
get
메서드는 특정 쿼리 해시를 통해 캐시된 쿼리를 검색하는 역할을 한다. 만약 해당 쿼리가 캐시에 존재하지 않으면 undefined
를 반환하여, 호출하는 쪽에서 쿼리가 없음을 처리할 수 있도록 한다.
export class QueryCache extends Subscribable<QueryCacheListener> {
...
get(
queryHash: string,
): Query<TQueryFnData, TError, TData, TQueryKey> | undefined {
return this.#queries.get(queryHash) as
| Query<TQueryFnData, TError, TData, TQueryKey>
| undefined
}
}
다음으로 Query
를 살펴보면 쿼리를 구별하는 고유키인 queryKey, staleTime 등의 옵션을 저장한 options, 현재 상태를 나타내는 state를 가지는 것을 확인할 수 있다.
export class Query extends Removable {
queryKey: TQueryKey
queryHash: string
options!: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
state: QueryState<TData, TError>
#initialState: QueryState<TData, TError>
#revertState?: QueryState<TData, TError>
#cache: QueryCache
#client: QueryClient
#retryer?: Retryer<TData>
observers: Array<QueryObserver<any, any, any, any, any>>
#defaultOptions?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
#abortSignalConsumed: boolean
...
}
이때 쿼리의 속성 중 observers
가 중요한데, 바로 이 QueryObserver
를 통해서 쿼리에 관심을 가지는 관찰자들에게 변경 사항을 알릴 수 있는 것이다!
export class QueryObserver extends Subscribable {
...
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()
}
}
}
QueryObserver
에 구독 요청이 들어오게 되면 현재 쿼리(#currentQuery
)의 #observers
에 현재 옵저버를 추가하게 된다.
export class Query extends Removable {
...
addObserver(observer: QueryObserver<any, any, any, any, any>): void {
if (!this.observers.includes(observer)) {
this.observers.push(observer)
// Stop the query from being garbage collected
this.clearGcTimeout()
this.#cache.notify({ type: 'observerAdded', query: this, observer })
}
}
}
Query
의 QueryState
가 업데이트 되는 경우에는 QueryObserver
에 알리기 위해 Dispatch, Reducer, Action 개념을 활용한다. 액션에 타입에 따라 QueryState
를 업데이트하고 Query
인스턴스를 구독하는 QueryObserver
인스턴스의 메서드를 실행한다 (onQueryUpdate
).
export class Query extends Removable {
constructor() {}
#dispatch(action: Action<TData, TError>): void {
const reducer = (
state: QueryState<TData, TError>,
): QueryState<TData, TError> => {
switch (action.type) {
case 'failed': return {...}
case 'pause': return {...}
case 'continue': return {...}
case 'fetch': return {...}
case 'success': return {...}
case 'error': return {...}
case 'invalidate': return {...}
case 'setState': return {...}
}
}
this.state = reducer(this.state)
notifyManager.batch(() => {
this.observers.forEach((observer) => {
observer.onQueryUpdate()
})
this.#cache.notify({ query: this, type: 'updated', action })
})
}
}
QueryObserver
의 onQueryUpdate
에서 호출하는 updateResult
에서는 Query
의 상태의 전과 후를 비교해 알림이 필요한 리스너에 대해서 최신화된 Query
의 상태를 전달한다.
export class QueryObserver extends Subscribable {
onQueryUpdate(): void {
this.updateResult()
if (this.hasListeners()) {
this.#updateTimers()
}
}
...
updateResult(notifyOptions?: NotifyOptions): void {
const prevResult = this.#currentResult as
| QueryObserverResult<TData, TError>
| undefined
const nextResult = this.createResult(this.#currentQuery, this.options)
...
// 전후 상태 비교
if (shallowEqualObjects(nextResult, prevResult)) {
return
}
this.#currentResult = nextResult
...
if (notifyOptions?.listeners !== false && shouldNotifyListeners()) {
defaultNotifyOptions.listeners = true
}
this.#notify({ ...defaultNotifyOptions, ...notifyOptions })
}
...
// Query 상태 업데이트 시 필요한 리스너에 대해서 호출
#notify(notifyOptions: NotifyOptions): void {
notifyManager.batch(() => {
// First, trigger the listeners
if (notifyOptions.listeners) {
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
}
...
})
}
}
여기까지 알아본 흐름을 다이어그램으로 나타내면 아래와 같다.
그러면 Observer
가 QueryState
업데이트 시 호출하는 listner의 정체는 뭘까? 이를 위해서는 useQuery
훅의 실행 흐름을 살펴보아야 한다.
export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
return useBaseQuery(options, QueryObserver, queryClient)
}
useQuery
는 전달받은 인자와 QueryObserver
클래스로 useBaseQuery
훅을 호출한다.
export function useBaseQuery(
options: UseBaseQueryOptions,
Observer: typeof QueryObserver,
queryClient?: QueryClient,
): QueryObserverResult<TData, TError> {
const client = useQueryClient(queryClient)
const isRestoring = useIsRestoring()
const errorResetBoundary = useQueryErrorResetBoundary()
const defaultedOptions = client.defaultQueryOptions(options)
// Observer 생성
const [observer] = React.useState(
() =>
new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
client,
defaultedOptions,
),
)
// 초기 결과 설정
const result = observer.getOptimisticResult(defaultedOptions)
const shouldSubscribe = !isRestoring && options.subscribed !== false
React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = shouldSubscribe
? observer.subscribe(notifyManager.batchCalls(onStoreChange))
: noop
// 결과를 업데이트
observer.updateResult()
return unsubscribe
},
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
)
return !defaultedOptions.notifyOnChangeProps
? observer.trackResult(result)
: result
}
useBaseQuery
는 내부적으로 QueryObserver
객체를 생성하여 React 컴포넌트와 연결한다.
useSyncExternalStore
는 observer의 상태 변화를 리액트 컴포넌트에 동기화하는데, 이때 첫 번째 인자인 콜백 파라미터에 리액트 컴포넌트를 렌더링 시키는 메서드 onStoreChange
가 들어가게 된다.
export class Subscribable<TListener extends Function> {
protected listeners = new Set<TListener>()
constructor() {
this.subscribe = this.subscribe.bind(this)
}
subscribe(listener: TListener): () => void {
this.listeners.add(listener)
...
}
}
observer.subscribe
는 QueryObserver의 부모 클래스인 Subscribable
에 구현된 것으로, Set 자료형으로 listner를 저장하고 있음을 알 수 있다!
다시 한번 QueryObserver의 코드를 살펴보자.
export class QueryObserver extends Subscribable {
onQueryUpdate(): void {
this.updateResult()
if (this.hasListeners()) {
this.#updateTimers()
}
}
...
updateResult(notifyOptions?: NotifyOptions): void {
const prevResult = this.#currentResult as
| QueryObserverResult<TData, TError>
| undefined
const nextResult = this.createResult(this.#currentQuery, this.options)
...
this.#notify({ ...defaultNotifyOptions, ...notifyOptions })
}
...
// Query 상태 업데이트 시 필요한 리스너에 대해서 호출
#notify(notifyOptions: NotifyOptions): void {
notifyManager.batch(() => {
// First, trigger the listeners
if (notifyOptions.listeners) {
this.listeners.forEach((listener) => {
listener(this.#currentResult)
})
}
...
})
}
}
observer.updateResult
는 상태가 업데이트될 때 최신 데이터를 반영하도록 observer를 갱신하게 된다. 이때 내부에서 호출되는 #notify
를 통해 등록되어있던 listner가 실행되는데 이것이 바로 연결된 리액트 컴포넌트들을 리렌더링하는 메서드인 것이다!
결과적으로, useQuery
를 사용하는 컴포넌트는 하나의 QueryObserver
와 연결되어 상태 변화에 따라 자동으로 리렌더링된다는 것을 알 수 있었다.
위 과정을 다이어그램으로 표현하면 아래와 같다.
이제 전체 흐름을 정리해보자!
useQuery
훅을 호출한다.useBaseOuery
훅으로 옵션과 QueryObserver
클래스가 전달된다.useBaseOuery
훅 실행 과정동안 리액트 컴포넌트와 QueryObserver
인스턴스가 연결된다.QueryObserver
는 QueryCache
에 있는 Query
를 구독한다.Query
의 상태가 업데이트되면 이를 관찰한 QueryObserver
가 notify
메서드를 실행해 리액트 컴포넌트를 리렌더링한다.이번 글에서는 SWR의 개념을 바탕으로 TanStack Query가 어떻게 캐싱을 수행하는지 살펴보았다. 단순히 캐싱 기능을 활용하는 것을 넘어, 내부 코드 분석을 통해 동작 원리를 깊이 이해하는 데 초점을 맞췄다.
분량 상 더 알아보지는 못했지만 QueryObserver
내부에는 isStale
,onSubscribe
과 같이 stale 상태를 판단하고 리패칭하는 과정도 살펴볼 수 있어 흥미로웠다.
앞으로 이 내용을 바탕으로, 실제 프로젝트에서 TanStack Query를 어떻게 효율적으로 적용할지 고민해볼 수 있을 것이다. 🚀
https://tanstack.com/query/latest/docs/framework/react/overview
https://www.timegambit.com/blog/digging/react-query/01
https://fe-developers.kakaoent.com/2023/230720-react-query/
https://velog.io/@hyunjine/Inside-React-Query
https://mingos-habitat.tistory.com/86