Y J S

TanStack React Query ํ•ต์‹ฌ ๊ฐ€์ด๋“œ

์™œ React Query์ธ๊ฐ€

  • ์„œ๋ฒ„ ์ƒํƒœ๋Š” ํด๋ผ์ด์–ธํŠธ ์ƒํƒœ์™€ ๋‹ฌ๋ฆฌ "์›๊ฒฉยท์œ ํ•œํ•˜์ง€ ์•Š์Œยท๊ณต์œ ๋จยท์บ์‹œ ๊ฐ€๋Šฅ" ํŠน์„ฑ์„ ๊ฐ€์ง„๋‹ค. React Query๋Š” ์ด๋ฅผ ์œ„ํ•œ ํ‘œ์ค€ํ™”๋œ ํŒจํ„ด(ํŒจ์นญยท์บ์‹ฑยท๋™๊ธฐํ™”ยท๋ฌดํšจํ™”)์„ ์ œ๊ณตํ•ด ์ค‘๋ณต ์š”์ฒญ, ๋กœ๋”ฉ/์—๋Ÿฌ ์ฒ˜๋ฆฌ, ์žฌ์‹œ๋„, ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๊ฐฑ์‹ ์„ ์ž๋™ํ™”ํ•œ๋‹ค.

ํ•ต์‹ฌ ๊ฐœ๋… ํ•œ๋ˆˆ์—

  • Query(useQuery): ์ฝ๊ธฐ. ์ž๋™ ์บ์‹ฑ, ์ค‘๋ณต ์š”์ฒญ ๋ณ‘ํ•ฉ, ๋ฐฑ๊ทธ๋ผ์šด๋“œ ๋ฆฌํŒจ์น˜
  • Mutation(useMutation): ์“ฐ๊ธฐ. ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ, ์‹คํŒจ ๋กค๋ฐฑ, ์„ฑ๊ณต ์‹œ ๋ฌดํšจํ™”
  • ํ‚ค(queryKey): ์„œ๋ฒ„ ๋ฐ์ดํ„ฐ์˜ ์‹๋ณ„์ž(๋ฐฐ์—ด ํ˜•ํƒœ). ์•ˆ์ •์ ์ด๊ณ  ๊ฒฐ์ •์ ์ด์–ด์•ผ ํ•จ
  • ์‹ ์„ ๋„/์ˆ˜๋ช…: staleTime(์‹ ์„ ํ•œ ๊ธฐ๊ฐ„), gcTime(๋ฉ”๋ชจ๋ฆฌ์— ๋‚จ์•„์žˆ๋Š” ๊ธฐ๊ฐ„, v5)
  • ๋ฌดํšจํ™”: queryClient.invalidateQueries({ queryKey })๋กœ ๊ด€๋ จ ์ฟผ๋ฆฌ ์žฌ์กฐํšŒ

์„ค์น˜/์ดˆ๊ธฐ ์„ค์ •

npm i @tanstack/react-query
// app/providers.tsx ๋˜๋Š” _app.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

์กฐํšŒ: useQuery ๊ธฐ๋ณธ

import { useQuery } from "@tanstack/react-query";

function useTodos() {
  return useQuery({
    queryKey: ["todos"],
    queryFn: () => fetch("/api/todos").then((r) => r.json()),
    staleTime: 60_000, // 1๋ถ„๊ฐ„ ์‹ ์„  โ†’ ํฌ์ปค์Šค ์‹œ ์žฌํŒจ์น˜ ์•ˆ ํ•จ
    retry: 2, // ์‹คํŒจ ์‹œ 2ํšŒ ์žฌ์‹œ๋„
    refetchOnWindowFocus: true,
    select: (data) => data.items, // ์„œ๋ฒ„ ์‘๋‹ต ํ›„ ์„ ํƒ ๋ณ€ํ™˜
  });
}

๋ณ€๊ฒฝ: useMutation + ๋ฌดํšจํ™”/๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ

import { useMutation, useQueryClient } from "@tanstack/react-query";

function useAddTodo() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (title: string) =>
      fetch("/api/todos", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title }),
      }).then((r) => r.json()),
    onMutate: async (title) => {
      await qc.cancelQueries({ queryKey: ["todos"] });
      const prev = qc.getQueryData<any>(["todos"]);
      qc.setQueryData<any>(["todos"], (old) => ({
        ...old,
        items: [...(old?.items ?? []), { id: "temp", title }],
      }));
      return { prev };
    },
    onError: (_e, _v, ctx) => {
      if (ctx?.prev) qc.setQueryData(["todos"], ctx.prev); // ๋กค๋ฐฑ
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ["todos"] }),
  });
}

ํ‚ค ์„ค๊ณ„ ๋ฒ ์ŠคํŠธ ํ”„๋ž™ํ‹ฐ์Šค

  • ๋ฐฐ์—ด ๊ธฐ๋ฐ˜(์˜ˆ: ['post', postId]), ์ˆœ์„œยทํƒ€์ž… ์ผ๊ด€์„ฑ ์œ ์ง€
  • ์กฐ๊ฑด๋ถ€ ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” ๋ช…์‹œ์ ์œผ๋กœ ํฌํ•จ(ํŽ˜์ด์ง€๋„ค์ด์…˜, ํ•„ํ„ฐ)
  • ๋ถˆ์•ˆ์ •ํ•œ ๊ฐ์ฒด ์ƒ์„ฑ ์ง€์–‘(๋ฉ”๋ชจ์ด์ œ์ด์…˜ ๋˜๋Š” ์•ˆ์ •์  ์ง๋ ฌํ™”)

์‹ ์„ ๋„ ๋ชจ๋ธ(staleTime vs gcTime)

  • staleTime: ์‹ ์„ ํ•œ ๋™์•ˆ ์žฌํŒจ์น˜ ํŠธ๋ฆฌ๊ฑฐ(ํฌ์ปค์Šค/๋„คํŠธ์›Œํฌ ๋ณต๊ตฌ)๊ฐ€ ๋ฌด์‹œ๋จ
  • gcTime(v5): ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” ์บ์‹œ๊ฐ€ ๋ฉ”๋ชจ๋ฆฌ์—์„œ ์ œ๊ฑฐ๋˜๊ธฐ๊นŒ์ง€์˜ ์‹œ๊ฐ„(์ด์ „ v4์˜ cacheTime)

์ž์ฃผ ์“ฐ๋Š” ์˜ต์…˜ ์š”๋ น

  • placeholderData: ์ตœ์ดˆ ๋กœ๋”ฉ ์‹œ ์ฆ‰์‹œ ํ‘œ์‹œํ•  ์ž„์‹œ ๋ฐ์ดํ„ฐ
  • keepPreviousData: ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ „ํ™˜ ์‹œ ์ด์ „ ๋ฐ์ดํ„ฐ ์œ ์ง€๋กœ ๊นœ๋นก์ž„ ๊ฐ์†Œ
  • enabled: ์กฐ๊ฑด ์ถฉ์กฑ ์‹œ์—๋งŒ ์‹คํ–‰(์˜์กด ๋ฐ์ดํ„ฐ ๋กœ๋“œ ํ›„ ๋“ฑ)

์˜์กด/๋ณ‘๋ ฌ/๋ฌดํ•œ ์ฟผ๋ฆฌ

// ์˜์กด ์ฟผ๋ฆฌ: userId๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ
useQuery({ queryKey: ["user", id], queryFn: fetchUser, enabled: !!id });

// ๋ฌดํ•œ ์Šคํฌ๋กค
import { useInfiniteQuery } from "@tanstack/react-query";
useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: ({ pageParam = 0 }) =>
    fetch(`/api/feed?page=${pageParam}`).then((r) => r.json()),
  getNextPageParam: (last) => last.nextPage ?? undefined,
});

์˜ค๋ฅ˜ยท๋กœ๋”ฉ UX

  • ๊ธ€๋กœ๋ฒŒ: Suspense/์—๋Ÿฌ ๋ฐ”์šด๋”๋ฆฌ, ํ† ์ŠคํŠธ/์Šค๋‚ต๋ฐ”๋กœ ํ”ผ๋“œ๋ฐฑ
  • ๋กœ์ปฌ: isLoading, isFetching, isError, error๋กœ ์ƒํƒœ ์„ธ๋ถ„ํ™”
  • ์žฌ์‹œ๋„/๋ฐฑ์˜คํ”„: ๋„คํŠธ์›Œํฌ ์—๋Ÿฌ๋งŒ ์žฌ์‹œ๋„ํ•˜๊ณ  ๋น„์ฆˆ๋‹ˆ์Šค ์—๋Ÿฌ๋Š” ์ฆ‰์‹œ ๋…ธ์ถœ

SSR/ISR Hydration(Next.js)

// ์„œ๋ฒ„: ํ”„๋ฆฌํŽ˜์น˜ ํ›„ ํƒˆ์ˆ˜(dehydrate)
import { QueryClient, dehydrate } from "@tanstack/react-query";
export async function generateMetadata() {
  /* ... */
}
export default async function Page() {
  const qc = new QueryClient();
  await qc.prefetchQuery({
    queryKey: ["todos"],
    queryFn: () => fetch("https://api/todos").then((r) => r.json()),
  });
  const state = dehydrate(qc);
  return <Hydrated state={state} />;
}

// ํด๋ผ์ด์–ธํŠธ: Hydrate๋กœ ๋ณต์ˆ˜ ์š”์ฒญ ๋ฐฉ์ง€
("use client");
import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
export function Hydrated({ state }: { state: unknown }) {
  const qc = new QueryClient();
  return (
    <QueryClientProvider client={qc}>
      <HydrationBoundary state={state}>{/* children */}</HydrationBoundary>
    </QueryClientProvider>
  );
}

ํ”ํ•œ ์‹ค์ˆ˜ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

  • ๋ถˆ์•ˆ์ •ํ•œ queryKey๋กœ ๋งค ๋ Œ๋” ์žฌ์š”์ฒญ ๋ฐœ์ƒ
  • staleTime๊ณผ gcTime ํ˜ผ๋™(์‹ ์„ ๋„ vs ๋ฉ”๋ชจ๋ฆฌ ์ž”์กด)
  • Mutation ํ›„ ๊ด€๋ จ ํ‚ค ๋ฌดํšจํ™” ๋ˆ„๋ฝ
  • ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ ์‹œ ๋กค๋ฐฑ ์ปจํ…์ŠคํŠธ ๋ฏธ๊ตฌํ˜„

SWR๊ณผ์˜ ๊ฐ„๋‹จ ๋น„๊ต

  • ๋‘˜ ๋‹ค ์บ์‹ฑ/๋ฆฌ๋ฐธ๋ฆฌ๋ฐ์ด์…˜ ์ œ๊ณต. ๋Œ€๊ทœ๋ชจ ์•ฑ์—์„œ React Query๋Š” ์ฟผ๋ฆฌ/๋ฎคํ…Œ์ด์…˜, ๋ฌดํ•œ ์ฟผ๋ฆฌ, DevTools, SSR ํƒˆ์ˆ˜/์ˆ˜ํ™” ๋“ฑ ๊ด€๋ฆฌ ๊ธฐ๋Šฅ์ด ๋” ํ’๋ถ€ํ•˜๋‹ค. SWR์€ API๊ฐ€ ๋‹จ์ˆœํ•˜๊ณ  ๊ฐ€๋ฒผ์›Œ ๋น ๋ฅธ ๋„์ž…์— ์œ ๋ฆฌ.

๊ฒฐ๋ก 

  • React Query๋Š” ์„œ๋ฒ„ ์ƒํƒœ ๊ด€๋ฆฌ์˜ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ UX๋ฅผ ๋งŒ๋“ ๋‹ค. ํ‚ค ์„ค๊ณ„, ์‹ ์„ ๋„/๋ฌดํšจํ™” ์ „๋žต, ๋‚™๊ด€์  ์—…๋ฐ์ดํŠธ๋งŒ ์ž˜ ์„ค๊ณ„ํ•˜๋ฉด ๋Œ€๋‹ค์ˆ˜ ๋ฐ์ดํ„ฐ ํ๋ฆ„์ด ๋‹จ์ˆœํ•ด์ง„๋‹ค.