탠스택 쿼리 핸드북: 기본 문법부터 Next.js 심화 사용까지
목차
- 탠스택 쿼리 개요
 - 핵심 개념과 기본 설정
 - Query: 데이터 조회
 - Mutation: 데이터 변경
 - 캐싱 전략과 최적화
 - Next.js와의 통합
 - 고급 기능
 - 성능 최적화와 디버깅
 - 테스팅 가이드
 
1. 탠스택 쿼리 개요
1.1 만들어진 이유
탠스택 쿼리(이전 React Query)는 현대 웹 애플리케이션에서 서버 상태 관리의 복잡성을 해결하기 위해 만들어졌습니다. 전통적인 상태 관리 라이브러리들이 클라이언트 상태 관리에 특화되어 있는 반면, 서버에서 가져온 데이터는 다음과 같은 고유한 특성을 가집니다1:
- 원격성: 네트워크를 통해 비동기적으로 가져와야 함
 - 공유성: 여러 컴포넌트에서 동일한 데이터가 필요할 수 있음
 - 시간 의존성: 시간이 지나면서 데이터가 오래될 수 있음
 - 소유권 부재: 클라이언트가 데이터를 직접 제어하지 않음
 
1.2 구조와 철학
탠스택 쿼리는 서버 상태 관리에 특화된 라이브러리로, 다음과 같은 핵심 원칙을 기반으로 설계되었습니다2:
- 자동 캐싱: 중복 요청 방지 및 성능 최적화
 - 백그라운드 업데이트: 사용자 경험을 해치지 않으면서 데이터 최신화
 - 스마트 리페칭: 창 포커스, 네트워크 재연결 등의 상황에서 자동 갱신
 - 낙관적 업데이트: 즉각적인 UI 반응을 위한 optimistic updates
 
2. 핵심 개념과 기본 설정
2.1 설치 및 기본 설정
npm install @tanstack/react-query기본 설정은 QueryClient와 QueryClientProvider로 시작합니다3:
import {
  useQuery,
  useMutation,
  useQueryClient,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
 
// QueryClient 생성
const queryClient = new QueryClient()
 
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
    </QueryClientProvider>
  )
}2.2 핵심 개념
탠스택 쿼리는 세 가지 핵심 개념을 기반으로 합니다3:
- Queries: 데이터 조회
 - Mutations: 데이터 변경
 - Query Invalidation: 쿼리 무효화
 
3. Query: 데이터 조회
3.1 기본 문법
useQuery 훅은 데이터를 조회하는 기본 방법입니다4:
import { useQuery } from '@tanstack/react-query'
 
function TodoList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  })
 
  if (isLoading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
 
  return (
    <ul>
      {data?.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}3.2 Query Key와 Query Function
Query Key는 쿼리를 고유하게 식별하는 배열입니다5:
// 단순한 키
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })
 
// 매개변수가 있는 키
useQuery({ 
  queryKey: ['todo', todoId], 
  queryFn: () => fetchTodo(todoId) 
})
 
// 복잡한 키
useQuery({ 
  queryKey: ['todos', { status: 'done', page: 1 }], 
  queryFn: ({ queryKey }) => fetchTodos(queryKey[^1]) 
})Query Function은 Promise를 반환하는 모든 함수가 될 수 있습니다6:
// fetch 사용
const fetchTodos = async () => {
  const response = await fetch('/api/todos')
  if (!response.ok) {
    throw new Error('Network response was not ok')
  }
  return response.json()
}
 
// axios 사용
const fetchTodos = () => axios.get('/api/todos').then(res => res.data)
 
// queryKey 매개변수 활용
const fetchTodo = ({ queryKey }) => {
  const [, todoId] = queryKey
  return fetch(`/api/todos/${todoId}`).then(res => res.json())
}3.3 상태 관리
useQuery는 다양한 상태 정보를 제공합니다4:
const {
  data,           // 성공적으로 가져온 데이터
  error,          // 에러 객체
  isLoading,      // 첫 번째 로딩 상태
  isFetching,     // 백그라운드에서 데이터를 가져오는 상태
  isError,        // 에러 상태
  isSuccess,      // 성공 상태
  refetch,        // 수동으로 다시 가져오기
  status,         // 'loading' | 'error' | 'success'
  fetchStatus     // 'fetching' | 'paused' | 'idle'
} = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos
})3.4 다중 쿼리 처리
여러 쿼리를 동시에 실행할 때는 useQueries 훅을 사용합니다78:
// 방법 1: 개별 useQuery 사용
const { data: userData } = useQuery({ 
  queryKey: ['users'], 
  queryFn: fetchUsers 
})
const { data: postData } = useQuery({ 
  queryKey: ['posts'], 
  queryFn: fetchPosts 
})
 
// 방법 2: useQueries 사용
const [users, posts] = useQueries({
  queries: [
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['posts'], queryFn: fetchPosts }
  ]
})
 
// 모든 쿼리가 완료될 때까지 기다리기
const results = useQueries({
  queries: [
    { queryKey: ['users'], queryFn: fetchUsers },
    { queryKey: ['posts'], queryFn: fetchPosts }
  ]
})
 
const isLoading = results.some(query => query.isLoading)4. Mutation: 데이터 변경
4.1 기본 문법
useMutation은 데이터를 생성, 수정, 삭제할 때 사용합니다910:
import { useMutation, useQueryClient } from '@tanstack/react-query'
 
function AddTodo() {
  const queryClient = useQueryClient()
  
  const mutation = useMutation({
    mutationFn: (newTodo) => {
      return axios.post('/todos', newTodo)
    },
    onSuccess: () => {
      // 성공 시 todos 쿼리 무효화
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    },
  })
 
  return (
    <div>
      {mutation.isLoading ? (
        'Adding todo...'
      ) : (
        <>
          {mutation.isError ? (
            <div>An error occurred: {mutation.error.message}</div>
          ) : null}
 
          {mutation.isSuccess ? <div>Todo added!</div> : null}
 
          <button
            onClick={() => {
              mutation.mutate({ id: Date.now(), title: 'Do Laundry' })
            }}
          >
            Create Todo
          </button>
        </>
      )}
    </div>
  )
}4.2 Mutation 생명주기
Mutation은 다양한 생명주기 콜백을 제공합니다9:
useMutation({
  mutationFn: addTodo,
  onMutate: async (variables) => {
    // 뮤테이션이 시작되기 직전에 호출
    // 낙관적 업데이트를 위한 컨텍스트 반환 가능
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    
    const previousTodos = queryClient.getQueryData(['todos'])
    
    queryClient.setQueryData(['todos'], old => [...old, variables])
    
    return { previousTodos }
  },
  onError: (error, variables, context) => {
    // 에러 발생 시 실행
    queryClient.setQueryData(['todos'], context.previousTodos)
  },
  onSuccess: (data, variables, context) => {
    // 성공 시 실행
    console.log('Todo added successfully!')
  },
  onSettled: (data, error, variables, context) => {
    // 성공이든 실패든 항상 실행
    queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})4.3 Query Invalidation
데이터 변경 후 관련 쿼리를 무효화하여 최신 데이터를 가져올 수 있습니다11:
// 특정 키로 시작하는 모든 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] })
 
// 정확히 일치하는 쿼리만 무효화
queryClient.invalidateQueries({ 
  queryKey: ['todos'], 
  exact: true 
})
 
// 조건부 무효화
queryClient.invalidateQueries({
  predicate: (query) =>
    query.queryKey[^0] === 'todos' && query.queryKey[^1]?.version >= 10,
})5. 캐싱 전략과 최적화
5.1 캐싱 기본 개념
탠스택 쿼리의 캐싱은 두 가지 주요 옵션으로 제어됩니다1213:
- staleTime: 데이터가 신선한 상태로 유지되는 시간
 - gcTime (이전 cacheTime): 사용하지 않는 데이터가 가비지 컬렉션되기까지의 시간
 
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  staleTime: 5 * 60 * 1000, // 5분간 신선한 상태 유지
  gcTime: 10 * 60 * 1000,   // 10분 후 가비지 컬렉션
})5.2 백그라운드 리페칭
탠스택 쿼리는 다양한 상황에서 자동으로 데이터를 다시 가져옵니다14:
useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  refetchOnWindowFocus: true,    // 창 포커스 시 리페치
  refetchOnReconnect: true,      // 네트워크 재연결 시 리페치
  refetchInterval: 30000,        // 30초마다 자동 리페치
  refetchIntervalInBackground: false, // 백그라운드에서는 리페치 안함
})5.3 낙관적 업데이트
const updateTodoMutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // 진행 중인 쿼리 취소
    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'] })
  },
})6. Next.js와의 통합
6.1 App Router와 함께 사용하기
Next.js 13+ App Router와 함께 사용할 때는 다음과 같이 설정합니다1718:
// app/providers.tsx
'use client'
 
import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
 
function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // SSR과 함께 사용할 때는 staleTime을 0보다 크게 설정
        staleTime: 60 * 1000,
      },
    },
  })
}
 
let browserQueryClient: QueryClient | undefined = undefined
 
function getQueryClient() {
  if (isServer) {
    // 서버: 항상 새로운 클라이언트 생성
    return makeQueryClient()
  } else {
    // 브라우저: 기존 클라이언트가 없으면 생성
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}
 
export default function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = getQueryClient()
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}// app/layout.tsx
import Providers from './providers'
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head />
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}6.2 서버사이드 데이터 프리페칭
Next.js에서 서버사이드 데이터 프리페칭을 구현합니다18:
// app/posts/page.tsx
import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
import Posts from './posts'
 
export default function PostsPage() {
  const queryClient = getQueryClient()
 
  queryClient.prefetchQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
  })
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Posts />
    </HydrationBoundary>
  )
}// app/posts/posts.tsx
'use client'
 
import { useQuery } from '@tanstack/react-query'
 
export default function Posts() {
  // 이 데이터는 이미 서버에서 프리페치되었음
  const { data } = useQuery({ 
    queryKey: ['posts'], 
    queryFn: getPosts 
  })
 
  return (
    <div>
      {data?.map(post => (
        <div key={post.id}>{post.title}</div>
      ))}
    </div>
  )
}6.3 SSR과 하이드레이션
SSR 환경에서 하이드레이션 처리19:
// utils/get-query-client.ts
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
 
export function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
      dehydrate: {
        // 대기 중인 쿼리도 포함
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
    },
  })
}7. 고급 기능
7.1 무한 쿼리 (Infinite Queries)
페이지네이션이나 무한 스크롤 구현을 위한 useInfiniteQuery2021:
import { useInfiniteQuery } from '@tanstack/react-query'
 
function Projects() {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/projects?cursor=${pageParam}`)
        .then(res => res.json()),
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })
 
  return status === 'loading' ? (
    <p>Loading...</p>
  ) : status === 'error' ? (
    <p>Error: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.data.map(project => (
            <p key={project.id}>{project.name}</p>
          ))}
        </React.Fragment>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
            ? 'Load More'
            : 'Nothing more to load'}
        </button>
      </div>
    </>
  )
}7.2 Suspense 지원
import { useSuspenseQuery } from '@tanstack/react-query'
 
function Profile() {
  // Suspense 경계에서 자동으로 로딩 처리
  const { data } = useSuspenseQuery({
    queryKey: ['profile'],
    queryFn: fetchProfile,
  })
 
  return <div>Hello {data.name}!</div>
}
 
function App() {
  return (
    <Suspense fallback={<div>Loading profile...</div>}>
      <Profile />
    </Suspense>
  )
}7.3 의존적 쿼리 (Dependent Queries)
한 쿼리의 결과에 따라 다른 쿼리를 실행24:
function Profile({ userId }) {
  // 사용자 정보 먼저 가져오기
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  })
 
  // 사용자 정보가 있을 때만 프로젝트 가져오기
  const { data: projects } = useQuery({
    queryKey: ['projects', user?.id],
    queryFn: () => fetchUserProjects(user.id),
    enabled: !!user?.id,
  })
 
  return <div>...</div>
}8. 성능 최적화와 디버깅
8.1 렌더링 최적화
탠스택 쿼리는 자동으로 렌더링 최적화를 수행합니다2526:
// select 옵션으로 필요한 데이터만 구독
const todoCount = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  select: data => data.length, // 길이만 필요할 때
})
 
// notifyOnChangeProps로 특정 속성만 감시
const { data, isError } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
  notifyOnChangeProps: ['data', 'isError'], // 이 속성들만 변경될 때 리렌더
})8.2 DevTools 활용
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
 
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}Chrome 확장 프로그램도 사용 가능합니다29. DevTools를 통해 다음을 확인할 수 있습니다:
- 실시간 쿼리 상태 모니터링
 - 캐시 내용 검사
 - 뮤테이션 추적
 - 쿼리 리페치 및 무효화 제어
 
8.3 에러 처리
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        // 전역 에러 처리
        console.error('Query Error:', error)
        toast.error(`Something went wrong: ${error.message}`)
      },
      retry: (failureCount, error) => {
        // 특정 에러는 재시도하지 않음
        if (error.status === 404) return false
        return failureCount < 3
      },
    },
    mutations: {
      onError: (error) => {
        console.error('Mutation Error:', error)
        toast.error(`Failed to update: ${error.message}`)
      },
    },
  },
})9. 테스팅 가이드
9.1 기본 테스팅 설정
// test-utils.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
 
export const createTestQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
      mutations: {
        retry: false,
      },
    },
  })
 
export const renderWithClient = (ui) => {
  const testQueryClient = createTestQueryClient()
  const { rerender, ...result } = render(
    <QueryClientProvider client={testQueryClient}>
      {ui}
    </QueryClientProvider>
  )
  return {
    ...result,
    rerender: (rerenderUi) =>
      rerender(
        <QueryClientProvider client={testQueryClient}>
          {rerenderUi}
        </QueryClientProvider>
      ),
  }
}9.2 useQuery 훅 테스팅
// TodoList.test.jsx
import { screen, waitFor } from '@testing-library/react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { renderWithClient } from './test-utils'
import TodoList from './TodoList'
 
const server = setupServer(
  rest.get('/api/todos', (req, res, ctx) => {
    return res(
      ctx.json([
        { id: 1, title: 'Learn React Query' },
        { id: 2, title: 'Build awesome apps' },
      ])
    )
  })
)
 
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
 
test('renders todos', async () => {
  renderWithClient(<TodoList />)
  
  expect(screen.getByText('Loading...')).toBeInTheDocument()
  
  await waitFor(() => {
    expect(screen.getByText('Learn React Query')).toBeInTheDocument()
  })
})9.3 Mutation 테스팅
test('adds a new todo', async () => {
  const user = userEvent.setup()
  
  server.use(
    rest.post('/api/todos', (req, res, ctx) => {
      return res(ctx.json({ id: 3, title: 'New Todo' }))
    })
  )
 
  renderWithClient(<AddTodo />)
  
  const input = screen.getByLabelText('Todo title')
  const button = screen.getByRole('button', { name: 'Add Todo' })
  
  await user.type(input, 'New Todo')
  await user.click(button)
  
  await waitFor(() => {
    expect(screen.getByText('Todo added successfully!')).toBeInTheDocument()
  })
})이 핸드북은 탠스택 쿼리의 기본 개념부터 Next.js와의 심화 통합까지 포괄적으로 다룹니다. 각 섹션의 예제 코드를 참고하여 프로젝트에 적용해보시기 바랍니다. 추가적인 고급 기능이나 특정 사용 사례에 대한 질문이 있으시면 언제든지 문의해주세요.
Footnotes
- 
https://www.atlantbh.com/asynchronous-state-management-with-tanstack-query/ ↩
 - 
https://tanstack.com/query/v4/docs/react/guides/queries ↩ ↩2
 - 
https://tanstack.com/query/v5/docs/framework/react/guides/query-functions ↩
 - 
https://dev.to/calvin087/how-to-handle-multiple-queries-with-react-query-24gn?comments_sort=oldest ↩
 - 
https://calvintorra.com/blog/how-to-handle-multiple-queries-with-react-query/ ↩
 - 
https://tanstack.com/query/v4/docs/react/guides/mutations ↩ ↩2
 - 
https://tanstack.com/query/v5/docs/react/guides/query-invalidation ↩
 - 
https://dev.to/thechaudhrysab/simple-understanding-of-gctime-staletime-in-react-query-35be ↩
 - 
https://stackoverflow.com/questions/72828361/what-are-staletime-and-cachetime-in-react-query ↩
 - 
https://www.reddit.com/r/nextjs/comments/184a4aw/tanstackquery_refetching_even_when_data_has/ ↩
 - 
https://www.tenxdeveloper.com/blog/optimistic-updates-react-query-guide ↩
 - 
https://stackoverflow.com/questions/75443779/how-to-effectively-do-optimistic-update-for-deeply-nested-data-in-react-query ↩
 - 
https://brockherion.dev/blog/posts/setting-up-and-using-react-query-in-nextjs/ ↩
 - 
https://tanstack.com/query/v5/docs/react/guides/advanced-ssr ↩ ↩2
 - 
https://tanstack.com/query/v5/docs/react/guides/infinite-queries ↩
 - 
https://tanstack.com/query/v4/docs/react/guides/infinite-queries ↩
 - 
https://tanstack.com/query/v5/docs/react/guides/request-waterfalls ↩
 - 
https://tanstack.com/query/v5/docs/framework/react/guides/render-optimizations ↩
 - 
https://chromewebstore.google.com/detail/tanstack-query-devtools/annajfchloimdhceglpgglpeepfghfai ↩
 - 
https://stackoverflow.com/questions/70654287/is-there-any-solution-to-mock-react-querys-usequery-and-usemutation-while-worki ↩