웹 또는 앱의 기능을 구현하는데 서버를 통해서 데이터를 가져올 상황이 아주 많습니다. 특히, React를 이용하는 상황에서 데이터를 가져오기 위해 API를 호출하여 적절한 state에 집어넣게 되고 state가 변경된 것을 감지하여 원하는 데이터 및 컴포넌트를 도출할 수 있습니다. 대표적으로 아래와 같은 코드로 많이 구현한 경험이 있었습니다.
function App() {
const [todoData, setTodoData] = useState([]);
const fetchTodoData = useCallback(async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/todos");
const json = await response.json();
console.log(json);
setTodoData(json);
}, []);
useEffect(() => {
fetchTodoData();
}, [fetchTodoData]);
return (
<div>
{todoData.map(({ id, title, completed }) => (
<ul>
<li>{id}</li>
<li>{title}</li>
<li>{completed ? "YES" : "NO"}</li>
</ul>
))}
</div>
);
}
여기까지는 문제가 없었습니다. 아주 간단한 일이라고 생각했지만, 사용자 중점으로 다양한 요구사항을 구현해야 하는 상황이 왔을 때 코드가 점점 복잡해지고 사소한 영역에서 신경 써야하는 일이 많아졌습니다. 아래와 같은 상황이 대표적이였습니다.
Context API를 넘어 Redux, Recoil, Zustand와 같은 전역 상태 라이브러리를 활용하여 캐싱, 로딩중, 에러에 대한 구현을 시도했습니다. 그래도 여전히 코드의 복잡함은 남아있었고 전역 상태가 사용자의 데이터 및 서버 데이터를 혼용해서 저장한 결과 상태의 모호함이 느껴졌습니다. 혼자서는 이해하고 넘어갈 수 있었지만 다른 사람한테 코드를 쉽게 설명하기가 어려울 정도로 민망했으며 Data Fetching에 대해서 고민을 해보기로 했습니다. 이런 문제점을 한번에 해결할 수 있었던 것은 바로 React Query였습니다.
React Query는 데이터 Fetching, 캐싱, 동기화, 서버 쪽 데이터 업데이트 등을 쉽게 만들어 주는 React 라이브러리입니다. 기존에 Redux, Mobx, Recoil과 같은 다양하고 훌륭한 상태 관리 라이브러리들이 있긴 하지만, 클라이언트 쪽의 데이터들을 관리하기에 적합할 순 있어도 서버 쪽의 데이터들을 관리하기에는 적합하지 않은 점들이 있어서 등장하게 되었습니다.
“My구독의 React Query 전환기”, 카카오테크 블로그
Overview | TanStack Query Docs
fetching
: 데이터를 요청한 상태fresh
: 데이터가 만료되지 않는 상태stale
: 데이터가 만료된 상태(기본값 0)inactive
: 사용하지 않는 상태(기본값 5분)delete
: Garbage Collector에 의해 캐시에서 제거된 상태별도의 옵션을 적용하지 않는 상태로 한 쿼리를 마운트하면 데이터를 네트워크 통해서 가져와 fresh 상태로 바뀝니다. 이후, staleTime에 의해 쿼리는 stale 상태로 변경되어 컴포넌트 상태 변경, refocus 상태로 인해 데이터를 다시 가져옵니다. 다른 쿼리를 이용할 경우, 기존 쿼리는 inactive 상태로 변경되어 5분이 지나면 delete가 됩니다.
useQuery | TanStack Query Docs
👉 useQuery(queryKey, queryFn?, options?) ⇒ ({ data, status, isError, isLoading, … })// useTodoData.ts
interface ITodoData {
userId: number;
id: number;
title: string;
completed: boolean;
}
const useTodoData = () => {
const { data, isLoading, error } = useQuery<ITodoData[]>(
"todo",
async () => {
const response = await fetch(
"https://jsonplaceholder.typicode.com/todos"
);
const result = await response.json();
return result;
},
{ initialData: [] }
);
return { data, isLoading, error };
};
// App.tsx
function App() {
const { data, isLoading, error } = useTodoData();
if (isLoading) {
return <div>loading</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
{data?.map(({ id, title, completed }) => (
<ul key={`item_${id}`}>
<li>{id}</li>
<li>{title}</li>
<li>{completed ? "YES" : "NO"}</li>
</ul>
))}
</div>
);
}
useQuery
를 쓸 때, 상황별로 필요한 값이나 옵션들은 적절하게 선언해 Custom Hook으로 감싸줬습니다. useQuery
라는 hook 하나로 데이터 처리, 캐싱, 동기화가 바로 해결되니까 속이 시원했습니다. 이제, 데이터를 가져오는 것은 문제가 없지만 사용자의 상호작용에 의해 자연스럽게 데이터를 최신 상태로 보여줄 수 있는 방법은 useMutation
과 같은 함수를 사용하면 됩니다. 서버로 데이터를 Insert, Update, Delete가 필요할 경우에 사용합니다.
useMutation | TanStack Query Docs
👉 useMutation(mutationFn, options?) ⇒ ({ mutation, data, status, isError, isLoading, … })// useTodoMutation.ts
const postTodoData = async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
body: JSON.stringify({
title: "foo",
body: "bar",
userId: 1,
}),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
const result = await response.json();
return result;
};
const useTodoMutation = () => {
const { mutate, isLoading, error } = useMutation(postTodoData, {
onSuccess: () => {
console.log("onSuccess");
},
onError: () => {
console.log("onError");
},
onSettled: () => {
console.log("onSettled");
},
});
return { mutate, isLoading, error };
};
// App.tsx
function App() {
const { mutate } = useTodoMutation();
...
return (
...
<button onClick={mutate}></button>
...
)
}
useMutation
을 정의해주면 우리가 어떤 이벤트를 발생시킬 때 mutate를 사용하면 됩니다. onSuccess
, onError
, onSettled
과 같은 함수들은 굳이 선언할 필요없이 기본값으로 사용할 수 있으나 최신 데이터 상태를 위해서 별도의 로직을 구현할 필요가 있습니다. 그래서, useMutation을 사용할 때, React Query에서 제공해 다음과 같은 기능들을 참고할 필요있습니다.
만약, 쿼리의 상태가 fresh
이면 stale
로 변경할 시간 이전에 사용자가 새로운 데이터를 입력해도 동일한 데이터를 볼 수 밖에 없습니다. 새로운 데이터는 이미 서버에 저장된 상태인데 이전 데이터를 보여주면 사용자는 당연히 혼란이 올 수 있기에 invaildateQueries 함수를 통해 query key의 유효성을 제거할 수 있습니다.
const queryClient = useQueryClient();
const useTodoMutation = () => {
const { mutate, isLoading, error } = useMutation(postTodoData, {
onSuccess: () => {
queryClient.invaildateQueries('todo');
console.log("onSuccess");
},
...
});
return { mutate, isLoading, error };
};
유효성을 제거하면 캐싱되어 있는 데이터를 없애고 새로운 데이터를 가져올 수 있게 서버로 부터 요청합니다.
이것도 Mutation를 사용할 때, 함께 고려하면 좋을 것 같아서 읽어봤습니다. Mutation의 성공, 실패 여부를 확인하기 전 성공할 것이라는 낙관적인 가정을 가지고 미리 화면의 UI를 바꿔줍니다. 그리고, 결과에 따라 확정 또는 Rollback 처리가 됩니다.
const queryClient = useQueryClient();
const useTodoMutation = () => {
const { mutate, isLoading, error } = useMutation(postTodoData, {
...
onMutate: async (newTodo) => {
// 해당된 쿼리의 연산에 영향 가지 않도록 정지
await queryClient.cancelQueries("todo");
// Snapshot(이전 쿼리 값을 가져온다)
const previousTodo = queryClient.getQueryData("todo");
// 캐시 데이터를 우선 수정하여 UI를 변경한다.
queryClient.setQueryData("todo", (prevData) => ({ ...prevData, data: [...prevData.data, ...newTodo]}));
// 에러 발생 대비를 위한 스냅샷 데이터 반환
return { previousTodo };
},
// 에러에 대한 롤백 처리 구문
onError: (error, payload, context) => {
// context의 스냅샷 데이터로 캐시 데이터 원상 복구
queryClient.setQueryData(
"todo",
context.previousTodo,
);
},
// 정보 변경 완료 시(onSuccess도 포함) 쿼리 갱신
onSettled: () => {
queryClient.invalidateQueries("todo");
}
});
return { mutate, isLoading, error };
};
React Query를 사용할 때, 아무래도 Refetching 시점을 알아야 효율적으로 코드를 작성하거나 에러를 찾기가 쉬울 것 같습니다.
하지만, 쿼리의 옵션에 따라 위에 있는 상황임에도 불구하고 refetching을 방지할 수 있습니다. 결국, query key 및 옵션을 얼마나 적절하게 사용하느냐에 따라 사용자가 적절하게 최신 데이터를 볼 수 있고 효율적인 캐싱이 이루어지지 않을까 싶습니다.
일단, React Query를 알아보는 상황에서 좀 더 배워야 할 점들이 많기에 유용했던 점들을 다음에 이어서 작성하겠습니다.