2025-09-20 15:34
탠스택 쿼리 완벽 핸드북 서버 상태 관리의 새로운 표준
- 탠스택 쿼리는 서버에서 가져온 데이터를 클라이언트에서 관리하는 ‘서버 상태 관리’ 라이브러리다.
- 데이터 캐싱, 동기화, 업데이트 등 비동기 로직을 선언적으로 만들어 복잡성을 크게 줄여준다.
- React 뿐만 아니라 Vue, Svelte, Solid 등 다양한 프레임워크에서 사용할 수 있는 핵심 도구다.
1. 들어가며 탠스택 쿼리는 왜 탄생했나
현대 웹 애플리케이션 개발은 ‘상태(State)‘와의 싸움이라고 해도 과언이 아니다. 특히 사용자와 상호작용하는 프론트엔드에서는 이 상태를 어떻게 효율적으로 관리하느냐가 애플리케이션의 품질과 개발 경험을 좌우한다.
우리가 관리하는 상태는 크게 두 가지로 나눌 수 있다.
- 클라이언트 상태 (Client State): UI의 상태(예: 다크 모드 여부, 모달 창 열림/닫힘), 사용자가 입력한 폼 데이터 등 클라이언트 내에서만 존재하고 제어되는 데이터.
- 서버 상태 (Server State): 데이터베이스에 저장된 데이터처럼 서버에서 비동기적으로 가져와야 하는 데이터. 이 데이터는 우리가 직접 소유하지 않고, 원격 공간에 저장되어 있으며, 언제든 다른 사용자에 의해 변경될 수 있다.
React의 useState, useReducer나 Redux, Zustand 같은 상태 관리 라이브러리들은 클라이언트 상태를 관리하는 데 탁월한 도구다. 하지만 서버 상태는 근본적으로 다른 특징을 가지고 있어 이 도구들만으로 관리하기에는 어려움이 따른다.
서버 상태의 까다로운 특징들
- 캐싱(Caching): 동일한 데이터를 불필요하게 여러 번 요청하는 것을 방지해야 한다.
- 데이터 동기화 (Synchronization): 서버의 데이터가 변경되었을 때 클라이언트의 데이터를 최신 상태로 유지해야 한다. (Stale-while-revalidate)
- 업데이트: 사용자의 액션으로 서버 데이터를 변경(Create, Update, Delete)하고, 그 결과를 UI에 즉시 반영해야 한다.
- 페이지네이션 및 무한 스크롤: 대량의 데이터를 효율적으로 불러와야 한다.
- 낙관적 업데이트 (Optimistic Updates): 서버의 응답을 기다리지 않고 UI를 먼저 변경하여 사용자 경험을 향상시켜야 한다.
과거에는 이런 복잡한 로직을 useEffect와 useState를 조합하여 직접 구현하거나, Redux-saga, Redux-thunk 같은 미들웨어를 사용해 처리했다. 이는 수많은 보일러플레이트 코드를 낳았고, 코드를 복잡하게 만들며, 휴먼 에러가 발생할 가능성을 높였다.
탠스택 쿼리(TanStack Query, 구 React Query)는 바로 이 ‘서버 상태 관리’의 복잡성을 해결하기 위해 탄생했다. 비동기 로직을 추상화하고, 몇 줄의 선언적인 코드로 위에서 언급한 까다로운 문제들을 우아하게 처리할 수 있도록 돕는다. 탠스택 쿼리는 데이터를 ‘가져오는 것(fetching)‘에 그치지 않고 ‘관리’하는 것에 초점을 맞춘 라이브러리다.
2. 핵심 개념 정복하기 탠스택 쿼리의 구조
탠스택 쿼리를 제대로 사용하려면 몇 가지 핵심 개념을 이해해야 한다. 마치 새로운 도구를 사용하기 전에 각 부품의 명칭과 역할을 숙지하는 것과 같다.
| 개념 (Concept) | 역할 및 설명 | 비유 |
|---|---|---|
| Queries | 데이터를 읽는(Read) 작업을 정의. useQuery 훅을 통해 사용하며, 고유한 키를 기반으로 데이터를 가져오고 캐싱한다. | 도서관에서 특정 책을 찾아보는 행위. |
| Mutations | 데이터를 **생성(Create), 수정(Update), 삭제(Delete)**하는 작업을 정의. useMutation 훅을 통해 사용한다. | 도서관에 새로운 책을 기증하거나, 책의 내용을 수정하는 행위. |
| Query Keys | 쿼리 데이터를 관리하는 고유 식별자. 배열 형태로 사용되며, 이 키를 기준으로 캐싱과 재요청이 이루어진다. | 도서관의 도서 청구기호. 이 기호로 정확한 책을 찾는다. |
| Query Client | 탠스택 쿼리의 두뇌. 쿼리 캐시를 관리하고, 모든 쿼리와 상호작용하는 중앙 제어 장치. QueryClientProvider를 통해 앱에 제공된다. | 도서관의 사서. 모든 책의 위치와 상태를 파악하고 관리한다. |
| Query Invalidation | 특정 쿼리 키에 해당하는 데이터를 ‘오래된 데이터(stale)‘로 만들어, 다음에 필요할 때 다시 가져오도록 유도하는 메커니즘. | 책의 개정판이 나왔을 때, 기존 책에 ‘구판’ 스티커를 붙여 최신판을 찾아보도록 유도하는 것. |
2.1 Queries와 Query Keys: 데이터 식별 및 가져오기
useQuery는 데이터를 가져오는 가장 기본적인 훅이다. 이 훅은 최소 두 개의 인자를 필요로 한다.
queryKey: 쿼리의 고유 식별자. 배열로 지정하며, 직렬화가 가능해야 한다.queryFn: Promise를 반환하는 비동기 함수. 실제 데이터 fetching 로직이 담긴다.
import { useQuery } from '@tanstack/react-query';
// 'todos'라는 키로 모든 할 일 목록을 가져오는 쿼리
const { data, isLoading, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
// id가 1인 특정 할 일을 가져오는 쿼리
const { data: todo } = useQuery({
queryKey: ['todos', 1],
queryFn: () => fetchTodoById(1),
});queryKey는 매우 중요하다. 탠스택 쿼리는 이 키를 사용해 데이터를 캐싱한다. ['todos']와 ['todos', 1]은 서로 다른 데이터로 인식되어 별도의 캐시에 저장된다. 만약 queryKey가 변경되면, 탠스택 쿼리는 새로운 데이터를 가져온다. 이는 마치 도서관에서 청구기호를 바꾸면 다른 책을 가져다주는 것과 같다.
useQuery는 data, isLoading, isError, isSuccess 등 데이터의 현재 상태를 나타내는 다양한 값을 반환하여 UI를 손쉽게 제어할 수 있게 한다.
2.2 Mutations: 데이터 변경하기
useMutation은 서버의 데이터를 변경해야 할 때 사용한다. 예를 들어, 새로운 할 일을 추가하거나 기존 할 일을 수정하는 경우다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: createTodo, // 데이터를 생성하는 비동기 함수
onSuccess: () => {
// 성공 시 'todos' 쿼리를 무효화하여 최신 데이터를 다시 가져오도록 함
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// 컴포넌트 내에서 호출
<button onClick={() => mutate({ title: '새로운 할 일' })}>
{isPending ? '추가 중...' : '할 일 추가'}
</button>useMutation의 핵심은 onSuccess, onError, onSettled와 같은 콜백 함수를 통해 데이터 변경 후의 작업을 손쉽게 처리할 수 있다는 점이다. 가장 일반적인 패턴은 onSuccess 콜백에서 queryClient.invalidateQueries를 호출하는 것이다. 이는 관련된 데이터를 ‘오래됐다’고 표시하여, 해당 데이터가 다시 화면에 필요할 때 탠스택 쿼리가 자동으로 최신 데이터를 서버에서 가져오도록 만든다. 이 과정을 통해 서버와 클라이언트의 데이터 동기화를 손쉽게 유지할 수 있다.
3. 기본 사용법 익히기 useQuery와 useMutation
이론을 알았으니 실제 코드로 어떻게 적용되는지 살펴보자. 먼저, 탠스택 쿼리를 사용하기 위한 초기 설정이 필요하다.
1. 설치
npm install @tanstack/react-query
# 또는
yarn add @tanstack/react-query2. QueryClientProvider 설정
애플리케이션의 최상단(일반적으로 App.jsx 또는 main.jsx)에서 QueryClientProvider로 앱 전체를 감싸준다.
// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
// QueryClient 인스턴스 생성
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>
);이제 앱 내의 모든 컴포넌트에서 useQuery, useMutation 등의 훅을 사용할 수 있다.
3.1 useQuery로 데이터 목록 보여주기
할 일 목록을 불러와 화면에 표시하는 간단한 예제다.
// Todos.jsx
import { useQuery } from '@tanstack/react-query';
// API 호출 함수 (예시)
const fetchTodos = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
function Todos() {
const { data, error, isLoading, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
if (isLoading) {
return <span>로딩 중...</span>;
}
if (isError) {
return <span>에러 발생: {error.message}</span>;
}
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}이 몇 줄의 코드는 이미 강력한 기능을 내포하고 있다.
- 최초 렌더링 시
fetchTodos를 호출하고 로딩 상태를 관리한다. - 데이터 fetching에 성공하면
data에 결과를 담아 컴포넌트를 리렌더링한다. - 가져온 데이터는
['todos']라는 키로 메모리 캐시에 저장된다. - 다른 컴포넌트에서 동일한
queryKey로useQuery를 호출하면, API를 다시 호출하는 대신 캐시된 데이터를 즉시 반환한다. - 사용자가 브라우저 창을 다시 포커스하면 백그라운드에서 데이터를 자동으로 다시 가져와(refetch) 최신 상태를 유지한다.
3.2 useMutation으로 데이터 추가하기
이제 할 일을 추가하는 기능을 구현해 보자.
// AddTodo.jsx
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
const createTodo = async (newTodo) => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
return response.json();
};
function AddTodo() {
const [title, setTitle] = useState('');
const queryClient = useQueryClient();
const { mutate, isPending, isError, error } = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// 뮤테이션 성공 시 'todos' 쿼리를 무효화
console.log('성공! todos 목록을 다시 가져옵니다.');
queryClient.invalidateQueries({ queryKey: ['todos'] });
setTitle('');
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutate({ title, completed: false, userId: 1 });
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="새로운 할 일"
/>
<button type="submit" disabled={isPending}>
{isPending ? '추가 중...' : '추가'}
</button>
{isError && <span>에러: {error.message}</span>}
</form>
);
}사용자가 ‘추가’ 버튼을 누르면 mutate 함수가 호출되고, createTodo API 요청이 실행된다. 요청이 성공적으로 완료되면 onSuccess 콜백이 실행되어 queryClient.invalidateQueries({ queryKey: ['todos'] })를 호출한다.
이 호출은 ‘todos’라는 키를 가진 쿼리가 더 이상 최신이 아님을 탠스택 쿼리에게 알리는 신호다. 마침 Todos.jsx 컴포넌트가 화면에 useQuery({ queryKey: ['todos'], ... })를 사용하고 있으므로, 탠스택 쿼리는 자동으로 해당 쿼리를 백그라운드에서 다시 실행하여 목록을 최신 상태로 업데이트한다. 개발자는 그저 ‘데이터가 변했으니 관련된 쿼리를 무효화하라’고 명령하기만 하면 된다.
4. 심화 학습 더 강력하게 활용하기
탠스택 쿼리의 진가는 기본 사용법을 넘어 심화 기능을 활용할 때 드러난다.
4.1 데이터의 ‘신선도’ 관리: staleTime vs cacheTime
staleTime: 데이터가 ‘오래되었다(stale)‘고 간주되기까지의 시간. 기본값은0이다.staleTime이 지나기 전까지는 데이터가 ‘신선하다(fresh)‘고 판단하여, 컴포넌트가 마운트되거나 창이 포커스되어도 데이터를 다시 가져오지 않는다. 자주 바뀌지 않는 데이터의 경우staleTime을 길게 설정하면 불필요한 API 요청을 크게 줄일 수 있다.cacheTime: 쿼리가 비활성 상태(해당 쿼리를 사용하는 컴포넌트가 모두 언마운트된 상태)가 된 후, 캐시에서 데이터가 제거되기까지의 시간. 기본값은 5분이다. 이 시간이 지나면 가비지 컬렉션에 의해 캐시가 정리된다.
비유:
staleTime은 냉장고에 있는 우유의 ‘유통기한’과 같다. 유통기한이 지나지 않았으면(fresh) 그냥 마셔도 되지만, 유통기한이 지났다면(stale) 새 우유가 있는지 확인해봐야 한다.cacheTime은 냉장고에서 우유를 꺼내 상온에 둔 후, 상해서 버리기까지의 시간이다. 아무도 마시지 않으면(inactive) 결국 버려진다.
useQuery({
queryKey: ['user-profile'],
queryFn: fetchUserProfile,
staleTime: 1000 * 60 * 5, // 5분 동안은 fresh 상태로 유지
cacheTime: 1000 * 60 * 10, // 10분 동안 비활성이면 캐시에서 제거
});4.2 낙관적 업데이트 (Optimistic Updates)
사용자 경험을 극대화하는 강력한 기능이다. 서버의 응답을 기다리지 않고, 요청이 성공할 것이라고 ‘낙관적으로’ 가정하여 UI를 먼저 업데이트하는 기법이다.
예를 들어, 사용자가 할 일 목록에서 항목 하나를 ‘완료’로 체크했다고 가정해 보자.
- 일반적인 업데이트: 체크 → 서버에 업데이트 요청 → 서버 응답 성공 → UI 변경 (체크 표시). 이 과정에서 약간의 딜레이가 발생한다.
- 낙관적 업데이트: 체크 → UI 즉시 변경 → 서버에 업데이트 요청. 만약 요청이 실패하면, 원래 상태로 UI를 되돌린다(rollback).
사용자는 자신의 행동에 대한 즉각적인 피드백을 받기 때문에 훨씬 부드러운 경험을 하게 된다.
const useUpdateTodoMutation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateTodo,
// 뮤테이션이 시작되기 전에 호출
onMutate: async (updatedTodo) => {
// 진행 중인 refetch를 취소하여 덮어쓰기 방지
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 이전 상태를 스냅샷
const previousTodos = queryClient.getQueryData(['todos']);
// 새로운 값으로 캐시를 낙관적으로 업데이트
queryClient.setQueryData(['todos'], (old) =>
old.map((todo) =>
todo.id === updatedTodo.id ? { ...todo, ...updatedTodo } : todo
)
);
// 스냅샷한 값을 context에 반환
return { previousTodos };
},
// 뮤테이션 실패 시 onMutate에서 반환된 context를 사용하여 롤백
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
// 성공 또는 실패 여부와 관계없이 뮤테이션 완료 후 항상 refetch
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
};코드가 다소 복잡해 보이지만, onMutate에서 이전 데이터를 백업하고 UI를 미리 수정한 뒤, onError에서 문제가 생기면 백업한 데이터로 복원하는 구조다. 이 패턴을 통해 매우 정교한 사용자 경험을 구현할 수 있다.
4.3 페이지네이션과 무한 스크롤
대량의 데이터를 다룰 때 필수적인 기능이다. 탠스택 쿼리는 이를 위한 전용 훅을 제공한다.
useQuery와keepPreviousData: 페이지네이션을 구현할 때 유용하다. 다음 페이지 데이터를 불러오는 동안 이전 페이지 데이터를 화면에 그대로 유지하여 로딩 상태에서 화면이 깜빡이는 현상을 방지한다.useInfiniteQuery: ‘더 보기’ 버튼이나 스크롤 기반의 무한 로딩을 구현할 때 사용한다. 여러 페이지의 데이터를 하나의 배열에 누적하여 관리해주므로 매우 편리하다.getNextPageParam옵션을 통해 다음 페이지를 어떻게 가져올지 정의할 수 있다.
5. 리액트를 넘어 탠스택 쿼리의 생태계
원래 ‘리액트 쿼리(React Query)‘라는 이름으로 시작했지만, 라이브러리의 핵심 로직이 특정 UI 프레임워크에 종속되지 않는다는 것을 깨닫고, 코어 로직을 분리하여 ‘탠스택 쿼리(TanStack Query)‘로 재탄생했다.
이제 탠스택 쿼리는 공식 어댑터를 통해 다음과 같은 다양한 프레임워크와 라이브러리를 지원한다.
- React (
@tanstack/react-query) - Vue (
@tanstack/vue-query) - Svelte (
@tanstack/svelte-query) - Solid (
@tanstack/solid-query) - Vanilla JavaScript (Core:
@tanstack/query-core)
이는 탠스택 쿼리가 단순히 React 생태계의 유용한 도구를 넘어, 현대적인 프론트엔드 개발의 표준적인 ‘서버 상태 관리’ 패러다임으로 자리 잡았음을 의미한다.
또한, TanStack Query Devtools는 개발 과정에서 필수적인 도구다. 각 쿼리의 상태, 캐시된 데이터, 마지막 업데이트 시간 등을 시각적으로 보여주어 디버깅을 매우 용이하게 만들어 준다.
6. 마치며 탠스택 쿼리를 써야 하는 이유
탠스택 쿼리는 단순한 데이터 fetching 라이브러리가 아니다. 서버 상태와 관련된 복잡하고 반복적인 문제들을 해결하기 위한 포괄적인 솔루션이자 프레임워크다.
- 선언적 코드: “어떻게” 데이터를 가져오고 동기화할지 명령하는 대신, “무엇을” 원하는지만 선언하면 된다.
- 생산성 향상: 캐싱, 재시도, 데이터 동기화 등 수많은 보일러플레이트 코드를 직접 작성할 필요가 없다. 개발자는 비즈니스 로직에 더 집중할 수 있다.
- 뛰어난 사용자 경험:
stale-while-revalidate,refetchOnWindowFocus, 낙관적 업데이트 등의 기능을 통해 항상 최신 데이터를 보여주고, 빠른 피드백을 제공하는 애플리케이션을 만들 수 있다. - 강력한 개발 도구: Devtools를 통해 쿼리의 내부 동작을 투명하게 들여다볼 수 있어 디버깅과 최적화가 용이하다.
만약 당신의 프로젝트가 서버와의 비동기 통신을 필요로 하고, 로딩 및 에러 상태 관리, 데이터 캐싱과 동기화로 인해 어려움을 겪고 있다면, 탠스택 쿼리는 의심할 여지 없이 최고의 선택이 될 것이다. 그것은 당신의 코드를 더 깔끔하고, 더 예측 가능하며, 더 강력하게 만들어 줄 것이다.