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

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

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

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

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

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

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


이 글에서 다루는 내용

TanStack Query의 QueryCacheMap<string, Query>에 쿼리를 보관합니다. 해시 문자열로 하나를 찾는 경로와 원본 키를 비교해 여러 쿼리를 고르는 경로가 따로 있습니다. 이 차이를 이해하려면 키를 문자열로 만드는 과정캐시에 담긴 값을 비교하고 옮기는 과정을 함께 봐야 합니다.

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


 

인용한 코드는 모두 @tanstack/query-core@5.101.4src/ 기준이며 설명에 필요한 부분만 발췌했습니다. 실행 결과는 해당 버전의 빌드 산출물로 확인했습니다. 별도 언급이 없으면 기본 해시 함수와 구조적 공유 설정을 사용하며, 타이머 오버플로 예제는 Node.js 기준입니다.




캐시를 담는 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.get(queryHash)로 조회하며 문자열 값이 같으면 같은 쿼리를 찾습니다. 원본 객체의 참조를 비교하거나 프리픽스 인덱스를 사용하는 방식은 아닙니다.

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

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





hashKey: 정렬 replacer를 끼운 JSON.stringify

기본 해시 함수의 구현은 짧습니다.

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]";
}

이 조건을 통과하지 못하는 값에는 객체 키 정렬을 적용하지 않습니다. 클래스 인스턴스, Map, Set 등의 표현은 JSON.stringify의 규칙에 맡겨집니다. 모든 JavaScript 값을 원래 형태로 보존하는 규칙은 아니므로 어떤 결과가 나오는지 확인해야 합니다. Date는 아래의 toJSON() 단계도 거칩니다.


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

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

  1. 값을 읽는다
  2. 값이 객체 또는 BigInt이고 호출 가능한 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()은 해시 결과에만 영향을 줍니다. 뒤에서 볼 부분 매칭은 원본 키를 비교하므로 이 메서드만으로 두 경로가 같아지지는 않습니다. 키를 만들 때 필요한 필드를 plain object나 원시 값으로 직접 꺼내는 편이 일관적입니다.




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

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

입력                                       해시 결과
────────────────────────────────────────────────────────────────────
['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', 자기 자신을 참조하는 plain object] RangeError: Maximum call stack size exceeded

값의 형태에 따라 나누어 보겠습니다.


{}가 되는 것들: 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이라는, 타입도 내용도 다른 두 값이 같은 캐시 엔트리 하나를 공유합니다. 이 예제에서는 나중의 setQueryData가 앞의 값을 덮습니다. useQuery에서도 필터 변화가 키에 반영되지 않으면 같은 쿼리를 재사용합니다. 어떤 목록이 보일지는 남아 있는 데이터와 요청·갱신 시점에 영향을 받으므로 필터별 데이터를 구분할 수 없게 됩니다.

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

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

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

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라면 구분이 사라집니다.


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()처럼 값이 바뀌는 표현도 같은 문제를 만듭니다.

반면 ['report', { page: 1 }]처럼 내용이 같은 plain object 리터럴은 렌더마다 새로 만들어도 해시가 같습니다. 객체 참조가 바뀌었다는 이유만으로 useMemo를 붙일 필요는 없습니다.

키에는 실제 요청을 구분하는 값을 넣습니다. 일별 보고서라면 기준 시간대에 맞춘 날짜 문자열을, 특정 순간의 조회라면 선택한 시각의 ISO 문자열을 사용합니다. 일별 조회에 필요한 날짜를 표현하는 예시는 다음과 같습니다.

queryKey: ["report", { date: reportDate }]; // reportDate: '2026-03-17'

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


클래스 인스턴스: 정렬을 못 받습니다

클래스 인스턴스는 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가 같은 캐시가 됩니다. 필드 구성이나 대입 순서를 바꾸면 이전과 다른 해시를 만들 수 있습니다. 이는 기존 엔트리의 무효화가 아니라 다른 엔트리를 조회하는 동작입니다.


던지는 것: BigInt와 순환 참조

별도의 직렬화 처리가 없는 BigIntTypeError를 던집니다. 순환 참조도 실패하지만 예외 종류는 구조에 따라 다릅니다. 위의 자기 참조 plain object는 정렬 replacer가 새 객체를 계속 만들어 RangeError가 나고, 자기 참조 배열은 JSON.stringify의 순환 검사에서 TypeError가 납니다. 쿼리 키에는 순환 구조를 넣지 않습니다.


 

기본 동작에서 키를 구성할 때 권하는 값string, number(유한값), boolean, null, 그리고 이들로만 구성된 plain object와 배열입니다. 같은 해시가 되는 값들을 서비스에서도 같은 요청 조건으로 취급하는지 확인해야 합니다.

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

new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: (key) => {
// assertSerializable은 팀에서 구현할 검증 함수다.
if (process.env.NODE_ENV !== "production") assertSerializable(key);
return hashKey(key);
},
},
},
});

Recap

MapSet은 내용을 열거 가능한 프로퍼티로 갖지 않아 전부 {}가 되고, 배열 위치의 undefined·함수·NaN은 전부 null이 되며, 객체 프로퍼티의 undefined는 키째 사라집니다. Date는 ISO 문자열이 되며 시각 값이 달라져야 해시도 바뀝니다. 내용이 같은 plain object는 새로 만들어도 같은 해시이고, 클래스 인스턴스에는 키 정렬이 적용되지 않습니다. BigInt와 순환 참조는 위 예제에서 예외를 던집니다. 쿼리 키에 안전한 값은 문자열, 유한한 숫자, 불리언, null, 그리고 이들로만 이뤄진 plain object와 배열입니다.




조회의 두 기준: 해시와 원본 키

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는 해시 문자열을 비교하고, findAll·invalidateQueries의 기본 부분 매칭은 원본 키를 재귀로 비교합니다. QueryCache.find()는 이들과 달리 기본값이 exact: true입니다. 캐시를 담는 Map에서 말한 이중성이 여기서 드러납니다. 같은 필터를 넘겨도 두 경로의 판정 기준이 아예 다릅니다.

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 }]을 매칭하는 프리픽스 동작이 여기서 나옵니다.


열거 프로퍼티가 없는 필터의 매칭 범위

비교하는 ab가 모두 null이 아닌 객체이고, 필터 b가 일반적인 Map이나 Date라면 Object.keys(b)는 빈 배열입니다. 확인할 프로퍼티가 없어 true가 됩니다. 해당 위치의 a가 숫자·문자열·null이거나 아예 없다면 이 객체 분기에 들어오지 않으므로 같은 결과가 아닙니다.

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()] })
.map((q) => q.queryHash);
// → ["todos",{"page":1}] , ["todos","1970-01-01T00:00:00.000Z"] , ["todos",[1,2,3]]

c.getQueryCache()
.findAll({ queryKey: ["todos", new Date(1)] })
.map((q) => q.queryHash);
// → 위와 동일하게 전부

c.getQueryCache()
.findAll({ queryKey: ["todos", {}] })
.map((q) => q.queryHash);
// → 위와 동일하게 전부

이 예제에서는 서로 다른 세 객체 키가 모두 무효화 대상이 됩니다. 다만 ['todos', 1]이나 ['todos']까지 매칭되는 것은 아닙니다. 무효화는 캐시 삭제와도 다르며, 기본적으로 매칭된 활성 쿼리를 다시 요청합니다. 실제 재요청 여부에는 enabled·staleTime 등의 조건도 적용됩니다.


여기서 해시가 조용히 무너지는 지점들과의 비대칭이 생깁니다. Map을 키에 넣으면 다른 내용이 같은 해시가 되고, 필터에 넣으면 의도보다 많은 객체 키가 매칭될 수 있습니다. 해시만 커스터마이즈해도 부분 매칭의 기준은 바뀌지 않습니다.

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

c.getQueryCache()
.findAll({ queryKey: ["todos", [1]] })
.map((q) => q.queryHash);
// → ["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'], ...)을 함께 쓴다면 등록 순서를 의식해야 합니다.




쓰기: 구조적 공유가 지키는 것과 포기하는 것

요청 성공 결과와 setQueryData로 넣는 값은 Query.setData를 거칩니다. 뒤에서 볼 hydrate처럼 상태를 직접 복원하는 경로는 구분해야 합니다.

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초마다 폴링하는 목록의 내용이 같아도 배열 참조가 바뀌면, 그 배열을 prop이나 의존성으로 사용하는 React.memo·useMemo는 이전 결과를 재사용하기 어렵습니다.


순회 대상은 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;
}

먼저 a === b라면 같은 참조를 그대로 반환합니다. 서로 다른 값일 때는 ★ 표시한 분기처럼 양쪽이 plain object나 plain array로 비교 가능한 경우에만 내부를 순회하고, 그 밖에는 새 값(b)을 반환합니다. Date, Map, Set, 클래스 인스턴스가 전부 여기 걸립니다.

여기서 오해하기 쉬운데, 구조적 공유가 Date망가뜨리지는 않습니다. DateDate로 남습니다. 응답을 처리할 때마다 새 Date를 만들면 같은 시각이어도 이전 참조를 재사용하지 못하며, 그 영향이 상위 객체로 전파됩니다.

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는 새 쪽 것

응답마다 새 Date를 만들면 루트 객체의 참조도 갱신마다 바뀝니다. 따라서 아래 이펙트는 같은 시각의 응답을 받아도 다시 실행될 수 있습니다.

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

useEffect(() => {
syncToStore(data); // 새 data 참조를 받은 갱신마다 실행됨
}, [data]);

이 코드만으로 무한 루프가 생기지는 않습니다. syncToStore가 다시 쿼리 키를 바꾸거나 요청을 유발하는 등 다음 데이터 갱신으로 이어질 때 루프가 될 수 있습니다. 날짜를 문자열로 캐시에 두고 표시할 때 변환하면 같은 응답에서 참조를 재사용하기 쉽습니다.

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

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 또는 별도의 공유 함수를 검토하고, JSON 전송 가능 여부는 따로 확인합니다.


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나 클래스 인스턴스를 담는 순간 구조적 공유의 문제가 그대로 재현됩니다. select가 비싼 연산을 한다면 함수를 모듈 스코프로 빼거나 useCallback으로 고정하는 게 좋습니다.


Recap

replaceEqualDeep은 같은 참조를 먼저 재사용하고, 서로 다른 plain object와 plain array의 내용을 비교합니다. 응답마다 새 Date를 만들면 같은 시각이어도 상위 객체의 참조가 바뀔 수 있지만 이것만으로 이펙트가 무한 반복되지는 않습니다. select 결과도 같은 처리를 거치므로 원본 데이터와 선택 결과의 형태를 함께 확인합니다.




직렬화: dehydrate와 hydrate

SSR에서 서버 캐시를 클라이언트로 옮기거나 persistQueryClient로 저장·복원할 때 이 경로를 사용합니다. 프리페치 자체는 캐시에 데이터를 채우는 동작이며 dehydrate를 반드시 거치는 것은 아닙니다. 또한 dehydrate는 캐시 상태를 객체로 추출할 뿐 JSON 문자열을 만들지는 않습니다.

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"]

제외된 쿼리의 상태는 이 페이로드로 전달되지 않습니다. 클라이언트에서 해당 쿼리를 사용하면 enabled 등 요청 조건에 따라 새로 가져옵니다. 에러 상태도 전달하려면 shouldDehydrateQuery로 포함하고, Error 객체의 전송 형식과 노출할 내용을 함께 정해야 합니다.


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

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

복원된 엔트리의 해시는 서버에서 보낸 값으로 유지됩니다. 하지만 클라이언트가 이후 useQuerygetQueryData로 조회할 때는 다시 해시를 계산하므로 서버와 클라이언트에서 같은 queryKeyHashFn을 사용해야 합니다. 설정이 다르면 복원한 엔트리를 찾지 못하고 다른 엔트리를 만들 수 있습니다.

중간에 일반 JSON 직렬화를 거쳤다면 복원된 queryKey도 JSON 왕복본입니다. 해시 문자열이 보존돼도 partialMatchKey가 보는 키의 형태는 달라질 수 있습니다. 조회의 두 기준의 부분 매칭이 서버에서 온 쿼리와 클라이언트에서 만든 쿼리에 대해 다르게 판정될 수 있다는 뜻입니다.


왕복하면 이렇게 남습니다

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키 자체가 사라짐

반대로 hydrate(client, dehydrate(server))처럼 같은 실행 환경에서 객체를 직접 전달하면 기본 설정의 Date는 그대로 남습니다. 위 예제의 변환은 중간의 JSON 왕복에서 발생합니다.

쿼리 함수의 반환 타입이나 getQueryData의 제네릭을 계속 at: Date로 선언하면 TypeScript는 런타임의 문자열 변환을 알아채지 못합니다. JSON으로 복원한 데이터에 data.at.getFullYear()를 호출하면 TypeError가 날 수 있습니다.

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

하나, 데이터의 형태를 복원할 수 있는 직렬화기를 쓰는 것. serializeData/deserializeData로 데이터 변환을 지정할 수 있습니다. 두 설정을 서버와 클라이언트에 맞춰 적용합니다.

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

이 훅이 변환하는 것은 쿼리의 data입니다. queryKey, meta, 에러까지 페이로드 전체를 자동으로 처리하지는 않으므로 전송 형식은 별도로 확인합니다.

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


Recap

dehydrate는 기본적으로 성공한 쿼리의 상태를 객체로 추출하고, hydrate는 전달받은 해시와 상태를 복원합니다. 데이터의 형태가 바뀌는지는 중간의 전송·저장 방식에 달려 있습니다. 일반 JSON을 거치면 Date·Set·undefined의 표현이 달라지므로 데이터 타입과 직렬화 정책을 맞춰야 합니다. 커스텀 해시 함수도 서버와 클라이언트에서 같은 규칙을 사용합니다.




수명: gcTime과 타이머의 조건

gcTime은 데이터의 신선도를 정하는 staleTime과 다릅니다. 옵저버가 없는 쿼리의 정리 타이머에 쓰이며, 시간이 만료돼도 fetchStatusidle인 경우에만 제거합니다. 아래는 타이머를 설정하는 부분입니다.

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이 아님

어딘가 한 곳에서 크게 잡아두면 그 Query 인스턴스가 유지되는 동안 큰 값이 남습니다. 엔트리를 제거한 뒤 새로 만들면 다시 결정됩니다.


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

isValidTimeoutInfinity를 거르므로 타이머가 등록되지 않고, 그 쿼리는 이 타이머에 의해 자동 정리되지 않습니다. 의도한 동작이지만, 서버에서는 기본값이 Infinity라는 점을 같이 기억해야 합니다. 서버에서 QueryClient를 여러 요청이 공유하면 메모리 사용뿐 아니라 사용자별 데이터가 섞이는지도 살펴야 합니다. 일반적인 SSR에서는 요청마다 클라이언트를 만들고 사용이 끝난 캐시를 정리합니다. Infinity는 새로고침이나 프로세스 종료 뒤까지 데이터를 보관하는 영속 저장 옵션이 아닙니다.


기본 타이머의 최대 지연 시간

기본 타이머는 플랫폼의 setTimeout을 사용합니다. 2 ** 31 - 1밀리초(약 24.8일)를 넘는 지연은 의도대로 동작하지 않을 수 있습니다. 아래의 30일 예제는 Node.js가 지연을 1ms로 바꾸므로 옵저버 없는 idle 쿼리가 빠르게 제거됩니다. 브라우저에서도 오버플로 동작이 있지만 모든 큰 값이 같은 지연으로 실행되는 것은 아닙니다.

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 ← Node.js가 타이머 지연을 1ms로 바꿈

Node.js에서는 TimeoutOverflowWarning도 확인할 수 있습니다. 기본 타이머에 큰 유한값을 그대로 넘기지 말고, 자동 정리를 끄려는 의도라면 Infinity를 사용합니다. 긴 유한 지연이 꼭 필요하면 timeoutManager.setTimeoutProvider로 장기 타이머를 지원하는 구현을 설정할 수 있습니다. staleTime 관련 타이머도 같은 관리자를 사용하므로 실제 타이머 구현을 함께 확인합니다.




정리: 캐시를 안전하게 쓰는 규칙

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

쿼리 키

  • JSON으로 표현 가능한 값만 넣습니다. string, 유한한 number, boolean, null, plain object, 배열.
  • Map, Set, URLSearchParams, Date, 클래스 인스턴스는 넣지 않습니다. 필요한 필드를 plain object나 원시 값으로 꺼내 키를 구성합니다.
  • 렌더 시점마다 달라지는 현재 시각·난수를 넣지 않습니다. 같은 내용을 담은 새 plain object는 같은 해시입니다.
  • 순서에 의미가 없는 배열은 호출부에서 정렬합니다.

무효화

  • invalidateQueries에 넘기는 필터 키에도 같은 규칙이 적용됩니다. 내용을 비교할 수 없는 DateMap 필터는 의도보다 많은 객체 키에 매칭될 수 있습니다.
  • 정확히 하나만 노리려면 exact: true를 붙입니다. 판정 기준이 해시 비교로 바뀝니다.
  • setQueryDefaults가 여러 개 겹치면 등록 순서가 늦은 쪽이 이깁니다.

데이터

  • 캐시에 넣는 값은 응답 포맷 그대로 둡니다. 응답마다 새 Date나 클래스로 변환하면 해당 부분의 참조를 재사용하지 못합니다.
  • 배열에 부가 프로퍼티를 붙이지 않습니다. { items, total }로 감쌉니다.
  • 순환 데이터가 꼭 필요하면 구조적 공유를 끄거나 별도 비교 함수를 사용합니다.

직렬화

  • SSR과 persist의 전송·저장 방식을 확인합니다. 일반 JSON을 거쳤다면 그 변환을 반영한 타입과 복원 처리가 필요합니다.
  • 형태를 유지해야 한다면 serializeData/deserializeData에 superjson 같은 직렬화기를 물립니다.
  • 기본값은 성공한 쿼리만 옮깁니다. 에러 상태도 옮기려면 포함 조건과 에러의 직렬화 방식을 함께 정합니다.

수명

  • gcTimeMath.max로만 갱신되어 줄어들지 않습니다.
  • 기본 타이머의 상한을 넘는 큰 유한값을 피합니다. Infinity는 자동 GC를 끄며 영속 저장과는 다릅니다.

이 글에서 살펴본 기본 해싱과 구조적 공유는 JSON으로 표현 가능한 값에 잘 맞습니다. 캐시에 다른 타입을 담을 수는 있지만 키의 구분, 부분 매칭, 참조 재사용, 전송 후 복원이 각각 어떻게 달라지는지는 따로 확인해야 합니다. 쿼리 함수의 요청 조건이 달라지면 키도 달라지고, 같은 조건이면 같은 키가 되는지부터 확인하면 검토를 시작하기 좋습니다.


References

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