본문으로 건너뛰기
2026. 3. 17
© WONKOOK LEE

쿼리 키는 어떻게 캐시 주소가 되나요?

서로 다른 모양의 값 두 개가 같은 캐시 슬롯을 가리키는 그림

필터 상태를 Map으로 들고 있었습니다. 키 순서에 흔들리지 않고 순회 순서가 보장되니까 좋은 선택이라고 생각했고, 그대로 쿼리 키에 넣었습니다.

useQuery({ queryKey: ["todos", filters], queryFn: () => fetchTodos(filters) })

그날 QA에서 올라온 리포트는 "필터를 바꿨는데 이전 목록이 그대로 보여요"였습니다. 재현이 잘 안 됐고, 어떤 날은 멀쩡했습니다.

Map을 넣은 순간 그 쿼리 키는 필터가 무엇이든 항상 같은 캐시 슬롯을 가리키고 있었습니다. 왜 그런지 알려면 캐시가 키를 어떻게 다루는지를 봐야 합니다.


이 글에서 다루는 내용

TanStack Query의 캐시는 개념적으로 화려하지 않습니다. Map<string, Query> 하나이고, 조회는 문자열 하나가 같은지 보는 게 전부입니다. 그래서 모든 흥미로운 일은 그 문자열을 만드는 과정거기 담기는 값을 옮기는 과정에서 벌어집니다.

이 글은 그 두 경로를 소스 코드로 따라갑니다. 쿼리 키가 해시가 되는 hashKey, 조회 필터가 쓰는 partialMatchKey, 데이터를 저장할 때 도는 replaceEqualDeep, 서버와 클라이언트를 오갈 때 도는 dehydrate/hydrate. 각 지점마다 어떤 값이 조용히 형태를 잃는지를 실제 실행 결과와 함께 표시합니다.


 

인용한 코드는 모두 @tanstack/query-core@5.101.4src/ 기준입니다. 실행 결과로 표시한 출력은 해당 버전의 빌드 산출물을 그대로 돌려 얻은 값입니다.




1. 캐시는 Map 하나입니다

QueryCache의 실체부터 보겠습니다.

query-core/src/queryCache.ts
export class QueryCache extends Subscribable<QueryCacheListener> {
#queries: QueryStore

constructor(public config: QueryCacheConfig = {}) {
super()
this.#queries = new Map<string, Query>()
}

build(client, options, state) {
const queryKey = options.queryKey
const queryHash = options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
let query = this.get(queryHash)

if (!query) {
query = new Query({ client, queryKey, queryHash, /* ... */ })
this.add(query)
}

return query
}

get(queryHash: string) {
return this.#queries.get(queryHash)
}
}

여기서 확인할 게 세 가지 있습니다.

첫째, 키는 문자열입니다. Map의 키가 string이므로 조회는 === 한 번입니다. 트리도, 프리픽스 인덱스도 없습니다.

둘째, build()는 없을 때만 만듭니다. 이미 같은 해시의 쿼리가 있으면 options를 보지 않고 기존 인스턴스를 그대로 돌려줍니다. 옵션 반영은 그 뒤에 옵저버가 query.setOptions()를 호출해서 이뤄지고, 캐시 슬롯 자체는 해시로만 결정됩니다. 해시가 같다는 건 "같은 데이터"라고 선언한 것과 같습니다.

셋째, 원본 queryKey 객체도 함께 보관합니다. Query 인스턴스는 queryHash(문자열)와 queryKey(원본 객체)를 둘 다 들고 있습니다. 나중에 보겠지만 조회 경로에 따라 둘 중 다른 쪽을 봅니다. 이 이중성이 헷갈리는 동작 대부분의 출처입니다.


쿼리 키가 hashKey를 거쳐 문자열이 되고 그 문자열로 Map 슬롯을 찾는 흐름


Recap

캐시는 Map<string, Query> 하나이고 조회는 문자열 하나의 동일성 비교입니다. build()는 해당 해시의 쿼리가 없을 때만 인스턴스를 만들고, 있으면 옵션을 보지 않고 기존 것을 돌려줍니다. Query는 해시 문자열과 원본 키 객체를 둘 다 보관하며 조회 경로에 따라 서로 다른 쪽을 봅니다. 그래서 캐시의 동작을 이해하는 일은 해시 문자열이 어떻게 만들어지는지를 이해하는 일과 거의 같습니다.




2. hashKey — 정렬 replacer를 끼운 JSON.stringify

기본 해시 함수는 15줄입니다.

query-core/src/utils.ts
export function hashQueryKeyByOptions(queryKey, options) {
const hashFn = options?.queryKeyHashFn || hashKey
return hashFn(queryKey)
}

export function hashKey(queryKey: QueryKey | MutationKey): string {
return JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key]
return result
}, {} as any)
: val,
)
}

JSON.stringify에 replacer 하나를 끼운 것이고, replacer가 하는 일은 plain object의 키를 정렬해 다시 담는 것뿐입니다. 이유는 명확합니다. JSON.stringify는 프로퍼티 삽입 순서를 그대로 따르므로 정렬이 없으면 { page: 1, size: 20 }{ size: 20, page: 1 }이 다른 캐시 엔트리가 됩니다.

replacer는 모든 노드에서 호출되므로 중첩 객체까지 정렬됩니다.

hashKey(['a', { b: 1, a: 2 }])        → ["a",{"a":2,"b":1}]
hashKey(['a', { a: 2, b: 1 }]) → ["a",{"a":2,"b":1}]
hashKey(['a', { o: { z: 1, y: 2 } }]) → ["a",{"o":{"y":2,"z":1}}]
hashKey(['a', { o: { y: 2, z: 1 } }]) → ["a",{"o":{"y":2,"z":1}}]

배열은 정렬하지 않습니다. 배열은 순서 자체가 의미이므로 당연한 선택이지만, 뒤집어 말하면 ['todos', ['a', 'b']]['todos', ['b', 'a']]는 다른 캐시입니다. 태그 목록처럼 순서에 의미가 없는 값을 배열로 넣는다면 호출부에서 정렬해서 넣어야 합니다.


isPlainObject가 까다로운 이유

정렬 대상을 고르는 isPlainObject는 생각보다 엄격합니다.

query-core/src/utils.ts
export function isPlainObject(o: any): o is Record<PropertyKey, unknown> {
if (!hasObjectPrototype(o)) return false // [object Object] 아니면 탈락

const ctor = o.constructor
if (ctor === undefined) return true // Object.create(null)

const prot = ctor.prototype
if (!hasObjectPrototype(prot)) return false
if (!prot.hasOwnProperty('isPrototypeOf')) return false
if (Object.getPrototypeOf(o) !== Object.prototype) return false

return true
}

function hasObjectPrototype(o: any): boolean {
return Object.prototype.toString.call(o) === '[object Object]'
}

이 조건을 통과하지 못하는 값은 정렬 없이 그대로 JSON.stringify로 넘어갑니다. 클래스 인스턴스, Map, Set, Date가 전부 여기에 해당합니다. 즉 라이브러리는 "이건 내가 안전하게 정규화할 수 있는 값이 아니다"라고 판단하고 손을 떼며, 그 뒤는 온전히 JSON.stringify의 규칙입니다. 그리고 JSON.stringify의 규칙은 정보를 조용히 버리는 쪽으로 설계돼 있습니다.


toJSON이 replacer보다 먼저 실행됩니다

명세상 SerializeJSONProperty는 이 순서로 동작합니다.

  1. 값을 읽는다
  2. 값이 객체이고 toJSON이 있으면 toJSON()을 호출해 그 결과로 교체한다
  3. 그다음 replacer를 호출한다

그래서 DateisPlainObject에 도달하기 전에 이미 문자열이 되어 있습니다. 반대로 toJSON()이 plain object를 반환하는 클래스는 replacer의 정렬 혜택을 받습니다.

class WithToJSON {
constructor() { this.b = 1; this.a = 2 }
toJSON() { return { b: this.b, a: this.a } }
}

hashKey(['a', new WithToJSON()]) // → ["a",{"a":2,"b":1}] 정렬됨

커스텀 클래스를 쿼리 키에 꼭 넣어야 한다면 toJSON()을 구현하는 게 가장 저렴한 안전장치입니다.


Recap

hashKeyJSON.stringify에 키 정렬 replacer 하나를 끼운 함수입니다. 정렬은 plain object에만 적용되고 배열은 순서를 그대로 유지하므로, 순서에 의미가 없는 배열은 호출부에서 정렬해야 합니다. isPlainObject를 통과하지 못한 값은 정규화 없이 JSON.stringify의 규칙에 그대로 맡겨집니다. 그리고 toJSON()이 replacer보다 먼저 실행되기 때문에 Date는 판정에 닿기 전에 이미 문자열이 되어 있습니다.




3. 해시가 조용히 무너지는 지점들

아래는 전부 실행 결과입니다. 예외를 던지는 두 줄을 빼면 경고도, 로그도 없습니다.

입력                                       해시 결과
────────────────────────────────────────────────────────────────────
['a', { x: undefined }] ["a",{}]
['a', {}] ["a",{}] ← 위와 동일

['a', undefined] ["a",null]
['a', null] ["a",null] ← 위와 동일
['a', () => {}] ["a",null] ← 위와 동일
['a', NaN] ["a",null] ← 위와 동일
['a', Infinity] ["a",null] ← 위와 동일

['a', { fn: () => {} }] ["a",{}]
['a', new Map([[1, 2]])] ["a",{}] ← 위와 동일
['a', new Map()] ["a",{}] ← 위와 동일
['a', new Set([1, 2, 3])] ["a",{}] ← 위와 동일
['a', new URLSearchParams({ x: '1' })] ["a",{}] ← 위와 동일

['a', new Date(0)] ["a","1970-01-01T00:00:00.000Z"]
['a', '1970-01-01T00:00:00.000Z'] ["a","1970-01-01T00:00:00.000Z"] ← 위와 동일

['a', -0] ["a",0]

['a', 1n] TypeError: Do not know how to serialize a BigInt
['a', 순환참조] RangeError: Maximum call stack size exceeded

서로 다른 여러 값이 하나의 해시 문자열로 수렴하는 그림


크게 네 부류로 나뉩니다.


3-1. {}가 되는 것들 — Map, Set, 열거 프로퍼티가 없는 객체

Map, Set, URLSearchParams, WeakMap은 내용을 자기 자신의 열거 가능한 프로퍼티로 갖고 있지 않습니다. 데이터는 내부 슬롯에 있고, JSON.stringify는 내부 슬롯을 모릅니다. 그래서 전부 {}가 됩니다.

이게 서두의 버그였습니다. 실제로 어떻게 보이는지가 중요합니다.

const client = new QueryClient()

client.setQueryData(['x', new Map([['a', 1]])], 'first')
client.setQueryData(['x', new Set([9])], 'second')

client.getQueryCache().getAll().length // → 1
client.getQueryData(['x', new Map([['a', 1]])]) // → 'second'

MapSet이라는, 타입도 내용도 아무 관계 없는 두 값이 같은 캐시 엔트리 하나를 공유합니다. 나중에 쓴 쪽이 앞의 것을 덮고, 앞의 화면은 뒤의 데이터를 읽습니다. 필터를 바꿔도 목록이 안 바뀌던 이유이고, 어떤 날은 멀쩡했던 이유도 같습니다. 어느 쿼리가 먼저 마운트되느냐에 따라 승자가 달라지니까요.

같은 논리로 열거 가능한 프로퍼티가 없는 클래스 인스턴스도 전부 {}입니다. 값을 getter로만 노출하는 도메인 모델이 특히 위험합니다. getter는 프로토타입에 있으므로 JSON.stringify가 보지 못합니다.

class Filter {
#page = 1
get page() { return this.#page } // 프로토타입에 있음
}

hashKey(['todos', new Filter()]) // → ["todos",{}]

3-2. null이 되는 것들 — undefined, 함수, NaN, Infinity

배열 위치에 있는 undefined, 함수, Symbol, NaN, Infinity는 전부 null로 직렬화됩니다. JSON에는 그런 값이 없기 때문입니다.

useQuery({ queryKey: ['user', userId], /* ... */ })

userId가 아직 안 왔을 때 undefined라면 이 키의 해시는 ["user",null]입니다. 나중에 진짜 null을 넣는 경로가 있다면 두 상태가 같은 캐시를 공유합니다. enabled: !!userId로 요청을 막아도 캐시 엔트리는 이미 만들어져 있습니다. build()enabled와 무관하게 호출되니까요.

객체 프로퍼티에 있는 undefined와 함수는 조금 다르게, 키 자체가 사라집니다. 그래서 { x: undefined }{}가 같은 해시입니다. 옵셔널 필드를 가진 필터 객체를 그대로 넣으면 이 동작 덕분에 대개 원하는 대로 굴러가지만, { sort: undefined }{}를 구분해야 하는 API라면 구분이 사라집니다.


3-3. 형태를 잃는 것 — Date

DatetoJSON()이 있어서 ISO 문자열이 됩니다. 값 자체는 보존되므로 언뜻 안전해 보이지만 두 가지 문제가 있습니다.

하나, 문자열과 충돌합니다. new Date(0)'1970-01-01T00:00:00.000Z'는 같은 해시입니다.

둘, 이쪽이 훨씬 자주 사고를 냅니다 — 키가 매번 바뀝니다.

// 렌더마다 새 Date, 밀리초까지 다름
useQuery({
queryKey: ['report', new Date()],
queryFn: fetchReport,
})

렌더될 때마다 다른 해시가 나오므로 매번 새 캐시 엔트리가 생기고, 매번 새로 페치합니다. 이전 엔트리는 옵저버를 잃고 gcTime이 지나야 사라지므로 그동안 캐시는 계속 부풀어 오릅니다. Date.now(), Math.random(), 매 렌더 새로 만든 객체 리터럴이 전부 같은 함정입니다.

정밀도를 낮춰 안정된 문자열로 넣는 게 정답입니다.

queryKey: ['report', format(date, 'yyyy-MM-dd')]

Date 자체의 함정은 별도 글에서 더 다뤘습니다.


3-4. 클래스 인스턴스 — 정렬을 못 받습니다

클래스 인스턴스는 isPlainObject를 통과하지 못하므로 키 정렬이 적용되지 않습니다. 자기 자신의 열거 가능한 프로퍼티는 직렬화되지만 순서는 생성자에서 대입한 순서 그대로입니다.

class UserA { constructor() { this.name = 'kim'; this.id = 1 } }
class UserB { constructor() { this.id = 1; this.name = 'kim' } }

hashKey(['a', new UserA()]) // → ["a",{"name":"kim","id":1}]
hashKey(['a', new UserB()]) // → ["a",{"id":1,"name":"kim"}] 다름
hashKey(['a', { id: 1, name: 'kim' }]) // → ["a",{"id":1,"name":"kim"}] UserB와 동일

논리적으로 완전히 같은 두 인스턴스가 필드 대입 순서만으로 다른 캐시가 되고, 반대로 인스턴스와 plain object가 같은 캐시가 됩니다. 클래스에 필드를 하나 추가하면서 대입 위치를 바꾸는 것만으로 배포 직후 캐시가 통째로 무효화될 수 있다는 뜻이기도 합니다.


3-5. 던지는 것 — BigInt와 순환 참조

BigIntTypeError, 순환 참조는 RangeError를 던집니다. 이 둘은 조용하지 않다는 점에서 오히려 다행입니다. 다만 던지는 위치가 useQuery 렌더 중이라 스택만 보면 원인이 잘 안 보입니다.


 

쿼리 키에 넣어도 안전한 값string, number(유한값), boolean, null, 그리고 이들로만 구성된 plain object와 배열입니다. 이 범위를 벗어나는 순간 위 표의 어딘가에 걸립니다.

이 규칙을 팀 단위로 강제하고 싶다면 queryKeyHashFn을 교체해 개발 환경에서만 검증을 넣는 방법이 있습니다.

new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: (key) => {
if (process.env.NODE_ENV !== 'production') assertSerializable(key)
return hashKey(key)
},
},
},
})

Recap

MapSet은 내용을 열거 가능한 프로퍼티로 갖지 않아 전부 {}가 되고, 배열 위치의 undefined·함수·NaN은 전부 null이 되며, 객체 프로퍼티의 undefined는 키째 사라집니다. Date는 값이 남지만 렌더마다 새 키를 만들어 캐시를 부풀리고, 클래스 인스턴스는 정렬을 받지 못해 필드 대입 순서가 해시를 바꿉니다. 예외를 던지는 것은 BigInt와 순환 참조뿐이고 나머지는 전부 조용합니다. 쿼리 키에 안전한 값은 문자열, 유한한 숫자, 불리언, null, 그리고 이들로만 이뤄진 plain object와 배열입니다.




4. 조회는 두 갈래입니다 — 해시와 원본 키

invalidateQueries, removeQueries, findAll이 쓰는 매칭 함수를 보겠습니다.

query-core/src/utils.ts
export function matchQuery(filters: QueryFilters, query: Query): boolean {
const { type = 'all', exact, fetchStatus, predicate, queryKey, stale } = filters

if (queryKey) {
if (exact) {
if (query.queryHash !== hashQueryKeyByOptions(queryKey, query.options)) {
return false
}
} else if (!partialMatchKey(query.queryKey, queryKey)) {
return false
}
}
// ... type, stale, fetchStatus, predicate
return true
}

exact: true는 해시 문자열을 비교하고, 기본값인 부분 매칭은 원본 키 객체를 재귀로 비교합니다. 1절에서 말한 이중성이 여기서 드러납니다. 같은 필터를 넘겨도 두 경로의 판정 기준이 아예 다릅니다.

query-core/src/utils.ts
export function partialMatchKey(a: any, b: any): boolean {
if (a === b) return true
if (typeof a !== typeof b) return false

if (a && b && typeof a === 'object' && typeof b === 'object') {
if (Array.isArray(a) && Array.isArray(b)) {
for (let i = 0; i < b.length; i++) {
if (!partialMatchKey(a[i], b[i])) return false
}
return true
}

const bKeys = Object.keys(b)
for (const key of bKeys) {
if (!partialMatchKey(a[key], b[key])) return false
}
return true
}

return false
}

핵심은 순회 대상이 b, 즉 필터로 넘긴 쪽이라는 점입니다. b에 있는 것만 확인하고 a에 뭐가 더 있는지는 보지 않습니다. ['todos']['todos', { page: 1 }]을 매칭하는 프리픽스 동작이 여기서 나옵니다.


열거 프로퍼티가 없으면 전부 매칭됩니다

bMap이나 DateObject.keys(b)가 빈 배열입니다. 반복문이 한 번도 안 돌고 곧장 return true입니다.

const c = new QueryClient()
c.setQueryData(['todos', { page: 1 }], 'A')
c.setQueryData(['todos', new Date(0)], 'C')
c.setQueryData(['todos', [1, 2, 3]], 'D')

c.getQueryCache().findAll({ queryKey: ['todos', new Map()] })
// → ["todos",{"page":1}] , ["todos","1970-01-01T00:00:00.000Z"] , ["todos",[1,2,3]]

c.getQueryCache().findAll({ queryKey: ['todos', new Date()] })
// → 위와 동일하게 전부

c.getQueryCache().findAll({ queryKey: ['todos', {}] })
// → 위와 동일하게 전부

invalidateQueries({ queryKey: ['todos', someDate] })['todos', ...]로 시작하는 형제 쿼리를 남김없이 무효화합니다. 하나만 지우려던 호출이 그 네임스페이스 전체를 다시 페치시킵니다. {}를 넣어도 마찬가지고, 열거 프로퍼티가 없는 클래스 인스턴스도 마찬가지입니다.

같은 필터가 exact 경로에서는 0개, 부분 매칭 경로에서는 전부 매칭되는 대비


여기서 3절과의 비대칭이 생깁니다. Map은 쓰기에서는 과소 구분(전부 같은 슬롯)을, 읽기에서는 과대 매칭(전부 매칭)을 일으킵니다. 같은 값이 방향에 따라 반대로 망가집니다.

배열은 별도 분기를 타서 인덱스별로 비교하되 길이는 b 기준이므로, 중첩 배열에서도 프리픽스 매칭이 됩니다.

c.getQueryCache().findAll({ queryKey: ['todos', [1]] })
// → ["todos",[1,2,3]] 하나만

setQueryDefaults도 같은 함수를 씁니다

getQueryDefaultspartialMatchKey를 쓴다는 점은 놓치기 쉽습니다.

query-core/src/queryClient.ts
getQueryDefaults(queryKey: QueryKey) {
const defaults = [...this.#queryDefaults.values()]
const result = {}

defaults.forEach((queryDefault) => {
if (partialMatchKey(queryKey, queryDefault.queryKey)) {
Object.assign(result, queryDefault.defaultOptions)
}
})
return result
}

forEach로 전부 순회하며 매칭되는 것을 차례로 Object.assign 합니다. 즉 여러 기본값이 겹치면 나중에 등록된 것이 이깁니다. 구체적인 키가 이기는 게 아니라 등록 순서가 이깁니다. setQueryDefaults(['todos'], ...)setQueryDefaults(['todos', 'detail'], ...)을 함께 쓴다면 등록 순서를 의식해야 합니다.


Recap

exact: true는 해시 문자열을 비교하고 기본값인 부분 매칭은 원본 키 객체를 재귀로 비교합니다. 순회 대상이 필터로 넘긴 쪽이라 프리픽스 매칭이 성립하고, 넘긴 값에 열거 가능한 프로퍼티가 없으면 반복문이 한 번도 돌지 않아 형제 쿼리가 전부 매칭됩니다. 같은 Map 하나가 쓰기에서는 과소 구분을, 읽기에서는 과대 매칭을 일으키는 셈입니다. setQueryDefaults 역시 같은 함수를 쓰며 겹칠 경우 등록 순서가 늦은 쪽이 이깁니다.




5. 쓰기 — 구조적 공유가 지키는 것과 포기하는 것

데이터를 넣는 경로는 Query.setData 하나로 모입니다.

query-core/src/query.ts
setData(newData: TData, options?: SetDataOptions & { manual: boolean }): TData {
const data = replaceData(this.state.data, newData, this.options)
this.#dispatch({ data, type: 'success', /* ... */ })
return data
}
query-core/src/utils.ts
export function replaceData(prevData, data, options) {
if (typeof options.structuralSharing === 'function') {
return options.structuralSharing(prevData, data)
} else if (options.structuralSharing !== false) {
if (process.env.NODE_ENV !== 'production') {
try {
return replaceEqualDeep(prevData, data)
} catch (error) {
console.error(
`Structural sharing requires data to be JSON serializable. ` +
`To fix this, turn off structuralSharing or return JSON-serializable ` +
`data from your queryFn. [${options.queryHash}]: ${error}`,
)
throw error
}
}
return replaceEqualDeep(prevData, data)
}
return data
}

replaceEqualDeep새 데이터가 이전 데이터와 깊은 값이 같은 부분은 이전 참조를 그대로 재사용합니다. 목적은 오직 참조 안정성입니다. 5초마다 폴링하는 목록에서 실제로는 아무것도 안 바뀌었는데 매번 새 배열이 오면 React.memouseMemo도 전부 무력해지니까요.


순회 대상은 plain object와 plain array뿐입니다

query-core/src/utils.ts
export function replaceEqualDeep(a: any, b: any, depth = 0): any {
if (a === b) return a
if (depth > 500) return b

const array = isPlainArray(a) && isPlainArray(b)
if (!array && !(isPlainObject(a) && isPlainObject(b))) return b // ★

// ... bItems를 돌며 재귀, 전부 같으면 a를 그대로 반환
return aSize === bSize && equalItems === aSize ? a : copy
}

export function isPlainArray(value: unknown): value is Array<unknown> {
return Array.isArray(value) && value.length === Object.keys(value).length
}

★ 표시한 줄이 전부입니다. plain object도 plain array도 아니면 비교를 포기하고 새 값(b)을 그대로 돌려줍니다. Date, Map, Set, 클래스 인스턴스가 전부 여기 걸립니다.

여기서 오해하기 쉬운데, 구조적 공유가 Date망가뜨리지는 않습니다. DateDate로 남습니다. 문제는 참조가 매번 새것이 된다는 것이고, 그게 위로 전파됩니다.

const prev = { list: [{ id: 1 }], at: new Date(0) }
const next = { list: [{ id: 1 }], at: new Date(0) } // 값은 완전히 동일

const out = replaceEqualDeep(prev, next)

out === prev // → false ← 루트가 새 객체
out.list === prev.list // → true ← 배열은 공유됨
out.at === prev.at // → false
out.at === next.at // → true ← Date는 새 쪽 것

list는 이전 참조를 재사용하지만 Date 때문에 루트가 새 객체가 되는 그림


at 하나 때문에 루트 객체의 참조가 매 페치마다 바뀝니다. 그러면 이런 코드가 무한 루프가 됩니다.

const { data } = useQuery({ queryKey: ['report'], queryFn: fetchReport })

useEffect(() => {
syncToStore(data) // data 참조가 매번 달라져 계속 실행됨
}, [data])

queryFn에서 응답을 파싱해 Date로 바꾸는 흔한 패턴이 정확히 이 상황을 만듭니다. 날짜는 문자열로 두고 필요한 시점에 포맷하는 편이 캐시 입장에서는 훨씬 안전합니다.

배열에 프로퍼티를 붙이는 경우도 같은 결과입니다.

const a1 = [1, 2]; a1.total = 2
const a2 = [1, 2]; a2.total = 2

replaceEqualDeep(a1, a2) === a1 // → false

isPlainArraylength === Object.keys(value).length를 요구하므로 total 같은 부가 프로퍼티가 붙는 순간 plain array가 아니게 되고, plain object도 아니라서 통째로 교체됩니다. 페이지네이션 메타데이터는 배열 옆에 두고 { items, total } 형태로 감싸는 게 맞습니다.


depth 500과 순환 참조

depth > 500이면 비교를 포기하고 b를 돌려줍니다. 스택 오버플로 방어 장치라서, 아주 깊은 트리 데이터는 어느 깊이부터 조용히 공유가 끊깁니다.

순환 참조가 있는 데이터는 개발 환경에서 위에 인용한 친절한 메시지와 함께 던지고, 프로덕션에서는 그대로 던집니다. 이 경우 structuralSharing: false가 정답입니다.


select도 같은 경로를 지납니다

query-core/src/queryObserver.ts
if (options.select && data !== undefined && !skipSelect) {
if (
prevResult &&
data === prevResultState?.data &&
options.select === this.#selectFn // ★
) {
data = this.#selectResult // 메모된 결과 재사용
} else {
this.#selectFn = options.select
data = options.select(data as any)
data = replaceData(prevResult?.data, data, options) // select 결과에도 구조적 공유
this.#selectResult = data
}
}

select의 메모이제이션 조건은 원본 데이터의 참조가 같을 것select 함수의 참조가 같을 것 두 가지입니다. 인라인 화살표 함수를 넘기면 두 번째 조건이 항상 깨지므로 select가 매번 다시 실행됩니다.

다만 그 결과도 replaceData를 통과하므로 반환값이 plain object/array라면 최종 참조는 안정적으로 유지됩니다. 그래서 인라인 select는 대개 문제가 안 보이지만, 결과에 Date나 클래스 인스턴스를 담는 순간 5-1절의 문제가 그대로 재현됩니다. select가 비싼 연산을 한다면 함수를 모듈 스코프로 빼거나 useCallback으로 고정하는 게 좋습니다.


Recap

replaceEqualDeep은 plain object와 plain array만 순회하고 나머지는 비교를 포기해 새 값으로 통째 교체합니다. 그래서 Date 하나가 섞이면 값이 완전히 같아도 루트 참조가 매 페치마다 바뀌고, data를 의존성에 둔 이펙트가 계속 다시 실행됩니다. 배열에 부가 프로퍼티를 붙이면 plain array 판정을 잃어 공유가 끊기고, 깊이가 500을 넘으면 그 아래부터는 비교하지 않습니다. select의 결과도 같은 경로를 지나므로 반환값을 JSON으로 표현 가능한 형태로 두는 편이 안전합니다.




6. 직렬화 — dehydrate와 hydrate

SSR, 프리페치, persistQueryClient가 전부 이 경로를 씁니다.

query-core/src/hydration.ts
function dehydrateQuery(query, serializeData, shouldRedactErrors): DehydratedQuery {
return {
dehydratedAt: Date.now(),
state: {
...query.state,
...(query.state.data !== undefined && { data: serializeData(query.state.data) }),
},
queryKey: query.queryKey,
queryHash: query.queryHash, // ★ 해시를 그대로 실어 보냄
...(query.state.status === 'pending' && { promise: dehydratePromise() }),
...(query.meta && { meta: query.meta }),
}
}

export function defaultShouldDehydrateQuery(query: Query) {
return query.state.status === 'success'
}

기본값은 성공한 쿼리만 옮깁니다

defaultShouldDehydrateQuerystatus === 'success'이므로 에러와 pending 상태는 기본적으로 빠집니다.

// cache:      ["ok"]:success, ["bad"]:error, ["pending"]:pending
// dehydrated: ["ok"]

서버에서 실패한 요청은 클라이언트가 아무것도 모르는 상태로 시작해 처음부터 다시 시도합니다. 대개는 원하는 동작이지만, 서버 에러를 그대로 보여주려면 shouldDehydrateQuery를 직접 넘겨야 합니다.


해시는 재계산되지 않습니다

hydrate는 페이로드에 담긴 queryHashbuild()에 그대로 넘깁니다. 1절에서 본 options.queryHash ?? hashQueryKeyByOptions(...)의 왼쪽 가지입니다. 해시를 다시 계산하지 않습니다.

이 설계 덕분에 queryKeyHashFn을 커스터마이즈해도 서버와 클라이언트의 해시가 어긋나지 않습니다. 대신 부작용이 하나 생깁니다.

하이드레이션 이후 캐시에 남는 queryKey는 원본이 아니라 JSON 왕복본입니다. 해시는 원본 기준으로 맞지만, partialMatchKey가 보는 원본 키 객체는 이미 변형돼 있습니다. 4절의 부분 매칭이 서버에서 온 쿼리와 클라이언트에서 만든 쿼리에 대해 다르게 판정될 수 있다는 뜻입니다.


왕복하면 이렇게 남습니다

server.setQueryData(['post', 1], {
title: 'hi',
at: new Date(0),
tags: new Set(['a']),
draft: undefined,
})

const wire = JSON.parse(JSON.stringify(dehydrate(server)))
hydrate(client, wire)

client.getQueryData(['post', 1])
// → { title: 'hi', at: '1970-01-01T00:00:00.000Z', tags: {} }
  • atDate에서 문자열
  • tagsSet에서 {}
  • draft키 자체가 사라짐

서버 캐시의 값들이 JSON을 건너오며 형태를 잃는 표

그리고 TypeScript는 여전히 at: Date라고 말합니다. dehydrate/hydrate의 시그니처에는 이 변환이 드러나지 않기 때문입니다. data.at.getFullYear()가 서버 렌더 직후에만 TypeError를 던지는 종류의 버그가 여기서 나옵니다.

이건 TanStack Query의 문제라기보다 직렬화 경계를 타입이 표현하지 못하는 문제입니다. 해결책은 두 가지입니다.

하나, 경계에서 형태를 유지하는 직렬화기를 쓰는 것. serializeData/deserializeData 훅이 이걸 위해 있습니다.

new QueryClient({
defaultOptions: {
dehydrate: { serializeData: superjson.serialize },
hydrate: { deserializeData: superjson.deserialize },
},
})

둘, 애초에 JSON으로 표현 가능한 데이터만 캐시에 넣는 것. 개인적으로는 이쪽을 선호합니다. 5절의 구조적 공유 문제까지 같이 해결되기 때문입니다. 캐시에 들어가는 값은 전송 포맷 그대로 두고, 도메인 타입으로의 변환은 화면 쪽에서 하는 편이 전체적으로 문제를 덜 만듭니다.


Recap

dehydrate는 기본적으로 성공한 쿼리만 옮기므로 서버에서 실패했거나 아직 진행 중인 쿼리는 클라이언트가 처음부터 다시 시작합니다. queryHash를 그대로 실어 보내 재계산하지 않는 덕분에 커스텀 해시 함수를 써도 양쪽이 어긋나지 않지만, 그 대가로 하이드레이션 이후 원본 키는 JSON 왕복본이 됩니다. 데이터 쪽에서는 Date가 문자열로, Set{}로 남고 undefined 프로퍼티는 사라지는데 타입은 아무것도 바뀌지 않았다고 말합니다. 형태를 지켜야 한다면 serializeDatadeserializeData를 물리고, 그럴 이유가 없다면 애초에 JSON으로 표현 가능한 값만 캐시에 넣는 편이 낫습니다.




7. 수명 — gcTime이 무시되는 경우

캐시에서 나가는 경로도 짧습니다.

query-core/src/removable.ts
export abstract class Removable {
gcTime!: number
#gcTimeout?: ManagedTimerId

protected scheduleGc(): void {
this.clearGcTimeout()
if (isValidTimeout(this.gcTime)) {
this.#gcTimeout = timeoutManager.setTimeout(() => {
this.optionalRemove()
}, this.gcTime)
}
}

protected updateGcTime(newGcTime: number | undefined): void {
this.gcTime = Math.max(
this.gcTime || 0,
newGcTime ?? (environmentManager.isServer() ? Infinity : 5 * 60 * 1000),
)
}
}

export function isValidTimeout(value: unknown): value is number {
return typeof value === 'number' && value >= 0 && value !== Infinity
}

세 줄에 함정이 세 개 있습니다.


Math.max — gcTime은 줄어들지 않습니다

같은 키를 여러 컴포넌트가 서로 다른 gcTime으로 구독하면 가장 큰 값이 이기고, 이후로 절대 내려가지 않습니다.

query.gcTime                         // → 300000  (기본 5분)
query.setOptions({ gcTime: 3600_000 })
query.gcTime // → 3600000
query.setOptions({ gcTime: 1000 })
query.gcTime // → 3600000 ← 1000이 아님

어딘가 한 곳에서 크게 잡아두면 그 쿼리는 앱이 사는 동안 그 값을 유지합니다.


Infinity는 타이머를 아예 걸지 않습니다

isValidTimeoutInfinity를 거르므로 타이머가 등록되지 않고, 그 쿼리는 수동으로 지우기 전까지 영원히 남습니다. 의도한 동작이지만, 서버에서는 기본값이 Infinity라는 점을 같이 기억해야 합니다. 요청마다 QueryClient를 새로 만들지 않으면 서버 프로세스의 캐시가 계속 자랍니다.


2³¹을 넘는 값은 즉시 GC됩니다

setTimeout의 지연 시간은 32비트 부호 있는 정수로 제한됩니다. 넘으면 오버플로해서 즉시 실행됩니다. 라이브러리에는 이 값을 클램프하는 코드가 없습니다.

const DAYS_30 = 30 * 24 * 60 * 60 * 1000   // 2,592,000,000
DAYS_30 > 2 ** 31 - 1 // → true

const c = new QueryClient({
defaultOptions: { queries: { gcTime: DAYS_30 } },
})
c.setQueryData(['long'], 1)

c.getQueryCache().getAll().length // → 1
// 30ms 후
c.getQueryCache().getAll().length // → 0 ← 30일이 아니라 1ms 만에 사라짐

Node는 TimeoutOverflowWarning을 찍어주지만 브라우저는 아무 말도 하지 않습니다. "오래 유지"가 목적이라면 gcTime: Infinity를 쓰고, 유한한 값을 쓸 거라면 24.8일(2 ** 31 - 1 밀리초) 미만이어야 합니다. staleTime 타이머도 같은 setTimeout 경로를 지나므로 동일한 상한이 적용됩니다.


Recap

gcTimeMath.max로만 갱신되므로 한 곳에서 크게 잡으면 그 쿼리는 앱이 사는 동안 그 값을 유지합니다. Infinity는 타이머를 아예 등록하지 않아 수동으로 지우기 전까지 남으며, 서버 환경의 기본값이 바로 그것입니다. 그리고 setTimeout의 32비트 상한 때문에 24.8일을 넘는 값은 오버플로해 즉시 GC를 유발하는데, 브라우저는 이때 아무 경고도 주지 않습니다.




8. 정리 — 캐시를 안전하게 쓰는 규칙

소스를 따라오면 규칙은 몇 줄로 줄어듭니다.

쿼리 키

  • JSON으로 표현 가능한 값만 넣습니다. string, 유한한 number, boolean, null, plain object, 배열.
  • Map, Set, URLSearchParams, Date, 클래스 인스턴스는 넣지 않습니다. 꼭 필요하면 toJSON()을 구현하거나 원시 값으로 펴서 넣습니다.
  • 렌더마다 새로 만들어지는 값(new Date(), Date.now())을 넣지 않습니다. 캐시가 무한히 자랍니다.
  • 순서에 의미가 없는 배열은 호출부에서 정렬합니다.

무효화

  • invalidateQueries에 넘기는 필터 키에도 같은 규칙이 적용됩니다. DateMap을 넣으면 형제 쿼리가 전부 무효화됩니다.
  • 정확히 하나만 노리려면 exact: true를 붙입니다. 판정 기준이 해시 비교로 바뀝니다.
  • setQueryDefaults가 여러 개 겹치면 등록 순서가 늦은 쪽이 이깁니다.

데이터

  • 캐시에 넣는 값은 응답 포맷 그대로 둡니다. Date나 클래스로 변환하면 구조적 공유가 끊겨 참조가 매번 바뀝니다.
  • 배열에 부가 프로퍼티를 붙이지 않습니다. { items, total }로 감쌉니다.
  • 순환 참조가 있다면 structuralSharing: false를 켭니다.

직렬화

  • SSR과 persist를 쓴다면 하이드레이션 이후의 데이터는 JSON 왕복본이라고 가정합니다. 타입은 이 변환을 말해주지 않습니다.
  • 형태를 유지해야 한다면 serializeData/deserializeData에 superjson 같은 직렬화기를 물립니다.
  • 기본값은 성공한 쿼리만 옮깁니다. 에러 상태도 옮기려면 shouldDehydrateQuery를 직접 넘겨야 합니다.

수명

  • gcTimeMath.max로만 갱신되어 줄어들지 않습니다.
  • 24.8일을 넘는 gcTime은 즉시 GC를 유발합니다. 영구 보관은 Infinity를 씁니다.

관통하는 원칙은 하나입니다. TanStack Query의 캐시는 JSON을 전제로 설계돼 있습니다. 해시도, 매칭도, 구조적 공유도, 직렬화도 모두 JSON이 표현할 수 있는 값의 범위 안에서만 정확합니다. 그 밖의 값을 넣는 순간 라이브러리는 예외를 던지는 대신 조용히 최선을 다하고, 그 최선이 대체로 {}이거나 null이거나 새 참조입니다.


References

좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자