예전 팀프로젝트에서 부족했던 tanstack-query의 query-key 관리에 대해서 정리해 본다.
흔히 react-query 라고도 불리는 tanstack-query는 비동기적인 서버 상태를 관리하기 위한 상태관리 라이브러리이다. 타 상태관리 라이브러리들 또한 비동기 상태를 지원하기는 하지만 tanstack-query는 preFech, cache 등서버 상태 관리에 최적화 되어있다.
tanstack-query 자체에 대한 정보는 공식문서나 다른 블로그 글에 잘 정리된 게 많아서 key 관리에 대한 부분만 정리해 본다.
query-key는 tanstack-query의 핵심적인 부분중 하나이다. key를 통해 데이터를 캐싱하고, 관리하기 때문이다.
query-key는 다음과 같은 특징을 가진다.
query-key들 사이에서 유일해야 한다.
tanstack-query는 query-key를 가지고 server 데이터를 다룬다. 때문에 query-key는 하나의 server state에 1대 1로 매칭되어야 한다. 따라서 하나의 query-key는 다른 query-key와 중복되어서는 안된다.
배열로 취급해야 한다.
useQuery등의 메서드에서는 query-key가 필수적으로 필요한데 공식문서에서 소개하는 기본적인 코드는 다음과 같다.
useQuery(['todos', 'detail'], ...)
// 첫 번째 인자로 query-key 배열을 받는다. 이는 상수 값이 포함된 배열이어야 한다.(key들 중에서 유일해야 한다.)
// v3 에서는 query-key를 단일 문자열로만 제공해도 내부적으로 배열로 변환됐지만, v4부터는 배열로만 제공해야 한다.
query-function이 의존하는 변수도 포함해야 한다.
query-key는 query-function에 대한 종속적인 역할을 하기 때문에 query-function에 전달되는 인자(종속 변수)는 query의 독립적인 캐싱을 위해서 query-key에 포함시켜 query-key를 유일하게 만들어야 한다.(query-key들 사이에서)
useQuery(['todos', id],() => fetchTodoById(id))
해시된다.
전달된 배열은 내부적으로 해시되어 사용된다. 따라서 다음과 같은 경우 같거나 다르다고 평가된다.
// 같다고 평가되는 경우
useQuery({ queryKey: ['todos', { status, page }], ... })
useQuery({ queryKey: ['todos', { page, status }], ...})
useQuery({ queryKey: ['todos', { page, status, other: undefined }], ... })
// 다르다고 평가되는 경우
useQuery({ queryKey: ['todos', status, page], ... })
useQuery({ queryKey: ['todos', page, status], ...})
useQuery({ queryKey: ['todos', undefined, page, status], ...})
이는 배열 값들의 순서가 query-key 값이 결정되는 것에 영향을 미친다는 뜻이 된다.
객체를 query-key 배열에 전달하는 경우 같은 key로 평가될 수 있으니 주의해야 할 필요가 있다.
내가 tanstack-query를 처음 프로젝트에 적용할 때 했던 실수 중 하나는 query-key를 한곳에서 관리하지 않고 사용되는 곳에서 정의해 관리했다는 것이다.
이는 1번(query-key는 유일해야 한다.)을 지키지 못할 가능성이 높게 query-key를 관리했다는 의미가 된다.
만약 각 key(query-key)가 독립적으로 관리하게 되면 다른 팀원이 우연히 나와 같은 key를 만들어 의도치 않은 데이터를 가지고 오게 되거나, 하나의 기능이 작동할 때마다 다른 곳에서 에러가 발생하는 상황이 만들어지게 될 것이다.
혹은 다음과 같이 key를 잘못 관리해버리는 상황이 만들어 질 수도 있다.
A는 all article list를 가져와 렌더링하는 컴포넌트와 favorite article list를 가져와 렌더링 하는 컴포넌트를 만들었다. 이 과정에서 getArticle이라는 함수를 통해 재사용성을 높이려고 시도했다.
const getArticle = async(page: number, favorite: boolean | undefined) => {
try {
const url = favorite ? `${BASE_URL}/favorite/${page}` : `${BASE_URL}/${page}`;
const res = await fetch(url)
if(res.ok) {
const data = await res.json();
return data;
}
throw new Error('요청 실패');
} catch (e) {
alert(e.message);
}
}
// 1 allArticle
const {data} = useQuery(['articleList',{page}], ()=> getArticle(page));
// 2 favoriteArticle
const {data} = useQuery(['articleList',{page, isFavorite}], ()=> getArticle(page, isFavorite));
하지만 위 코드는 잠재적인 위험이 존재하고 있다. 만약 isFavorite이라는 변수가 undefined가 되어버릴 경우 ['articleList',{page}]
와 ['articleList',{page, isFavorite: true}]
가 같은 query key로 평가되어 버린다.
이는 4번 특징 때문이다. 전달한 string 배열은 해시되기 때문에, 배열에 존재하는 객체의 속성값이 undefined가 되면 없는 해당 속성이 없는것으로 평가되기 때문이다.
이를 해결하기 위해서 다음과 같이 수정할 수 있다.
// ...
const getFavoriteArticle = async(page: number) => {
try {
const url = `${BASE_URL}/favorite/${page}`;
const res = await fetch(url)
if(res.ok) {
const data = await res.json();
return data;
}
throw new Error('요청 실패');
} catch (e) {
alert(e.message);
}
}
//...
// 2 favoriteArticle
const {data} = useQuery(['articleList',{page, favorite: 'favorite'}], ()=> getFavoriteArticle(page, isFavorite));
위와 같은 다소 억지스러운 문제를 예방하기 위해서는 query 마다 사용되어야 할 key를 확실하게 구분 지어놓고, function도 한가지 역할을 하도록 만드는 것이 필요하다 이를 위해 key와 function을 따로 분리해 선언해 놓을 필요가 있다.
이를 위해 query-key를 관리하는 방법은 크게 2가지 정도가 있다.
Object
query-key를 생성하는 객체를 미리 정의하고, 가져다가 사용한다.
객체를 통해 query-key의 중복을 피하며, 확장하기 유용하도록 계층적으로 생성하는 것이 좋다.
const articleKeys = {
all: ['articles'] as const,
list: (page: number) => [...articleKeys.all, 'list', page] as const,
favoriteList: (page: number) => [...articleKeys.all, 'favorite', page] as const,
detail: (id: number) => [...articleKeys.all, 'detail', id] as const,
}
const {data} = useQuery(articleKeys.list(page), ()=> getArticle(page));
한 곳에서 미리 정의하기 때문에 이미 사용하고 있는 key가 있는지 확인하고 작업하거나 새롭게 중복되지 않는 key 배열을 추가할 수 있다.
Query Key Factory
query-key 관리를 위한 라이브러리로 query-key 뿐만 아니라 query-function도 미리 정의해 놓을 수 있다. 이를 통해 customHook으로 간단한 처리만 하면 컴포넌트 단에서는 호출만으로 사용할 수도 있다.
https://github.com/lukemorales/query-key-factory
// queries/queries.ts
import { createQueryKeys, mergeQueryKeys } from "@lukemorales/query-key-factory";
// queries/users.ts
export const users = createQueryKeys('users', {
all: null,
detail: (userId: string) => ({
queryKey: [userId],
queryFn: () => api.getUser(userId),
}),
});
// utills/useUsers.ts
import { queries } from '../queries';
export function useUsers() {
return useQuery({
...queries.users.all,
queryFn: () => api.getUsers(),
});
};
export function useUserDetail(id: string) {
return useQuery(queries.users.detail(id));
};
query-key 관리에 대한 필요성을 잘 체감하지 못하다가(그저 클린코드의 연장선이라고만 생각했다.) 이번에 다시 공부해보면서 예전 내 코드를 보다가 아.. 관리를 해야 할 필요가 있구나..! 라고 다시 한번 생각하게 되었다.
Reference
queryKey를 하나의 Constant로 관리하는 방식은 유용하다고 생각되네요 감사합니다!