React Query란 무엇이고 왜 필요한가: 기본 개념부터 이해하기
React Query를 처음 보면 보통 이렇게 받아들이기 쉽습니다.
- API 호출을 편하게 해주는 라이브러리
- 로딩 상태와 에러 상태를 자동으로 관리해주는 도구
- 캐시가 되는 비동기 상태 라이브러리
이 설명들도 맞지만, 여기서 멈추면 실무에서 왜 React Query가 강력한지까지는 잘 안 보입니다. 핵심은 단순 fetch 유틸이 아니라, 서버 상태(server state)를 React 애플리케이션 안에서 어떻게 다룰 것인가에 대한 기준을 제공한다는 점입니다.
참고로 요즘 공식 명칭은 TanStack Query지만, 많은 팀과 문서에서는 여전히 React Query라는 이름이 익숙하게 쓰입니다. 이 글에서도 두 표현을 함께 쓰되, 맥락상 React Query라고 부르겠습니다.
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
React Query는 서버에서 온 데이터를 위한 상태 관리 도구입니다.- 이 데이터는 내가 완전히 소유하는 client state와 성격이 다릅니다.
- 그래서 단순
useState + useEffect로는 점점 관리 포인트가 많아집니다. React Query는 fetch 자체보다 캐시, 동기화, 재요청, 무효화, 백그라운드 갱신을 더 잘 다루게 해줍니다.
즉, 중요한 질문은 "API 호출을 어떻게 할까?"보다 **서버에서 온 데이터를 얼마나 일관되고 신뢰할 수 있게 화면에 유지할까?**에 더 가깝습니다.
왜 useEffect만으로는 점점 버거워질까?
가장 익숙한 방식은 보통 이렇게 시작합니다.
import { useEffect, useState } from 'react';
type User = {
id: number;
name: string;
};
export function UserList() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
async function fetchUsers() {
setIsLoading(true);
try {
const response = await fetch('/api/users');
const data = await response.json();
if (isMounted) {
setUsers(data);
}
} catch (err) {
if (isMounted) {
setError(err as Error);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
fetchUsers();
return () => {
isMounted = false;
};
}, []);
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러가 발생했습니다.</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}처음에는 충분히 괜찮아 보입니다. 하지만 실제 서비스로 가면 곧 질문이 늘어납니다.
- 같은 데이터를 다른 페이지에서도 쓰면?
- 화면을 다시 들어왔을 때 매번 새로 요청해야 할까?
- 탭을 다시 포커스했을 때 최신 데이터로 갱신해야 할까?
- 리스트를 수정한 뒤 어떤 화면을 다시 불러와야 할까?
- 상세 화면과 리스트 캐시는 어떻게 연결해야 할까?
즉, 문제는 fetch 한 번보다 데이터의 수명 주기와 동기화 정책입니다.
client state와 server state는 왜 다를까?
React Query를 이해하려면 먼저 이 구분이 중요합니다.
client state
내 애플리케이션이 완전히 소유하는 상태입니다.
- 모달 open 여부
- 드롭다운 선택 상태
- 검색 필터 input 값
- 탭 active 상태
이 상태는 보통 useState, useReducer, Zustand 같은 도구로 충분히 다룰 수 있습니다.
server state
서버에서 가져오고, 내가 완전히 소유하지 않는 상태입니다.
- 유저 목록
- 주문 상세
- 알림 개수
- 댓글 목록
이 상태는 아래 특성이 있습니다.
- 비동기다
- 오래되었을 수 있다
- 다른 사용자나 다른 요청에 의해 바뀔 수 있다
- 같은 데이터를 여러 화면이 공유할 수 있다
즉, server state는 "값 하나 저장"보다 언제 최신으로 볼 것인지, 언제 다시 가져올 것인지, 어떤 캐시를 믿을 것인지가 더 중요합니다.
React Query는 바로 이 문제를 다루는 데 특화되어 있습니다.
가장 기본 구조
보통은 앱 루트에 QueryClientProvider를 두고 시작합니다.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export function AppProviders({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}그리고 실제 데이터는 useQuery로 읽습니다.
import { useQuery } from '@tanstack/react-query';
async function getUsers() {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('유저 목록을 불러오지 못했습니다.');
}
return response.json();
}
export function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: getUsers,
});
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러가 발생했습니다.</div>;
return (
<ul>
{data.map((user: { id: number; name: string }) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}표면적으로는 useEffect 코드가 줄어든 것처럼 보이지만, 중요한 변화는 아래에 있습니다.
- 캐시가 생겼고
- 같은
queryKey를 쓰는 화면끼리 데이터를 공유할 수 있고 - 다시 포커스될 때 재검증할 수 있고
- 오래된 데이터를 언제 신선하다고 볼지 정책을 줄 수 있습니다
queryKey는 왜 중요할까?
queryKey는 단순 문자열이 아니라, 이 데이터가 무엇을 의미하는지 설명하는 식별자입니다.
예를 들어:
['users'][('users', userId)][('posts', { page: 1, sort: 'latest' })];이 키를 기준으로 React Query는:
- 캐시를 저장하고
- 같은 데이터를 재사용하고
- 어떤 데이터를 무효화할지 판단합니다
즉, queryKey를 잘 설계하는 것은 단순 문법 문제가 아니라 캐시 설계를 어떻게 할 것인가에 가깝습니다.
캐시가 있다는 것은 무슨 뜻일까?
React Query는 한 번 가져온 데이터를 메모리에 보관합니다. 그래서 같은 화면을 다시 방문했을 때 매번 빈 로딩 화면부터 시작하지 않을 수 있습니다.
예를 들어 유저 목록을 한 번 봤다면:
- 다시 해당 화면으로 이동했을 때
- 같은
queryKey를 쓰는 다른 컴포넌트가 마운트됐을 때
이미 있는 데이터를 즉시 보여줄 수 있습니다.
이 지점이 UX에서 꽤 큽니다. 사용자는 "이미 본 데이터"를 매번 처음부터 기다리지 않아도 됩니다.
staleTime은 왜 중요한가?
캐시가 있다고 해서 항상 최신이라는 뜻은 아닙니다. 그래서 React Query는 데이터의 신선도 개념을 가집니다.
useQuery({
queryKey: ['users'],
queryFn: getUsers,
staleTime: 1000 * 60,
});위 설정은 "이 데이터는 1분 동안은 신선하다고 본다"는 의미입니다.
이 값을 주면 1분 안에는:
- 같은 query가 다시 마운트되어도
- 무조건 재요청하지 않고
- 기존 캐시를 우선 신뢰할 수 있습니다
즉, staleTime은 단순 성능 옵션이 아니라 이 데이터가 얼마나 자주 변한다고 보는가에 대한 도메인 판단입니다.
예를 들어:
- 환율, 주식, 실시간 알림: 짧게
- 사용자 프로필, 카테고리 목록: 길게
같은 식으로 다뤄볼 수 있습니다.
그럼 gcTime은 무엇일까?
예전에는 cacheTime이라는 표현이 더 익숙했지만, 최근 버전에서는 gcTime이라는 이름을 더 자주 보게 됩니다. 이 값은 아무도 구독하지 않는 캐시를 얼마나 오래 메모리에 남겨둘 것인가에 가깝습니다.
즉:
staleTime: 이 데이터를 언제까지 신선하다고 볼까gcTime: 이 캐시를 언제 메모리에서 정리할까
둘은 비슷해 보이지만 다루는 문제가 다릅니다.
background refetch는 왜 좋을까?
React Query의 좋은 점 중 하나는 화면은 먼저 보여주고, 뒤에서 조용히 최신화를 시도할 수 있다는 점입니다.
즉 사용자는:
- 빈 화면을 덜 보게 되고
- 이전 데이터를 먼저 보고
- 필요하면 뒤에서 최신 데이터로 갱신된 결과를 받습니다
이 흐름은 사용감에서 꽤 중요합니다. 단순 "로딩을 줄였다"가 아니라, 데이터를 기다리는 방식 자체를 부드럽게 만든다고 볼 수 있습니다.
isLoading, isFetching, isPending은 어떻게 다를까?
처음엔 이름이 비슷해서 헷갈립니다. 크게 보면 이렇게 이해하면 편합니다.
isPending: 아직 이 query가 성공 데이터를 만들기 전인 초기 대기 상태isFetching: 첫 요청이든 백그라운드 재요청이든, 네트워크 요청이 실제로 진행 중인 상태isLoading: v5에서는 보통isPending && isFetching에 가까운, "첫 로딩 중" 의미로 이해하면 편한 상태
실무에서는 이 차이가 UI를 더 자연스럽게 만듭니다.
예를 들어:
isPending && !data면 처음 진입 스켈레톤isFetching && data면 이미 데이터는 보여주되 작은 spinner만 표시
같은 식으로 상태를 나눌 수 있습니다.
mutation은 query와 무엇이 다를까?
query는 읽기입니다. mutation은 쓰기입니다.
예를 들어:
- 게시글 목록 조회:
query - 게시글 생성/수정/삭제:
mutation
기본 예시는 이렇게 볼 수 있습니다.
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function createPost(input: { title: string }) {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error('게시글 생성에 실패했습니다.');
}
return response.json();
}
export function CreatePostButton() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
return <button onClick={() => mutation.mutate({ title: '새 글' })}>글 생성</button>;
}여기서 중요한 포인트는 POST 자체보다, 성공 후 어떤 캐시를 다시 신뢰하지 않을 것인가입니다. 그래서 invalidateQueries()가 핵심으로 자주 나옵니다.
invalidation은 왜 핵심 개념일까?
데이터를 수정했다면 기존 캐시는 오래되었을 수 있습니다. 이때 특정 query를 "더 이상 최신이라고 믿지 않는다"라고 표시하는 것이 invalidation입니다.
queryClient.invalidateQueries({ queryKey: ['posts'] });이 한 줄이 중요한 이유는 명확합니다.
- 쓰기와 읽기를 연결해주고
- 화면 간 데이터 불일치를 줄이고
- "어디를 다시 불러와야 하지?"를 일관된 규칙으로 만들 수 있기 때문입니다
실무에서 React Query를 잘 쓰는 팀은 결국 fetch 코드보다 query key와 invalidation 규칙을 더 신경 씁니다.
React Query가 주는 실제 장점
정리하면 아래 장점이 큽니다.
- 서버 상태를 위한 일관된 모델이 생긴다
- 로딩/에러/재시도/재요청을 매번 직접 짜지 않아도 된다
- 캐시 재사용으로 UX가 부드러워진다
- 같은 데이터를 여러 화면이 공유하기 쉬워진다
- mutation 이후 동기화 전략을 명시적으로 가져갈 수 있다
즉, 이 도구의 가치는 "코드가 짧아진다"보다 데이터 흐름을 예측 가능하게 만든다는 쪽에 더 가깝습니다.
정리하면
React Query를 한 줄로 줄이면, 서버 상태를 읽고 쓰고 동기화하는 과정을 더 일관되게 다루게 해주는 도구입니다.
이 글에서 기억할 핵심은 이렇습니다.
- client state와 server state는 문제 자체가 다르고
React Query는 server state 쪽을 위해 설계되어 있으며- 중요한 개념은 fetch보다
queryKey, cache,staleTime, invalidation이고 - 실무에서는 API 호출 코드보다 데이터 수명 주기와 갱신 정책이 더 중요합니다
다음 글에서는 이 개념 위에서 실제로:
- 리스트/상세 조회
- pagination과 infinite query
- mutation과 optimistic update
- prefetch와 query 조합
같은 활용 패턴을 코드 중심으로 이어서 보겠습니다.
