비동기 통신과 전역 상태 관리의 최신 흐름
우리는 지금까지 아래의 행동들을 배웠다.
- API 호출하기
- 전역 상태 관리하기
- API 호출하고 전역 상태에 Update 하기
🤷♀️ 그런데 API 호출하고 전역 상태를 update 해주는 게 어렵고 복잡하지 않는가?
redux-toolkit의 createAsyncThunk 예시
하나의 API에 대한 코드가 위의 코드이다.
이 때, 여러 API를 호출하고 전역 상태 관리를 한다면....?
프로젝트의 크기가 커질 수록 API 호출 후 전역 상태를 업데이트 해줘야 하는 상황마다
- 작성해야 하는 코드가 많다.
- store의 크기도 비대해진다.
😭 전역 상태를 이렇게 관리하는 게 맞을까...?
< 전역 상태로 관리하는 데이터들의 종류 >
- API 호출 후 응답 데이터 ➡ Server State
- UI 개발을 위한 데이터 ex) theme ➡ Client State
이런 고민을 해결하기 위해 나온 기술이 React Query 이다.
https://react-query.tanstack.com/overview
React Query
React Query는 종종 React의 데이터 fetching을 위한 라이브러리라고 오해되어 설명된다.
사실 React Query는 server state를 fetching, caching, synchronizing, updating 해준다.
React는 컴포넌트 안에서 데이터를 fetching 하거나 update 하는 의견을 제시하지 않았다.
그래서 개발자들은 결국 그들만의 방법으로 데이터를 fetching 하고 있었다. ex) Redux thunk, Redux saga
대부분 전통적인 state 관리 라이브러리들은 client state를 관리하기에 훌륭하지만, async 로직이나 server state를 관리하기에는 훌륭하지 않다. server state는 client state와 완전히 다르기 때문이다.
React-query 실습 프로젝트 소개
깃헙 링크: https://github.com/zerobase-school/2022-frontend-school-react/tree/master/12/my-store
React Query (1) Queries
Queries
query란 비동기 데이터 소스의 선언적인 dependency이고 unique key에 연결되어 있다.
또한 서버로부터 데이터를 fetch하기 위해 Promise를 기반으로 한 메서드 ex. GET and POST 와 함께 사용된다.
Query의 기본 핵심
useQuery() Hook을 사용할 때 최소한 두 가지 인자가 필요하다.
- query를 위한 unique key
- Promise를 리턴하는 함수
import { useQuery } from '@tanstack/react-query'
function App() {
const info = useQuery(['todos'], fetchTodoList)
}
우리가 제공한 unique key는 refetching, cahing, query를 app 전역에서 공유하기 위해 내부적으로 사용된다.
useQuery()에 의해 reurn된 query 결과값은 아래의 정보들을 포함한다.
const result = useQuery(['todos'], fetchTodoList)
result에는 중요한 state가 들어있다.
- isLoading 또는 status === 'loading' : 쿼리에 아직 데이터가 없다.
- isError 또는 status === 'error' : 쿼리에 오류가 발생했다.
- isSuccess 또는 status === 'success' : 쿼리가 성공했고 데이터를 사용할 수 있다.
- error : 쿼리가 에러 상태인 경우 속성 isError을 통해 오류가 발생한다.
- data : 쿼리가 success상태인 경우 data 속성을 통해 데이터를 사용할 수있다.
useQuery를 사용하기 전에,
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
// Create a client
const queryClient = new QueryClient()
function App() {
return (
// Provide the client to your App
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
query 객체를 생성하여 QueryClientProvider 컴포넌트에 넣어줘야 한다.
queryClient 객체를 전역에서 사용할 수 있도록 감싸주는 것이다.
App 컴포넌트가 감싸고 있는 모든 컴포넌트 안에서 react query가 제공하는 함수들을 사용할 수 있다.
React Query (2) Mutations Query Invalidation
https://tanstack.com/query/v4/docs/guides/mutations
앞서 배웠던 Query와 달리,
Mutation은 전형적으로 create/update/delete나 server side-effects를 수행할 때 사용된다.
이러한 목적으로 useMutation() Hook을 사용한다.
function App() {
const mutation = useMutation(newTodo => {
return axios.post('/todos', newTodo)
})
return (
<div>
{mutation.isLoading ? (
'Adding todo...'
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}
{mutation.isSuccess ? <div>Todo added!</div> : null}
<button
onClick={() => {
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
</>
)}
</div>
)
}
위의 코드는 '/todos' API에 newTodo를 보내주는 Promise 기반의 함수를 리턴한다.
mutaion.mutate() 함수에 들어있는 객체는 useMutation Hook 함수의 newTodo 자리에 들어가게 된다.
Mutation은 특별하지 않지만,
- onSuccess 옵션
- Query Client의 invalidateQueries() 메서드
- Query Client의 setQueryData() 메서드
를 함께 사용 한다면 매우 강력한 도구가 될 수 있다.
Query Invalidation
우리가 다시 데이터를 fetch하기 전에, queries가 상할 때까지 (특히 우리가 쿼리의 데이터가 만료되었다는 사실을 할 때 ) 기다리는 것은 항상 잘 동작하는 것은 아니다.
이런 목적을 위해, QueryClient는 우리가 쿼리를 상했다고 평가할 수 있고 refetch할 수 있는 invalidateQueries 메서드를 가진다.
// Invalidate every query in the cache
queryClient.invalidateQueries()
// Invalidate every query with a key that starts with `todos`
queryClient.invalidateQueries(['todos'])
useMutation Hook을 이용해 데이터를 create, update, delete 하고 성공했다면, 다음과 같이 코드를 짤 수 있다.
const mutation = useMutation(updateNickname, {
onSuccess: () => {
queryClient.invalidateQueries('@getUser');
},
});
'@getUser' 아이디를 갖고 있는 Query가 상했다는 것을 알려주고, 새로운 데이터를 다시 불러오는 것이다.
React Query (3) cache staleTime과 cacheTime
A와 B 컴포넌트가 있고 전역 상태 관리 Tool (ex. Redux) 을 사용하고 있다고 가정해보자.
이 때 A 컴포넌트와 B 컴포넌트는 멀리 떨어져있다.
A 컴포넌트에서 API를 호출하여 데이터를 전역 상태에 저장을 했다.
🤔 그럼 B 컴포넌트에서는 어떤 일을 해야 할까?
➡ 또 다시 B 컴포넌트에서 API 호출하여 네트워크 비용을 발생 시킬 필요 없이,
store에 내가 찾는 데이터가 있는지 검색 하고, 있으면 가져오고 없으면 다시 fetch 하면 된다.
하지만, react-query를 사용하면 Server를 위해 전역 상태로 저장하고 있는 것이 없다.
😢 이런 문제를 어떻게 해결 할까?
➡ cache(캐시)를 활용하자!!!
cache : 서버 상태를 cache 해두었다가, 나중에 다시 데이터를 사용할 수 있는 개념
https://tanstack.com/query/v4/docs/guides/important-defaults
Important Defaults
때때로 react-query가 갖고 있는 dafaults는, 이것을 잘 알지 못하는새로운 유저가 배우거나 디버깅할 때 어렵다고 느낄 수 있다.
React Query는 아래와 같이 캐시를 사용한다.
- useQuery나 useInfiniteQuery 같은 Query instances는 기본적으로 캐시된 데이터를 상한 상태라고 판단한다.
이러한 행동을 바꾸기 위해, staleTime 옵션을 수정할 수 있다.
const { data, isLoading } = useQuery('@getUser', getUser, {
staleTime: Infinity,
});
위와 같이 staleTime을 Infinity로 변경시켜 주면, cache 데이터를 fresh한 데이터라고 생각하여 refetch를 하지 않는다.
🤔 staleTime: Inifinity 속성을 default로 주는 방법은 없을까?
➡ <QueryClientProvider> 컴포넌트가 넘겨주는 QueryClient 객체를 만들면서 옵션을 줄 수 있다.
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
},
},
})
Import Defaults 중 cacheTime 이라는 옵션도 있다.
React Query가 한 캐싱이 되어있는지 아닌지를 기반으로 staleTime을 보고 데이터를 fetch 할지, 안할지를 결정한다.
react query의 입장에서는 쿼리를 호출한 결과를 언제까지 캐시하고 있어야 할까?
cacheTime
쿼리를 호출한 컴포넌트가 unmount 되고 나서 이 캐시를 지우는 것에 대한 옵션
default : 5분
staleTime vs cacheTime
- 캐싱이 되어있는지/없는지 여부를 판단하고 그것을 바탕으로 데이터를 refetch 할 것인지 결정 옵션
- 캐싱이 되어있는 데이터를 언제까지 유지할지에 대한 결정 옵션
* cacheTime의 default는 5분이기 때문에, staleTime이 infinity여도 5분이 지나면 refetch 한다.
Suspense
https://ko.reactjs.org/blog/2021/12/17/react-conf-2021-recap.html
최근 2022년 3월에 React 18 알파 버전이 나왔다.
React 18은 concurrent rednerer를 더했고, major breaking change 없이 Suspense를 업데이트 했다.
사실 Suspense는 React 16.6 버전에서도 포함이 되어있었다.
https://ko.reactjs.org/docs/concurrent-mode-suspense.html#what-is-suspense-exactly
Suspense
코드를 불러오는 동안 기다릴 수 있고, 기다리는 동안 로딩 상태(스피너와 같은 것)를 선언적으로 지정할 수 있는 컴포넌트
const ProfilePage = React.lazy(() => import('./ProfilePage')); // Lazy-loaded
// Show a spinner while the profile is loading
<Suspense fallback={<Spinner />}>
<ProfilePage />
</Suspense>
위의 코드는 프로필을 불러오는 동안 스피너를 표시한다.
데이터를 가져오기 위한 Suspense는 <Suspense>를 사용하여 선언적으로 데이터를 비롯한 무엇이든 기다릴 수 있도록 해주는 새로운 기능이다.
이 기능은 이미지, 스크립트, 그 밖의 비동기 작업을 기다리는 데에도 사용될 수 있다.
기존의 접근 방식 vs Suspense
Suspense를 아래의 단계로 소개하겠다.
- 렌더링 직후 불러오기 (ex. useEffect 내에서 fetch)
- 불러오기 이후 렌더링 (ex. Suspense 없이 Relay 사용)
- 볼러올 때 렌더링 (ex. Suspense와 함께 Relay 사용)
접근 방식 1: 렌더링 직후 불러오기 (Suspense 미사용)
React 앱에서 데이터를 불러오는 가장 흔한 방식은 Effect를 사용하는 것이다.
// In a function component:
useEffect(() => {
fetchSomething();
}, []);
// Or, in a class component:
componentDidMount() {
fetchSomething();
}
이러한 접근 방식을 렌더링 직후 불러오기라고 부른다.
왜냐하면 화면 상 컴포넌트가 mount 된 후에 데이터 불러오기를 시작하기 때문이다.
이는 waterfall이라고 부르는 문제로 이어진다.
아래의 <ProfilePage>와 <ProfileTimeline> 컴포넌트를 살펴보자.
function ProfilePage() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(u => setUser(u));
}, []);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<>
<h1>{user.name}</h1>
<ProfileTimeline />
</>
);
}
function ProfileTimeline() {
const [posts, setPosts] = useState(null);
useEffect(() => {
fetchPosts().then(p => setPosts(p));
}, []);
if (posts === null) {
return <h2>Loading posts...</h2>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
ProfilePage 컴포넌트는 user에 대한 name를 보여주고 있다.
ProfileTimeline 컴포넌트는 유저 정보가 있는 상태에서 fetchPosts를 한다.
💁🏻♀️ 여기서 waterfall 문제란?
ProfilePage 컴포넌트의 fetchUser() 작업이 완료 되어야 ProfileTimeLine 컴포넌트가 렌더링된다.
WHY? fetchUser() 작업이 완료되지 않으면 if문에 걸려 Loading 문구만 나타나게 된다.
위의 코드를 실행하고 콘솔 로그를 살펴보면, 아래와 같은 결과를 확인할 수 있다.
- 사용자 정보 불러오기 시작
- 기다리기...
- 사용자 정보 불러오기 완료
- 게시글 불러오기
- 기다리기...
- 게시글 불러오기 완료
이것이 바로 waterfall 문제이다.
워터폴은 렌더링 직후 데이터를 불러오는 코드에서 흔히 발생한다.
이를 고치는 것은 가능하지만, 앱이 거대해짐에 따라 많은 사람들은 이 문제를 방지할 수 잇는 해결책을 원할 것이다.
접근 방식 2: 불러오기 이후 렌더링 (Suspense 미사용)
라이브러리는 데이터를 불러오는 데에 있어 보다 중앙화된 방식을 제공하는 것으로 워터폴을 방지할 수 있다.
예를 들어 Relay의 경우, 컴포넌트가 필요로 하는 데이터에 대한 정보를 정적으로 분석할 수 있는 부분들로 옮겨서 이 문제를 해결한다. 이 부분들은 이후에 하나의 단일 쿼리로 통합된다.
function fetchProfileData() {
return Promise.all([
fetchUser(),
fetchPosts()
]).then(([user, posts]) => {
return {user, posts};
})
}
아래의 예시는 <ProfilePage>가 두 요청을 기다리는데, 두 요청은 동시에 시작된다.
// 최대한 일찍 불러오기를 발동시킨다.
const promise = fetchProfileData();
function ProfilePage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState(null);
useEffect(() => {
promise.then(data => {
setUser(data.user);
setPosts(data.posts);
});
}, []);
if (user === null) {
return <p>Loading profile...</p>;
}
return (
<>
<h1>{user.name}</h1>
<ProfileTimeline posts={posts} />
</>
);
}
// The child doesn't trigger fetching anymore
function ProfileTimeline({ posts }) {
if (posts === null) {
return <h2>Loading posts...</h2>;
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
이벤트가 발동하는 순서는 이제 아래와 같이 변경된다.
- 사용자 정보를 불러오기 시작
- 게시글 불러오기 시작
- 기다리기...
- 사용자 정보 불러오기 완료
- 게시글 불러오기 완료
기존에 존재했던 네트워크 워터폴 현상은 고쳤지만, 의도하지 않은 또 다른 문제를 만들었다.
Promise 안에 fetchUser, fetchPost 두가지의 API 가 있다.
이때 fetchUser는 1초가 걸리고, fetchPost는 3초가 걸린다고 가정해보자.
모든 API 작업이 끝나야 Promise 문법도 종료기 되기 때문에,
😢 fetchUser는 fetchPost의 작업이 완료될 때까지 기다려야 하는 문제가 발생하는 것이다.
fetchProfileData 내에서 Promise.all()을 사용하는 과정에서 모든 데이터가 반환되기를 기다려야 한다. 따라서 게시글들 모두 불러오기 전까지는 프로필 정보를 렌더링할 수 없다. 둘 다 기다려야 한다.
물론, 이 예시에서는 이를 고칠 수 있다. Promise.all() 호출을 없애고, 두 프라미스를 따로 기다리면 된다. 하지만, 이러한 접근 방식은 데이터와 컴포넌트 트리의 복잡도가 커짐에 따라 점점 더 어려워진다. 데이터 트리 내의 임의 부분이 사라지거나 오래될 수 있는 상황에서는 신뢰할 수 잇는 컴포넌트를 작성하기 어렵다. 따라서 새로운 화면을 위한 데이터를 모두 불러오고 그 다음에 렌더링하는 것이 종종 보다 현실적인 선택지이다.
접근 방식 3: 불러올 때 렌더링 (Suspense 사용)
직전의 접근 방식에서는 아래와 같이, setState를 호출하기 전에 데이터를 불러왔다.
- 불러오기 시작
- 불러오기 완료
- 렌더링 시작
Suspense를 사용하면, 불러오기를 먼저 시작하면서도 아래와 같이 마지막 두 단계의 순서를 바꿔줄 수 있다.
- 불러오기 시작
- 렌더링 시작
- 불러오기 완료
😎 Suspense를 사용하면, 렌더링을 시작하기 전에 응답이 오기를 기다리지 않아도 된다.
사실 네트워크 요청을 발동시키고서, 아래와 같이 상당히 바로 렌더링을 발동시킨다.
// 이것은 프라미스가 아니다. Suspense 통합에서 만들어낸 특별한 객체이다.
const resource = fetchProfileData();
function ProfilePage() {
return (
<Suspense fallback={<h1>Loading profile...</h1>}>
<ProfileDetails />
<Suspense fallback={<h1>Loading posts...</h1>}>
<ProfileTimeline />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
// 아직 로딩이 완료되지 않았더라도, 사용자 정보 읽기를 시도한다.
const user = resource.user.read();
return <h1>{user.name}</h1>;
}
function ProfileTimeline() {
// 아직 로딩이 완료되지 않았더라도, 게시글 읽기를 시도한다.
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.text}</li>
))}
</ul>
);
}
화면 상에 <ProfilePage>를 렌더링 할 때에 아래와 같은 일들이 벌어진다.
- 이미 fetchProfileData() 내에서 요청을 발동시켰다. 이 함수는 프라미스가 아니라 특별한 '자원'을 돌려준다. 보다 현실적인 예시에서는, Relay와 같은 데이터 라이브러리에서 제공하는 Suspense 통합을 제공할 것이다.
- React는 <ProfilePage>의 렌더링을 시도한다. 자식 컴포넌트로 <ProfileDetails>와 <ProfileTimeline>을 반환한다.
- React는 <ProfileDetails>의 렌더링을 시도한다. resource.user.read()를 호출한다. 아직 불러온 데이터가 아무것도 없으므로, 이 컴포넌트는 '정지한다'. React는 이 컴포넌트를 넘기고, 트리 상의 다른 컴포넌트의 렌더링을 시도한다.
- React는 <ProfileTimeline>의 렌더링을 시도한다. resource.posts.read()를 호출한다. 또 한 번, 아직 데이터가 없으므로, 이 컴포넌트 또한 '정지한다'. React는 이 컴포넌트도 넘기고, 트리 상의 다른 컴포넌트의 렌더링을 시도한다.
- 렌더링을 시도할 컴포넌트가 남아있지 않다. <ProfileDetails>가 정지된 상태이므로, React는 트리 상에서 <ProfileDetails>위에 존재하는 것 중 가장 가까운 <Suspense> Fallback을 찾습니다. 그것은 <h1>Lading profile...</h1>이다. 일단, 지금으로서는 할 일이 다 끝났다.
여기에서 resource 객체는 아직은 존재하지 않지만, 결국엔 로딩이 이루어질 데이터를 나타낸다.
read()를 호출할 경우, 데이터를 얻거나, 또는 컴포넌트가 '정지한다'.
데이터가 계속 흘러들어옴에 따라, React는 렌더링을 다시 시도하며, 그 대마다 React가 '더 깊은 곳까지' 처리할 수 있게 될 것이다.
resource.user를 불러오고 나면, <ProfileDetails> 컴포넌트는 성공적으로 렌더링이 이루어지고 <h1>Loading profile...</h1> Fallback은 더이상 필요가 없어진다. 결국 모든 데이터가 준비될 것이고, 화면 상에는 Fallback이 사라질 것이다.
컴포넌트에서 "로딩 여부를 확인하는" if(...) 검사가 제거된 것을 유의하기 바란다.
해당 컴포넌트의 로딩 여부는 Suspense를 사용하여 컴포넌트 외부에서 확인할 수 있기 때문에 컴포넌트 내부에서 if문으로 처리할 필요가 없어진다.
if문의 코드를 명령형 코드라고 하는데, React는 보통 선언적으로 코드를 짜는 것을 지향하기 때문에 Suspense를 사용하는 것이 더 선언적으로 코드를 작성할 수 있는 방법이다.
이렇게 하면 보일러플레이트 코드를 제거할 뿐만 아니라, 간단한 절차만으로 신속한 디자인 변화를 만들 수 있게 해준다.
예를 들어, 프로필 정보와 게시글이 항상 함께 "나타나도록" 해야 한다면, 그 둘 사이의 <Suspense> 경계를 제거해주면 된다. 또는 각 컴포넌트에게 고유한 <Suspense> 경계를 부여하여 각각을 독립시켜줄 수도 있다. Suspense는 로딩 상태의 기본 단위를 변경할 수 있고, 코드를 크게 변경하지 않고도 로딩 상태의 배치를 조정할 수 있도록 해준다.
React Query와 Suspense 함께 사용하기
https://react-query.tanstack.com/guides/suspense
React Query를 위한 Suspense는 실험적인 단계이다.
현업에서는 안정적으로 사용하고 있다.
Suspense 옵션 설정하는 방법 (Global)
// Configure for all queries
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
})
function Root() {
return (
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)
}
Suspense 옵션 설정하는 방법 (Query)
import { useQuery } from '@tanstack/react-query'
// Enable for an individual query
useQuery(queryKey, queryFn, { suspense: true })
QueryErrorResetBoundary 컴포넌트와 함께 사용하여, Error가 발생했을 때 어떤 컴포넌트를 반환할 지 지정할 수 있다.
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
const App: React.FC = () => (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
There was an error!
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
Parallel Queries
https://tanstack.com/query/v4/docs/guides/parallel-queries
React Query의 useQuery는 기본적으로 병렬적으로 실행된다.
function App () {
// The following queries will execute in parallel
const usersQuery = useQuery(['users'], fetchUsers)
const teamsQuery = useQuery(['teams'], fetchTeams)
const projectsQuery = useQuery(['projects'], fetchProjects)
...
}
But!!! 🔥 React Query를 suspense mode로 사용할 때, 병렬적으로 실행하지 않는다.
이러한 경우에는 useQueries hook을 사용해야 병렬적으로 실행이 가능하다.
useQueries
https://tanstack.com/query/v4/docs/reference/useQueries
useQueries는 병렬적으로 동작하게 해주는 Hook이다.
const results = useQueries({
queries: [
{ queryKey: ['post', 1], queryFn: fetchPost, staleTime: Infinity},
{ queryKey: ['post', 2], queryFn: fetchPost, staleTime: Infinity}
]
})
Suspense와 useQueries를 사용할 때 제대로 동작하지 않는 이슈가 있다.
https://github.com/TanStack/query/issues/1523
'React' 카테고리의 다른 글
[React] Redux vs Recoil vs React Query (0) | 2022.10.06 |
---|---|
[React] 상태 관리, 이제 Recoil 하세요 (FE CONF) (0) | 2022.10.05 |
[React] 리액트 렌더링이 두 번 발생하는 이유 (0) | 2022.07.28 |
[React] Why React (0) | 2022.07.28 |
[Medium] 341. Flatten Nested List Iterator (0) | 2022.07.27 |
Comment