내가 가진 데이터를 정적으로 보여주는 서비스가 아니라면, 네트워크 요청을 통하여 데이터를 보여주는 것이 일반적인 서비스이다. 각 페이지 혹은 컴포넌트별로 필요한 데이터가 다르고 이에 따라 네트워크 요청도 달라지게 된다. 필요하지 않은 데이터거나 혹은 이미 요청을 받아와서 가지고 있는 데이터임에도 또 요청을 하는 것은 자원 낭비이다. 네트워크 요청으로 얻어낸 데이터를 효율적으로 관리해주는 것이 바로 TanStack Query이다.
TanStack Query를 사용하면 데이터를 캐싱하고, 적절한 타이밍에 캐시된 데이터를 사용하거나 새로운 데이터를 요청할 수 있다. 이 때 사용되는 핵심적인 요소가 바로 Query Key이다.
공식문서에서 쿼리 키를 설명하는 내용을 살펴보면,
TanStack Query는 쿼리 키를 기반으로 쿼리 캐싱을 관리하는 것이 핵심이다. 쿼리 키는 최상위 수준에서 배열이어야 하며, 단일 문자열이 포함된 배열처럼 단순할 수도 있고 여러 문자열과 중첩된 객체가 포함된 배열처럼 복잡할 수도 있다. 쿼리 키가 직렬화 가능하고 쿼리 데이터에 고유한 것이면 사용할 수 있다.
여기서 중요한 개념은 배열과 고유한 것이다.
// A list of todos
useQuery({ queryKey: ['todos'], ... })
// Something else, whatever!
useQuery({ queryKey: ['something', 'special'], ... })
// An individual todo
useQuery({ queryKey: ['todo', 5], ... })
// An individual todo in a "preview" format
useQuery({ queryKey: ['todo', 5, { preview: true }], ...})
// A list of todos that are "done"
useQuery({ queryKey: ['todos', { type: 'done' }], ... })
보통 쿼리키는 상수로 관리하는 것이 일반적인데, 계층적 혹은 중첩된(공식문서에서 이렇게 설명함) 리소스를 사용하거나 추가 매개 변수가 있을 경우 위처럼 객체가 포함된 배열을 사용하여 관리할 수도 있다.
쿼리키는 TanStack Query에서 데이터 캐싱의 주소 역할을 한다.
['todos'] : 할 일 목록 전체['todos', 'morning'] : 할 일 목록 중 아침에 할 일['todos', 'evening'] : 할 일 목록 중 저녁에 할 일// 전체 할 일 목록 조회
const { data: allTodos } = useQuery({
queryKey: ['todos'],
queryFn: fetchAllTodos
});
// 특정 시간대(아침) 할 일 목록 조회
const { data: morningTodos } = useQuery({
queryKey: ['todos', 'morning'],
queryFn: () => fetchTodosByTime('morning')
});
// 완료된 할 일 목록 조회
const { data: completedTodos } = useQuery({
queryKey: ['todos', { status: 'done' }],
queryFn: () => fetchTodosByStatus('done')
});
// 아침 할 일 중 완료된 목록 조회
const { data: completedMorningTodos } = useQuery({
queryKey: ['todos', 'morning', { status: 'done' }],
queryFn: () => fetchTodosByTimeAndStatus('morning', 'done')
});
[’todos’] 주소에 캐싱['todos', 'morning'] 주소에 저장 → 시간대라는 카테고리 추가['todos', { status: 'done' }] 주소에 저장 → 객체를 사용하여 상태 필터 적용['todos', 'morning', { status: 'done' }] 주소에 저장 → 시간과 상태 두 가지 필터 조건을 모두 적용이처럼 특정 조건이나 필터에 따라 쿼리키를 계층적으로 구성하면, 데이터를 세분화하여 캐싱할 수 있다.
Query Key는 고유한 주소처럼 쿼리가 필요한 데이터를 정확히 찾아서 관리할 수 있게 해준다.
export const QUERY_KEYS = {
members: {
all: ['members'] as const,
lists: (projectId: number) =>
[...QUERY_KEYS.members.all, 'list', projectId] as const,
detail: (id: number) => [...QUERY_KEYS.members.all, id] as const,
},
projects: {
all: ['projects'] as const,
lists: (projectId: number) =>
[...QUERY_KEYS.projects.all, 'list', projectId] as const,
detail: (id: number) => [...QUERY_KEYS.projects.all, id] as const,
},
sections: {
all: ['sections'] as const,
lists: (projectId: number) =>
[...QUERY_KEYS.sections.all, projectId, 'list'] as const,
detail: (projectId: number, sectionId: number) =>
[...QUERY_KEYS.sections.all, projectId, sectionId] as const,
},
}
이번에 작업한 프로젝트에서 상수로 선언하여 사용한 Query Key의 일부이다.
members의 경우, 프로젝트 별로 참여하는 멤버를 뜻하는 상수이다.
따라서 Query Key로 사용할 때는 members와 projectId를 배열에 넣어주면 된다.
// 프로젝트 id가 1일 때
queryKey: QUERY_KEYS.members.lists(1) // ['members', 'list', 1]
members의 list 중 1번 주소에 데이터가 저장되는 것이다.
// 멤버 추가 mutation
const addMemberMutation = useMutation({
mutationFn: (newMember) => addMemberToProject(projectId, newMember),
onSuccess: () => {
// 성공 후 해당 프로젝트의 멤버 목록 쿼리를 무효화
queryClient.invalidateQueries({
queryKey: QUERY_KEYS.members.lists(projectId)
});
}
});
mutation을 사용할 때 onSuccess 콜백에서 queryClient.invalidateQueries를 호출하면, 해당 Query Key와 관련된 캐시 데이터를 무효화하고 새로운 데이터를 요청하게 된다.
위의 mutation을 호출하며 projectId로 1을 넘겨주고 요청이 성공하게 되면, ['members', 'list', 1] Query Key로 캐싱된 데이터가 무효화되고 새로운 데이터를 요청하게 된다.
invalidateQueries를 사용하여 특정 Query Key나 관련 쿼리들을 무효화setQueryData를 사용하여 캐시를 직접 업데이트부분 Query Key 무효화 활용하기
// 'todos'로 시작하는 모든 쿼리키 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 특정 시간대의 할 일만 무효화
queryClient.invalidateQueries({ queryKey: ['todos', 'morning'] });
Query Key 디버깅
Query Key를 사용하면 불필요한 네트워크 요청을 줄이고, 캐시된 데이터와 새롭게 갱신된 데이터를 적절하게 사용할 수 있게 된다. Query Key를 제대로 활용하여 서버 요청을 최소화하면서도 UI에 항상 최신 데이터를 표시할 수 있도록 하자!