React Query์ ํจ๊ป ์ฌ์ฉํ๊ธฐ
โํค๋ฅผ ์ด๋์ ๋์ด์ผ ํ๋๊ฐโ ๋ฌธ์ โ
ํด๊ฒฐ์ฑ โ ์ํฐํฐ๋ณ๋ก ๋ถ๋ฆฌํ๊ธฐโ
ํ๋ก์ ํธ๊ฐ ์ด๋ฏธ ์ํฐํฐ ๋จ์๋ก ๊ตฌ์ฑ๋์ด ์์ผ๋ฉฐ, ๊ฐ ์์ฒญ์ด ๋จ์ผ ์ํฐํฐ์ ํด๋นํ๋ค๋ฉด, ์ํฐํฐ๋ณ๋ก ์ฝ๋๋ฅผ ๊ตฌ์ฑํ๋ ๊ฒ์ด ์ข์ต๋๋ค. ์๋ฅผ ๋ค์ด, ๋ค์๊ณผ ๊ฐ์ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค:
โโโ src/ #
โโโ app/ #
| ... #
โโโ pages/ #
| ... #
โโโ entities/ #
| โโโ {entity}/ #
| ... โโโ api/ #
| โโโ `{entity}.query` # ์ฟผ๋ฆฌ ํค์ ํจ์
| โโโ `get-{entity}` # ์ํฐํฐ ์กฐํ ํจ์
| โโโ `create-{entity}` # ์ํฐํฐ ์์ฑ ํจ์
| โโโ `update-{entity}` # ์ํฐํฐ ์
๋ฐ์ดํธ ํจ์
| โโโ `delete-{entity}` # ์ํฐํฐ ์ญ์ ํจ์
| ... #
| #
โโโ features/ #
| ... #
โโโ widgets/ #
| ... #
โโโ shared/ #
... #
๋ง์ฝ ์ํฐํฐ ๊ฐ์ ์ฐ๊ฒฐ์ด ํ์ํ ๊ฒฝ์ฐ (์: Country ์ํฐํฐ์ City ์ํฐํฐ ํ๋๊ฐ ํฌํจ๋๋ ๊ฒฝ์ฐ), @x-notation์ ํ์ฉํ ๊ต์ฐจ ์ํฌํธ ๋ฐฉ์์ ์ฌ์ฉํ๊ฑฐ๋ ๋์์ผ๋ก ์๋์ ๊ตฌ์กฐ๋ฅผ ๊ณ ๋ คํ ์ ์์ต๋๋ค.
๋์ ๋ฐฉ์ โ shared์ ์ ์งํ๊ธฐโ
์ํฐํฐ๋ณ ๋ถ๋ฆฌ๊ฐ ์ ์ ํ์ง ์์ ๊ฒฝ์ฐ, ๋ค์๊ณผ ๊ฐ์ ๊ตฌ์กฐ๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค:
โโโ src/ #
... #
โโโ shared/ #
โโโ api/ #
... โโโ `queries` # ์ฟผ๋ฆฌ ํฉํ ๋ฆฌ๋ค
| โโโ `document.ts` #
| โโโ `background-jobs.ts` #
| ... #
โโโ index.ts #
์ดํ @/shared/api/index.ts
์์ ๋ค์๊ณผ ๊ฐ์ด ์ฌ์ฉํฉ๋๋ค:
export { documentQueries } from "./queries/document";
"mutation ์์น ์ค์ " ๋ฌธ์ โ
์ฟผ๋ฆฌ์ mutation์ ๊ฐ์ ์์น์ ๋๋ ๊ฒ์ ๊ถ์ฅ๋์ง ์์ต๋๋ค. ๋ค์ ๋ ๊ฐ์ง ์ต์ ์ด ์์ต๋๋ค:
1. ์ฌ์ฉ ์์น ๊ทผ์ฒ์ api
๋๋ ํ ๋ฆฌ์์ ์ปค์คํ
ํ
์ ์ํ๊ธฐโ
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,
});
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"],
};
queryOptions
๋ react-query@v5์ ๋ด์ฅ ์ ํธ๋ฆฌํฐ์
๋๋ค (์ ํ ์ฌํญ)
queryOptions({
queryKey,
...options,
});
๋ ํฐ ํ์ ์์ ์ฑ, react-query์ ํฅํ ๋ฒ์ ๊ณผ์ ํธํ์ฑ, ํจ์ ๋ฐ ์ฟผ๋ฆฌ ํค์ ๋ํ ์ฌ์ด ์ก์ธ์ค๋ฅผ ์ํด, "@tanstack/react-query"์ ๋ด์ฅ queryOptions ํจ์๋ฅผ ์ฌ์ฉํ ์ ์์ต๋๋ค (์์ธํ ๋ด์ฉ์ ์ฌ๊ธฐ).
1. ์ฟผ๋ฆฌ ํฉํ ๋ฆฌ ์์ฑ ์์โ
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
ํ์ผ์ ์์ต๋๋ค.
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. ์ ํ๋ฆฌ์ผ์ด์ ์ฝ๋์์์ ์ฌ์ฉโ
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} />
</>
);
};
์์๋ ๋จ์ํ๋ ๋ฒ์ ์ด๋ฉฐ, ์ ์ฒด ์ฝ๋๋ GitHub์์ ํ์ธํ ์ ์์ต๋๋ค.
์ฟผ๋ฆฌ ๊ด๋ฆฌ๋ฅผ ์ํ QueryProvider
โ
์ด ๊ฐ์ด๋์์๋ QueryProvider
๋ฅผ ์ด๋ป๊ฒ ๊ตฌ์ฑํ๋์ง ์ดํด๋ด
๋๋ค.
1. QueryProvider
์์ฑํ๊ธฐโ
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
๋ฅผ ์์ฑํฉ๋๋ค.
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์์ ์ํธ์์ฉ์ ๋ํ ๋ณ๊ฒฝ ์ฌํญ์ ์ฝ๊ฒ ๋ฐ์ํ ์ ์๊ฒ ํ์ฌ, ํ๋ก์ ํธ์ ์ ์ง๋ณด์์ฑ๊ณผ ๊ฐ๋ฐ ํธ์์ฑ์ ํฌ๊ฒ ํฅ์์ํต๋๋ค.
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);