효율적인 Query Key 관리 전략: 팩토리 패턴으로 유지보수성 높이기

크리스가 심각한 표정으로 모니터를 보고 있다. 게시글을 작성하고 목록으로 돌아왔는데, 방금 쓴 글이 보이지 않는다.
"분명 invalidateQueries로 캐시를 날렸는데 왜 안 먹히지?"
코드를 뜯어보니 원인은 오타였다.
TanStack Query에서 Query Key는 데이터의 유일한 식별자이자, 캐싱 시스템의 핵심이다. 하지만 이것을 컴포넌트마다 문자열 배열로 하드코딩해서 쓰다 보면, 위와 같은 실수는 필연적으로 발생한다.
오늘은 이러한 "매직 스트링(Magic String)"의 늪에서 벗어나, 유지보수하기 쉬운 쿼리 키 팩토리(Query Key Factory) 패턴을 소개한다.
1. 하드코딩의 문제점
초기에는 그냥 배열을 직접 써도 문제가 없어 보인다.
// ❌ 나쁜 예시: 컴포넌트 A
useQuery({ queryKey: ['users', 'list', filter], ... });
// ❌ 나쁜 예시: 컴포넌트 B (다른 개발자가 작성)
// 'users'가 아니라 'user'라고 씀 -> 캐시 공유 안 됨
useQuery({ queryKey: ['user', 'list', filter], ... });
// ❌ 나쁜 예시: Mutation
// 필터 조건을 빼먹고 무효화함 -> 원하는 데이터가 갱신 안 됨
queryClient.invalidateQueries({ queryKey: ['users'] });프로젝트가 커지면 키가 어디에 어떻게 정의되어 있는지 파악하기 힘들어진다. 구조를 바꾸려 해도 프로젝트 전체를 뒤져서 문자열을 찾아 바꿔야 한다. 이것은 재앙이다.
2. 해결책: 쿼리 키 팩토리 (Query Key Factory) 패턴
해결책은 간단하다. "키를 생성하는 로직을 한곳에 모아두는 것"이다.
마치 공장(Factory)처럼, 필요한 키를 달라고 요청하면 일관된 규칙으로 찍어내 주는 객체를 만든다.
팩토리 객체 설계하기
보통 Domain(도메인) > Scope(범위) > Parameters(변수) 순서로 계층을 쌓는다.
// queryKeys.ts
export const todoKeys = {
// 1. 최상위 키 (모든 todo 관련)
all: ['todos'] as const,
// 2. 목록 관련 키
lists: () => [...todoKeys.all, 'list'] as const,
// 세부 필터가 적용된 목록 키
list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
// 3. 단건 상세 관련 키
details: () => [...todoKeys.all, 'detail'] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};이렇게 as const를 붙여서 정의하면 타입스크립트가 배열의 내용을 리터럴 타입으로 정확하게 추론해준다.
3. 실전 적용: 오타 없는 세상
이제 컴포넌트에서는 키를 직접 타이핑할 필요가 없다. 자동 완성이 우리를 인도한다.
데이터 가져오기 (useQuery)
// TodoList.tsx
import { todoKeys } from './queryKeys';
function TodoList({ filter }) {
// ✅ IDE가 todoKeys.list(filter)를 자동 완성해준다.
const { data } = useQuery({
queryKey: todoKeys.list(filter),
queryFn: () => fetchTodos(filter),
});
// ...
}캐시 무효화하기 (invalidateQueries)
이 패턴의 진가는 캐시를 관리할 때 드러난다. 계층 구조 덕분에 원하는 범위만 정밀하게 타격할 수 있다.
// TodoMutation.tsx
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// 1. 모든 Todo 관련 쿼리 무효화 (목록, 상세 모두)
// queryClient.invalidateQueries({ queryKey: todoKeys.all });
// 2. Todo '목록'만 무효화 (상세 페이지는 유지)
// queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
// ✅ 우리가 원하는 것: 목록 전체를 다시 불러오게 함
queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
},
});이제 크리스가 todo라고 쓸지 todos라고 쓸지 고민할 필요가 없다. 팩토리 객체가 주는 대로 쓰면, 그것이 곧 정답(Single Source of Truth)이다.
4. 라이브러리 활용하기 (@lukemorales/query-key-factory)
직접 객체를 만들기 귀찮다면, 커뮤니티에서 사랑받는 라이브러리를 쓰는 것도 방법이다.
// query-key-factory 사용 예시
import { createQueryKeys } from '@lukemorales/query-key-factory';
export const todos = createQueryKeys('todos', {
list: (filters: string) => ({
queryKey: [{ filters }],
queryFn: () => api.getTodos(filters),
}),
detail: (todoId: string) => ({
queryKey: [todoId],
queryFn: () => api.getTodo(todoId),
}),
});
// 사용
useQuery(todos.list('done'));queryFn까지 함께 묶어서 관리할 수 있어 응집도가 더 높아진다.
핵심 정리
이제 데이터를 가져오고 관리하는 법을 완벽히 익혔다. 하지만 아직 부족하다. 사용자 경험의 정점은 "로딩 중일 때"와 "에러가 났을 때" 얼마나 우아하게 대처하느냐에 달려있다.
if (loading) return <Spinner />의 반복에서 벗어나는 방법, Suspense와 Error Boundary를 만날 시간이다.
"선언적인 비동기 처리: Suspense와 Error Boundary로 로딩/에러 우아하게 처리하기" 편에서 계속된다.