
왜 TanStack Query를 사용해야 할까요?
TanStack Query는 React 애플리케이션에서 서버 상태(Server State) 를 효율적으로 다루기 위한 라이브러리입니다.
예전에는 React Query라는 이름으로 많이 알려져 있었고, v4부터 TanStack Query로 이름이 바뀌었습니다.
React에서 상태는 크게 두 가지로 나눌 수 있습니다.
하나는 사용자 입력, 모달 열림 여부, 탭 상태처럼 프론트엔드 내부에서 직접 관리하는 클라이언트 상태이고,
다른 하나는 서버에서 받아오고, 다시 동기화해야 하며, 시간이 지나면 낡을 수 있는 서버 상태입니다.
서버 상태는 다음과 같은 특징을 가집니다 :
- 비동기다 (요청 → 응답 기다림)
- 실패할 수 있다
- 여러 컴포넌트에서 공유된다
- 시간이 지나면 낡는다 (stale)
- 다시 가져올 타이밍을 결정해야 한다
이러한 요소들을 useState나 useEffect로 다루기엔 로직이 계속 반복되고 점점 복잡해질 수 있습니다.
Tanstack Query (구 React Query)는 이러한 복잡한 문제를 손쉽게 다루기 위해 나온 라이브러리입니다.
TanStack Query 없이 데이터를 가져오면 보통 이렇게 작성합니다.
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
fetch("/api/posts")
.then((res) => res.json())
.then((data) => {
setData(data);
setIsLoading(false);
})
.catch((err) => {
setError(err);
setIsLoading(false);
});
}, []);
이 코드를 모든 API 호출마다 반복해야 합니다.
그러나 TanStack Query를 쓰면 같은 작업이 이렇게 줄어듭니다.
const { data, isLoading, error } = useQuery({
queryKey: ["posts"],
queryFn: () => fetch("/api/posts").then((res) => res.json()),
});
코드가 줄어드는 것 이상으로, 캐싱 / 재요청 / 에러 처리 / 로딩 상태 관리까지 자동으로 챙겨줍니다.
이렇게 편리한 기능을 사용할 수 있는 TanStack Query에 대해서 본격적으로 알아봅시다!
설치와 기본 세팅
설치
npm install @tanstack/react-query
개발 중 쿼리 상태를 눈으로 확인하고 싶다면 DevTools도 함께 설치하는 것을 추천합니다.
npm install @tanstack/react-query-devtools
QueryClient와 QueryClientProvider 설정
앱의 최상단에서 QueryClient를 생성하고 QueryClientProvider로 감싸야 합니다.
이 설정이 있어야 앱 어디서든 TanStack Query 훅을 사용할 수 있습니다.
// main.tsx 또는 App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 기본 staleTime: 1분
retry: 1, // 실패 시 1회 재시도
},
},
});
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
{/* 개발 환경에서만 Devtools 표시 */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
QueryClient는 앱 전체에서 공유하는 캐시 저장소입니다.
defaultOptions로 모든 쿼리에 공통 설정을 적용할 수 있어, 각 훅마다 같은 옵션을 반복할 필요가 없습니다.
useQuery - 데이터 읽기
서버에서 데이터를 가져올 때 사용하는 기본 훅입니다. 반드시 queryKey와 queryFn 두 가지를 전달해야 합니다.
import { useQuery } from "@tanstack/react-query";
async function fetchPosts() {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("데이터를 불러오지 못했습니다.");
return res.json();
}
function PostList() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ["posts"], // 이 데이터를 구분하는 고유 키
queryFn: fetchPosts, // 실제 데이터를 가져오는 함수
});
if (isLoading) return <div>로딩 중...</div>;
if (isError) return <div>에러: {error.message}</div>;
return (
<ul>
{data.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
동적 데이터 - queryKey에 변수 넣기
특정 ID에 해당하는 데이터를 가져올 때는 queryKey에 해당 변수를 함께 넣어야 합니다.
키가 바뀌면 자동으로 새 요청을 보냅니다.
function PostDetail({ postId }: { postId: number }) {
const { data, isLoading } = useQuery({
queryKey: ["posts", "detail", postId], // postId가 바뀌면 자동으로 재요청
queryFn: () =>
fetch(`/api/posts/${postId}`).then((r) => r.json()),
});
if (isLoading) return <div>로딩 중...</div>;
return <h1>{data.title}</h1>;
}
queryFn에서 에러가 발생하려면 반드시 throw를 해야 합니다.
fetch는 404, 500 응답도 성공으로 처리하므로, res.ok를 체크하고 직접 throw하는 습관이 중요합니다.
자주 쓰는 옵션
| enabled | false이면 쿼리를 실행하지 않습니다. 특정 조건이 충족됐을 때만 요청할 때 씁니다. |
| staleTime | 데이터를 신선하게 볼 시간(ms). 이 시간 안에는 재요청하지 않습니다. |
| refetchOnWindowFocus | 탭을 다시 활성화했을 때 자동으로 재요청할지 여부. 기본값은 true입니다. |
| retry | 실패 시 재시도 횟수. 기본값은 3입니다. |
| select | 응답 데이터를 가공해서 반환합니다. 원본 캐시는 그대로 유지됩니다. |
| placeholderData | 데이터가 없을 때 보여줄 임시 데이터입니다. 스켈레톤 대신 쓸 수 있습니다. |
// enabled 예시 — userId가 있을 때만 요청
const { data } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
enabled: !!userId,
});
// select 예시 — 제목만 추출해서 반환
const { data: titles } = useQuery({
queryKey: ["posts"],
queryFn: fetchPosts,
select: (data) => data.map((post) => post.title),
});
useMutation - 데이터 쓰기
데이터를 생성/수정/삭제할 때 사용하는 훅입니다.
useQuery와 달리 자동으로 실행되지 않고, mutate()를 직접 호출해야 실행됩니다.
import { useMutation, useQueryClient } from "@tanstack/react-query";
async function createPost(newPost: { title: string; body: string }) {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newPost),
});
if (!res.ok) throw new Error("게시글 생성 실패");
return res.json();
}
function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
// 성공하면 게시글 목록 캐시를 무효화 → 다음 접근 시 재요청 발생
queryClient.invalidateQueries({ queryKey: ["posts"] });
},
onError: (error) => {
alert(`에러: ${error.message}`);
},
});
const handleSubmit = () => {
mutation.mutate({ title: "새 게시글", body: "내용입니다." });
};
return (
<button onClick={handleSubmit} disabled={mutation.isPending}>
{mutation.isPending ? "저장 중..." : "게시글 작성"}
</button>
);
}
useMutation의 콜백 함수들
| onSuccess | 요청이 성공했을 때 실행됩니다. 캐시 무효화나 리다이렉트 처리를 주로 합니다. |
| onError | 요청이 실패했을 때 실행됩니다. 에러 메시지 표시나 롤백 처리를 합니다. |
|
onSettled
|
성공/실패 관계없이 항상 실행됩니다. 최종 동기화나 로딩 해제에 씁니다. |
| onMutate | 요청 직전에 실행됩니다. Optimistic Update의 시작점입니다. |
useQueryClient - 캐시 직접 다루기
useQueryClient는 캐시에 직접 접근할 수 있는 훅입니다.
주로 mutation 이후 캐시를 갱신하거나, 특정 쿼리를 강제로 다시 가져올 때 씁니다.
const queryClient = useQueryClient();
// 특정 쿼리 캐시를 무효화 → 다음 접근 시 재요청 발생
queryClient.invalidateQueries({ queryKey: ["posts"] });
// 캐시에 직접 데이터 쓰기 → 재요청 없이 즉시 반영
queryClient.setQueryData(["posts", "detail", 1], updatedPost);
// 캐시에서 데이터 읽기
const cachedPost = queryClient.getQueryData(["posts", "detail", 1]);
// 미리 데이터 가져오기 (Prefetch)
await queryClient.prefetchQuery({
queryKey: ["posts", "detail", 2],
queryFn: () => fetchPostDetail(2),
});
invalidateQueries는 캐시를 "낡았다"고 표시해 다음 접근 시 재요청을 유도합니다.
setQueryData는 재요청 없이 캐시를 직접 덮어씁니다. 이 둘의 차이를 이해하면 mutation 후처리가 훨씬 명확해집니다.
주요 상태값 한눈에 보기
useQuery와 useMutation이 반환하는 상태값은 처음엔 헷갈릴 수 있습니다. 자주 쓰는 것들만 정리했습니다.
useQuery 상태
| isLoading | 캐시가 없고 처음으로 데이터를 가져오는 중 |
| isFetching | 백그라운드 포함, 요청이 진행 중인 모든 상태 |
| isSuccess | 데이터를 성공적으로 가져온 상태 |
| isError | 요청이 실패한 상태 |
| isStale | staleTime이 지나 데이터가 낡은 상태 |
| isPending | 데이터가 아직 없는 상태 (v5 이상) |
isLoading은 캐시가 없어 처음 로딩 중일 때만 true입니다. isFetching은 백그라운드에서 갱신 중일 때도 true입니다. 스켈레톤 UI에는 isLoading을, 상단 로딩 바에는 isFetching을 쓰는 것이 일반적입니다.
useMutation 상태
| isPending | 요청이 진행 중인 상태 |
| isSuccess | 요청이 성공한 상태 |
| isError | 요청이 실패한 상태 |
| isIdle | 아직 실행되지 않은 초기 상태 |
실전 예제 -게시글 CRUD
지금까지 배운 것들을 하나로 묶어봅니다. 게시글 목록 조회, 상세 조회, 생성, 삭제를 모두 담은 예제입니다.
// api.ts — API 함수 정의
export const api = {
getPosts: () =>
fetch("/api/posts").then((r) => r.json()),
getPost: (id: number) =>
fetch(`/api/posts/${id}`).then((r) => r.json()),
createPost: (data: { title: string; body: string }) =>
fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}).then((r) => r.json()),
deletePost: (id: number) =>
fetch(`/api/posts/${id}`, { method: "DELETE" }),
};
// postKeys.ts — Query Key 팩토리
export const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
detail: (id: number) => [...postKeys.all, "detail", id] as const,
};
// PostList.tsx — 목록 조회 + 삭제
function PostList() {
const queryClient = useQueryClient();
const { data: posts, isLoading } = useQuery({
queryKey: postKeys.lists(),
queryFn: api.getPosts,
});
const deleteMutation = useMutation({
mutationFn: api.deletePost,
onSuccess: () => {
// 삭제 성공 시 목록 캐시 무효화
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
},
});
if (isLoading) return <div>로딩 중...</div>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title}
<button
onClick={() => deleteMutation.mutate(post.id)}
disabled={deleteMutation.isPending}
>
삭제
</button>
</li>
))}
</ul>
);
}
// CreatePost.tsx — 게시글 생성
function CreatePost() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const createMutation = useMutation({
mutationFn: api.createPost,
onSuccess: (newPost) => {
// 목록 캐시 무효화 → 새 글이 포함된 목록을 다시 가져옴
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
// 생성된 글을 상세 캐시에 바로 저장 → 상세 진입 시 재요청 없이 즉시 표시
queryClient.setQueryData(postKeys.detail(newPost.id), newPost);
},
});
return (
<div>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="게시글 제목"
/>
<button
onClick={() => createMutation.mutate({ title, body: "" })}
disabled={createMutation.isPending || !title}
>
{createMutation.isPending ? "저장 중..." : "작성"}
</button>
{createMutation.isError && (
<p style={{ color: "red" }}>저장에 실패했습니다.</p>
)}
</div>
);
}
생성 후 invalidateQueries와 setQueryData를 함께 쓰는 패턴은 실무에서 흔합니다. 목록은 서버에서 다시 받아오고, 상세는 응답 데이터를 바로 캐시에 넣어 다음 진입 시 즉시 보여줄 수 있습니다.
기초 정리 체크리스트
- QueryClientProvider로 앱 최상단을 감싼다
- 데이터 읽기는 useQuery, 데이터 쓰기는 useMutation
- queryKey는 리소스 종류와 조회 조건을 모두 포함한다
- queryFn에서 에러가 나면 반드시 throw를 한다
- mutation 성공 후에는 관련 캐시를 invalidateQueries로 무효화한다
- isLoading과 isFetching의 차이를 구분해서 UI에 적용한다
- 개발 중에는 Devtools로 캐시 상태를 항상 확인한다
Query Key 설계 전략과 캐시 관리
TanStack Query에서 Query Key는 캐시를 식별하는 기준입니다.
필터·정렬·페이지네이션이 붙기 시작하면 단순한 이름표로 다루기 어려워집니다. 조건이 다른 요청이 같은 캐시를 공유하거나, 의도치 않게 캐시가 재사용되는 문제가 생깁니다.
좋은 Query Key의 조건
리소스 종류가 드러나야 합니다. 게시글 목록인지, 상세인지, 댓글인지 — 키만 봐도 구분이 가야 합니다.
조회 조건이 키에 포함되어야 합니다. 페이지 번호, 정렬 기준, 검색어가 빠지면 서로 다른 조건의 요청이 같은 캐시를 바라보게 됩니다.
일관된 계층 구조를 가져야 합니다.
["posts"]
["posts", "list", { page, sort, category }]
["posts", "detail", postId]
["posts", "comments", postId]
이 구조를 유지하면 ["posts"]를 invalidate하는 것만으로 하위 캐시 전체를 무효화할 수 있습니다.
Query Key Factory 패턴
프로젝트가 커질수록 Query Key를 하드코딩하는 방식은 유지보수가 어려워집니다. 이럴 때 쓰는 것이 Query Key Factory 패턴입니다.
export const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (params: { page: number; sort: string; category?: string }) =>
[...postKeys.lists(), params] as const,
details: () => [...postKeys.all, "detail"] as const,
detail: (id: number) => [...postKeys.details(), id] as const,
};
useQuery({
queryKey: postKeys.list({ page: 1, sort: "latest", category: "react" }),
queryFn: () => fetchPosts({ page: 1, sort: "latest", category: "react" }),
});
오타를 줄이고, invalidateQueries나 setQueryData를 쓸 때 실수를 방지해 줍니다.
부수적으로 캐시 구조를 코드로 문서화하는 역할도 합니다.
staleTime, gcTime
Query Key 설계만큼 캐시 정책도 같이 잡아야 합니다.
staleTime 은 데이터를 신선하다고 볼 시간입니다.
useQuery({
queryKey: postKeys.detail(1),
queryFn: () => fetchPostDetail(1),
staleTime: 1000 * 60,
});
1분으로 설정하면 1분 안에 같은 쿼리가 다시 마운트되어도 재요청하지 않습니다.
값이 너무 짧으면 요청이 과도해지고, 너무 길면 낡은 데이터를 오래 보여주게 됩니다.
자주 바뀌지 않는 데이터는 길게, 실시간성이 중요한 데이터는 짧게 가져가는 것이 일반적입니다.
gcTime 은 사용하지 않는 캐시를 메모리에 얼마나 유지할지 결정합니다.
staleTime이 신선도의 문제라면, gcTime은 보관 기간의 문제입니다. 둘을 혼동하지 않는 것이 중요합니다.
invalidateQueries vs setQueryData
mutation 이후에 무조건 invalidateQueries만 쓰는 경우가 많은데, 상황에 따라 setQueryData가 더 적합할 때가 있습니다.
// 데이터를 다시 가져와야 할 때
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
// 서버 응답을 이미 갖고 있을 때
queryClient.setQueryData(postKeys.detail(post.id), post);
서버 응답으로 확정된 데이터를 캐시에 바로 반영할 수 있다면 setQueryData가 효율적입니다. 영향 범위가 넓거나 최신 상태를 반드시 서버에서 받아와야 한다면 invalidateQueries가 안전합니다.
Optimistic Update 패턴 심화
개념
서버 응답을 기다리지 않고 UI를 먼저 바꾸는 방식입니다. 좋아요 수를 누르는 즉시 올려 보여주고, 실패하면 되돌리는 식입니다. UX를 크게 개선할 수 있지만, 잘못 쓰면 데이터 불일치나 롤백 복잡도가 높아집니다.
기본 흐름
const mutation = useMutation({
mutationFn: toggleLike,
onMutate: async (postId: number) => {
// 진행 중인 쿼리 취소 — 덮어쓰기 충돌 방지
await queryClient.cancelQueries({ queryKey: postKeys.detail(postId) });
// 롤백을 위해 이전 데이터 저장
const previousPost = queryClient.getQueryData(postKeys.detail(postId));
// 캐시를 먼저 수정
queryClient.setQueryData(postKeys.detail(postId), (old: any) => {
if (!old) return old;
return {
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
};
});
return { previousPost };
},
onError: (error, postId, context) => {
// 실패 시 원래 상태로 복원
if (context?.previousPost) {
queryClient.setQueryData(postKeys.detail(postId), context.previousPost);
}
},
onSettled: (_, __, postId) => {
// 성공·실패 무관하게 서버 기준으로 재동기화
queryClient.invalidateQueries({ queryKey: postKeys.detail(postId) });
},
});
흐름은 단순합니다. 미리 반영하고, 실패하면 되돌리고, 끝나면 서버와 맞춥니다.
쓰면 안 되는 경우
좋아요, 북마크, 체크박스처럼 결과가 단순하고 예측 가능한 경우에 잘 맞습니다. 반면 아래 상황에서는 피하는 것이 낫습니다.
- 서버 검증이 복잡한 경우
- 실패 가능성이 높은 경우
- 응답이 서버 계산 결과에 크게 의존하는 경우 (slug 생성, 작성자 정보 가공 등)
이런 경우에는 로딩 상태를 보여주고 성공 후 invalidate하는 것이 더 안정적입니다.
목록과 상세 캐시를 함께 다뤄야 하는 문제
게시글 하나가 목록 캐시와 상세 캐시 양쪽에 존재하는 경우가 많습니다. 상세만 수정하고 목록을 그대로 두면 UI가 어긋납니다.
queryClient.setQueriesData(
{ queryKey: postKeys.lists() },
(oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages?.map((page: any) => ({
...page,
items: page.items.map((post: any) =>
post.id === postId
? {
...post,
liked: !post.liked,
likeCount: post.liked ? post.likeCount - 1 : post.likeCount + 1,
}
: post
),
})),
};
}
);
Optimistic Update를 설계할 때는 해당 데이터가 어느 캐시에 중복으로 존재하는지를 반드시 함께 고려해야 합니다.
Prefetching 전략과 성능 최적화
사용자가 진입하기 전에 데이터를 미리 가져와 화면 전환 시 로딩을 줄이는 전략입니다.
const handlePrefetch = (postId: number) => {
queryClient.prefetchQuery({
queryKey: postKeys.detail(postId),
queryFn: () => fetchPostDetail(postId),
staleTime: 1000 * 60,
});
};
상세 페이지 진입 시 캐시가 이미 존재하면 로딩 없이 바로 표시되거나, 대기 시간이 크게 줄어듭니다.
효과적인 경우
모든 페이지에 붙이는 것이 아니라, 사용자의 다음 행동이 예측 가능한 지점에 한해 적용해야 합니다.
- 목록 → 상세 이동이 잦은 구조
- 탭 전환이 빈번한 구조
- hover, focus, viewport 진입 시점을 활용할 수 있는 경우
예측 없이 모든 페이지를 prefetch하면 네트워크 낭비와 캐시 오염으로 이어집니다.
Infinite Query 최적화
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam = 1 }) => fetchFeed(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
cursor 기반 페이지네이션
페이지 번호 방식은 중간에 데이터가 추가·삭제될 때 항목이 밀려 중복이나 누락이 발생할 수 있습니다.
cursor 기반은 "마지막으로 본 항목의 다음부터"가 기준이므로 목록이 변해도 안정적입니다.
백엔드 API 설계 단계부터 cursor 방식을 고려하는 것이 좋습니다.
성능 문제
const allPosts = data?.pages.flatMap((page) => page.items) ?? [];
흔하게 쓰는 패턴이지만 페이지가 많이 쌓이면 렌더링 비용이 올라갑니다. 이 시점부터는 리스트 가상화, memoization, item key 안정성까지 같이 고려해야 합니다.
중복 호출 방지
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
hasNextPage와 isFetchingNextPage 조건이 없으면 sentinel이 보이는 동안 요청이 연속으로 나갑니다.
Error / Loading 바운더리 패턴
컴포넌트마다 처리할 때의 문제
const { data, isLoading, isError, error } = useQuery(...);
if (isLoading) return <Loading />;
if (isError) return <ErrorMessage error={error} />;
return <Content data={data} />;
페이지가 적을 때는 괜찮습니다. 화면이 늘어나면 이 분기문이 모든 컴포넌트에 반복되고, 로딩 UI와 에러 처리 기준이 제각각이 됩니다.
Suspense 기반 로딩 처리
useSuspenseQuery({
queryKey: postKeys.detail(postId),
queryFn: () => fetchPostDetail(postId),
});
<Suspense fallback={<PostDetailSkeleton />}>
<PostDetail />
</Suspense>
컴포넌트 내부에서 isLoading 분기를 작성할 필요 없이, 상위에서 fallback을 통일해 관리할 수 있습니다.
QueryErrorResetBoundary
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
데이터를 불러오지 못했습니다.
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
<Suspense fallback={<div>로딩 중...</div>}>
<PostDetail />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
쿼리 실패 시 Error Boundary가 에러를 잡고, "다시 시도"를 누르면 reset을 통해 쿼리가 재실행됩니다.
에러 처리 계층 나누기
모든 에러를 Boundary로 올릴 필요는 없습니다.
- Boundary: 페이지 진입에 필수적인 데이터 조회 실패
- 로컬 메시지·토스트: 댓글 등록, 좋아요처럼 페이지 자체는 유지되어야 하는 경우
- 섹션 수준 fallback: 특정 영역만 실패한 경우
계층을 나눠두면 에러 처리 코드도 정리되고 UX도 자연스러워집니다.
마무리
오늘은 서버 상태 관리의 필수적이라고 봐도 무방한 TanStack Query 에 대해서 알아봤습니다.
저도 처음에 Tanstack Query를 사용했을때는 단순히 data, isFetching과 error등의 서버 상태 관리 훅만 사용하였었는데,
깊이 공부하면 할 수록 신경써야할 부분이 많다는 것을 깨달았습니다.
TanStack Query를 깊이 이해한다는 건 결국 서버 상태를 어떤 정책으로 운영할지 설계할 수 있다는 뜻입니다.
작은 프로젝트에서는 편리함이 먼저 보이지만, 규모가 커지면 설계력이 바로 드러나는 도구입니다.
여러분들도 TanStack Query를 정복하셔서 효과적인 서버 상태 관리를 이루어내셨으면 좋겠습니다!
읽어주셔서 감사합니다!
'React' 카테고리의 다른 글
| 에러 핸들링 & 안정성 - 프론트엔드 에러 처리 (0) | 2026.05.04 |
|---|---|
| Profiler부터 memo까지 제대로 사용해보자! (0) | 2026.04.27 |
| 상태 관리 전략 비교 - Context API, Zustand, Redux Toolkit 중 무엇을 선택해야 할까? (0) | 2026.03.23 |
| React useState 완전 이해하기 (0) | 2026.03.21 |
| React Hook 완전 이해하기 (0) | 2026.03.21 |