React Query Options 기반 관리 패턴

우빈·2025년 4월 11일
5
post-thumbnail

TL;DR

React Query에서 제공하는 queryOptions를 사용하여 도메인 기반으로 객체를 만들어 관리할 수 있습니다. 이 경우 invalidateQueries와 같은 queryKey가 필요한 작업을 진행할 때 매우 쉽게 접근하고 변경할 수 있어 유지보수에 용이합니다. 하나의 API마다 custom hook을 만들어 파일별로 관리하는 구조와 달리, 외부에서 query에 대한 정보가 필요할 때 내부 구현체에 접근하지 않아도 정보를 빠르게 얻을 수 있다는 점이 장점입니다.

이 글에서는...
React Query를 사용하며 아키텍처 구조를 효율적으로 작성하는 방법에 대해 소개합니다.

적절한 모듈화

지금껏 API를 다루어보면서 참 다양한 패턴으로 모듈화를 진행해보았는데요, 어떤 모듈화가 '적절한 모듈화', 즉 유지/보수가 용이하고 접근하기 쉬운 모듈화인지 고민을 많이 해보게 되었습니다.

기존 패턴의 문제점

간단한 CRUD를 지원하는 게시판이 있다고 가정해보겠습니다.
API는 총 5개로, 목록 조회, 상세 조회, 생성, 수정, 삭제로 이루어져있습니다.
독자분들은 React Query를 사용하는 상황에서 해당 API들을 어떻게 관리하실건가요?

모듈화를 잘게 쪼갠 다음, 물리적인 파일 또한 분리하여 표현해보겠습니다.

// api/axios/getPostList.ts
export const getPostList = async () => { axios.get ... }
// api/axios/getPostDetail.ts
export const getPostDetail = async (id: number) => { axios.get ... }
// api/axios/createPost.ts
export const createPost = async (req: ...) => { axios.post ... }
// api/axios/updatePost.ts
export const updatePost = async (id: number) => { axios.put ... }
// api/axios/deletePost.ts
export const deletePost = async (id: number) => { axios.delete ... }

이런 식으로 다섯 벌의 api 모듈을 각 물리적인 파일에 주입해주었습니다.
비슷한 방식으로 이들을 import하여 사용할 수 있는 React Query Custom hook도 정의해보겠습니다.

// api/queries/useGetPostDetailQuery.ts
export const useGetPostDetailQuery = (id: number) => {
	return useQuery({
    	queryKey: ['post', id],
      	queryFn: () => getPostDetail(id),
    })
}
// api/queries/useGetPostListQuery.ts
export const useGetPostListQuery = () => {
	return useQuery({
    	queryKey: ['post'],
      	queryFn: getPostList,
    })
}

GET이 아닌 method들은 useMutation으로 관리합니다.

// api/mutations/useCreatePostMutation.ts
export const useCreatePostMutation = (req: ...) => {
	return useMutation({
      	mutationFn: createPost,
    })
}
// api/mutations/useUpdatePostMutation.ts
export const useUpdatePostMutation = (id: number) => {
	return useMutation({
    	mutationFn: updatePost,
    })
}
// api/mutations/useDeletePostMutation.ts
export const useDeletePostMutation = (id: number) => {
	return useMutation({
    	mutationFn: deletePost,
    })
}

이제 모든 API 서비스 로직을 정의했으니, 사용하는 단에서 원하는 hook을 호출해 사용할 수 있습니다.

...

const PostDetailPageView = ({ id }: PostDetailPageViewProps) => {
	const { data: post } = useGetPostDetailQuery();
  
  	return (
      <main>{...}</main>
    )
}

그런데 React Query를 사용해보신 분들은 아시겠지만, mutation을 진행한 후 별도의 페이지 새로고침이 없이 새로운 데이터를 가져오려면 기존 데이터를 refetching해주는 작업이 필요합니다.

그러기 위해서 일반적으로 queryClient의 invalidateQueries를 많이 사용합니다.
데이터를 refetching하기 원하는 queryKey를 배열로 주입해주면 해당하는 쿼리에 refetching이 이루어집니다.

const queryClient = useQueryClient();
const { mutate } = useUpdatePostMutation();

mutate({ post })
queryClient.invalidateQueries({ queryKey: [/** ?? */] });

그런데 문제는 여기서 발생합니다. 우리는 update 로직을 개발하면서 get의 queryKey에 대한 정보를 알아야 하는데, 위에서 설계했던대로라면 update 로직과 get 로직은 모듈도 다르고, queryKey도 따로 정의를 해 두었습니다.

파일 구조로 본다면 이렇게 되어있겠죠.

개발자가 이 상황에서 get detail의 queryKey를 찾으려면 다음과 같은 플로우를 거쳐야 합니다.

여러 모듈과 패키지를 횡단하여 끝끝내 목적지에 도착한다면, 새로운 데이터를 fetch하는 간단한 작업에서 꽤 많은 시간을 사용할 수도 있겠네요.

지금은 비록 간단해보이고 큰 문제가 아닌 것 같다면, update에 get과 관련된 단서는 네이밍을 제외하고는 없는 상태에서 query와 mutation이 각각 100개씩 정의되어있다고 생각해볼 수 있습니다.

객관적으로는 프론트엔드에서 가장 합리적인 아키텍처는 개발자가 쉽게 모듈을 찾을 수 있는 구조라고 생각합니다. 에디터에서 디렉터리 -> 파일 -> 파일 -> 다시 뒤로 -> 디렉터리 ... 같은 횡단 없이, 깔끔하게 원하는 모듈을 거의 한 번에 볼 수 있는 구조 말입니다.

이제 글의 본문인 Options를 기반으로 React Query를 사용한 API 로직을 관리하는 패턴을 제시하겠습니다.

Options 기반 관리 패턴

api 패키지 내부 파일을 어떻게 나눌지를 먼저 정하겠습니다.

├── api/
│   ├── post
│   │   ├── axios.ts
│   │   ├── queries.ts
│   │   └── types.ts

api 내부에서 추가적인 디렉터리를 사용하지 않고 이 세 개의 파일로 모든 API 시나리오를 관리합니다.
queries나 mutations, axios와 같은 디렉터리가 아닌, post라는 도메인을 기반으로 파일들을 묶습니다.

도메인을 기반으로 그룹화를 하여 아키텍처가 기술이나 라이브러리에 종속되지 않고, 히스토리가 없는 개발자도 api -> post로 이루어지는 구조를 보며 쉽게 이해할 수 있도록 합니다.

axios.ts

axios.ts에는 베이스인 fetch 함수를 모두 정의합니다.

export const getPostList = async () => { axios.get ... }
export const getPostDetail = async (id: number) => { axios.get ... }
export const createPost = async (req: ...) => { axios.post ... }
export const updatePost = async (id: number) => { axios.put ... }
export const deletePost = async (id: number) => { axios.delete ... }

기본적으로 fetch 함수를 건드리는 일은 잘 없습니다. 앞서 말한 데이터를 fetch하고, refetch하고, mutation하는 모든 일에서 fetch가 필요한 정보를 가지고 있지는 않기 때문입니다.

여기서 request를 정의해야할 때 위에서 정의한 types.ts 파일에 CreatePostRequest와 같은 인터페이스명으로 정의 후 이를 import하여 사용합니다.

queries.ts

quereis.ts에는 하나의 객체를 정의해줄겁니다.
위에서 서술한 예제처럼, mutation을 관리하다가 query를 보러 위치를 이동하고 하는 불상사가 없게 queries.ts에 mutation 또한 함께 정의합니다.

간단하게 설명하기 위해 주제와 무관한 타입 주석은 잠시 빼두겠습니다.

import { queryOptions, UseQueryOptions, MutationOptions } from '@tanstack/react-query';

export const postQueries = {
  all: () => ['post'],
  list: (): UseQueryOptions<GetPostListResponse> =>
    queryOptions({
      queryKey: [...postQueries.all(), 'list'],
      queryFn: getPostList,
    }),
  detail: (id): UseQueryOptions<GetPostListResponse> =>
    queryOptions({
      queryKey: [...postQueries.all(), id],
      queryFn: () => getPostDetail(id),
    }),
  ...
  create: (req): UseMutationOptions<CreatePostResponse> => 
  	({
  	  mutationFn: () => createPost(req),
      onSuccess: () => { ... }
  	})
};

React Query에서 제공하는 queryOptions라는 함수를 사용하여 각 인스턴스마다 queryKey, queryFn을 갖는 객체를 정의합니다.

서비스가 따로 하나의 useQuery를 물고 있는 hook을 가지는 게 아닌, useQuery와 useMutation을 호출할 때 필요한 정보들만을 가지고 있습니다.

mutationOptions는 4월 11일을 기준으로 아직 정의되지 않은 상태여서, UseMutationOptions 타입을 정의하여 Type Safety하게 개발을 진행할 수 있습니다.

이제 Options 기반 패턴으로 API 관련 로직을 정의했으니, 이를 다시 사용해보겠습니다.

...

const PostDetailPageView = ({ id }: PostDetailPageViewProps) => {
	const { data: post } = useQuery(postQueries.detail(id));
  
  	return (
      <main>{...}</main>
    )
}

이 방식대로 개발을 진행했을 때, 업데이트하는 로직에서 queryKey를 찾으려면 어떻게 해야할까요?
정말 간편하게도 해당 options를 호출해주면 됩니다.

const queryClient = useQueryClient();
const { mutateAsync } = useMutation(postQueries.update());

...

const { id } = await mutateAsync({ post })
queryClient.invalidateQueries({ queryKey: postQueries.detail(id).queryKey });

같은 객체에 존재하는 detail 프로퍼티의 queryKey에 접근해 이를 전달하면, 개발자가 api 관련 디렉터리에 방문하지 않아도 queryKey에 대한 정보를 가질 수 있게 됩니다.

구조분해할당을 진행한다면 코드가 더욱 간결해지고 명시적여지기도 하겠네요.

queryClient.invalidateQueries(postQueries.detail(id))

invalidateQueries가 v5로 넘어오면서 ([]) => ({ queryKey: [] })로 파라미터가 고정이 되었는데, 이런 패치 사항이 queryOptions의 스펙과 딱 맞아 더욱 사용하기 적절하다고 느낍니다.

마무리

라이브러리나 프레임워크에서 점점 개발자가 고민해야 할 문제들을 직접 해결하려고 하면서, 프론트엔드 개발자가 고민해야하는 일들이 클린 아키텍처에 더욱 관심이 쏠리고 있는 추세인 것 같습니다.

여러 파일을 만들지 않고도 빠르게 API를 정의할 수 있으며, 개발한 API를 사용하는 입장에서도 리소스를 대폭 줄일 수 있는 Options 기반 관리 패턴을 사용해보시는 걸 추천드립니다.

profile
프론트엔드 공부중

0개의 댓글