본문 바로가기
Frontend2026년 9월 7일19분 읽기

TanStack Query로 서버 상태 관리 — 캐싱·무효화·낙관적 업데이트 실전 패턴

YS
김영삼
조회 9
TanStack Query로 서버 상태 관리 — 캐싱·무효화·낙관적 업데이트 실전 패턴

TanStack Query는 "서버에서 온 데이터"를 다루는 전용 도구다. useState/useEffect로 직접 짜던 로딩·에러·캐시·재요청 로직을 캐싱·무효화·낙관적 업데이트라는 세 축으로 대체하면, 컴포넌트에서 상태 관리 코드가 눈에 띄게 줄어든다.

핵심은 서버 상태와 클라이언트 상태를 분리하는 사고방식이다. staleTime과 gcTime의 차이, queryKey를 어떻게 설계하느냐, 언제 invalidate하고 언제 setQueryData로 직접 갱신하느냐 — 이 감각만 잡으면 나머지는 API 문서 수준이다. 아래는 내가 실무에서 데여가며 정리한 패턴들이다.

처음 TanStack Query(예전 이름 React Query)를 접했을 때 나는 이걸 그냥 "fetch 래퍼" 정도로 봤다. 그러다 한 프로젝트에서 useEffect 안에 요청을 넣고, 로딩 플래그를 useState로 관리하고, 화면 이동 후 돌아올 때마다 데이터가 깜빡이는 걸 손으로 막다가 결국 항복했다. 서버 데이터는 근본적으로 클라이언트 상태와 다르게 취급해야 한다는 걸 그때 배웠다.

서버 상태와 클라이언트 상태는 왜 나눠야 하나

둘의 소유권이 다르기 때문이다. 클라이언트 상태는 내 브라우저가 유일한 진실의 출처다 — 모달 열림 여부, 입력 폼의 임시 값, 다크모드 토글 같은 것. 반면 서버 상태는 진짜 주인이 서버에 있고, 내 화면에 있는 건 그 데이터의 "복사본이자 스냅샷"일 뿐이다. 다른 사용자가 값을 바꾸면 내 복사본은 조용히 낡는다.

이 차이를 인정하면 필요한 기능이 저절로 정해진다. 서버 상태에는 캐싱, 백그라운드 갱신, 중복 요청 제거(dedup), 요청 실패 시 재시도, 여러 컴포넌트 간 공유가 필요하다. 이걸 Redux나 useState로 손수 구현하면 결국 TanStack Query를 반쯤 다시 만들게 된다. 개인적으로는 전역 상태 라이브러리에 서버 응답을 통째로 넣던 습관을 버린 게 가장 큰 변화였다.

참고 Zustand/Redux는 클라이언트 상태에, TanStack Query는 서버 상태에 쓰는 조합이 요즘 가장 무난하다. 둘은 경쟁 관계가 아니라 역할이 다른 도구다. 서버 응답을 굳이 전역 스토어로 복사하지 말고, 필요한 컴포넌트에서 useQuery로 같은 queryKey를 읽으면 캐시가 알아서 공유된다.

기본값부터 알고 시작하자

TanStack Query의 동작 대부분은 기본값이 결정한다. 그런데 이 기본값이 초보자를 가장 많이 헷갈리게 한다. 특히 staleTime의 기본이 0이라는 점 — 즉 데이터를 받자마자 곧바로 "낡음(stale)"으로 표시된다. 아래는 v5 기준 자주 만나는 기본값이다.

옵션기본값의미
staleTime0ms받자마자 stale 처리 → 트리거 시 재요청
gcTime5분(300000ms)비활성 캐시가 메모리에서 제거되기까지
retry3회쿼리 실패 시 재시도 횟수(지수 백오프)
refetchOnWindowFocustrue탭 복귀 시 stale이면 자동 재요청
refetchOnReconnecttrue네트워크 재연결 시 재요청

"왜 창을 클릭할 때마다 네트워크 탭에 요청이 뜨지?"의 범인이 바로 staleTime=0 + refetchOnWindowFocus=true 조합이다. 이건 버그가 아니라 설계다. 데이터를 항상 신선하게 유지하려는 기본 정책. 다만 실무에선 이게 과할 때가 많아서 staleTime을 올려 조정한다.

staleTime과 gcTime, 헷갈리면 여기만 보자

한 줄 요약: staleTime은 "언제 다시 가져올까", gcTime은 "언제 캐시를 버릴까"다. 완전히 다른 축인데 이름이 비슷해서 자꾸 섞인다. staleTime이 지나면 데이터는 stale이 되지만 화면엔 여전히 보인다 — 다음 트리거 때 백그라운드로 새로 받아 조용히 교체할 뿐이다. 반면 gcTime은 그 쿼리를 구독하는 컴포넌트가 하나도 없을 때부터 카운트가 시작되고, 시간이 다 되면 메모리에서 아예 삭제된다.

구분staleTimegcTime
질문언제 재요청?언제 메모리에서 삭제?
카운트 시작데이터 수신 시점구독자(observer)가 0이 된 시점
지나면stale 표시(화면엔 유지)캐시 제거(다음엔 새 로딩)
기본값05분

보통 staleTime은 gcTime보다 작거나 같게 잡는 게 자연스럽다. gcTime이 staleTime보다 짧으면 캐시가 사라진 뒤엔 stale이고 뭐고 남는 게 없으니까. 데이터 성격에 맞춰 값을 정하는 게 실무 감각인데, 아래 예시가 출발점으로 쓸 만하다.

// QueryClient 전역 기본값 — 앱 성격에 맞게 한 번만 정해두면 편하다
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,        // 1분간은 fresh로 간주 → 불필요한 재요청 억제
      gcTime: 5 * 60 * 1000,       // 5분 뒤 비활성 캐시 정리
      retry: 1,                    // 기본 3회는 실패 UX가 느려서 1회로 줄이는 편
      refetchOnWindowFocus: false, // 관리자 대시보드처럼 실시간성이 덜하면 끈다
    },
  },
});

// 데이터 성격별로 개별 쿼리에서 덮어쓰기
// - 거의 안 변하는 값(국가 목록, 카테고리): staleTime: Infinity
// - 실시간성 높은 값(재고, 알림): staleTime: 0 + refetchInterval 사용

queryKey 설계가 절반이다

queryKey는 캐시의 "주소"이자 무효화의 "필터"다. 이걸 대충 잡으면 나중에 invalidation이 지옥이 된다. 규칙은 단순하다 — 배열로 쓰고, 큰 범주에서 작은 범주로 계층을 만들고, 쿼리 함수가 의존하는 모든 변수를 키에 포함시킨다. 변수를 키에 안 넣으면 파라미터가 바뀌어도 캐시가 안 갈려서 옛날 데이터가 화면에 남는다. 이거, 문서에 잘 안 나오는데 초보 때 진짜 많이 데인다.

// 나쁜 예: 문자열 키 + 의존 변수 누락
useQuery({ queryKey: ['todos'], queryFn: () => fetchTodos(status) });
// status가 바뀌어도 같은 캐시를 봐서 화면이 안 바뀐다

// 좋은 예: 배열 계층 + 의존 변수 포함
useQuery({
  queryKey: ['todos', 'list', { status, page }],
  queryFn: () => fetchTodos({ status, page }),
});
useQuery({
  queryKey: ['todos', 'detail', todoId],
  queryFn: () => fetchTodo(todoId),
});

// Query Key Factory 패턴 — 키를 한 곳에서 관리하면 오타·불일치가 사라진다
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
};

이 팩토리 패턴을 쓰면 무효화가 아름다워진다. todoKeys.all로 무효화하면 todos로 시작하는 모든 캐시가 한 방에 날아가고, todoKeys.list(filters)로 하면 딱 그 목록만 갱신된다. 접두사 매칭이라 가능한 일이다.

무효화(invalidation): 뮤테이션 후 데이터 갱신하기

데이터를 바꾸는 요청(생성·수정·삭제) 뒤에 목록을 최신으로 맞추는 가장 정석적인 방법이 invalidateQueries다. 동작은 두 단계다 — 해당 키의 캐시를 stale로 표시하고, 지금 화면에서 쓰이는(active) 쿼리는 즉시 재요청한다. "서버가 진실"이라는 원칙에 가장 충실한 방식이라, 나는 확신이 없으면 일단 invalidate부터 쓴다.

function useAddTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (newTodo: NewTodo) => api.post('/todos', newTodo),
    onSuccess: () => {
      // todos로 시작하는 목록 계열을 stale로 만들고 active면 재요청
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
}

// 특정 조건만 정밀 무효화하고 싶을 때 predicate 사용
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[0] === 'todos' &&
    (query.queryKey[2] as any)?.status === 'done',
});
주의 invalidateQueries는 기본적으로 부분 일치(접두사 매칭)다. ['todos']로 무효화하면 ['todos', 'detail', 5]까지 전부 stale이 된다. 이게 편할 때도 있지만, 상세는 안 건드리고 목록만 갱신하고 싶다면 키를 lists()처럼 구체적으로 잡아야 한다. 반대로 정확히 일치만 원하면 exact: true를 준다.

낙관적 업데이트: 응답을 기다리지 않는 UI

낙관적 업데이트(optimistic update)는 서버 응답이 오기 전에 "성공했다고 가정하고" UI를 먼저 바꾸는 기법이다. 좋아요 버튼, 체크박스 토글처럼 실패 확률이 낮고 즉각성이 중요한 상호작용에 잘 맞는다. 핵심은 실패했을 때 원래대로 되돌리는 롤백을 반드시 짝으로 준비하는 것. 이걸 빼먹으면 서버는 실패했는데 화면만 성공한 채로 남는 최악의 버그가 생긴다.

TanStack Query에선 useMutation의 onMutate → onError → onSettled 3단 콜백으로 구현한다. onMutate에서 진행 중인 재요청을 취소하고 이전 값을 스냅샷으로 저장한 뒤 캐시를 미리 갱신하고, onError에서 스냅샷으로 되돌리고, onSettled에서 서버와 최종 동기화를 위해 무효화한다.

function useToggleTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (todo: Todo) =>
      api.patch(`/todos/${todo.id}`, { done: !todo.done }),

    onMutate: async (todo) => {
      // 1) 이 키에 대한 진행 중 재요청 취소 — 낙관값이 덮어써지는 걸 막는다
      await queryClient.cancelQueries({ queryKey: todoKeys.lists() });

      // 2) 롤백용 이전 스냅샷 저장
      const previous = queryClient.getQueryData(todoKeys.lists());

      // 3) 캐시를 낙관적으로 즉시 갱신
      queryClient.setQueryData(todoKeys.lists(), (old: Todo[] = []) =>
        old.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t))
      );

      // onError로 넘길 context 반환
      return { previous };
    },

    onError: (_err, _todo, context) => {
      // 4) 실패 시 스냅샷으로 롤백
      if (context?.previous) {
        queryClient.setQueryData(todoKeys.lists(), context.previous);
      }
    },

    onSettled: () => {
      // 5) 성공/실패 무관하게 서버 상태와 최종 재동기화
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
}

순서를 지키는 게 생명이다. cancelQueries를 빼면, 낙관값을 넣은 직후 도착한 이전 재요청 응답이 낙관값을 덮어써서 화면이 순간 되돌아갔다가 다시 바뀌는 깜빡임이 생긴다. 실무에서 이 깜빡임 원인 못 찾아 한참 헤맨 적 있다. 참고로 v5부터는 mutation의 variables와 isPending을 렌더링에 직접 써서 캐시를 안 건드리는 더 가벼운 낙관 UI도 가능한데, 목록 정합성이 중요하면 위 캐시 방식이 여전히 안전하다.

프리페치로 체감 속도 끌어올리기

프리페치(prefetch)는 사용자가 데이터를 요청하기 전에 미리 받아 캐시에 심어두는 기법이다. 목록에서 항목에 마우스를 올리는 순간 상세를 미리 받아두면, 클릭했을 때 로딩 스피너 없이 즉시 화면이 뜬다. 체감 속도가 확 달라진다. 리스트→상세 이동이 잦은 화면에서 투자 대비 효과가 가장 좋은 최적화다.

function TodoRow({ todo }: { todo: Todo }) {
  const queryClient = useQueryClient();

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: todoKeys.detail(todo.id),
      queryFn: () => fetchTodo(todo.id),
      staleTime: 30 * 1000, // 이미 fresh 캐시가 있으면 프리페치 건너뜀
    });
  };

  return (
    <li onMouseEnter={prefetch} onFocus={prefetch}>
      <Link to={`/todos/${todo.id}`}>{todo.title}</Link>
    </li>
  );
}

Next.js App Router 같은 서버 컴포넌트 환경에선 서버에서 prefetchQuery로 받아 dehydrate → 클라이언트에서 HydrationBoundary로 넘기는 SSR 패턴을 쓴다. 서버에서 이미 받아둔 데이터라 첫 화면에 로딩이 아예 없고, 클라이언트는 그 캐시를 그대로 이어받아 이후 갱신만 담당한다.

흔한 실수 6가지

코드 리뷰에서 반복적으로 잡게 되는 것들만 추렸다. 대부분은 "기본값을 몰라서" 혹은 "서버 상태를 클라이언트 상태처럼 다뤄서" 생긴다.

  • queryKey에 의존 변수 누락 — 파라미터가 바뀌어도 옛 캐시를 봐서 화면이 안 갱신된다. queryFn이 쓰는 값은 전부 키에 넣어라.
  • useEffect로 데이터를 다시 useState에 복사 — 캐시를 두고 또 상태를 만드는 안티패턴. useQuery가 반환하는 data를 그냥 쓰면 된다.
  • 낙관적 업데이트에서 롤백 누락 — onError 없이 onMutate만 있으면 실패가 화면에 반영되지 않는다.
  • 서버 응답 전체를 Redux/Zustand에 복사 — 두 소스가 어긋나기 시작한다. 서버 데이터는 Query 캐시가 단일 출처.
  • enabled 없이 조건부 쿼리 — id가 undefined인데 요청이 나가 400/404가 뜬다. enabled: !!id로 막아라.
  • 에러/로딩 무시 — isPending, isError를 안 다루면 undefined 접근으로 런타임 에러. select로 파생 값을 만들 때 특히.
참고 v5에서 이름이 몇 개 바뀌었다. cacheTime → gcTime, isLoading은 최초 로딩 전용으로 좁아지고 일반적 대기는 isPending, 재요청 중 표시는 isFetching이다. v4 기준 예제를 복붙하다 isLoading이 기대와 다르게 동작하면 이 변경 때문일 가능성이 높다.

언제 쓰지 말아야 하나

솔직히 말하면 모든 데이터에 TanStack Query가 답은 아니다. 폼 입력값처럼 순수하게 로컬한 임시 상태는 그냥 useState가 낫다. 서버와 왕복이 없는 값을 Query 캐시에 억지로 넣으면 오히려 개념이 꼬인다. 또 WebSocket으로 밀려오는 실시간 스트림은 Query의 요청-응답 모델과 결이 달라서, 소켓으로 받은 데이터를 setQueryData로 캐시에 반영하는 하이브리드가 현실적이다. 도구의 경계를 아는 게 도구를 아는 것보다 중요할 때가 많다.

자주 묻는 질문

staleTime과 gcTime 중 뭘 먼저 조정해야 하나요?

staleTime부터 조정하세요. 불필요한 재요청과 창 포커스 시 깜빡임은 대부분 기본값 0에서 비롯됩니다. 데이터가 얼마나 자주 바뀌는지에 맞춰 1분~무한대 사이로 잡으면 체감 개선이 큽니다. gcTime은 메모리 관리라 웬만하면 기본 5분으로 둬도 됩니다.

invalidateQueries와 setQueryData는 언제 각각 쓰나요?

서버를 신뢰하고 최신을 다시 받고 싶으면 invalidateQueries, 서버 응답에 새 데이터가 이미 들어있어 재요청이 아깝거나 낙관적 업데이트로 즉시 반영해야 하면 setQueryData입니다. 확신이 없으면 invalidate가 안전합니다. 정합성이 서버 기준으로 맞춰지니까요.

낙관적 업데이트에서 onSettled의 invalidate가 꼭 필요한가요?

권장됩니다. 낙관값과 실제 서버 결과가 미세하게 다를 수 있기 때문입니다(예: 서버가 부여한 id, 정렬 순서, 파생 필드). onSettled에서 무효화해 서버 상태로 최종 재동기화하면 클라이언트 추정과 실제의 불일치를 없앨 수 있습니다.

React Query와 TanStack Query는 다른 건가요?

같은 라이브러리입니다. v4 이후 React 외 프레임워크(Vue, Svelte, Solid)를 지원하며 TanStack Query로 이름을 바꿨습니다. React용 패키지 이름은 @tanstack/react-query이고, 예전 react-query는 더 이상 유지되지 않습니다.

전역 상태 라이브러리(Redux/Zustand)를 없애도 되나요?

서버 상태만 있었다면 상당 부분 없앨 수 있습니다. 하지만 모달·테마·다단계 폼 같은 순수 클라이언트 상태가 남으면 가벼운 Zustand나 Context를 함께 쓰는 게 자연스럽습니다. Query가 서버, 별도 스토어가 클라이언트를 맡는 분담이 유지보수에 유리합니다.

v4 코드를 v5로 올릴 때 주의할 점은?

cacheTimegcTime으로, 상태 플래그 의미가 바뀌었습니다(isPending/isLoading/isFetching). 또 useQuery/useMutation이 단일 옵션 객체 형태만 받도록 시그니처가 통일됐습니다. 공식 codemod가 대부분 자동 변환해주니 먼저 돌려보길 권합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록