주요 콘텐츠로 건너뛰기

React Query와 함께 사용하기

“키를 어디에 두어야 하는가” 문제

해결책 — 엔티티별로 분리하기

프로젝트가 이미 엔티티 단위로 구성되어 있으며, 각 요청이 단일 엔티티에 해당한다면, 엔티티별로 코드를 구성하는 것이 좋습니다. 예를 들어, 다음과 같은 디렉토리 구조를 사용할 수 있습니다:

└── src/                                        #
├── app/ #
| ... #
├── pages/ #
| ... #
├── entities/ #
| ├── {entity}/ #
| ... └── api/ #
| ├── `{entity}.query` # 쿼리 키와 함수
| ├── `get-{entity}` # 엔티티 조회 함수
| ├── `create-{entity}` # 엔티티 생성 함수
| ├── `update-{entity}` # 엔티티 업데이트 함수
| ├── `delete-{entity}` # 엔티티 삭제 함수
| ... #
| #
├── features/ #
| ... #
├── widgets/ #
| ... #
└── shared/ #
... #

만약 엔티티 간에 연결이 필요한 경우 (예: Country 엔티티에 City 엔티티 필드가 포함되는 경우), 교차 가져오기를 위한 공개 API을 사용하거나 대안으로 아래의 구조를 고려할 수 있습니다.

대안 방안 — shared에 유지하기

엔티티별 분리가 적절하지 않은 경우, 다음과 같은 구조를 사용할 수 있습니다:

└── src/                                        #
... #
└── shared/ #
├── api/ #
... ├── `queries` # 쿼리 팩토리들
| ├── `document.ts` #
| ├── `background-jobs.ts` #
| ... #
└── index.ts #

이후 @/shared/api/index.ts에서 다음과 같이 사용합니다:

@/shared/api/index.ts
export { documentQueries } from "./queries/document";

"mutation 위치 설정" 문제

쿼리와 mutation을 같은 위치에 두는 것은 권장되지 않습니다. 다음 두 가지 옵션이 있습니다:

1. 사용 위치 근처의 api 디렉토리에서 커스텀 훅 정의하기

@/features/update-post/api/use-update-title.ts
export const useUpdateTitle = () => {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({ id, newTitle }) =>
apiClient
.patch(`/posts/${id}`, { title: newTitle })
.then((data) => console.log(data)),

onSuccess: (newPost) => {
queryClient.setQueryData(postsQueries.ids(id), newPost);
},
});
};

2. 공용 또는 엔티티에서 mutation 함수를 정의하고, 컴포넌트에서 useMutation을 직접 사용하기

const { mutateAsync, isPending } = useMutation({
mutationFn: postApi.createPost,
});
@/pages/post-create/ui/post-create-page.tsx
export const CreatePost = () => {
const { classes } = useStyles();
const [title, setTitle] = useState("");

const { mutate, isPending } = useMutation({
mutationFn: postApi.createPost,
});

const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
mutate({ title, userId: DEFAULT_USER_ID });
};

return (
<form className={classes.create_form} onSubmit={handleSubmit}>
<TextField onChange={handleChange} value={title} />
<LoadingButton type="submit" variant="contained" loading={isPending}>
Create
</LoadingButton>
</form>
);
};

요청의 조직화

쿼리 팩토리

쿼리 팩토리는 쿼리 키 목록을 반환하는 함수를 포함한 객체입니다. 사용 방법은 다음과 같습니다:

const keyFactory = {
all: () => ["entity"],
lists: () => [...postQueries.all(), "list"],
};
info

queryOptions는 react-query@v5의 내장 유틸리티입니다 (선택 사항)

queryOptions({
queryKey,
...options,
});

더 큰 타입 안정성, react-query의 향후 버전과의 호환성, 함수 및 쿼리 키에 대한 쉬운 액세스를 위해, "@tanstack/react-query"의 내장 queryOptions 함수를 사용할 수 있습니다 (자세한 내용은 여기).

1. 쿼리 팩토리 생성 예시

@/entities/post/api/post.queries.ts
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";
import { getDetailPost } from "./get-detail-post";
import { PostDetailQuery } from "./query/post.query";

export const postQueries = {
all: () => ["posts"],

lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),

details: () => [...postQueries.all(), "detail"],
detail: (query?: PostDetailQuery) =>
queryOptions({
queryKey: [...postQueries.details(), query?.id],
queryFn: () => getDetailPost({ id: query?.id }),
staleTime: 5000,
}),
};

2. 애플리케이션 코드에서의 쿼리 팩토리 사용 예시

import { useParams } from "react-router-dom";
import { postApi } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";

type Params = {
postId: string;
};

export const PostPage = () => {
const { postId } = useParams<Params>();
const id = parseInt(postId || "");
const {
data: post,
error,
isLoading,
isError,
} = useQuery(postApi.postQueries.detail({ id }));

if (isLoading) {
return <div>Loading...</div>;
}

if (isError || !post) {
return <>{error?.message}</>;
}

return (
<div>
<p>Post id: {post.id}</p>
<div>
<h1>{post.title}</h1>
<div>
<p>{post.body}</p>
</div>
</div>
<div>Owner: {post.userId}</div>
</div>
);
};

쿼리 팩토리 사용의 장점

  • 요청 구조화: 팩토리를 통해 모든 API 요청을 한 곳에 조직화하여 코드의 가독성과 유지보수성을 높입니다.
  • 쿼리 및 키에 대한 편리한 접근: 다양한 유형의 쿼리와 해당 키에 쉽게 접근할 수 있는 메서드를 제공합니다.
  • 쿼리 재호출 용이성: 애플리케이션의 여러 부분에서 쿼리 키를 변경할 필요 없이 쉽게 재호출할 수 있습니다.

페이지네이션

이 섹션에서는 페이지네이션을 사용하여 게시물 엔티티를 가져오는 API 요청을 수행하는 getPosts 함수의 예를 소개합니다.

1. getPosts 함수 생성하기

getPosts 함수는 api 세그먼트의 get-posts.ts 파일에 있습니다.

@/pages/post-feed/api/get-posts.ts
import { apiClient } from "@/shared/api/base";

import { PostWithPaginationDto } from "./dto/post-with-pagination.dto";
import { PostQuery } from "./query/post.query";
import { mapPost } from "./mapper/map-post";
import { PostWithPagination } from "../model/post-with-pagination";

const calculatePostPage = (totalCount: number, limit: number) =>
Math.floor(totalCount / limit);

export const getPosts = async (
page: number,
limit: number,
): Promise<PostWithPagination> => {
const skip = page * limit;
const query: PostQuery = { skip, limit };
const result = await apiClient.get<PostWithPaginationDto>("/posts", query);

return {
posts: result.posts.map((post) => mapPost(post)),
limit: result.limit,
skip: result.skip,
total: result.total,
totalPages: calculatePostPage(result.total, limit),
};
};

2. 페이지네이션을 위한 쿼리 팩토리

postQueries 쿼리 팩토리는 특정 페이지와 제한에 맞춰 게시물 목록을 요청하는 등 게시물 관련 다양한 쿼리 옵션을 정의합니다.

import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";

export const postQueries = {
all: () => ["posts"],
lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),
};

3. 애플리케이션 코드에서의 사용

@/pages/home/ui/index.tsx
export const HomePage = () => {
const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN;
const [page, setPage] = usePageParam(DEFAULT_PAGE);
const { data, isFetching, isLoading } = useQuery(
postApi.postQueries.list(page, itemsOnScreen),
);
return (
<>
<Pagination
onChange={(_, page) => setPage(page)}
page={page}
count={data?.totalPages}
variant="outlined"
color="primary"
/>
<Posts posts={data?.posts} />
</>
);
};
note

예시는 단순화된 버전이며, 전체 코드는 GitHub에서 확인할 수 있습니다.

쿼리 관리를 위한 QueryProvider

이 가이드에서는 QueryProvider를 어떻게 구성하는지 살펴봅니다.

1. QueryProvider 생성하기

query-provider.tsx 파일은 @/app/providers/query-provider.tsx 경로에 있습니다.

@/app/providers/query-provider.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactNode } from "react";

type Props = {
children: ReactNode;
client: QueryClient;
};

export const QueryProvider = ({ client, children }: Props) => {
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
};

2. QueryClient 생성하기

QueryClient는 API 요청을 관리하는 인스턴스입니다. query-client.ts 파일은 @/shared/api/query-client.ts에 속해 있으며, 쿼리 캐싱을 위해 특정 설정으로 QueryClient를 생성합니다.

@/shared/api/query-client.ts
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});

코드 생성

API 코드를 생성해주는 도구들이 있지만, 이러한 방식은 위의 예제 처럼 직접 코드를 작성하는 방법보다 유연성이 부족할 수 있습니다. 그러나 Swagger 파일이 잘 구성되어 있고 이러한 자동 생성 도구를 사용하는 경우, 생성된 코드를 @/shared/api 디렉토리에 두어 관리하는 것이 효율적일 수 있습니다.

React Query를 조직화하기 위한 추가 조언

API 클라이언트

공유 레이어에서 커스텀 API 클라이언트 클래스를 사용하면, 프로젝트 내 API 작업을 일관성 있게 관리할 수 있습니다. 이를 통해 로깅, 헤더 설정, 데이터 전송 형식(JSON 또는 XML 등)을 한 곳에서 관리할 수 있게 됩니다. 또한 이 접근 방식은 API와의 상호작용에 대한 변경 사항을 쉽게 반영할 수 있게 하여, 프로젝트의 유지보수성과 개발 편의성을 크게 향상시킵니다.

@/shared/api/api-client.ts
import { API_URL } from "@/shared/config";

export class ApiClient {
private baseUrl: string;

constructor(url: string) {
this.baseUrl = url;
}

async handleResponse<TResult>(response: Response): Promise<TResult> {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}

try {
return await response.json();
} catch (error) {
throw new Error("Error parsing JSON response");
}
}

public async get<TResult = unknown>(
endpoint: string,
queryParams?: Record<string, string | number>,
): Promise<TResult> {
const url = new URL(endpoint, this.baseUrl);

if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value.toString());
});
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});

return this.handleResponse<TResult>(response);
}

public async post<TResult = unknown, TData = Record<string, unknown>>(
endpoint: string,
body: TData,
): Promise<TResult> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});

return this.handleResponse<TResult>(response);
}
}

export const apiClient = new ApiClient(API_URL);

참고 자료