포스트

[이제와서 시작하는 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로 무한 스크롤 구현하기

숙제

필수 과제

  1. 게시글 목록 앱
    • useQuery로 게시글 목록 가져오기
    • 로딩 스피너 표시
    • 에러 처리
  2. 댓글 추가 기능
    • useMutation으로 댓글 추가
    • onSuccess에서 목록 새로고침
    • 로딩 중 버튼 비활성화

선택 과제

  1. 사용자 검색 앱
    • 검색어를 queryKey에 포함
    • 검색어가 바뀔 때마다 자동으로 fetch
    • debounce 적용
  2. 북마크 앱
    • 북마크 목록 조회
    • 북마크 추가/삭제
    • 낙관적 업데이트 (Optimistic UI)

“서버 데이터는 React Query에 맡기세요! 🎯”

loading, error, caching을 자동으로 처리해줘서 코드가 훨씬 간단해집니다.


React 완벽 가이드 시리즈

  1. React란 무엇인가? 시작하기 전 알아야 할 모든 것
  2. useState와 useEffect - 상태와 생명주기 기초
  3. 컴포넌트 이해하기 - 레고 블록으로 만드는 웹
  4. Props - 컴포넌트끼리 대화하기
  5. useEffect - 생명주기 이해하기
  6. 조건부/리스트 렌더링 - 똑똑하게 보여주기
  7. Context API - Props 지옥 탈출하기
  8. Custom Hooks - 나만의 Hook 만들기
  9. React Router - 페이지 이동하기
  10. 폼 처리 - 사용자 입력 받기
  11. 성능 최적화 - 똑똑하게 렌더링하기
  12. 에러 처리와 로딩 화면 - 사용자 경험 챙기기
  13. 상태 관리 라이브러리 - 전역 상태 관리하기
  14. React Query - 서버 데이터 쉽게 관리하기 ← 현재 글
  15. React와 TypeScript - 타입 안전한 React 개발
  16. Next.js 입문 - SSR/SSG와 풀스택 React
  17. React 테스팅 전략 - Jest, React Testing Library, E2E
  18. 고급 패턴과 베스트 프랙티스 - HOC, Render Props, Compound Components
  19. 실전 프로젝트 - Todo 앱에서 쇼핑몰까지
  20. React 배포와 DevOps - 빌드 최적화와 CI/CD

관련 자료

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.