일단 기본 리액트 앱을 만들어준다.
터미널에서 다음과 같은 명령어를 실행하여 리액트 쿼리를 설치한다.
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import HomePage from "./HomePage";
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<HomePage />
</QueryClientProvider>
);
}
export default App;
React Context에서 데이터를 전역적으로 사용하기 위해서 Context Provider를 설정해준것처럼 리액트 쿼리를 사용하려면 쿼리 클라이언트를 제공하는 Context Provider를 설정해 줘야 한다. 그것이 QueryClientProvider
이다.
참고로 import를 할 때는 반드시 react-query가 아닌 @tanstack/react-query에서 하도록 해야한다. (최신 버전이기 때문에)
리액트 쿼리를 사용할 때 편리한 개발자 도구도 설치한다.
npm install @tanstack/react-query-devtools
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import HomePage from './HomePage';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<HomePage />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default App;
initialIsOpen
은 리액트 쿼리 개발자 도구가 열려있는 채로 실행할 것인가를 선택하는 옵션이다.
useQuery()
는 필요한 데이터를 백엔드에 요청해서 받아오는 React Hook이다.
getPosts()
라는 함수로 api에서 데이터를 받아오고 아래는 useQuery
를 import하고 실행한 다음 그 결과를 콘솔로 출력하는 코드이다.
import { useQuery } from '@tanstack/react-query';
import { getPosts } from './api';
function HomePage() {
const result = useQuery({ queryKey: ['posts'], queryFn: getPosts });
console.log(result);
return <div>홈페이지</div>;
}
export default HomePage;
리액트 쿼리에는 두 가지 status가 있다.
query status는 실제로 받아 온 data 값이 있는지 없는지를 나타내는 상태값이다.
etch status는 queryFn() 함수가 현재 실행되는 중인지 아닌지를 나타내는 상태값이다.
query status는 useQuery() 결괏값에서 status 값을 통해 확인할 수 있고, fetch status는 fetchStatus 값을 통해 확인할 수 있다.
query status는 query status는 'pending'
, 'success'
, 'error'
의 상태값 중 하나의 상태값을 가지게 된다.
'pending'은 아직 데이터를 받아오지 못했을 때를 의미하고, 'error'는 데이터를 받아오는 중에 에러가 발생했음을 뜻한다. 그리고 데이터를 성공적으로 받아오게 되면 'success' 값을 가지게 된다.
이 상태 값들은 각각 isPending, isError, isSuccess와 매칭이 되는데요. 이 값들을 이용해 현재 쿼리의 상태가 어떤지 확인할 수 있다.
fetch status는 'fetching', 'paused', 'idle'의 상태 값을 갖는다.
useQuery()
를 사용할 때 쿼리 함수라는 걸 queryFn
으로 등록해 줬는데, 이 쿼리 함수의 실행 상태를 말해주는 값이 바로 fetch status이다.
현재 쿼리 함수가 실행되는 중일 때에는 'fetching'
상태가 되고, 쿼리 함수가 시작은 했는데 실제로 실행되고 있지는 않다면 'paused'
상태가 된다.
대표적으로 네트워크가 오프라인이 된 경우 기본적으로 fetch status
가 'paused'
상태가 된다.
마지막으로 쿼리 함수가 어떤 작업도 하고 있지 않은 상황, 즉 'fetching'
상태도 아니고 'paused'
상태도 아니라면 'idle'
상태가 된다.
status의 흐름
이처럼 query status와 fetch status는 엄연히 독립적인 상태이기 때문에, 상황에 따라 query status와 fetch status가 다양한 조합의 형태로 나타날 수 있다.
내 컴퓨터에 있는 데이터를 가져오는 것에 비해서 백엔드에서 데이터를 가져오는 일은 시간이 많이 걸린다. 만약 유저가 방금 전에 확인한 데이터를 여러번 보는 경우에는 유저가 자주 보는 이 데이터를 어딘가에 저장해두었다가 백엔드에 요청할 필요없이 바로 보는게 좋다. 이럴 때 캐시라는 것을 사용한다.
캐시란 데이터를 미리 복사해놓는 임시 장소를 말하는데 저장 공간의 크기는 작지만 데이터를 가져오는 속도는 아주 빠르다는 특징이 있다.
웹브라우저는 기본적으로 캐시를 사용해서 속도를 높이고 네트워크 비용을 아낀다. 사이트에 접속했을 때 받아 온 데이터를 캐시 형태로 저장해서, 사용자가 같은 사이트에 접속하면 저장해놓은 데이터를 유저에게 보여준다.
리액트 쿼리 역시 이런 캐싱을 지원한다.
언제 데이터를 백엔드에서 다시 받아오는 refetch를 하는지 이해하려면 리액트 쿼리의 데이터 라이프 사이클을 알아야 한다.
리액트 쿼리 개발자 도구를 열어보면 현재 캐시에 저장되어 있는 데이터들을 확인할 수 있다.
쿼리 키로 받아 온 포스트 데이터가 캐시에 저장되어 있는 것을 볼 수 있다.
useQuery()
는 먼저 전달받은 쿼리 키로 캐시에 저장된 데이터가 있는지 확인한다. 만약 저장되어 있는 데이터가 없으면 쿼리 함수를 실행해 데이터를 백엔드로부터 받아오고 그런 다음에 쿼리 키로 데이터를 캐시에 저장하는 것이다.
(위에 있는 코드에서 ['posts'])
만약 useQuery()
가 실행이 되었는데 이미 ['posts']라는 쿼리 키로 저장된 데이터가 캐시에 있으면 데이터에 상태에 따라 다르게 동작한다.
리액트 쿼리는 백엔드에서 이제 막 데이터를 받아와 캐시에 저장된 데이터는
fresh
, 즉 신선한 상태로 판단한다. 그러다가stale time
이라고 불리는 특정 시간이 지나면 데이터는stale
, 즉 신선하지 않은 상태가 된다. 마지막으로 컴포넌트가 언마운트되면(DOM 트리에서 제거되면) 해당 데이터가 쓰이지 않는 상태가 되어서 데이터는inactive
상태가 된다.
이미 ['posts']라는 쿼리 키로 저장된 데이터가 있는 경우, useQuery는 캐시에 저장되어 있는 데이터를 리턴한다. 만약 데이터가 fresh 상태라면 캐시에 저장된 데이터를 리턴하고 끝이지만, 데이터가 stale 상태라면 백그라운드에서 refetch를 진행한다. 그리고 백엔드에서 새로 받아 온 데이터로 기존의 ['posts']로 저장되어 있는 데이터를 갱신한다.
데이터가 stale 상태라면 리액트 쿼리는 기본적으로 다음 네 가지 상황에서 refetch를 진행하게 되는데, 새로운 쿼리 인스턴스가 마운트 되거나, 브라우저 창에 다시 포커스가 가거나, 네트워크가 다시 연결되거나, 혹은 미리 설정해 둔 refetch interval 시간이 지났을 때 refetch를 하게 된다. 이를 변경하고 싶다면 각각 refetchOnMount
, refetchOnWindowFocus
, refetchOnReconnect
, refetchInterval
옵션을 변경하면 된다.
리액트 쿼리에서는 디폴트 값으로 stale time이 0으로 설정되어 있다.
그래서 사실상 모든 데이터는 백엔드에서 막 받아왔어도 바로 stale
상태가 되고, 매번 데이터가 필요할 때마나 refetch를 하게 된다. 매번 refetch를 할 필요가 없는 상황에서는 stale time을 적절히 변경해주면 좋다.
캐시는 한정된 공간이기 때문에 필요없는 데이터는 삭데해서 다른 데이터가 사용할 수 있는 공간을 마련해줘야 한다.
리액트 쿼리에서는 필요없는 데이터를 삭제하는 작업도 알아서 해준다. inactive
상태의 데이터는 가비지 컬렉션 타임이 지나면 캐시에서 삭제가 된다. 가비지 컬렉션 타임은 기본적으로 5분으로 설정되어 있는데 이 역시 값을 변경할 수 있다.
먼저 useQuery()가 실행되는 컴포넌트가 마운트되면 useQuery()를 통해 쿼리 함수가 실행되고 데이터를 받아온다.
받아 온 데이터는 useQuery()에서 지정해 줬던 쿼리 키를 이용해 캐싱, 즉 캐시에 저장이 되는데, 이렇게 캐시에 저장된 데이터는 fresh 상태에서 staleTime이 지나면 stale 상태로 변경된다.
유저가 데이터를 요청하게 되면 캐시된 데이터를 먼저 보여주게 되는데, 이때 데이터가 fresh 상태면 추가적인 refetch를 진행하지 않고, stale 상태면 백그라운드에서(자체적으로 알아서) refetch를 진행한다. (refetch가 끝나면 새로운 데이터로 유저에게 보여줌)
컴포넌트가 언마운트되어서 데이터가 inactive 상태가 되면 gcTime(가비지 컬렉션 타임) 동안 캐시에 저장되어 있다가 그 이후에 가비지 콜렉터에 의해 삭제가 되면서 마무리 된다.
stale 시간은 staleTime
옵션 값으로, 가비지 컬렉션 타임은 gcTime
옵션 값을 활용해서 변경할 수 있다.
function HomePage() {
const result = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
staleTime: 60 * 1000,
gcTime: 60 * 1000 * 10,
});
console.log(result);
return <div>홈페이지</div>;
}
만약에 특정 유저의 포스트 데이터만 따로 저장하고 싶다면 다음과 같이 계층적으로 쿼리 키를 지정하는 것도 가능하다.
function HomePage() {
const username = 'user1'; // 임의로 username을 지정
const { data: postsDataByUsername } = useQuery({
queryKey: ['posts', username],
queryFn: () => getPostsByUsername(username),
});
console.log(postsDataByUsername);
return <div>홈페이지</div>;
}
이렇게 하면 username에 대한 쿼리만 따로 캐싱이 된다.
이렇게 배열을 활용해 계층적인 쿼리 키를 설정하는 것이 가능하고, 또 상황에 따라 다양한 파라미터를 활용해 쿼리 키를 설정할 수도 있다.
포스트를 나만 볼 수 있는 private 상태로 지정할 수 있다고 할 때, 특정 유저의 포스트 중 private 상태의 포스트만 받아 와서 다음과 같은 쿼리 키로 저장할 수도 있다.
const { data: postsDataByUsername } = useQuery({
queryKey: ['posts', username, { status: private }],
queryFn: () => getPrivatePostsByUsername(username)
});
위 코드처럼 아규먼트를 전달해주어야 하는 상황에서는 이렇게 화살표 함수로 만들어주면 된다. 참고로 queryFn은 Promise를 리턴하는 형태라면 어떤 형태의 함수여도 상관이 없음
쿼리 키에서 객체의 특정 값을 가져와 활용할 수도 있다.
const username = 'codeit';
const { data: postsDataByUsername } = useQuery({
queryKey: ['posts', { username }],
queryFn: ({ queryKey }) => {
const [key, { username }] = queryKey;
return getPostsByUserId(username);
}
});
여기서 한 가지 주의할 점이 있는데, 객체를 쿼리 키로 전달하면 그 안에서는 순서에 상관없이 같은 값들을 가지고 있는 객체라면 같은 쿼리 키로 인식하지만, 배열을 쿼리 키로 전달하면 요소의 순서가 중요하다. 순서가 달라지면 다른 쿼리 키로 인식하기 때문에 배열의 요소로 쿼리 키를 지정할 경우 순서에 꼭 유의해야 한다
// 다음 세 가지는 모두 같은 쿼리로 인식한다
useQuery({ queryKey: ['posts', { username, userEmail }], ... });
useQuery({ queryKey: ['posts', { userEmail, username }], ... });
useQuery({ queryKey: ['posts', { userEmail, username, other: undefined }], ... });
// 다음 세 가지는 모두 다른 쿼리로 인식한다
useQuery({ queryKey: ['posts', username, userEmail], ... });
useQuery({ queryKey: ['posts', userEmail, username], ... });
useQuery({ queryKey: ['posts', undefined, userEmail, username], ... });
status 값을 활용해 상황에 맞는 로딩과 에러메시지를 보여주는 방법
데이터를 받아오는 과정이라 아직 데이터가 없는 상황에서는 query status가 pending 상태가 된다. useQuery의 리턴값들 중 ispending
이 true인 상황에서 로딩 중이라는 메시지를 보여주면 된다.
isPending
과 isError
값을 이용해서 로딩과 에러 처리를 구현할 수 있다.
리액트 쿼리에서는 에러가 발생하면 기본적으로 3번의 재시도를 하는데 테스트할 때는 retry 횟수를 0으로 조정하면 에러 화면을 더 빨리 볼 수 있어서 편하게 테스트할 수 있다./
function HomePage() {
const {
data: postsData,
isPending,
isError,
} = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
retry: 0,
});
if (isPending) return '로딩 중입니다...';
if (isError) return '에러가 발생했습니다.';
const posts = postsData?.results ?? [];
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.user.name}: {post.content}
</li>
))}
</ul>
</div>
);
}
뮤테이션은 사이드 이펙트를 가진 함수를 의미한다.
데이터베이스에 새로운 값을 추가하거나 수정, 삭제하는 행위도 사이드 이펙트에 해당한다. 이렇게 사이드 이펙트가 발생하는 경우에 useMutation()
이라는 훅을 사용한다.
useMutation()
과 useQuery()
의 차이점은 useQuery()
의 쿼리 함수는 컴포넌트가 마운트 되면서 자동으로 실행되지만 useMutation()
은 실제로 뮤테이션 하는 함수를 직접 실행해줘야 한다.
mutate()
함수를 통해 mutationFn
으로 등록했던 함수를 실행할 수 있고, 그래야만 백엔드 데이터를 실제로 수정하게 된다.
참고로 mutate()
를 하면 백엔드의 데이터는 변경이 되지만, 현재 캐시에 저장된 데이터는 refetch
를 하지 않는 이상 기존의 데이터가 그대로 저장되어 있다. 따라서 refetch
를 해줘야만 변경된 데이터를 화면에 제대로 반영할 수 있다.
import { useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { getPosts, uploadPost } from './api';
function HomePage() {
const [content, setContent] = useState('');
const {
data: postsData,
isPending,
isError,
} = useQuery({
queryKey: ['posts'],
queryFn: getPosts,
retry: 0,
});
const uploadPostMutation = useMutation({
mutationFn: (newPost) => uploadPost(newPost),
});
const handleInputChange = (e) => {
setContent(e.target.value);
}
const handleSubmit = (e) => {
e.preventDefault();
const newPost = { username: 'codeit', content };
uploadPostMutation.mutate(newPost);
setContent('');
};
if (isPending) return '로딩 중입니다...';
if (isError) return '에러가 발생했습니다.';
const posts = postsData?.results ?? [];
return (
<>
<div>
<form onSubmit={handleSubmit}>
<textarea
name="content"
value={content}
onChange={handleInputChange}
/>
<button disabled={!content} type="submit">
업로드
</button>
</form>
</div>
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.user.name}: {post.content}
</li>
))}
</ul>
</div>
</>
);
}
export default HomePage;
useMutation()을 작성하고, 업로드 버튼을 눌렀을 때 mutate() 함수를 실행하도록 했다.
하지만 포스트를 등록했을 때 새로 등록한 포스트가 화면에 보이지 않는다.
그 이유는 mutate()
를 한다고 캐시에 있는 데이터가 변경이 되는 것은 아니기 때문이다.
따라서 데이터를 refetch해야 새로운 데이터도 보여줄 수 있다.
업로드를 하고 난 이후에 refetch를 자동으로 하도록 하는 방법은 다음과 같다.
쿼리 클라이언트의 invalidateQueries()
함수를 사용하면 업로드가 끝난 이후에 자동으로 refetch를 하도록 설정할 수 있다.
invalidateQueries()
는 캐시에 있는 모든 쿼리 혹은 특정 쿼리들을 무효화하는 함수이다.
stale time이 지났는지 안 지났는지 상관없이 무조건 stale 상태로 만들고, 해당 데이터를 백그라운드에서 refetch하게 된다.
쿼리 클라이언트는 useQueryClient()
훅을 사용해서 가져올 수 있고, 원하는 시점에 queryClient.invalidateQueries()
함수를 실행하면 된다.
뮤테이션 객체에는 onMutate, onSuccess, onError, onSettled와 같은 주요 옵션들이 있어서 뮤테이션 사이클에 따라 적절한 동작을 추가할 수 있다.
아래 코드를 추가하면 포스트를 업로드하자마자 업로드된 포스트까지 화면에 잘 보이는 것을 확인할 수 있다.
(onSuccess, 즉 뮤테이션이 성공한 시점에 ['post'] 쿼리를 invalidate해 주는 함수를 콜백으로 등록한 코드)
const queryClient = useQueryClient();
// ...
const uploadPostMutation = useMutation({
mutationFn: (newPost) => uploadPost(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
이와 같은 onSuccess, onError, onSettled와 같은 옵션은 useMutation()에서도 사용할 수 있고 mutate() 함수에서도 사용할 수 있다. 이때 useMutation()에 등록한 콜백 함수들이 먼저 실행되고, 그다음에 mutate()에 등록한 콜백 함수들이 실행된다.
const uploadPostMutation = useMutation({
mutationFn: (newPost) => uploadPost(newPost),
onSuccess: () => {
console.log('onSuccess in useMutation');
},
onSettled: () => {
console.log('onSettled in useMutation');
},
});
...
uploadPostMutation.mutate(newPost, {
onSuccess: () => {
console.log('onSuccess in mutate');
},
onSettled: () => {
console.log('onSettled in mutate');
},
});
주의할 점
useMutation()에 등록된 콜백 함수들은 컴포넌트가 언마운트되더라도 실행이 되지만, mutate()의 콜백 함수들은 만약 뮤테이션이 끝나기 전에 해당 컴포넌트가 언마운트되면 실행되지 않는 특징을 가지고 있다.
따라서 query invalidation과 같이 뮤테이션 과정에서 꼭 필요한 로직은 useMutation()을 통해 등록하고, 그 외에 다른 페이지로 리다이렉트한다든가, 혹은 결과를 토스트로 띄워주는 것과 같이 해당 컴포넌트에 종속적인 로직은 mutate()를 통해 등록해 주면 된다.
...
const uploadPostMutation = useMutation({
mutationFn: (newPost) => uploadPost(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleUploadPost = (newPost) => {
uploadPostMutation.mutate(newPost, {
onSuccess: () => {
toast('포스트가 성공적으로 업로드 되었습니다!');
},
});
};
포스트가 업로드 되는 중에는 중복으로 업로드되면 안 되니까 버튼을 비활성화해보도록 하겠다.
uploadPostMutation.isPending
값을 이용하면 간단히 구현할 수 있다.
const uploadPostMutation = useMutation({
mutationFn: (newPost) => uploadPost(newPost),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
// ...
<button
disabled={uploadPostMutation.isPending || !content}
type='submit'
>
업로드
</button>