useEffect + useState로 데이터를 가져오면서 로딩, 에러, 캐싱, 재시도를 매번 직접 구현하고 있나요? 이 반복적인 패턴을 근본적으로 해결하는 방법은 없을까요?

개념 정의

TanStack Query(구 React Query)는 서버 상태(비동기 데이터)를 선언적으로 관리 하는 라이브러리입니다. 캐싱, 자동 재검증, 백그라운드 업데이트, 에러 재시도 등을 내장하고 있습니다.

왜 필요한가

useEffect로 데이터를 가져오는 기존 방식의 문제점입니다.

JSX
// ❌ 매번 반복되는 보일러플레이트
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의 핵심 전략입니다.

PLAINTEXT
1. 캐시에 데이터가 있으면 → 즉시 반환 (빠른 UI)
2. 데이터가 stale이면 → 백그라운드에서 refetch
3. 새 데이터 도착 → 캐시 업데이트 → UI 업데이트
JSX
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

캐시의 식별자이자 의존성 역할을 합니다.

JSX
// 기본 쿼리
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 옵션

JSX
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

JSX
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 — 데이터 변경

JSX
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를 먼저 업데이트합니다.

JSX
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 — 무한 스크롤

JSX
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

JSX
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() });

의존적 쿼리

JSX
// 첫 번째 쿼리가 성공해야 두 번째 쿼리 실행
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를 써보면, "이 많은 것을 왜 직접 구현하고 있었지?"라는 생각이 듭니다.

댓글 로딩 중...