Query Key는 캐시의 주소다
서버 데이터를 직접 가져오면 생각보다 많은 상태를 직접 관리해야 해요.
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는 그 데이터를 어디에 저장할지 정하는 주소예요. 같은 주소를 쓰면 같은 캐시를 공유하고, 다른 주소를 쓰면 다른 캐시로 나뉘어요.
그래서 Query Key 설계는 이름 짓기 문제가 아니에요. 캐시 정책을 정하는 일에 가까워요.
Query Key는 라벨이 아니라 주소예요
TanStack Query는 데이터를 캐시에 저장해요. 이때 어떤 데이터를 같은 데이터로 볼지 판단해야 해요.
그 기준이 queryKey예요.
useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});이 코드는 todos 목록을 ["todos"]라는 주소에 저장해요. 같은 queryKey를 쓰는 다른 쿼리는 이 캐시를 함께 봐요.
반대로 queryKey가 다르면 다른 데이터로 봐요.
useQuery({
queryKey: ["todos", { status: "done" }],
queryFn: fetchDoneTodos,
});
useQuery({
queryKey: ["todos", { status: "pending" }],
queryFn: fetchPendingTodos,
});두 쿼리는 모두 todos를 다루지만, 조회 조건이 달라요. 그래서 캐시도 나뉘어야 해요.
좋은 Query Key는 이 차이를 코드에 드러내요.
왜 배열로 시작해야 할까요
TanStack Query 공식 문서는 Query Key가 top-level array여야 한다고 설명해요.
["todos"];
["todo", id];
["todos", { status, page }];이 규칙은 문법 취향이 아니에요. 리소스 구조를 일정하게 표현하기 위한 약속이에요.
배열을 쓰면 데이터의 계층이 드러나요.
["todos"]; // todo 목록 전체
["todos", "list", { status, page }]; // 필터가 적용된 목록
["todos", "detail", id]; // 특정 todo 상세이렇게 쓰면 코드를 읽는 사람이 바로 알 수 있어요.
이 쿼리가 목록인지, 상세인지, 어떤 조건으로 조회하는지 보이기 때문이에요.
캐시를 무효화할 때도 좋아요.
queryClient.invalidateQueries({
queryKey: ["todos"],
});["todos"]를 상위 주소로 잡아두면, todo와 관련된 쿼리를 함께 다루기 쉬워져요.
queryFn이 쓰는 값은 queryKey에도 넣어야 해요
queryFn이 어떤 값을 보고 요청을 만든다면, 그 값은 queryKey에도 들어가야 해요.
예를 들어 todoId로 상세 데이터를 가져온다고 해볼게요.
useQuery({
queryKey: ["todo"],
queryFn: () => fetchTodo(todoId),
});이 코드는 위험해요.
todoId가 바뀌어도 queryKey는 그대로예요. TanStack Query 입장에서는 같은 데이터를 요청한다고 볼 수 있어요. 그러면 잘못된 캐시를 재사용하거나, 필요한 시점에 다시 가져오지 않을 수 있어요.
이 값은 key에 들어가야 해요.
useQuery({
queryKey: ["todo", todoId],
queryFn: () => fetchTodo(todoId),
});이제 todoId가 바뀌면 캐시 주소도 바뀌어요.
실무적으로 보면 Query Key는 queryFn의 dependency array처럼 동작해요. 요청에 영향을 주는 값이 key에 들어가야 캐시도 안전하게 분리돼요.
객체 순서는 괜찮고, 배열 순서는 중요해요
Query Key를 쓸 때 헷갈리는 지점이 있어요.
객체 안의 key 순서는 괜찮아요. 하지만 배열의 순서는 중요해요.
useQuery({
queryKey: ["todos", { status, page }],
queryFn: fetchTodos,
});
useQuery({
queryKey: ["todos", { page, status }],
queryFn: fetchTodos,
});이 두 key는 같은 의미로 처리돼요.
TanStack Query는 객체를 hash할 때 객체 key를 정렬해서 비교해요. 그래서 { status, page }와 { page, status }를 같은 값으로 볼 수 있어요.
하지만 배열은 달라요.
useQuery({
queryKey: ["todos", status, page],
queryFn: fetchTodos,
});
useQuery({
queryKey: ["todos", page, status],
queryFn: fetchTodos,
});이 둘은 다른 key예요.
배열은 위치가 의미를 가져요. 첫 번째 값, 두 번째 값, 세 번째 값이 각각 다른 역할을 해요.
이렇게 기억하면 쉬워요.
- 객체는 named parameter처럼 생각해요.
- 배열은 positional parameter처럼 생각해요.
필터나 옵션처럼 이름이 중요한 값은 객체로 묶는 편이 안전해요.
리소스 구조가 보이게 설계하세요
좋은 Query Key는 데이터 구조를 보여줘요.
const todoKeys = {
all: ["todos"] as const,
lists: () => ["todos", "list"] as const,
list: (filters: { status?: string; page?: number }) =>
["todos", "list", filters] as const,
detail: (id: number) => ["todos", "detail", id] as const,
};이렇게 모아두면 key를 매번 직접 만들지 않아도 돼요.
useQuery({
queryKey: todoKeys.list({ status: "done", page: 1 }),
queryFn: ({ queryKey }) => {
const [, , filters] = queryKey;
return fetchTodoList(filters);
},
});
useQuery({
queryKey: todoKeys.detail(42),
queryFn: ({ queryKey }) => {
const [, , id] = queryKey;
return fetchTodoById(id);
},
});이 방식의 장점은 세 가지예요.
- 목록과 상세 캐시가 분리돼요.
- invalidation 범위를 정하기 쉬워요.
- Query Key 규칙이 한 곳에 모여요.
기본 형태는 이렇게 잡을 수 있어요.
[domain, scope, id?, params?]예를 들면 ["todos", "detail", id]는 특정 상세 데이터예요. ["todos", "list", filters]는 필터가 들어간 목록 데이터예요.
중요한 건 팀 안에서 같은 규칙을 계속 쓰는 거예요.
흔한 실수
Query Key에서 자주 생기는 실수는 비슷해요.
queryFn에서 쓰는id,status,page를 key에 넣지 않아요.- 배열 위치를 일관되게 쓰지 않아요.
- 함수, 클래스 인스턴스처럼 serializable하지 않은 값을 key에 넣어요.
- 서버 데이터와 상관없는 일시적인 UI 상태까지 key에 넣어요.
마지막 실수는 특히 조심해야 해요.
Query Key는 자세할수록 좋은 게 아니에요.
너무 대충 만들면 캐시가 충돌해요. 반대로 너무 잘게 만들면 캐시가 파편화돼요.
Query Key에는 서버 데이터를 식별하는 값과 조회 조건만 넣는 게 좋아요. 모달이 열렸는지, 탭 애니메이션이 어디까지 갔는지 같은 UI 상태는 대부분 Query Key에 들어갈 이유가 없어요.
정리
Query Key는 캐시의 주소예요.
그래서 Query Key를 설계할 때는 이름보다 구조를 먼저 봐야 해요.
- 같은 데이터는 같은 key를 써요.
- 다른 데이터는 다른 key를 써요.
queryFn이 의존하는 값은 key에도 넣어요.- 필터와 옵션은 객체로 묶어요.
- 리소스 prefix를 일관되게 유지해요.
Query Key를 잘 설계하면 캐시가 예측 가능해져요. 캐시가 예측 가능하면 refetch, invalidation, 재사용도 자연스러워져요.
결국 좋은 Query Key는 좋은 캐시 설계예요.