C
React/API연동/Lesson 16

React Query로 서버 상태 관리

40분·theory
이 챕터
2/2
TypeScript

React Query로 서버 상태 관리

💡 왜 배워야 할까요?

🎯 서버 데이터 캐싱과 동기화를 자동으로 처리해 코드량을 70% 줄입니다.
💼 백그라운드 재검증으로 항상 최신 데이터를 유지하면서 UX를 개선합니다.
에러 처리, 재시도, 로딩 상태 관리가 선언적으로 간단해집니다.
🔗 대규모 서비스(당근마켓, 토스 등)에서 필수 기술입니다.
🏢 실무에서는
실무에서는 상품 목록을 불러올 때 React Query가 자동으로 캐싱하고, 사용자가 다른 페이지 갔다 돌아와도 캐시된 데이터를 즉시 보여줍니다. 5분마다 백그라운드에서 자동 새로고침되어 항상 최신 가격을 유지합니다.

개념

React Query는 서버 상태의 캐싱, 동기화, 재검증을 자동화하는 라이브러리입니다. fetch 코드와 로딩/에러 상태 관리의 반복을 획기적으로 줄입니다.

왜 중요한가?

서버 상태 관리의 70%를 React Query가 자동 처리합니다. 캐싱, 백그라운드 재검증, 낙관적 업데이트, 무한 스크롤을 선언적으로 구현할 수 있습니다.

핵심 개념

React Query 핵심 개념

쿼리 상태 머신

code
fetching (초기 로딩)
    ↓
fresh (신선, staleTime 내)
    ↓
stale (오래됨, 재검증 대기)
    ↓
inactive (구독 없음, cacheTime 후 제거)

주요 설정

javascript
{
  staleTime: 5 * 60 * 1000,  // 5분: 신선하다고 간주
  cacheTime: 10 * 60 * 1000, // 10분: 비활성 캐시 유지
  retry: 3,                   // 실패 시 재시도 횟수
  refetchOnWindowFocus: true, // 탭 활성화 시 재검증
  refetchInterval: 30000,     // 30초마다 폴링
}

캐시 키 설계 원칙

javascript
// 배열 형태: 계층적 무효화 가능
['users']                    // 모든 사용자 목록
['users', userId]            // 특정 사용자
['users', userId, 'posts']  // 특정 사용자의 게시물

// invalidateQueries(['users'])으로 모든 사용자 관련 캐시 한 번에 무효화

백그라운드 재검증 (stale-while-revalidate)

1. 캐시된 데이터를 즉시 반환 (빠른 응답)

2. 백그라운드에서 새 데이터 페칭

3. 새 데이터 도착 시 UI 업데이트

→ 사용자는 빈 화면 대신 즉시 이전 데이터를 봄

핵심 포인트

  • stale-while-revalidate: 캐시 즉시 반환 + 백그라운드 재검증
  • 쿼리 키: 계층적 설계로 관련 캐시 일괄 무효화
  • useMutation + onMutate: 낙관적 업데이트로 UX 향상
  • useInfiniteQuery: 무한 스크롤 선언적 구현
💻 useQuery + useInfiniteQuery 실전
import {
  useQuery,
  useInfiniteQuery,
  useQueryClient
} from '@tanstack/react-query';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';

// === 1. 기본 데이터 조회 ===
function LearningItem({ itemId }) {
  const {
    data,
    isLoading,
    isError,
    error,
    isFetching  // 백그라운드 재검증 중
  } = useQuery({
    queryKey: ['learning-items', itemId],
    queryFn: async () => {
      const res = await fetch(`/api/items/${itemId}`);
      if (!res.ok) throw new Error('항목 조회 실패');
      return res.json();
    },
    staleTime: 10 * 60 * 1000, // 10분간 신선
    enabled: !!itemId,
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorMessage message={error.message} />;

  return (
    <article>
      {isFetching && <span className="refreshing">갱신 중...</span>}
      <h1>{data.title}</h1>
      <p>{data.content}</p>
    </article>
  );
}

// === 2. 무한 스크롤 ===
function ItemList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/items?cursor=${pageParam}&limit=20`).then(r => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  // IntersectionObserver로 스크롤 끝 감지
  const [sentinelRef, isIntersecting] = useIntersectionObserver();

  React.useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, hasNextPage, isFetchingNextPage]);

  if (isLoading) return <Loading />;

  // pages: 각 페이지 데이터 배열
  const allItems = data.pages.flatMap(page => page.items);

  return (
    <div>
      {allItems.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      {/* 스크롤 감지 센티넬 */}
      <div ref={sentinelRef}>
        {isFetchingNextPage && <Spinner />}
        {!hasNextPage && <p>모든 항목을 불러왔습니다.</p>}
      </div>
    </div>
  );
}
💻 useMutation + 캐시 무효화 패턴
import { useMutation, useQueryClient } from '@tanstack/react-query';

// === 학습 진도 업데이트 + 낙관적 업데이트 ===
function ProgressButton({ itemId, isCompleted }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: async (completed) => {
      const res = await fetch(`/api/progress/${itemId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed })
      });
      if (!res.ok) throw new Error('진도 업데이트 실패');
      return res.json();
    },

    // 낙관적 업데이트: 응답 전 UI 즉시 변경
    onMutate: async (completed) => {
      // 진행 중인 쿼리 취소
      await queryClient.cancelQueries({ queryKey: ['progress', itemId] });

      // 롤백용 스냅샷
      const previousProgress = queryClient.getQueryData(['progress', itemId]);

      // UI 즉시 업데이트
      queryClient.setQueryData(['progress', itemId], old => ({
        ...old,
        completed,
        completedAt: completed ? new Date().toISOString() : null
      }));

      return { previousProgress };
    },

    // 에러 시 롤백
    onError: (err, completed, context) => {
      queryClient.setQueryData(
        ['progress', itemId],
        context?.previousProgress
      );
      alert('업데이트 실패: ' + err.message);
    },

    // 성공/실패 후 서버 데이터로 재동기화
    onSettled: () => {
      // 관련 캐시 모두 무효화 (progress + 전체 진도 통계)
      queryClient.invalidateQueries({ queryKey: ['progress', itemId] });
      queryClient.invalidateQueries({ queryKey: ['stats'] });
    }
  });

  return (
    <button
      onClick={() => toggleMutation.mutate(!isCompleted)}
      disabled={toggleMutation.isPending}
      className={isCompleted ? 'completed' : ''}
    >
      {toggleMutation.isPending ? '저장 중...' : (isCompleted ? '완료 ✓' : '완료하기')}
    </button>
  );
}

💡 ⚠️ 흔한 실수

  • queryKey를 문자열 하나로만 사용 — 배열 형태로 계층화해야 관련 캐시 일괄 무효화 가능
  • staleTime/cacheTime 미설정 — 기본값으로 매 포커스마다 재요청 발생. 데이터 특성에 맞게 조정
  • useMutation 후 invalidateQueries 누락 — 목록에 새 항목이 반영 안 됨. onSuccess에서 관련 쿼리 무효화 필수
  • queryFn에서 에러를 잡아서 처리 — throw를 해야 React Query가 에러 상태로 처리. catch 후 throw 필요

💡 🎯 면접 준비

Q: React Query와 Redux의 차이점은?
Q: staleTime과 cacheTime의 차이를 설명하세요
Q: 낙관적 업데이트를 어떻게 구현하나요?

힌트: Redux는 클라이언트 상태 전체를 관리하지만, React Query는 서버 상태에 특화해 캐싱·재검증·동기화를 자동화합니다. staleTime은 데이터를 신선하다고 간주하는 기간(재요청 안 함)이고, cacheTime은 비활성 캐시를 메모리에 유지하는 기간입니다. 낙관적 업데이트는 onMutate에서 setQueryData로 UI를 먼저 변경하고, 에러 시 onError에서 이전 데이터로 롤백, onSettled에서 서버 데이터로 재동기화합니다.

⚛️ React 패턴 — React Query로 서버 상태 관리

React Query로 서버 상태 관리을 React에서 어떻게 쓰는지 코드와 함께 단계별로 익혀보세요.
1 🧩 1. React Query로 서버 상태 관리 사용 시나리오
이 기능이 필요한 상황.
2 💻 2. 코드 작성
React Query로 서버 상태 관리의 기본 사용법.
3 🎨 3. 렌더링 결과
사용자가 보는 화면.
4 💡 4. 실무 팁
흔한 함정과 베스트 프랙티스.

🎮 React Query로 서버 상태 관리 — 단계별 이해

각 단계를 클릭해 내용을 읽고 ✓ 이해했어요 버튼으로 진행률을 확인하세요.
🖥️ 실행 결과 — 렌더링된 React 컴포넌트
✏️ React 코드 수정하기 (클릭해서 열기)
⚛️ React 18 + Babel Standalone — 결과 먼저 확인 후 코드 편집기에서 직접 수정해볼 수 있습니다.

확인 퀴즈

React Query의 핵심 기능이 아닌 것은?
💡 React Query는 서버 상태(API 데이터) 관리에 특화돼요. 캐싱, 자동 리패칭, 로딩/에러 상태를 자동 처리해요. 전역 클라이언트 상태(UI 상태 등)는 React Query보다 Zustand/Context가 적합해요.
먼저 읽으면 좋은 개념: 데이터 패칭
React Query (TanStack Query) - React