[이제와서 시작하는 React 마스터하기 #14] React Query - 서버 데이터 쉽게 관리하기
[이제와서 시작하는 React 마스터하기 #14] React Query - 서버 데이터 쉽게 관리하기
100일 챌린지 Day 14 - 서버에서 데이터 가져오기가 복잡하죠? React Query로 로딩, 에러, 캐싱을 자동으로 처리해봐요! 🔄
배울 내용
이번 시간에는 서버 데이터를 쉽게 관리하는 방법을 배워봐요.
- useEffect로 데이터 fetch할 때의 문제점
- React Query 기본 사용법
- useQuery로 데이터 가져오기
- useMutation으로 데이터 수정하기
시작하기 전에
이번 내용을 더 잘 이해하려면 아래 내용을 먼저 보고 오세요:
일상 비유로 이해하기
React Query를 일상으로 비유하면:
📚 도서관에서 책 빌리기
useEffect로 fetch = 매번 직접 도서관 가기
- 책이 있는지 확인
- 대출 가능한지 확인
- 책 위치 찾기
- 반납일 기록하기
- 모든 걸 수동으로!
React Query = 도서관 앱 사용
- 앱이 자동으로 책 위치 찾아줘요
- 대출 가능 여부 알려줘요
- 반납일 알림 보내줘요
- 최근에 본 책 기억해줘요
1️⃣ 문제 상황: useEffect로 데이터 fetch
기존 방식의 문제점
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// 매번 이 모든 코드를 작성해야 해요
const fetchUsers = async () => {
try {
setLoading(true);
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('데이터를 가져올 수 없습니다');
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUsers();
}, []);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
문제점:
- 매번 loading, error 상태를 직접 관리해야 해요
- 캐싱이 없어서 같은 데이터를 여러 번 요청해요
- 에러 재시도를 직접 구현해야 해요
- 새로고침 로직을 직접 만들어야 해요
2️⃣ React Query 시작하기
설치하기
1
npm install @tanstack/react-query
기본 설정
1
2
3
4
5
6
7
8
9
10
11
// main.jsx or index.js
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// QueryClient 생성
const queryClient = new QueryClient();
root.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
이게 전부예요! 이제 앱 어디서든 React Query를 사용할 수 있어요.
3️⃣ useQuery - 데이터 가져오기
기본 사용법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useQuery } from '@tanstack/react-query';
function UserList() {
// ✨ 이게 전부예요!
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'], // 고유한 키
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('가져오기 실패');
return response.json();
}
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
장점:
- loading, error 상태를 자동으로 관리해요
- 데이터를 자동으로 캐싱해요
- 같은 데이터를 여러 곳에서 요청해도 한 번만 fetch해요!
실습: 게시글 목록
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import { useQuery } from '@tanstack/react-query';
function PostList() {
const { data: posts, isLoading, error, refetch } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=10');
return response.json();
}
});
if (isLoading) {
return (
<div style=>
<div style=></div>
<p>게시글을 불러오는 중...</p>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
if (error) {
return (
<div style=>
<h3>오류가 발생했습니다</h3>
<p>{error.message}</p>
<button onClick={() => refetch()}>다시 시도</button>
</div>
);
}
return (
<div style=>
<div style=>
<h2>게시글 목록</h2>
<button onClick={() => refetch()}>🔄 새로고침</button>
</div>
<div>
{posts.map(post => (
<article
key={post.id}
style=
>
<h3>{post.title}</h3>
<p style=>{post.body.substring(0, 100)}...</p>
</article>
))}
</div>
</div>
);
}
export default PostList;
매개변수가 있는 쿼리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function UserProfile({ userId }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId], // userId를 키에 포함
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('사용자를 찾을 수 없습니다');
return response.json();
}
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error.message}</div>;
return (
<div style=>
<img
src={user.avatar}
alt={user.name}
style=
/>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
중요: queryKey에 매개변수를 포함하면, 매개변수가 바뀔 때마다 새로 fetch해요!
4️⃣ useMutation - 데이터 수정하기
기본 사용법
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePostForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newPost) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost)
});
if (!response.ok) throw new Error('게시글 생성 실패');
return response.json();
},
onSuccess: () => {
// 성공하면 게시글 목록을 다시 가져와요
queryClient.invalidateQueries({ queryKey: ['posts'] });
alert('게시글이 생성되었습니다!');
},
onError: (error) => {
alert(`오류: ${error.message}`);
}
});
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
mutation.mutate({
title: formData.get('title'),
body: formData.get('body')
});
};
return (
<form onSubmit={handleSubmit} style=>
<h2>새 게시글 작성</h2>
<div style=>
<input
name="title"
placeholder="제목"
style=
required
/>
</div>
<div style=>
<textarea
name="body"
placeholder="내용"
rows={5}
style=
required
/>
</div>
<button
type="submit"
disabled={mutation.isPending}
style=
>
{mutation.isPending ? '생성 중...' : '게시글 생성'}
</button>
{mutation.isError && (
<p style=>
오류: {mutation.error.message}
</p>
)}
</form>
);
}
실습: Todo 앱 with React Query
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
function TodoApp() {
const [newTodo, setNewTodo] = useState('');
const queryClient = useQueryClient();
// 할 일 목록 가져오기
const { data: todos, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5');
return response.json();
}
});
// 할 일 추가
const addMutation = useMutation({
mutationFn: async (title) => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false })
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
setNewTodo('');
}
});
// 할 일 완료 토글
const toggleMutation = useMutation({
mutationFn: async ({ id, completed }) => {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !completed })
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
}
});
const handleAddTodo = (e) => {
e.preventDefault();
if (newTodo.trim()) {
addMutation.mutate(newTodo);
}
};
if (isLoading) return <div>로딩 중...</div>;
return (
<div style=>
<h1>Todo 앱 (with React Query)</h1>
<form onSubmit={handleAddTodo} style=>
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="할 일을 입력하세요"
style=
/>
<button
type="submit"
disabled={addMutation.isPending}
style=
>
{addMutation.isPending ? '추가 중...' : '추가'}
</button>
</form>
<div>
{todos?.map(todo => (
<div
key={todo.id}
style=
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate({ id: todo.id, completed: todo.completed })}
style=
/>
<span style=>
{todo.title}
</span>
</div>
))}
</div>
</div>
);
}
export default TodoApp;
5️⃣ React Query vs Zustand
언제 무엇을 사용할까요?
React Query = 서버 데이터 (API에서 가져오는 데이터)
- 사용자 정보
- 게시글 목록
- 상품 정보
- 댓글 목록
Zustand = 클라이언트 상태 (앱 내부 상태)
- 모달 열림/닫힘
- 테마 설정
- 언어 설정
- 로컬 장바구니 (서버 저장 전)
함께 사용하기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Zustand: 앱 설정 관리
const useAppStore = create((set) => ({
theme: 'light',
setTheme: (theme) => set({ theme })
}));
// React Query: 사용자 데이터 관리
function UserProfile() {
const theme = useAppStore((state) => state.theme);
const { data: user } = useQuery({
queryKey: ['user'],
queryFn: fetchUser
});
return (
<div className={theme}>
<h2>{user?.name}</h2>
</div>
);
}
완벽한 조합! 각각의 역할이 명확해요.
흔한 실수와 해결 방법
실수 1: queryKey를 잊어버리기
1
2
3
4
// ❌ queryKey가 없으면 캐싱이 안 돼요
const { data } = useQuery({
queryFn: fetchUsers
}); // 에러!
해결:
1
2
3
4
5
// ✅ 항상 queryKey 제공
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
실수 2: onSuccess에서 invalidate 안 하기
1
2
3
4
5
// ❌ 데이터가 생성되었는데 목록이 업데이트 안 됨
const mutation = useMutation({
mutationFn: createPost
// onSuccess 없음!
});
해결:
1
2
3
4
5
6
7
// ✅ onSuccess에서 queryClient.invalidateQueries 호출
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
});
실수 3: 너무 많은 refetch
1
2
3
4
5
// ❌ 컴포넌트가 렌더링될 때마다 fetch
useQuery({
queryKey: ['users', Math.random()], // 매번 새 키!
queryFn: fetchUsers
});
해결:
1
2
3
4
5
// ✅ 안정적인 queryKey 사용
useQuery({
queryKey: ['users'],
queryFn: fetchUsers
});
정리
오늘 배운 내용
- useEffect로 fetch할 때의 문제점을 이해했어요
- React Query를 설치하고 설정할 수 있어요
- useQuery로 데이터를 가져올 수 있어요
- useMutation으로 데이터를 수정할 수 있어요
- React Query와 Zustand의 차이를 알아요
핵심 정리
React Query 기본 패턴:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 1. QueryClient 설정 (한 번만)
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
// 2. 데이터 가져오기
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
// 3. 데이터 수정하기
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
}
});
다음 단계
- React Query DevTools 사용하기
- Optimistic Updates 배우기
- Infinite Queries로 무한 스크롤 구현하기
숙제
필수 과제
- 게시글 목록 앱
- useQuery로 게시글 목록 가져오기
- 로딩 스피너 표시
- 에러 처리
- 댓글 추가 기능
- useMutation으로 댓글 추가
- onSuccess에서 목록 새로고침
- 로딩 중 버튼 비활성화
선택 과제
- 사용자 검색 앱
- 검색어를 queryKey에 포함
- 검색어가 바뀔 때마다 자동으로 fetch
- debounce 적용
- 북마크 앱
- 북마크 목록 조회
- 북마크 추가/삭제
- 낙관적 업데이트 (Optimistic UI)
“서버 데이터는 React Query에 맡기세요! 🎯”
loading, error, caching을 자동으로 처리해줘서 코드가 훨씬 간단해집니다.
React 완벽 가이드 시리즈
- React란 무엇인가? 시작하기 전 알아야 할 모든 것
- useState와 useEffect - 상태와 생명주기 기초
- 컴포넌트 이해하기 - 레고 블록으로 만드는 웹
- Props - 컴포넌트끼리 대화하기
- useEffect - 생명주기 이해하기
- 조건부/리스트 렌더링 - 똑똑하게 보여주기
- Context API - Props 지옥 탈출하기
- Custom Hooks - 나만의 Hook 만들기
- React Router - 페이지 이동하기
- 폼 처리 - 사용자 입력 받기
- 성능 최적화 - 똑똑하게 렌더링하기
- 에러 처리와 로딩 화면 - 사용자 경험 챙기기
- 상태 관리 라이브러리 - 전역 상태 관리하기
- React Query - 서버 데이터 쉽게 관리하기 ← 현재 글
- React와 TypeScript - 타입 안전한 React 개발
- Next.js 입문 - SSR/SSG와 풀스택 React
- React 테스팅 전략 - Jest, React Testing Library, E2E
- 고급 패턴과 베스트 프랙티스 - HOC, Render Props, Compound Components
- 실전 프로젝트 - Todo 앱에서 쇼핑몰까지
- React 배포와 DevOps - 빌드 최적화와 CI/CD
관련 자료
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.
