본 글은 카카오페이 프론트엔드 개발자들이React-Query를 선택한 이유을 요약한 글입니다.
자세한 내용은 React-Query 공식문서을 읽으시기 바랍니다.
React-Query 도입 이전
많은 프로젝트들은 React-Query를 도입하기 전 서버와의 API 통신과 비동기 데이터 관리에 주로 Redux를 사용했다. 서비스 특성과 개발자의 취향에 따라 redux-thunk, redux-saga 등 다양한 Asynchronous Middleware를 채택하여 사용하고 있었으며, 더 효율적인 업무를 위한 다양한 Custom Middleware를 작성하여 사용하기도 했다.
Redux, 불편한 점
위와 같은 환경에서 Redux를 사용하여 API 통신을 수행하고 비동기 데이터를 관리하며 얻은 다양한 장점이 존재했지만, 반대로 불편한 부분도 상당히 있었다. Redux는 “Global State Management Library” 이다. React Application을 개발함에 있어 일종의 De facto standard로써 여겨지고 있고 대부분의 React Application 개발 환경 설정 시 자연스럽게 Redux가 마치 React Stack의 일부인 것처럼 구성되곤 했다.
이러한 현실 아래서 웹 프론트엔드에서 빈번하게 수행되는 API 통신에 Redux를 사용하는 것은 일견 자연스러운 선택이였다.
비동기 데이터를 React Component의 State에 보관하게 될 경우 다수의 Component의 Lifecycle에 따라 비동기 데이터가 관리되므로 캐싱 등 최적화를 수행하기 어렵다. 그리고 다수의 Component에서 동일한 API를 호출하거나, 특정 API 응답이 다른 API에 영향을 미치는 경우 등 복잡하지만 빈번하게 요구되는 사용자 시나리오에 대응하기가 쉽지 않다.
하지만 Global State management Library인 Redux를 사용하여 비동기 데이터를 관리할 경우 Component의 Lifecycle과 관계없이 Global State에서 비동기 데이터가 관리되기 때문에 캐싱과 같은 최적화 작업을 쉽게 수행할 수 있고 복잡한 사용자 시나리오에 대한 대응도 용이해지기 때문이다.
카카오페이 프론트엔드 개발자들도 위와 같은 사유로 Redux로 API 통신과 비동기 데이터를 관리하고 있었으나 React Query를 접하고 난 뒤 다음과 같은 부분에서 (비동기 데이터 관리의 측면에서) React Query와 대비되는 Redux의 단점들을 느꼈다.
너무 장황한 Boilerplate 코드
이미 모두가 알다시피, Redux에는 Redux 기본 원칙이 존재한다. 이 기본 원칙을 충족하기 위해서 Redux를 사용하는데는 장황한 Boilerplate 코드가 요구된다. 이러한 이슈를 해결하기 위한 redux-toolkit의 등장 이후 Boilerplate 코드가 많이 줄어들었음에도 불구하고 Redux로 비동기 데이터를 관리하는 일에는 여전히 불필요하게 느껴지는 반복되는 Boilerplate 코드가 필요하다.
API 요청 수행을 위한 규격화 된 방식 부재
너무나 자명하게도, Redux는 API 통신 및 비동기 상태 관리를 위한 라이브러리가 아니다. Redux를 사용하여 비동기 데이터를 관리하기 위해서는 관련된 코드를 하나부터 열까지 개발자가 결정하고 구현해야 한다.
API 관련 상태 저장 방법이 가장 대표적인 예시입니다. 개발자의 선택에 따라 API 응답을 전부 State에 보관하고 Selector에서 필요한 값만 계산해서 사용할 수도 있고, 보관할 때부터 필요한 값만 State에 보관하는 경우도 있습니다. 더 나아가 API의 로딩 여부를 Boolean을 사용해서 관리하는 경우도 있고, IDLE | LOADING | SUCCESS | ERROR 등 상태를 세분화하여 관리하는 경우도 있다.
// 로딩 상태를 관리하는 방법도 개발자에 따라 다르게 구현됩니다.
interface ApiState {
data?: Data;
isLoading: boolean;
error?: Error;
}
interface ApiState {
data?: Data;
status: 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR';
error?: Error;
}
이는 Redux가 비동기 데이터를 관리하기 위한 전문 라이브러리가 아니라, 범용적으로 사용할 수 있는 전역 상태 관리 라이브러리여서 생겨나는 현상이다. Redux Middleware로 비동기 상태를 불러오고 그 값을 보관할 수는 있지만 내부적인 구현은 모두 개발자가 알아서 하다보니 상황에 따라 데이터를 관리하는 방식과 방법이 달라질 수 밖에 없다.
이러한 방식과 방법에 정답은 없지만, 팀의 구성원이 많아지고 협업 관계가 복잡하게 구성될수록 자연스러운 방향으로 통일된다면 더 효율적인 업무가 가능할 것이다. 더 나아가 팀 구성원들이 동일한 방법과 방식에 익숙해지고 숙련도가 높아진다면 새로운 Best Practice가 발굴되어 더 나은 제품을 만드는 기반이 될 수 있을 것이다.
이처럼 API 상태를 관리하기 위한 규격화된 방식이 있다면 더 좋은 제품을 보다 효율적으로 만들 수 있을 것이다. 다만 Redux를 사용하는 경우 구성원들의 환경과 경험이 다르고 프로젝트별 상황이 다르기 때문에 범용적인 방식을 발굴하기에 한계가 존재했다.
React Query 소개
위와 같은 Redux를 사용한 API 요청과 비동기 데이터 관리의 불편함을 해소하기 위해 카카오페이 프론트엔드 개발자들은 전향적으로 React Query를 도입하여 사용하고 있다.
React Query는 React Application에서 서버의 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트 하는 작업을 도와주는 라이브러리입니다. React Query는 우리에게 친숙한 Hook을 사용하여 React Component 내부에서 자연스럽게 서버(또는 비동기적인 요청이 필요한 Source)의 데이터를 사용할 수 있는 방법을 제안한다.
길고 거창한 설명 없이도 아래 샘플 코드를 한번 살펴보시면 React Query를 사용한 API 요청과 상태 관리가 얼마나 쉽고 자연스러운지 알 수 있다.
import axios from 'axios';
import {
QueryClient,
QueryClientProvider,
useMutation,
useQuery,
useQueryClient,
} from 'react-query';
// React Query는 내부적으로 queryClient를 사용하여
// 각종 상태를 저장하고, 부가 기능을 제공합니다.
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Menus />
</QueryClientProvider>
);
}
function Menus() {
const queryClient = useQueryClient();
// "/menu" API에 Get 요청을 보내 서버의 데이터를 가져옵니다.
const { data } = useQuery('getMenu', () =>
axios.get('/menu').then(({ data }) => data),
);
// "/menu" API에 Post 요청을 보내 서버에 데이터를 저장합니다.
const { mutate } = useMutation(
(suggest) => axios.post('/menu', { suggest }),
{
// Post 요청이 성공하면 위 useQuery의 데이터를 초기화합니다.
// 데이터가 초기화되면 useQuery는 서버의 데이터를 다시 불러옵니다.
onSuccess: () => queryClient.invalidateQueries('getMenu'),
},
);
return (
<div>
<h1> Tomorrow's Lunch Candidates! </h1>
<ul>
{data.map((item) => (
<li key={item.id}> {item.title} </li>
))}
</ul>
<button
onClick={() =>
mutate({
id: Date.now(),
title: 'Toowoomba Pasta',
})
}
>
Suggest Tomorrow's Menu
</button>
</div>
);
}
React Query는 API 요청을 Query그리고 Mutation 이라는 두 가지 유형으로 나누어 생각합니다.
React Query의 Query 요청
// 가장 기본적인 형태의 React Query useQuery Hook 사용 예시
const { data } = useQuery(
queryKey, // 이 Query 요청에 대한 응답 데이터를 캐시할 때 사용할 Unique Key (required)
fetchFn, // 이 Query 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
options, // useQuery에서 사용되는 Option 객체 (optional)
);
useQuery Hook으로 수행되는 Query 요청은 HTTP METHOD GET 요청과 같이 서버에 저장되어 있는 “상태”를 불러와 사용할 때 사용한다. React Query의 useQuery Hook은 요청마다 (API마다) 구분되는 **Unique Key (aka. Query Key)**를 필요로 한다. React Query는 이 Unique Key로 서버 상태 (aka. API Response)를 로컬에 캐시하고 관리한다.
function Users() {
const { isLoading, error, data } = useQuery(
'userInfo', // 'userInfo'를 Key로 사용하여 데이터 캐싱
// 다른 컴포넌트에서 'userInfo'를 QueryKey로 사용한 useQuery Hook이 있다면 캐시된 데이터를 우선 사용합니다.
() => axios.get('/users').then(({ data }) => data),
);
// FYI, `data === undefined`를 평가하여 로딩 상태를 처리하는것이 더 좋습니다.
// React Query는 내부적으로 stale-while-revalidate 캐싱 전략을 사용하고 있기 때문입니다.
if (isLoading) return <div> 로딩중... </div>;
if (error) return <div> 에러: {error.message} </div>;
return (
<div>
{' '}
{data?.map(({ id, name }) => (
<span key={id}> {name} </span>
))}{' '}
</div>
);
}
React Query의 Mutation 요청
// 가장 기본적인 형태의 React Query useMutation Hook 사용 예시
const { mutate } = useMutation(
mutationFn, // 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수 (required)
options, // useMutation에서 사용되는 Option 객체 (optional)
);
useMutation Hook으로 수행되는 Mutation 요청은 HTTP METHOD POST, PUT, DELETE 요청과 같이 서버에 Side Effect를 발생시켜 서버의 상태를 변경시킬 때 사용한다. useMutationHook의 첫번째 파라미터는 이 Mutation 요청을 수행하기 위한 Promise를 Return 하는 함수이며, useMutation의 return 값 중 mutate(또는 mutateAsync) 함수를 호출하여 서버에 Side Effect를 발생시킬 수 있다.
function NotificationSwitch({ value }) {
// mutate 함수를 호출하여 mutationFn 실행
const { mutate, isLoading } = useMutation(
(value) => axios.post(URL, { value }), // mutationFn
);
return (
<Switch
checked={value}
disabled={isLoading}
onChange={(checked) => {
// mutationFn의 파라미터 'value'로 checked 값 전달
mutate(checked);
}}
/>
);
}
React Query를 쓰고 이런 게 편해졌다.
Boilerplate 코드의 감소
앞에서 언급한대로, Redux를 사용할 경우 Redux의 기본 원칙 준수를 위한 다양한 Boilerplate 코드들이 필요하다. 더 나아가 (우리의 샘플 프로젝트에서는) API 상태 관리를 위해 하나의 API 요청을 3가지 Action을 사용해 처리하고 있고, 후에 기능이 추가되어 API 개수가 많아진다면 이런 상용구적인 코드도 함께 늘어나게 된다.
단순히 비교해봐도 Redux를 사용한 비동기 데이터 관리 코드와 React Query를 사용한 비동기 데이터 관리 코드의 분량이 크게 차이남을 알 수 있다. 코드의 분량이 적어졌다는 것은 개발자에게 불필요한 작업이 필요 없어짐을 뜻하기도 하지만, 소스코드의 복잡도를 낮추어 유지보수의 용이성을 높이고 작업 간에 발생할 수 있는 사이드 이펙트나 휴먼 에러를 사전에 더 잘 막을 수 있다는 의미도 갖게 될 것이다.
API 요철 수행을 위한 규격화된 방식 제공
앞에서 말씀드린 바와 같이 Redux는 비동기 데이터 관리를 위한 라이브러리가 아니다. Redux로 비동기 데이터를 관리하기 위해서 개발자들은 Middleware부터 State 구조까지 다양한 부분을 설계하고 구현해야 했다. 이러한 상황은 우리에게 하여금 불필요한 고민을 하게 만들고, 서로 간의 커뮤니케이션 비용을 증가시키는 요인으로 작동하기도 했다. 대부분의 케이스에 대응할 수 있는 편리하고 규격화된 방식을 제공한다면 이런 비효율적인 요소를 줄여 더 나은 제품을 만드는 방법에 집중할 수 있을 것이다.
React Query는 React에서 비동기 데이터를 관리하기 위한 라이브러리 입니다. React Query는 API 요청 및 상태 관리를 위해 (상당히 잘 만들어진!) 규격화된 방식을 제공한다.
interface ApiState {
data?: Data;
isLoading: boolean;
error?: Error;
}
interface ApiState {
data?: Data;
status: 'IDLE' | 'LOADING' | 'SUCCESS' | 'ERROR';
error?: Error;
}
React Query는 API 상태와 관련된 다양한 데이터를 제공하여 복잡한 구현과 설계 없이도 개발자가 효율적으로 화면을 구성할 수 있게끔 도와준다.
const {
data,
dataUpdatedAt,
error,
errorUpdatedAt,
failureCount,
isError,
isFetched,
isFetchedAfterMount,
isFetching,
isIdle,
isLoading,
isLoadingError,
isPlaceholderData,
isPreviousData,
isRefetchError,
isRefetching,
isStale,
isSuccess,
refetch,
remove,
status,
} = useQuery(queryKey, queryFn);
사용자 경험 향상을 위한 기능 제공
저희 카카오페이 프론트엔드 팀은 사용자 경험 향상을 위해 다양한 기법을 사용하고 있습니다. Redux로 비동기 데이터 관리를 할 때는 직접 구현해서 사용하곤 했는데, React Query는 자체적으로 제공하는 다양한 기능이 있어 이를 사용자 경험 향상에 손쉽게 사용할 수 있었습니다.
// Todo.tsx
function Todo() {
const dispatch = useDispatch();
// ...전략
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
dispatch(requestFetchTodos());
}
}
// window focus 이벤트 발생시 Todo API 요청
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [dispatch]);
return (
// ...후략
);
}
export default Todo;
앞에서 다룬 바와 같이 웹뷰 환경에서의 사용자 경험 향상을 위해 Window Focus 이벤트 발생 시 서버 상태를 동기화해야 하는 시나리오가 있다고 가정했을 때, Redux로 비동기 데이터 관리 시에서는 React Component 단에서 Window Focus 이벤트에 Dispatch Action을 직접 바인딩하여 구현해야 했다. 만약 소수의 컴포넌트에 이런 작업이 필요하다면 기꺼이 작업을 할 수 있겠지만, 여러 컴포넌트에서 여러 API에 걸쳐 이런 작업을 수행해야 한다면 유지보수 등 다양한 관점에서 부담스럽게 다가올 것이다.
// quires/useTodosQuery.ts
// API 상태를 불러오기 위한 React Query Custom Hook
// ...전략
const useTodosQuery = () => {
return useQuery(QUERY_KEY, fetcher, { refetchOnWindowFocus: true });
};
export default useTodosQuery;
React Query를 사용할 경우 단순한 옵션 부여만으로 Window Focus 이벤트 발생 시 서버 상태 동기화 시나리오를 달성할 수 있다. 다루는 API가 많아지고 컴포넌트 구조가 복잡해질수록 이전의 직접 Event Binding 하는 방식보다 유지보수하기 좋은 코드가 될 것이다.
React Query와 함께라면 이 아티클에서 다룬 Refetch on window focus 외에 API Caching, API Retry, Optimistic Update, Persist Caching 등 사용자 경험 향상을 위한 다양한 기법들을 손쉽게 프로젝트에 포함시킬 수 있다.
React Query에서 제공하는 이러한 기능들은 우리 개발자들로 하여금 제품과 직접적으로 연관되지 않는 작업에 투입해야 하는 리소스를 경감시켜 더 중요한 비즈니스 로직에 집중할 수 있게끔 도와준다. 이러한 환경은 우리가 더 견고한 제품을 만들 수 있는 바탕이 되어주고 있다.
TanStack Query v5
React Query의 버전업으로 인해 위 예제와 비교하였을 때 문법이 조금 달라졌다.
function Example() {
const { isPending, error, data } = useQuery({
queryKey: ['repoData'],
queryFn: () =>
fetch('https://api.github.com/repos/TanStack/query').then((res) =>
res.json(),
),
})
옵션이 객체 형태로 들어가야한다.