탠스택 쿼리 핸드북: 기본 문법부터 Next.js 심화 사용까지

목차

  1. 탠스택 쿼리 개요
  2. 핵심 개념과 기본 설정
  3. Query: 데이터 조회
  4. Mutation: 데이터 변경
  5. 캐싱 전략과 최적화
  6. Next.js와의 통합
  7. 고급 기능
  8. 성능 최적화와 디버깅
  9. 테스팅 가이드

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:

  1. Queries: 데이터 조회
  2. Mutations: 데이터 변경
  3. 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 낙관적 업데이트

사용자 경험 향상을 위한 낙관적 업데이트1516:

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 지원

React Suspense와 함께 사용하기2223:

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 활용

개발 환경에서 DevTools 설정2728:

import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
 
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MyApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  )
}

Chrome 확장 프로그램도 사용 가능합니다29. DevTools를 통해 다음을 확인할 수 있습니다:

  • 실시간 쿼리 상태 모니터링
  • 캐시 내용 검사
  • 뮤테이션 추적
  • 쿼리 리페치 및 무효화 제어

8.3 에러 처리

전역 에러 처리 설정3031:

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 기본 테스팅 설정

탠스택 쿼리 테스팅을 위한 기본 설정3233:

// 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

  1. https://tanstack.com/query/v4/docs/react/overview

  2. https://www.atlantbh.com/asynchronous-state-management-with-tanstack-query/

  3. https://tanstack.com/query/v5/docs/react/quick-start 2

  4. https://tanstack.com/query/v4/docs/react/guides/queries 2

  5. https://athanasu.hashnode.dev/ls-tanstack-query-concepts

  6. https://tanstack.com/query/v5/docs/framework/react/guides/query-functions

  7. https://dev.to/calvin087/how-to-handle-multiple-queries-with-react-query-24gn?comments_sort=oldest

  8. https://calvintorra.com/blog/how-to-handle-multiple-queries-with-react-query/

  9. https://tanstack.com/query/v4/docs/react/guides/mutations 2

  10. https://tanstack.com/query/v5/docs/react/guides/mutations

  11. https://tanstack.com/query/v5/docs/react/guides/query-invalidation

  12. https://dev.to/thechaudhrysab/simple-understanding-of-gctime-staletime-in-react-query-35be

  13. https://stackoverflow.com/questions/72828361/what-are-staletime-and-cachetime-in-react-query

  14. https://www.reddit.com/r/nextjs/comments/184a4aw/tanstackquery_refetching_even_when_data_has/

  15. https://www.tenxdeveloper.com/blog/optimistic-updates-react-query-guide

  16. https://stackoverflow.com/questions/75443779/how-to-effectively-do-optimistic-update-for-deeply-nested-data-in-react-query

  17. https://brockherion.dev/blog/posts/setting-up-and-using-react-query-in-nextjs/

  18. https://tanstack.com/query/v5/docs/react/guides/advanced-ssr 2

  19. https://blog.logrocket.com/using-tanstack-query-next-js/

  20. https://tanstack.com/query/v5/docs/react/guides/infinite-queries

  21. https://tanstack.com/query/v4/docs/react/guides/infinite-queries

  22. https://tanstack.com/query/v4/docs/react/guides/suspense

  23. https://tanstack.com/query/v5/docs/react/guides/suspense

  24. https://tanstack.com/query/v5/docs/react/guides/request-waterfalls

  25. https://tanstack.com/query/v5/docs/framework/react/guides/render-optimizations

  26. https://tkdodo.eu/blog/react-query-render-optimizations

  27. https://tanstack.com/query/v5/docs/react/devtools

  28. https://tanstack.com/query/v4/docs/react/devtools

  29. https://chromewebstore.google.com/detail/tanstack-query-devtools/annajfchloimdhceglpgglpeepfghfai

  30. https://github.com/TanStack/query/discussions/6490

  31. https://tkdodo.eu/blog/react-query-error-handling

  32. https://stackoverflow.com/questions/70654287/is-there-any-solution-to-mock-react-querys-usequery-and-usemutation-while-worki

  33. https://tanstack.com/query/v4/docs/react/guides/testing