TanStack Query와 Server State 관리 아키텍처

· 약 13분 읽기 · 조회 1 #TanStack Query#React#프론트엔드#상태 관리#캐시

화면에 보이는 데이터 대부분은 브라우저가 아니라 서버가 주인이고, 이것을 전역 스토어에 복사해 두는 순간 "언제 다시 받아야 하나"라는 문제가 코드 곳곳으로 흩어진다. 이 글은 TanStack Query v5를 기준으로 Server State를 캐시로 다루는 구조, Query Key 설계, staleTime·gcTime 조정, Mutation 뒤 캐시 맞추기, 낙관적 업데이트와 SSR Hydration까지 실무에서 정해야 할 것을 정리한다.

사서가 시계 카드를 세워 둔 데스크 옆 작은 서가를 지키는 동안 직원이 뒤쪽 서고에서 새 책을 수레로 가져오는 도서관

한눈에 보기

TanStack Query를 쓰는 앱은 다섯 층으로 나누면 책임이 겹치지 않는다.

층 하는 일 두는 곳
API 함수 HTTP 호출, 실패 시 throw api/todos.ts
Query 정의 Query Key와 queryFn, 기본 staleTime을 한 곳에 queries/todos.ts (queryOptions)
훅 화면이 쓰는 모양으로 묶기, Mutation과 무효화 hooks/useTodos.ts
컴포넌트 isPending·isError·data에 따라 그리기 화면 파일
QueryClient 재시도·재요청 기본값, 캐시 보관 앱 진입점에서 한 번

컴포넌트가 fetch나 Query Key를 직접 만지지 않아야 키 규칙이나 캐시 정책을 바꿀 때 고칠 곳이 한 파일로 줄어든다.

Server State는 Client State와 무엇이 다른가

Client State는 모달 열림, 입력 중인 글자처럼 브라우저가 유일한 주인이다. Server State는 다르다.

  • 주인이 서버다. 내가 보는 값은 받은 시점의 복사본이다.
  • 다른 사용자가 바꿀 수 있어 언젠가 낡는다.
  • 여러 화면이 같은 데이터를 동시에 원한다. 각자 부르면 같은 요청이 여러 번 나간다.

그래서 Server State는 캐시 관리 문제에 가깝다. 전역 스토어에 응답을 넣으면 로딩 플래그, 중복 요청 제거, 만료, 재요청을 직접 짜야 한다. TanStack Query는 이것을 Query Key 단위 캐시로 처리한다. 같은 키로 동시에 들어온 요청은 하나로 합쳐 보내므로, 백엔드에서 Cache Stampede를 막는 락과 같은 일을 브라우저 안에서 해 준다.

Query Key와 queryOptions로 캐시 주소 설계

Query Key는 캐시의 주소다. 키가 같으면 같은 데이터로 보고, 키 배열의 앞부분이 같으면 한꺼번에 무효화할 수 있다. 그래서 키는 넓은 범위에서 좁은 범위 순서로 쌓는다.

import { queryOptions } from '@tanstack/react-query'

export type Todo = { id: number; title: string; done: boolean }
type TodoFilter = { done?: boolean }

async function fetchTodos(filter: TodoFilter): Promise<Todo[]> {
  const qs = filter.done === undefined ? '' : `?done=${filter.done}`
  const res = await fetch(`/api/todos${qs}`)
  if (!res.ok) throw new Error(`GET /api/todos ${res.status}`) // fetch는 4xx·5xx에서 throw하지 않는다
  return res.json()
}

export const todoQueries = {
  all: () => ['todos'] as const,
  list: (filter: TodoFilter) =>
    queryOptions({
      queryKey: [...todoQueries.all(), 'list', filter] as const,
      queryFn: () => fetchTodos(filter),
      staleTime: 30_000,
    }),
}

컴포넌트에서는 useQuery(todoQueries.list({ done: false }))처럼 쓴다. queryOptions로 만든 키는 데이터 타입을 기억하므로 getQueryData·setQueryData에서도 Todo[]로 추론된다.

  • queryFn이 쓰는 변수(여기서는 filter)는 모두 키에 넣는다. 빠지면 필터를 바꿔도 이전 캐시가 그대로 보인다.
  • ['todos']를 무효화하면 조건이 다른 목록과 상세(['todos', 'detail', id])까지 함께 낡은 것으로 표시된다.

staleTime·gcTime과 재요청 시점

캐시 항목은 아래 상태를 오간다. 두 시간 값이 이 전이를 정한다.

Query 캐시 항목이 요청 중에서 fresh, stale, inactive를 거쳐 gcTime 뒤 삭제되고, mount·focus·invalidate 때 다시 요청되는 상태 전이

  • staleTime (기본 0): 받은 뒤 이 시간 동안은 fresh다. fresh인 데이터는 컴포넌트가 새로 mount돼도 다시 부르지 않는다. 기본값이 0이라 받자마자 stale이 된다.
  • gcTime (기본 5분, v4의 cacheTime): 그 키를 보는 컴포넌트가 하나도 없게 된(inactive) 뒤 캐시에 남겨 두는 시간이다. 지나면 삭제되고, 다음에는 처음부터 다시 받는다.

stale 데이터는 버려지지 않는다. 화면에는 캐시 값을 먼저 보여 주고 뒤에서 다시 받는다. 다시 받는 계기는 기본으로 켜져 있다(네트워크 재연결 refetchOnReconnect도 같다).

계기 옵션 (기본값) 끄는 경우
같은 키 컴포넌트 mount refetchOnMount: true 거의 없음, 대신 staleTime을 늘린다
브라우저 탭으로 돌아옴 refetchOnWindowFocus: true 입력 폼처럼 갑자기 바뀌면 안 되는 화면
실패 재시도 retry: 3 (지수 백오프) 401·403·404처럼 다시 해도 같은 오류

옵션을 끄기보다 staleTime을 데이터 성격에 맞게 정한다. 코드표·카테고리처럼 거의 안 바뀌는 데이터는 수십 분, 목록은 30초 안팎, 재고·잔액처럼 늘 최신이어야 하는 값은 0으로 둔다. staleTime이 30초면 그 안에 같은 키를 쓰는 컴포넌트가 몇 개 mount되든 추가 요청은 0번이다.

Mutation 뒤 캐시 맞추기

서버 값을 바꾼 뒤에는 캐시도 맞춰야 한다. 방법은 둘이다.

무효화 (invalidateQueries)

  • queryClient.invalidateQueries({ queryKey: todoQueries.all() })는 앞부분이 같은 키를 모두 stale로 만들고, 지금 화면에 있는 것은 바로 다시 받는다.
  • 정렬·집계·페이지처럼 서버가 계산하는 결과에 맞다.

직접 쓰기 (setQueryData)

  • 응답에 바뀐 객체가 그대로 오면 setQueryData로 상세 캐시를 바로 고친다. 요청이 한 번 줄어든다.
  • 목록 순서가 바뀌는 수정은 서버 결과와 어긋나기 쉬우니 무효화를 같이 건다.

onSettled에서 invalidateQueries의 Promise를 반환하면 다시 받기가 끝날 때까지 Mutation이 pending으로 남아, 저장 버튼 로딩이 새 목록이 그려질 때까지 이어진다.

낙관적 업데이트와 롤백

체크박스·좋아요처럼 거의 성공하고 즉시 반응해야 하는 동작은 응답 전에 캐시부터 바꾸고, 실패하면 스냅샷으로 되돌린다.

낙관적 업데이트에서 화면이 진행 중 요청을 취소하고 캐시를 먼저 바꾼 뒤 서버 실패 시 스냅샷으로 되돌리고 무효화로 다시 받는 순서

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { todoQueries, type Todo } from '../queries/todos'
import { patchTodo } from '../api/todos' // 실패하면 throw하는 PATCH 함수

export function useToggleTodo(filter: { done?: boolean }) {
  const queryClient = useQueryClient()
  const { queryKey } = todoQueries.list(filter)

  return useMutation({
    mutationFn: (todo: Todo) => patchTodo(todo.id, { done: !todo.done }),
    onMutate: async (todo) => {
      await queryClient.cancelQueries({ queryKey }) // 진행 중인 재요청이 낙관값을 덮지 않게
      const previous = queryClient.getQueryData(queryKey)
      queryClient.setQueryData(queryKey, (old) =>
        old?.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
      )
      return { previous }
    },
    onError: (_error, _todo, context) => {
      if (context?.previous) queryClient.setQueryData(queryKey, context.previous)
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: todoQueries.all() }),
  })
}
  • onSettled의 무효화는 성공·실패 모두에서 서버 값으로 최종 정렬한다.
  • Mutation의 retry 기본값은 0이다. 재시도를 켜려면 같은 요청이 두 번 와도 결과가 같아야 하므로 서버 쪽에 멱등 API와 Idempotency-Key를 먼저 갖춘다.

SSR·Hydration과 운영 기본값 정리

SSR 화면은 서버에서 미리 받은 캐시를 브라우저로 넘겨 첫 화면의 로딩 표시를 없앤다. 어떤 화면을 SSR로 그릴지는 SSR·SSG·ISR·CSR 렌더링 전략 비교에서 정한다.

// app/todos/page.tsx — Next.js App Router 서버 컴포넌트
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'
import { todoQueries } from '@/queries/todos'
import { TodoList } from './TodoList'

export default async function TodosPage() {
  const queryClient = new QueryClient() // 요청마다 새로 만든다
  await queryClient.prefetchQuery(todoQueries.list({}))
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <TodoList />
    </HydrationBoundary>
  )
}
  • 서버 QueryClient를 모듈 전역에 두면 사용자 간 데이터가 섞일 수 있다.
  • list의 staleTime: 30_000 덕분에 브라우저에서 mount되자마자 같은 데이터를 또 부르지 않는다. staleTime이 0이면 서버에서 받은 직후 다시 요청한다.

운영 기본값 정리

무엇을 누가 정하나 없으면 생기는 일
Query Key 팩토리 기능 담당 개발자 키 오타로 무효화가 빠져 낡은 화면이 남는다
데이터별 staleTime 기능 담당 + 기획 탭 전환마다 같은 목록을 다시 받는다
4xx 재시도 제외 규칙 프론트 리드 (QueryClient) 권한 오류 화면이 7초 넘게 늦게 뜬다
응답 스키마 검증 프론트·백엔드 함께 필드 이름 변경이 런타임 오류로 나타난다

응답 모양이 바뀌어 queryFn 타입과 어긋나는 문제는 캐시 설계로 막을 수 없다. 배포 전에 Contract Testing으로 API 호환성을 확인한다.

자주 하는 실수

응답을 Zustand·Redux에 다시 넣는다. 캐시가 두 벌이 되어 무효화한 쪽과 화면이 보는 쪽이 어긋난다. 전역 스토어에는 Client State만 둔다.

queryFn에서 res.ok를 확인하지 않는다. fetch는 500에도 성공으로 끝나므로 오류 JSON이 data로 들어가고 isError가 켜지지 않는다.

queryFn이 쓰는 변수를 키에 넣지 않는다. 필터나 페이지를 바꿔도 키가 같아 이전 결과가 보인다.

낙관적 업데이트에서 cancelQueries와 스냅샷을 빼먹는다. 늦게 온 응답이 바꾼 값을 덮거나, 실패해도 화면이 성공한 것처럼 남는다.

점검표

  • 컴포넌트에서 fetch와 Query Key 리터럴을 직접 쓰는 곳이 없다.
  • 모든 API 함수가 res.ok가 아니면 throw한다.
  • queryFn이 읽는 변수가 모두 Query Key에 들어 있다.
  • 키가 ['도메인', '종류', 조건] 순서로 쌓여 앞부분 무효화가 된다.
  • 데이터 종류마다 staleTime을 정한 근거가 주석이나 문서에 있다.
  • QueryClient의 retry가 401·403·404에서 재시도하지 않는다.
  • Mutation마다 무효화 범위 또는 setQueryData 대상이 정해져 있다.
  • 낙관적 업데이트는 cancelQueries, 스냅샷, onError 복원, onSettled 무효화를 모두 갖췄다.
  • SSR에서 QueryClient를 요청마다 새로 만들고, hydrate된 쿼리의 staleTime이 0보다 크다.

Server State를 캐시로 보고 키·시간·무효화 세 가지를 한 곳에서 정하면, 화면 코드는 데이터를 언제 다시 받을지 신경 쓰지 않고 그리기만 하면 된다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기