React Query/TanStack 심화
2025. 4. 2. 16:40ㆍFrameworks/React
React Query/TanStack 심화
TanStack Query란 무엇인가요?
TanStack Query(이전 이름: React Query)는 서버 상태 관리를 쉽게 해주는 라이브러리입니다. API 요청, 캐싱, 동기화, 데이터 업데이트를 간편하게 처리할 수 있습니다.
서버 상태 vs 클라이언트 상태
서버 상태의 특징
- 원격 서버에 저장됨
- 비동기적으로 가져와야 함
- 여러 사용자가 공유함
- 시간이 지나면 오래된 데이터가 될 수 있음
클라이언트 상태의 특징
- 클라이언트(브라우저)에 저장됨
- 즉시 접근 가능
- 특정 사용자에게만 속함
- 항상 최신 상태
TanStack Query의 장점
- 자동 캐싱 및 데이터 재검증
- 중복 요청 제거
- 백그라운드 데이터 업데이트
- 페이지네이션 및 무한 스크롤 지원
- 에러 및 로딩 상태 관리
- 서버 상태와 UI 동기화
시작하기
설치
npm install @tanstack/react-query
# 또는
yarn add @tanstack/react-query
기본 설정
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// QueryClient 인스턴스 생성
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5분
cacheTime: 1000 * 60 * 30, // 30분
retry: 1, // 실패 시 1번 재시도
refetchOnWindowFocus: false, // 창 포커스 시 재요청 비활성화
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* 애플리케이션 컴포넌트 */}
<ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
</QueryClientProvider>
);
}
기본 사용법: 데이터 가져오기
useQuery 훅
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUserData(userId),
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>오류 발생: {error.message}</div>;
return (
<div>
<h1>{data.name}</h1>
<p>이메일: {data.email}</p>
</div>
);
}
// API 요청 함수
async function fetchUserData(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('사용자 데이터를 가져올 수 없습니다');
}
return response.json();
}
주요 반환 값 이해하기
const {
data, // 요청 결과 데이터
isLoading, // 첫 요청 로딩 중
isFetching, // 모든 요청(재요청 포함) 중
error, // 오류 정보
isError, // 오류 발생 여부
refetch, // 수동으로 다시 요청 트리거
status, // 'loading', 'error', 'success' 상태
...
} = useQuery({
queryKey: ['user', userId],
queryFn: fetchUserData,
});
queryKey 이해하기
queryKey는 TanStack Query가 내부적으로 쿼리를 식별하고 캐싱하는 데 사용됩니다.
// 기본 키
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// 매개변수가 있는 키
useQuery({ queryKey: ['todo', todoId], queryFn: () => fetchTodo(todoId) });
// 객체 매개변수가 있는 키
useQuery({
queryKey: ['todos', { status, page }],
queryFn: () => fetchTodos({ status, page })
});
데이터 변경하기
useMutation 훅
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateTodoForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const mutation = useMutation({
mutationFn: (newTodo) => createTodo(newTodo),
onSuccess: () => {
// 성공 시 'todos' 쿼리 무효화하여 다시 가져오기
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 입력 폼 초기화
setTitle('');
},
});
const handleSubmit = (e) => {
e.preventDefault();
mutation.mutate({ title, completed: false });
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={mutation.isPending}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '추가 중...' : '할 일 추가'}
</button>
{mutation.isError && <div>오류: {mutation.error.message}</div>}
</form>
);
}
// API 함수
async function createTodo(newTodo) {
const response = await fetch('https://api.example.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!response.ok) {
throw new Error('할 일을 추가할 수 없습니다');
}
return response.json();
}
낙관적 업데이트
서버 응답을 기다리지 않고 UI를 즉시 업데이트하는 기법입니다.
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: updateTodo,
// 요청 전에 캐시 업데이트
onMutate: async (newTodo) => {
// 진행 중인 요청 취소
await queryClient.cancelQueries({ queryKey: ['todo', newTodo.id] });
// 이전 값 저장
const previousTodo = queryClient.getQueryData(['todo', newTodo.id]);
// 낙관적으로 캐시 업데이트
queryClient.setQueryData(['todo', newTodo.id], newTodo);
// 롤백을 위한 컨텍스트 반환
return { previousTodo };
},
// 오류 발생 시 롤백
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todo', newTodo.id],
context.previousTodo
);
},
// 성공 또는 실패 후 관련 쿼리 다시 가져오기
onSettled: (newTodo) => {
queryClient.invalidateQueries({ queryKey: ['todo', newTodo.id] });
},
});
고급 기능
무한 스크롤/페이지네이션
import { useInfiniteQuery } from '@tanstack/react-query';
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
});
if (status === 'pending') return
데이터 의존성 처리
한 쿼리의 결과에 따라 다른 쿼리를 실행해야 할 때 사용합니다.
function UserPosts() {
// 먼저 현재 사용자 정보 가져오기
const { data: user, isLoading: userLoading } = useQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUser,
});
// 사용자 정보가 있을 때만 게시글 가져오기
const { data: posts, isLoading: postsLoading } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user.id),
// 사용자 ID가 있을 때만 실행
enabled: !!user?.id,
});
if (userLoading) return <div>사용자 정보 로딩 중...</div>;
if (!user) return <div>사용자를 찾을 수 없습니다</div>;
if (postsLoading) return <div>게시글 로딩 중...</div>;
return (
<div>
<h1>{user.name}의 게시글</h1>
<ul>
{posts?.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
쿼리 사전 로딩
예측 가능한 사용자 경로에 대해 데이터를 미리 가져올 수 있습니다.
function PostListWithPrefetch() {
const queryClient = useQueryClient();
const [posts, setPosts] = useState([]);
// 게시글 목록 가져오기
const { data } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
// 마우스 오버 시 상세 데이터 미리 가져오기
const prefetchPost = (postId) => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPostDetails(postId),
staleTime: 1000 * 60 * 5, // 5분
});
};
return (
<ul>
{data?.map(post => (
<li
key={post.id}
onMouseEnter={() => prefetchPost(post.id)}
>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
);
}
상태 관리와의 통합
Zustand와 함께 사용하기
import create from 'zustand';
import { useQuery, useQueryClient } from '@tanstack/react-query';
// Zustand 스토어
const useStore = create((set) => ({
filters: {
status: 'all',
priority: 'all',
},
setStatusFilter: (status) => set((state) => ({
filters: { ...state.filters, status }
})),
setPriorityFilter: (priority) => set((state) => ({
filters: { ...state.filters, priority }
})),
}));
function TodoList() {
const queryClient = useQueryClient();
const { filters, setStatusFilter, setPriorityFilter } = useStore();
// 필터와 함께 할 일 목록 가져오기
const { data, isLoading } = useQuery({
queryKey: ['todos', filters],
queryFn: () => fetchTodos(filters),
});
if (isLoading) return <div>로딩 중...</div>;
return (
<div>
<div className="filters">
<select
value={filters.status}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">모든 상태</option>
<option value="active">진행 중</option>
<option value="completed">완료됨</option>
</select>
<select
value={filters.priority}
onChange={(e) => setPriorityFilter(e.target.value)}
>
<option value="all">모든 우선순위</option>
<option value="high">높음</option>
<option value="medium">중간</option>
<option value="low">낮음</option>
</select>
</div>
<ul>
{data?.map(todo => (
<li key={todo.id}>
{todo.title} - 우선순위: {todo.priority}
</li>
))}
</ul>
</div>
);
}
실전 활용 패턴
커스텀 훅 만들기
반복적인 쿼리 로직을 커스텀 훅으로 추출하여 재사용성을 높일 수 있습니다.
// API 클라이언트 함수
const api = {
getTodos: async (filters) => {
const response = await fetch(`/api/todos?status=${filters.status}&priority=${filters.priority}`);
return response.json();
},
getTodoById: async (id) => {
const response = await fetch(`/api/todos/${id}`);
return response.json();
},
createTodo: async (todo) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
return response.json();
},
updateTodo: async (todo) => {
const response = await fetch(`/api/todos/${todo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
});
return response.json();
},
deleteTodo: async (id) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
return id;
},
};
// 커스텀 훅
export function useTodos(filters) {
return useQuery({
queryKey: ['todos', filters],
queryFn: () => api.getTodos(filters),
});
}
export function useTodoById(id) {
return useQuery({
queryKey: ['todo', id],
queryFn: () => api.getTodoById(id),
enabled: !!id,
});
}
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.createTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.updateTodo,
onSuccess: (updatedTodo) => {
// 개별 할 일 쿼리 업데이트
queryClient.setQueryData(['todo', updatedTodo.id], updatedTodo);
// 목록 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
export function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.deleteTodo,
onSuccess: (deletedId) => {
// 캐시에서 삭제된 항목 제거
queryClient.removeQueries({ queryKey: ['todo', deletedId] });
// 목록 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// 사용 예시
function TodoApp() {
const [filters, setFilters] = useState({ status: 'all', priority: 'all' });
const { data: todos, isLoading } = useTodos(filters);
const createTodo = useCreateTodo();
const updateTodo = useUpdateTodo();
const deleteTodo = useDeleteTodo();
// 컴포넌트 로직
// ...
}
캐시 상태 조작하기
TanStack Query는 다양한 캐시 조작 메서드를 제공합니다.
const queryClient = useQueryClient();
// 쿼리 데이터 직접 접근
const todos = queryClient.getQueryData(['todos']);
// 쿼리 데이터 직접 설정
queryClient.setQueryData(['todos'], newTodos);
// 기존 데이터 기반 업데이트
queryClient.setQueryData(['todos'], (oldTodos) => {
return [...oldTodos, newTodo];
});
// 모든 쿼리 무효화
queryClient.invalidateQueries();
// 특정 키로 시작하는 쿼리만 무효화
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['todo', id] });
// 쿼리 데이터 제거
queryClient.removeQueries({ queryKey: ['todos'] });
// 쿼리 재설정 (refetch)
queryClient.resetQueries({ queryKey: ['todos'] });
웹소켓/실시간 업데이트 통합
function ChatRoom({ roomId }) {
const queryClient = useQueryClient();
// 초기 메시지 로드
const { data: messages } = useQuery({
queryKey: ['messages', roomId],
queryFn: () => fetchMessages(roomId),
});
// 웹소켓 연결 및 실시간 업데이트
useEffect(() => {
const socket = new WebSocket('wss://api.example.com/chat');
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ type: 'join', roomId }));
});
socket.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
// 새 메시지가 도착하면 쿼리 캐시 업데이트
if (data.type === 'new_message') {
queryClient.setQueryData(['messages', roomId], (oldMessages) => {
return [...oldMessages, data.message];
});
}
});
return () => {
socket.close();
};
}, [roomId, queryClient]);
// 메시지 전송 처리
const sendMessage = useMutation({
mutationFn: (text) => sendChatMessage(roomId, text),
// 낙관적 업데이트 적용
onMutate: (text) => {
const tempMessage = {
id: 'temp-' + Date.now(),
text,
sender: 'me',
pending: true,
};
queryClient.setQueryData(['messages', roomId], (oldMessages) => {
return [...oldMessages, tempMessage];
});
return { tempMessage };
},
// 성공 시 임시 메시지를 실제 메시지로 교체
onSuccess: (response, text, context) => {
queryClient.setQueryData(['messages', roomId], (oldMessages) => {
return oldMessages.map(msg =>
msg.id === context.tempMessage.id ? response.message : msg
);
});
},
});
// 컴포넌트 UI 렌더링
// ...
}
성능 최적화
쿼리 캐시 설정 최적화
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 데이터가 오래된 것으로 간주되는 시간 (ms)
staleTime: 1000 * 60 * 5, // 5분
// 사용하지 않는 캐시 데이터 유지 시간 (ms)
gcTime: 1000 * 60 * 30, // 30분
// 데이터 변경사항 폴링 간격 (ms)
// refetchInterval: 1000 * 30, // 30초
// 브라우저 탭 포커스 시 재요청
refetchOnWindowFocus: true,
// 브라우저 온라인 상태가 되면 재요청
refetchOnReconnect: true,
// 에러 발생 시 재시도 횟수
retry: 3,
},
},
});
선택적 쿼리 데이터 (Data Selection)
쿼리 결과의 일부만 선택하여 불필요한 리렌더링을 방지합니다.
function UserNameDisplay({ userId }) {
// 전체 사용자 데이터 대신 이름만 선택
const { data: userName } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
select: (user) => user.name,
});
return <div>{userName}</div>;
}
// 다른 곳에서 같은 쿼리를 사용하되 다른 데이터 선택
function UserEmailDisplay({ userId }) {
// 동일한 쿼리를 사용하지만 이메일만 선택
const { data: userEmail } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
select: (user) => user.email,
});
return <div>{userEmail}</div>;
}
페이지네이션 성능 최적화
function PaginatedList() {
const [page, setPage] = useState(1);
const { data, isPending } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
// 이전 페이지 데이터 유지
keepPreviousData: true,
});
// 다음 페이지 미리 가져오기
const queryClient = useQueryClient();
useEffect(() => {
if (data?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['posts', page + 1],
queryFn: () => fetchPosts(page + 1),
});
}
}, [data, page, queryClient]);
return (
<div>
{isPending ? (
<div>로딩 중...</div>
) : (
<>
<ul>
{data.posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<div>
<button
onClick={() => setPage(old => Math.max(old - 1, 1))}
disabled={page === 1}
>
이전 페이지
</button>
<span>페이지 {page}</span>
<button
onClick={() => setPage(old => old + 1)}
disabled={!data.hasMore}
>
다음 페이지
</button>
</div>
</>
)}
</div>
);
}
요약 및 모범 사례
핵심 개념
- TanStack Query는 서버 상태 관리를 위한 강력한 도구입니다
- useQuery로 데이터를 가져오고, useMutation으로 데이터를 변경합니다
- 쿼리 키는 캐싱과 무효화의 기준이 됩니다
- 자동 재요청, 에러 처리, 낙관적 업데이트 기능을 제공합니다
모범 사례
- API 함수와 쿼리 로직을 분리하여 테스트와 유지보수성 향상
- 복잡한 쿼리 로직은 커스텀 훅으로 추출
- 적절한 staleTime과 gcTime 설정으로 성능 최적화
- 낙관적 업데이트로 사용자 경험 개선
- QueryDevtools를 개발 환경에서 활성화하여 디버깅 용이성 확보
주의사항
- 너무 많은 의존성이 있는 쿼리 키는 불필요한 재요청 유발
- 큰 데이터셋은 select 옵션으로 필터링하여 메모리 사용량 관리
- 반복적인 백그라운드 재요청은 서버 부하 고려
- 모든 변경에 낙관적 업데이트를 적용할 필요는 없음
다음 단계
- TanStack Query 공식 문서 참조
- 실제 프로젝트에 점진적으로 도입
- 더 복잡한 사용 사례 탐색 (쿼리 무효화 전략, 사용자 정의 캐시 조작 등)
'Frameworks > React' 카테고리의 다른 글
| Next.js 확장과 차이점 (1) | 2025.04.02 |
|---|---|
| React 성능 분석 실전 (0) | 2025.04.02 |
| 테스트 전략 (Jest, RTL, Cypress) (0) | 2025.04.02 |
| React + TypeScript 도입과 패턴 (0) | 2025.04.02 |
| React 애플리케이션 성능 최적화 (0) | 2025.04.01 |