Query Key는 캐시의 주소다: TanStack Query 캐시 아키텍처 설계
서버 상태(Server State)를 다룰 때 직접 fetch와 useEffect를 조합하면 로딩, 에러, 캐시 동기화 등 수많은 부수 효과를 개발자가 직접 제어해야 합니다.
const [data, setData] = useState();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("/api/todos")
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);TanStack Query를 도입하면 비동기 통신과 상태 관리가 선언적인 형태로 단순화됩니다.
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});여기서 핵심적인 역할을 하는 것은 비동기 요청을 수행하는 queryFn뿐만이 아닙니다. 그 데이터를 캐시 저장소 어디에 할당하고 관리할지 결정하는 queryKey의 설계 가 시스템의 안정성을 좌우합니다.
queryFn이 데이터를 가져오는 함수라면, queryKey는 해당 데이터를 메모리에 보관하는 캐시의 물리적/논리적 주소(Address) 입니다. 동일한 주소를 지정하면 동일한 캐시를 공유하고, 주소가 다르면 독립된 캐시로 격리됩니다.
따라서 Query Key 설계는 단순한 명명 규칙의 문제가 아니라, 클라이언트 캐시의 라이프사이클과 격리 정책을 결정하는 아키텍처적 작업 입니다.
Query Key는 라벨이 아니라 주소입니다
TanStack Query는 서버 응답을 메모리 캐시에 보관하며, 어떤 요청들을 동일한 데이터 집합으로 취급할 것인지를 판단합니다. 그 판단의 절대적인 기준이 바로 queryKey입니다.
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});위 코드는 todos 응답을 ["todos"]라는 고유 주소에 저장합니다. 동일한 queryKey를 선언한 모든 컴포넌트는 별도의 네트워크 요청 없이 이 캐시를 공유하여 구독합니다.
반대로 조회 조건이 달라지면 반드시 주소 역시 분리되어야 합니다.
useQuery({
queryKey: ["todos", { status: "done" }],
queryFn: fetchDoneTodos,
});
useQuery({
queryKey: ["todos", { status: "pending" }],
queryFn: fetchPendingTodos,
});두 쿼리는 모두 todos 도메인을 다루지만 필터 조건이 다릅니다. 주소가 명확히 분기되어 있으므로 서로의 캐시를 오염시키지 않고 안전하게 독립 보관됩니다.
계층형 배열(Top-Level Array) 구조의 필요성
TanStack Query 공식 문서는 Query Key가 반드시 최상위 배열(Array) 형태여야 한다고 명시합니다.
["todos"];
["todo", id];
["todos", { status, page }];이 규칙은 단순한 문법적 제약이 아니라, 데이터의 도메인 계층 구조를 직관적으로 표현하기 위한 약속 입니다.
["todos"]; // todos 도메인 전체
["todos", "list", { status, page }]; // 필터링 조건이 적용된 목록
["todos", "detail", id]; // 특정 todo 단건 상세이러한 계층형 배열 구조는 캐시 무효화(Invalidation) 시점에 강력한 이점을 제공합니다.
// 'todos'로 시작하는 모든 하위 목록 및 상세 캐시를 일괄 무효화
queryClient.invalidateQueries({
queryKey: ["todos"],
});상위 주소를 기준으로 접두사(Prefix) 매칭이 적용되므로, 세부 쿼리들을 일일이 명시하지 않고도 도메인 단위로 안전하게 최신 상태를 동기화할 수 있습니다.
queryFn이 참조하는 모든 상태는 queryKey에 포함되어야 합니다
queryFn 내부에서 참조하는 동적 파라미터나 상태값이 존재한다면, 그 값은 반드시 queryKey에도 포함되어야 합니다.
// ❌ 위험한 패턴: 파라미터가 key에 누락된 경우
useQuery({
queryKey: ["todo"],
queryFn: () => fetchTodo(todoId),
});이 코드는 todoId가 변경되더라도 queryKey가 고정되어 있어, TanStack Query 엔진은 동일한 쿼리로 인식합니다. 그 결과 이전 todoId의 캐시가 잘못 재사용되거나 네트워크 재요청이 트리거되지 않는 치명적인 버그가 발생합니다.
// ✅ 올바른 패턴: 의존성 파라미터가 명시된 경우
useQuery({
queryKey: ["todo", todoId],
queryFn: () => fetchTodo(todoId),
});실무적인 관점에서 Query Key는 queryFn을 위한 Dependency Array 로 동작합니다. 요청에 영향을 미치는 모든 변수를 키에 선언해야 캐시 정합성이 보장됩니다.
객체 키의 순서와 배열 인덱스의 결정적 차이
Query Key를 구성할 때 객체와 배열의 해싱 차이를 정확히 이해해야 합니다.
객체 내부의 키 순서는 캐시 해싱에 영향을 주지 않습니다.
useQuery({
queryKey: ["todos", { status, page }],
queryFn: fetchTodos,
});
useQuery({
queryKey: ["todos", { page, status }],
queryFn: fetchTodos,
});TanStack Query는 객체 직렬화 시 키를 알파벳순으로 자동 정렬하여 비교하므로, 위 두 쿼리는 동일한 캐시 주소로 평가됩니다.
그러나 배열 내부의 인덱스 순서는 엄격한 위치적 의미(Positional Meaning) 를 갖습니다.
useQuery({
queryKey: ["todos", status, page],
queryFn: fetchTodos,
});
useQuery({
queryKey: ["todos", page, status],
queryFn: fetchTodos,
});배열의 순서가 뒤바뀌면 완전히 다른 주소로 인식됩니다. 따라서 순서에 구애받지 않아야 하는 필터나 정렬 옵션 등은 반드시 객체 형태로 묶어서 배열의 마지막 요소로 전달하는 것이 안전합니다.
Query Key Factory 패턴을 통한 중앙 집중화
규모가 큰 프로젝트에서는 문자열 리터럴을 직접 작성하는 대신 Query Key Factory 객체 를 구성하여 중앙에서 관리하는 패턴이 권장됩니다.
표준적인 계층 구조는 다음과 같은 스키마로 정립합니다.
[domain, scope, id?, params?]export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: { status?: string; page?: number }) =>
[...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};이 패턴을 적용하면 다음과 같은 공학적 이점을 확보할 수 있습니다.
- 타입 안전성(Type Safety): 팩토리 함수의 매개변수 타입을 통해 잘못된 파라미터 주입을 컴파일 타임에 차단합니다.
- 리팩토링 용이성: 캐시 키 스키마가 변경되더라도 팩토리 정의 한 곳만 수정하면 전체 코드베이스에 안전하게 반영됩니다.
- 일관된 Invalidation 스코프: 도메인 전체, 목록 전체, 특정 상세 단위의 무효화 범위를 명확하게 지정할 수 있습니다.
// 특정 필터 목록 조회
useQuery({
queryKey: todoKeys.list({ status: "done", page: 1 }),
queryFn: ({ queryKey }) => {
const [, , filters] = queryKey;
return fetchTodoList(filters);
},
});
// 단건 수정 후 목록 캐시만 선별적 무효화
queryClient.invalidateQueries({
queryKey: todoKeys.lists(),
});Query Key 설계 시 자주 범하는 흔한 실수
queryFn내부 참조 변수 누락:id,page,status등 요청 파라미터를 키에 누락하여 이전 캐시가 오염되는 현상.- 배열 인덱스 위치의 비일관성: 팀원마다 배열 요소 순서를 임의로 배치하여 동일 리소스에 대해 불필요한 캐시가 중복 생성되는 현상.
- 직렬화 불가능한(Non-serializable) 객체 주입: 함수나 클래스 인스턴스, DOM 참조 등을 키에 전달하여 해시 계산이 깨지는 현상.
- 일시적인 UI 상태의 키 오염: 모달의 개폐 여부나 애니메이션 상태처럼 서버 데이터와 무관한 클라이언트 UI 상태를 키에 포함하여 캐시를 과도하게 파편화하는 현상.
결론: 예측 가능한 캐시가 안정적인 UI를 만듭니다
Query Key 설계는 단순한 코드 작성이 아니라, 서버 상태와 클라이언트 상태 간의 정합성을 보장하는 핵심 아키텍처입니다.
- 동일한 데이터는 동일한 키를 사용합니다.
- 파라미터나 상태가 다르면 명확히 분기된 키를 사용합니다.
queryFn이 의존하는 모든 변수는 키에 선언합니다.- 선택적 파라미터는 객체로 캡슐화합니다.
- Query Key Factory를 통해 도메인별 계층 구조를 중앙에서 표준화합니다.
이 원칙들을 준수할 때 캐시의 동작을 완벽하게 예측할 수 있으며, 불필요한 네트워크 비용을 절감하고 높은 사용자 경험을 제공할 수 있습니다.