'서버 상태 관리'라는 멋진 키워드로 등장한 react-query는 리액트 생태계에 큰바람을 불러일으키고 있다. 많은 프로젝트에서 redux + redux-saga 조합을 사용한다. 이 조합은 UI와 비동기 작업의 관심사 분리에 있어 해법처럼 사용되곤 했다. 하지만 프로젝트 규모가 커짐에 따라 redux 본연의 가치가 redux-saga의 거대한 덩치에 가려지기 일쑤이다. 그리고 이 조합은 리액트의 학습 곡선을 가파르게 한 주범이기도 하다. 그래서 redux + redux-saga 조합을 새로운 패러다임인 react-query로 대체하려는 움직임이 많이 보인다. 마이그레이션 과정에서 클라이언트 상태 관리 방법에 대한 고민이 늘 따랐다. 이번 포스트는 해당 고민을 정리한 글이다.
서버 상태, 클라이언트 상태
react-query는 그동안 우리가 전역 상태라는 이름으로 뭉뚱그려 부르던 것을 클라이언트 상태와 서버 상태로 구분한다. 서버 상태는 서버와 클라이언트가 비동기적으로 공유하는 데이터를 말하고, 클라이언트 상태는 클라이언트에서만 발생하고 사용하는 상태를 말한다.
react-query는 자신을 복잡한 비동기 보일러플레이트 코드를 우리의 코드로부터 제거해준다고 소개한다. 그리고 자신만만하게 다음과 같이 얘기한다.
비동기 코드를 리액트 쿼리로 마이그레이션 한다면, 아마 남아있는 클라이언트 상태는 거의 없을 거야.
그런데 계속 클라이언트 상태 관리 라이브러리를 쓸 거야?
마이그레이션 한 후 남은 전역 상태는 '라이트/다크 모드'와 같은 UI와 관련된 상태일 것이고, 이마저도 그리 많지 않을 것이다. 또는 next.js를 사용하며 서버 사이드에서 user 상태를 불러와 사용하는 경우도 흔치 않게 있을 것이다. 도큐먼트 말미에는 "클라이언트 상태 관리 라이브러리를 걷어낼지는 당신에게 달려있어!"라는 최소한의 보험을 두긴 하지만, redux에 질려버린 탓인지 이 말이 유독 "리덕스를 꼭 쓸 필요는 없지 않아?"처럼 들린다.
가벼운 상태 관리 라이브러리
redux에 진절머리 난 사람들이 대안으로 선택하는 가벼운, 보일러플레이트가 거의 없다시피 한 세 가지가 있다. 바로 recoil, jotai, zustand이다. 이 세 가지를 분류하자면, recoil과 jotai는 상태를 atom으로 관리하고 zustand는 redux와 같은 flux 패턴을 사용한다. 그래서 zustand는 redux를 닮았고, jotai는 recoil을 닮았다. 그리고 recoil은 Meta(전 Facebook)에서 공식 개발하는 라이브러리이지만, 글 작성 기준 아직 자신을 실험 단계(0.7.0v)로 소개하고 있다. 반면 jotai는 빠른 템포의 커밋으로 편리한 기능이 많이 추가됐고, 앞으로도 그럴 예정이다. 프로덕트 수준에서는 상대적으로 신뢰성 있는 recoil을 사용하는 것이 선호되겠지만, 개인적으로는 사이드 혹은 토이 프로젝트에서 jotai 또는 zustand를 사용하는 편이다. 굳이 분류했지만, 세 가지 모두 redux에 비한다면 상당히 가볍고 편리하다.
여담으로, zustand와 jotai는 dai-shi라는 도쿄에 사는 멋진 개발자가 개발했다. 앞서 언급하지 않았지만, proxy 기반의 상태 관리 라이브러리인 valtio까지 함께 개발하고 있다. 그리고 친절하게도 _dai-shi_님이 zustand와 jotai 둘 중 무엇을 선택해야 할지 가이드해주고 있다.
- useState + useContext의 대체품을 찾는 다면 jotai를 써라.
- 리액트 바깥에서 상태를 업데이트하고 싶다면 zustand가 낫다.
- 코드 스플리팅이 중요하다면 jotai가 더 잘 작동할 것이다.
- 리덕스 개발자 도구를 선호한다면, zustand가 좋을 것이다.
- Suspense 사용을 원한다면, jotai를 사용해라.
위 사진은 recoil, jotai, zustand의 다운로드 수를 비교한 것인데 zustand의 가파른 성장세가 눈에 띈다. 작년만 해도 recoil과 비등비등했지만 22년 3월을 기준으로 두 배의 격차를 두고 고공 행진 중이다. 상태 관리 생태계의 새로운 삼국지가 시작됐고, 앞으로의 동향이 기대된다.
react-query로 클라이언트 상태 관리를?
앞서 언급한 세 가지 라이브러리 모두 매우 편리하지만, 외부 라이브러리라는 것은 변함없다. 전역으로 관리해야 하는 진짜 클라이언트 상태가 한두 개뿐인데 라이브러리에 의존하는 것이 석연찮은 것은 자연스럽다. 그래서 react-query를 마치 클라이언트 상태 관리 라이브러리처럼 사용하는 방법을 소개하고자 한다. 사용자의 이름(username
)을 전역 상태로 관리해야 하는 상황을 예로 들겠다.
파랑 컴포넌트에서 이름을 업데이트하면 이를 전역 상태에 반영하여 빨강 컴포넌트에 반영되도록 하겠다.
// src/App.js
import { QueryClient, QueryClientProvider } from 'react-query';
import Setting from './components/Setting';
import Profile from './components/Profile';
const queryClient = new QueryClient();
function App() {
return (
<QueryClientProvider client={queryClient}>
<Setting />
<Profile />
</QueryClientProvider>
);
}
export default App;
App
에서 QueryClientProvider
를 사용해서 queryClient
를 사용하도록 설정했다. Setting
은 파랑 컴포넌트, Profile
은 빨강 컴포넌트이다. 우선 Setting
컴포넌트를 살펴보겠다.
// src/components/Setting/index.jsx
import { useState } from 'react';
import { useQueryClient } from 'react-query';
function Setting() {
const [username, setUsername] = useState('');
const queryClient = useQueryClient();
const handleClick = () => queryClient.setQueryData('username', username);
return (
<div>
<input
value={username}
onChange={({ target: { value } }) => setUsername(value)}
/>
<button
onClick={handleClick}
>
update
</button>
</div>
);
}
export default Setting;
useQueryClient
의 결괏값으로 해당 앱에서 사용하는 queryClient
를 가져온다. 그리고 update 버튼을 클릭하면 setQueryData
를 통해 특정 쿼리 키에 해당하는 데이터를 직접 수정한다. 위 코드에서는 'username'
키에 해당하는 쿼리 데이터를 현재 컴포넌트의 username
으로 변경한다. 다음으로 Profile
컴포넌트를 살펴보겠다.
// src/components/Profile/index.jsx
import { useQuery } from 'react-query';
function Profile() {
const { data: username } = useQuery('username', {
initialData: '',
staleTime: Infinity,
});
return <h1>{username}</h1>;
}
export default Profile;
일반적인 useQuery
훅 사용과 매우 유사하다. 다른 점은 query function 인자를 생략했고, initialData
에 초기값을 입력하고 staleTime
을 Infinity
값으로 설정한 것이다. 클라이언트 상태는 서버 상태와 달리 상하지 않는다. 즉 클라이언트 상태는 클라이언트가 재설정할 때까지 최신 데이터이다. 그리고 initialData
데이터에 초깃값을 설정한 것은 동기적으로 상태를 저장하기 위함이다. 이 부분은 설명을 추가하기보단 아래의 두 코드를 비교해보면 어떻게 작동하는지 이해될 것이다.
// 1. sync
const { data: username } = useQuery('username', {
initialData: '',
staleTime: Infinity,
});
// 2. async
const { data: username } = useQuery('username', () => '', {
staleTime: Infinity,
});
지금까지가 react-query를 사용해서 클라이언트 상태 관리를 하는 방법을 간단하게 알아보았다. 하지만 실제로 사용하기에는 조잡한 면이 있다.
업그레이드
커스텀 훅을 사용해서 관심사를 분리하고 재사용성을 높여보겠다.
import { useQuery, useQueryClient } from 'react-query';
export const useSetClientState = (key) => {
const queryClient = useQueryClient();
return (state) => queryClient.setQueryData(key, state);
};
export const useClientValue = (key, initialData) =>
useQuery(key, {
initialData,
staleTime: Infinity,
}).data;
커스텀 훅의 이름은 recoil을 계승했다. 위와 같이 커스텀 훅을 만들고 나면, 컴포넌트는 아래와 같아진다.
// src/components/Profile/index.jsx
import { useClientValue } from '../index';
function Profile() {
const username = useClientValue('username', '');
return <h1>{username}</h1>;
}
export default Profile;
// src/components/Setting/index.jsx
import { useState } from 'react';
import { useSetClientState } from '../index';
function Setting() {
const [username, setUsername] = useState('');
const setClientState = useSetClientState('username');
const handleClick = () => setClientState(username);
return (
<div>
<input
value={username}
onChange={({ target: { value } }) => setUsername(value)}
/>
<button
type='button'
onClick={handleClick}
>
update
</button>
</div>
);
}
export default Setting;
이제 다른 클라이언트 상태 관리 라이브러리에 버금갈 정도의 모습이 됐다. 실제로 사용하기 위해 추가하면 좋을 두 가지 보완점을 제시하겠다.
- 현재의 상태 값과 같은 값으로 업데이트 시도 시 업데이트하지 않기.
- 서버 상태 쿼리와의 키 중복을 피하기 위해서 커스텀 훅 내부에서 처리하기.
마무리
이 방법은 공식 도큐먼트에서 나오는 것이 아닌, 그저 이런 방법도 있다 정도의 소개이다. 더 선호하는 방법으로는 Context API로 Provider 패턴을 사용하는 것이다. 이 포스트의 예제는 아래서 확인할 수 있다.
참고
https://leerob.io/blog/react-state-management
https://react-query.tanstack.com/guides/does-this-replace-client-state
https://react-query.tanstack.com/guides/initial-query-data
https://github.com/pmndrs/jotai
https://github.com/pmndrs/jotai/issues/13
'Web > React' 카테고리의 다른 글
react-query staleTime vs cacheTime (2) | 2022.09.18 |
---|---|
react-query props drilling 피하기 (1) | 2022.07.18 |