저번에 체쿠리 사이드 프로젝트를 소개한 데 이어, 이번에는 체쿠리 프로젝트에 적용된 Tanstack Query와 QueryKeyFactory에 대해 설명해보려고 합니다.
혹시 개선할 점이나 틀린 부분이 있으면 피드백 부탁드립니다.🙇🏻♂️
Tanstack Query는 서버에서 데이터를 가져오고, 이를 캐싱 및 동기화하며, 캐싱된 데이터를 조작할 수 있는 라이브러리입니다.
이번 체쿠리 프로젝트에서는 React Query를 적용하여 데이터 관리를 하고 있습니다.
왜 Tanstack Query를 이 프로젝트에 사용했을까요?
가장 큰 이유는, 데이터를 캐싱하여 쉽게 조작하고 관리할 수 있기 때문입니다. 캐싱된 데이터를 활용하면 사용자에게 빠른 페이지를 제공할 수 있고, 필요하지 않은 서버 요청을 줄일 수 있어 성능 개선에도 효과적입니다.
이번 포스팅에서는 Tanstack Query와 함께 사용한 QueryKeyFactory에 대해 집중적으로 다루겠습니다.
쿼리 키를 관리할 수 있도록 도와주는 도구입니다.
공식문서와 Tanstack-Query 공식 홈페이지에서 언급된 내용을 번역하여 정리해 보았습니다
자동 완성 기능을 제공하는 타입 안전한 쿼리 키 관리
쿼리 키 설정을 일일이 기억할 필요 없이 쿼리 작성과 무효화에 집중할 수 있습니다!
- 타입 안전한 쿼리 키 관리: 쿼리 키를 정의할 때 타입을 안전하게 관리할 수 있도록 도와줍니다.
- 자동 완성 기능: 쿼리 키를 작성할 때 자동 완성 기능을 제공하여, 쿼리 키 설정을 쉽게 할 수 있습니다.
- 쿼리 작성 및 무효화: 쿼리 작성과 무효화를 처리할 때, 쿼리 키 설정을 기억할 필요 없이 작업에 집중할 수 있습니다.
- queryKeyFactory로 나머지 작업 처리: 쿼리 키 설정과 관련된 복잡한 작업을 이 라이브러리가 대신 처리해줍니다.
라고 되어있습니다.
현재 개발 중인 체쿠리 서비스 디렉터리 구조는 아래와 같은 구조로 되어있습니다.
src
┣ api
┃ ┣ ApiClient.ts
┃ ┣ [도메인별]ApiClient.ts
┃ ┗ [도메인별]Schema.ts
┗ pages
┣ [기능별 폴더]
┃ ┣ queries.ts
┃ ┗ [기능 컴포넌트].tsx
┗ ...
여기서 queries.ts 파일은 useQuery와 ApiClient를 결합하여 훅으로 만들어 재사용하고 있습니다.
예를 들면
export const useBookDetail = (bookId: number) => {
return useQuery({
queryKey: [bookId],
queryFn: async () => {
const response = await getBookDetail(bookId)
if (response.status === 200) {
return response
}
},
})
}
const { data:bookDetail } = useBookDetail();
이런 식으로 말이죠.
그러면 데이터 Mutation이 일어날 때 BookDetail의 캐시를 업데이트해야 하며, 이를 onSuccess에서 처리할 수 있습니다
useMutation({
mutationFn: async (params: CreateBookRequest) => await createBook(params),
onSuccess: () => {
QueryClient.invalidateQueries({
queryKey: [bookId],
})
},
})
위와 같은 상황에서는 queryKey를 이렇게 지정하는 것이 어렵지 않았습니다.
하지만, 쿼리 키에 담긴 요소가 많아지면 어떻게 될까요?
export const useBookStatistic = (
bookId: number,
params: GetStatisticsRequest,
) => {
return useQuery({
queryKey:[bookId, params.from, params.periodType],
queryFn: async () => {
const response = await getStatistics({
params: params,
attendanceBookId: bookId,
})
if (response.status === 200) {
return response
}
},
})
}
캐싱을 업데이트하거나 데이터를 가져올 때 어떤 키로 데이터를 처리하는지 알기 어려울 수 있습니다.
또한, 이러한 쿼리 훅이 여러 개일 때 수동으로 쿼리 키를 작성하고 관리하는 것은 점점 더 복잡해지고, 실수를 유발할 가능성도 커집니다.
쿼리 키를 관리하는 파일인 queryKeys.ts의 위치를 고민한 끝에, 프로젝트 전체에서 사용되는 키들을 한 곳에서 관리할 수 있도록 src 하위 경로에 배치했습니다.
import { createQueryKeys } from '@lukemorales/query-key-factory'
createQueryKeys를 사용하려면 위와 같은 선언이 필요하고, 쿼리의 고유한 이름을 정의하는 queryDef와, 쿼리의 실제 식별자로 사용될 queryKey를 정의해야 합니다.
createQueryKeys( queryDef, {
objectKey: () => ({queryKey:[queryKey})
})
저는 보여드리기 위해서 작성된 일부 잘라서 짧게 가져왔습니다.
export const bookKeys = createQueryKeys('book', {
detail: (bookId: number) => ({
queryKey: [bookId],
queryFn: async () => {
const response = await getBookDetail(bookId)
if (response.status === 200) {
return response
}
},
}),
schedules: (bookId?: number, formattedDate?: string) => ({
queryKey: [bookId, formattedDate],
queryFn: async () =>
await getSchedule({
attendanceBookId: Number(bookId!),
params: {
date: formattedDate!,
pageable: {
page: 0,
size: 100,
sort: ['asc'],
},
},
}),
}),
})
bookKeys에서는 'book'이라는 이름으로 쿼리의 고유한 이름을 정의하고, 목적에 따라 'detail', 'schedules'와 같은 객체로 구분하여 각 쿼리의 queryKey를 정의했습니다.
이렇게 queryDef와 그 하위 객체들로 쿼리를 정의하면, 각 쿼리가 하나의 식별 키로 인식되며, 역할을 명확히 알 수 있고 관리하기가 훨씬 편리해집니다!
이런 식으로 queryDef와 객체 키로 지정한 것들이 key에서 저렇게 식별하는 걸 볼 수 있습니다.
그리고 정의했던 키들은 데이터 캐싱에 업데이트가 일어나는 경우에 아래와 같이 사용할 수 있습니다.
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: bookKeys.schedules(bookId, currentDate).queryKey,
})
// 또는
queryClient.setQueryData(bookKeys.schedules(bookId, currentDate).queryKey, updateData)
},
이렇게 명시적으로 쿼리 키를 설정함으로써, queryKey의 타입 추론을 통해 오류를 사전에 방지하고, 코드 가독성을 높일 수 있었습니다.
또한 초기에 수동으로 Query Keys를 작성할 때보다 훨씬 효율적으로 쿼리 키를 관리할 수 있어 QueryKeyFactory를 사용하게 되었습니다.
사실 이전에는 QueryKey만 관리하는 용도로 사용했었습니다. 그런데 리팩토링 작업 중 공식 문서를 다시 확인해보니, QueryKey를 정의할 때 queryFn도 함께 정의할 수 있다는 것을 알게 되었습니다.
"어? 그러면 중복되는 쿼리를 훅으로 만들 때, 각 쿼리를 일일이 정의할 필요가 없고, 코드도 훨씬 간결해지잖아??!"
그래서 다음과 같이 개선했습니다.
Before ((queryKeyFactory 미사용)
export const useBookDetail = (bookId: number) => {
return useQuery({
queryKey: [bookId],
queryFn: async () => {
const response = await getBookDetail(bookId)
if (response.status === 200) {
return response
}
},
})
}
const { data:bookdetail } = useBookDetail();
After (queryKeyFactory 사용)
Query Keys
detail: (bookId: number) => ({
queryKey: [bookId],
queryFn: async () => {
const response = await getBookDetail(bookId)
if (response.status === 200) {
return response
}
},
}),
export const useBookDetail = (bookId: number) =>
useQuery(bookKeys.detail(bookId))
const { data:bookDetail } = useBookDetail();
QueryKeyFactory를 사용하여 쿼리 키를 정의할 때, queryFn도 함께 정의할 수 있게 되었고 각 페이지별 기능에 대해 개별적으로 queryFn을 작성할 필요 없이, useQuery(bookKeys.detail(bookId))와 같이 간단하게 사용할 수 있게 되었습니다.
QueryKeyFactory를 사용하면서 구조와 전략을 어떻게 짜야 할지 고민했었습니다. 그래서 다른 개발자 분들의 QueryKeyFactory 관련 블로그를 참고하며 점차 감을 잡고 자리를 잡아가게 되었습니다.
프로젝트의 성격에 맞게 저는 데이터를 크게 book과 attendeesKey로 분리했습니다.
데이터가 서로 다른 성격을 가지고 있었기 때문입니다. book은 출석부와 관련된 데이터, attendeesKey는 학생의 정보 및 관련 데이터를 담당하게 되며, 각각의 캐싱 데이터는 그 하위에 세분화하여 키를 정의하였습니다.
queryKeyFactory를 사용해보면서 느낀점
- 키를 직관적으로 이해할 수 있었고, 타입 추론으로 쉽게 접근할 수 있었다.
- QueryKeyFactory를 통해 쿼리 키를 명확하게 정의할 수 있어서 어떤 데이터가 사용되는지 직관적으로 알 수 있었습니다. 타입 추론 덕분에 쿼리 키를 작성할 때 오류를 줄일 수 있었고, 코드의 안정성을 높였습니다.
- 키 수동으로 작성에 대한 오류 방지와 유지보수에 용이했다.
- 수동으로 쿼리 키를 작성할 때 발생할 수 있는 실수를 방지할 수 있었습니다. 예를 들어, 배열로 작성했던 쿼리 키가 길어지면서 오타나 중복 문제가 생길 수 있는데, QueryKeyFactory는 이를 해결해주어 코드가 깔끔하고 안전해졌습니다.
- queryFn도 정의하면서 하나의 모듈로 사용하기 편했다.
- QueryKeyFactory를 사용하면서 쿼리 키와 쿼리 함수(queryFn)를 한 곳에서 정의할 수 있어 코드가 더 깔끔해졌습니다. 중복되는 쿼리 코드도 줄어들고, 동일한 쿼리 키로 다양한 쿼리 함수들을 쉽게 관리할 수 있었습니다.
긴 글 읽어주셔서 감사합니다!