TanStack Query — 서버 상태 관리의 패러다임 전환
useEffect + useState로 데이터를 가져오면서 로딩, 에러, 캐싱, 재시도를 매번 직접 구현하고 있나요? 이 반복적인 패턴을 근본적으로 해결하는 방법은 없을까요?
개념 정의
TanStack Query(구 React Query)는 서버 상태(비동기 데이터)를 선언적으로 관리 하는 라이브러리입니다. 캐싱, 자동 재검증, 백그라운드 업데이트, 에러 재시도 등을 내장하고 있습니다.
왜 필요한가
useEffect로 데이터를 가져오는 기존 방식의 문제점입니다.
// ❌ 매번 반복되는 보일러플레이트
function UserList() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch('/api/users')
.then(res => res.json())
.then(data => { if (!cancelled) { setData(data); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; };
}, []);
// 문제: 캐싱 없음, 재시도 없음, 중복 요청 방지 없음,
// 다른 컴포넌트와 데이터 공유 불가
}
내부 동작
stale-while-revalidate
TanStack Query의 핵심 전략입니다.
1. 캐시에 데이터가 있으면 → 즉시 반환 (빠른 UI)
2. 데이터가 stale이면 → 백그라운드에서 refetch
3. 새 데이터 도착 → 캐시 업데이트 → UI 업데이트
import { useQuery, QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5분간 fresh
gcTime: 10 * 60 * 1000, // 10분간 캐시 유지 (garbage collection time)
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<UserList />
</QueryClientProvider>
);
}
Query Key
캐시의 식별자이자 의존성 역할을 합니다.
// 기본 쿼리
useQuery({ queryKey: ['users'], queryFn: fetchUsers });
// 파라미터가 있는 쿼리
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
// 복합 파라미터
useQuery({
queryKey: ['products', { category, page, sort }],
queryFn: () => fetchProducts({ category, page, sort }),
});
// queryKey가 변하면 자동으로 새 데이터를 가져옴
// ['products', { category: 'frontend' }]
// → category가 'backend'로 바뀌면
// ['products', { category: 'backend' }] → 자동 refetch
useQuery 옵션
const {
data,
error,
isLoading, // 처음 로딩 (캐시 없음)
isFetching, // 백그라운드 리페칭 포함
isError,
isSuccess,
refetch, // 수동 refetch
} = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // false면 쿼리 비활성화
staleTime: 5 * 60 * 1000, // 5분간 fresh
refetchOnWindowFocus: true, // 탭 복귀 시 refetch
retry: 3, // 실패 시 3회 재시도
select: (data) => data.name, // 데이터 변환
placeholderData: previousData, // 이전 데이터를 placeholder로 사용
});
캐시 무효화
queryClient.invalidateQueries
import { useQueryClient } from '@tanstack/react-query';
function AddUserButton() {
const queryClient = useQueryClient();
const handleAdd = async () => {
await addUser({ name: '새 사용자' });
// 'users'로 시작하는 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['users'] });
// → ['users'], ['users', { page: 1 }] 등 모두 refetch
};
return <button onClick={handleAdd}>사용자 추가</button>;
}
useMutation — 데이터 변경
import { useMutation, useQueryClient } from '@tanstack/react-query';
function TodoForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) => fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
}).then(res => res.json()),
onSuccess: () => {
// 성공 시 캐시 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
onError: (error) => {
alert(`에러: ${error.message}`);
},
});
return (
<button
onClick={() => mutation.mutate({ text: '새 할일' })}
disabled={mutation.isPending}
>
{mutation.isPending ? '추가 중...' : '할일 추가'}
</button>
);
}
낙관적 업데이트
서버 응답을 기다리지 않고 UI를 먼저 업데이트합니다.
const mutation = useMutation({
mutationFn: updateTodo,
// 요청 전: 캐시를 미리 수정
onMutate: async (newTodo) => {
// 진행 중인 refetch 취소 (충돌 방지)
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 이전 데이터 백업 (롤백용)
const previousTodos = queryClient.getQueryData(['todos']);
// 캐시에 낙관적 업데이트
queryClient.setQueryData(['todos'], (old) =>
old.map(todo => todo.id === newTodo.id ? { ...todo, ...newTodo } : todo)
);
return { previousTodos };
},
// 실패 시: 이전 데이터로 롤백
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
// 성공/실패 상관없이: 서버 데이터로 동기화
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
Infinite Query — 무한 스크롤
function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
const allPosts = data?.pages.flatMap(page => page.items) ?? [];
return (
<div>
{allPosts.map(post => (
<PostCard key={post.id} post={post} />
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? '로딩 중...' : '더 보기'}
</button>
)}
</div>
);
}
실전 팁
Query Key Factory
const userKeys = {
all: ['users'],
lists: () => [...userKeys.all, 'list'],
list: (filters) => [...userKeys.lists(), filters],
details: () => [...userKeys.all, 'detail'],
detail: (id) => [...userKeys.details(), id],
};
// 사용
useQuery({ queryKey: userKeys.detail(userId), queryFn: ... });
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
의존적 쿼리
// 첫 번째 쿼리가 성공해야 두 번째 쿼리 실행
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchPostsByUser(user.id),
enabled: !!user?.id, // user가 있을 때만 실행
});
주의할 점
Query Key 설계가 캐시 동작을 결정
Query Key가 너무 넓으면 불필요한 refetch가 발생하고, 너무 좁으면 캐시를 활용하지 못합니다. ['users', { page, filter }]처럼 계층적으로 설계해야 invalidateQueries(['users'])로 관련 캐시를 한 번에 무효화할 수 있습니다.
staleTime과 gcTime의 차이를 혼동
staleTime은 데이터가 "신선한" 상태를 유지하는 시간(이 기간에는 refetch 안 함), gcTime은 비활성 캐시가 메모리에 남아있는 시간입니다. staleTime 없이 gcTime만 길게 설정하면 매번 refetch하면서 캐시만 쌓이게 됩니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 전략 | stale-while-revalidate — 캐시된 데이터를 먼저 보여주고 백그라운드에서 갱신 |
| Query Key | 캐시 식별자이자 자동 refetch 기준 — 계층적 설계 필요 |
| 캐시 무효화 | mutation 후 invalidateQueries로 관련 데이터 자동 갱신 |
| 낙관적 업데이트 | 서버 응답 전 UI 먼저 변경, 실패 시 롤백 |
| Infinite Query | 무한 스크롤을 선언적으로 구현 |
useEffect + useState로 데이터를 관리하다가 TanStack Query를 써보면, "이 많은 것을 왜 직접 구현하고 있었지?"라는 생각이 듭니다.
댓글 로딩 중...