
탠스택 쿼리 핸드북: 기본 문법부터 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)
페이지네이션이나 무한 스크롤 구현을 위한 useInfiniteQuery
2021:
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 ↩