시작하며
Redux Toolkit(RTK)과 RTK Query를 프로젝트에 적용하며 검색을 해보다 SWR과 React Query에 관한 글을 종종 찾을 수 있습니다. 하지만 아직까지는 둘에 대해서는 관련 글들도 많지 않아 원하는 내용을 찾기 쉽지 않은 것 같습니다.
Redux Toolkit은 이전 글에서 간단하게 설명했듯이 Redux의 복잡성을 해결하기 위해 만들어졌으며, RTK Query는 Redux Toolkit 패키지에 추가 기능(optional addon)이라고 할 수 있습니다. RTK Query는 데이터를 가져오기와 캐싱 문제 해결을 위해 개발되어 앱의 API 인터페이스 계층을 간결하게 정의할 수 있습니다.
Redux를 비롯하여 Redux Toolkit과 RTK Query의 공식 문서는 설명이 잘 되어 있는 편이지만 익숙해지기 전에는 영어로만 된 설명이 눈에 잘 들어오지는 않아 주요 사용 방법들을 가져와봤습니다.
(2023-05-02) DevTools 관련 내용이 추가되었습니다.
RTK Query (with React Query & SWR)
기본적인 사용 방법
RTK Query는 Redux Toolkit 패키지에 포함되어 있어 아래 두 가지 방법을 이용하여 사용합니다.
import { createApi } from '@reduxjs/toolkit/query'
// hook을 자동 생성하는 react 전용 진입점(endpoint)
import { createApi } from '@reduxjs/toolkit/query/react'
API Slice 생성
일반적으로 React와 사용하기 위해 createApi
를 가져오고 서버의 기본 URL과 상호 작용할 엔드포인트를 나열하는 ‘API slice’를 정의하여 시작합니다.
import { createApi, fetchBaseQuery } from 'reduxjs/toolkit/query/react'
export const userApi = createApi({
reducerPath: 'userApi',
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:3000/api/' }),
endpoints: (builder) => ({
getUserInfo: builder.query({
query: (id) => `/user/${id}`,
})
})
})
// 위에서 정의한 endpoints를 바탕으로
// 함수형 컴포넌트 내에서 사용하기 위한 hook을 자동으로 생성하여 내보냅니다.
export const { useGetUserInfoQuery } = userApi
// 이 때 hook의 이름은 아래와 같은 규칙에 따라 생성됩니다.
// By camelCase: `use` + {{ KEYS_OF_ENDPOINT_DEFINITION }} + {{ ENDPOINT_TYPE: `query` | `mutation` }}
RTK Query에는 query
와 mutation
두 가지의 엔드포인트 타입이 있습니다. query
는 모든 종류의 데이터 전송이 가능하지만 mutation
은 캐시 된 데이터를 무효화하고 강제로 다시 가져올 수 있기 때문에 서버에 데이터 업데이트를 보내고 변경 사항을 로컬 캐시에 적용할 때 mutation
을 사용하도록 하고 있으며 query
는 데이터를 검색하여 받아오는 요청에 일반적으로 권장됩니다.
각자 정의 옵션과 반환 값이 다르기 때문에 참고하여 사용해야 합니다.
Queries
// Query endpoint definition
export type QueryDefinition<
QueryArg,
BaseQuery extends BaseQueryFn,
TagTypes extends string,
ResultType,
ReducerPath extends string = string
> = {
query(arg: QueryArg): BaseQueryArg
/* either `query` or `queryFn` can be present, but not both simultaneously */
queryFn(
arg: QueryArg,
api: BaseQueryApi,
extraOptions: BaseQueryExtraOptions,
baseQuery: (arg: Parameters[0]) => ReturnType
): MaybePromise<QueryReturnValue<ResultType, BaseQueryError>>
/* transformResponse only available with `query`, not `queryFn` */
transformResponse?(
baseQueryReturnValue: BaseQueryResult,
meta: BaseQueryMeta,
arg: QueryArg
): ResultType | Promise
/* transformErrorResponse only available with `query`, not `queryFn` */
transformErrorResponse?(
baseQueryReturnValue: BaseQueryError,
meta: BaseQueryMeta,
arg: QueryArg
): unknown
extraOptions?: BaseQueryExtraOptions
providesTags?: ResultDescription<
TagTypes,
ResultType,
QueryArg,
BaseQueryError
>
keepUnusedDataFor?: number
onQueryStarted?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
queryFulfilled,
getCacheEntry,
updateCachedData, // available for query endpoints only
}: QueryLifecycleApi
): Promise
onCacheEntryAdded?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
cacheEntryRemoved,
cacheDataLoaded,
getCacheEntry,
updateCachedData, // available for query endpoints only
}: QueryCacheLifecycleApi
): Promise
}
// Use Query Hook
type UseQuery = (
arg: any | SkipToken,
options?: UseQueryOptions
) => UseQueryResult
type UseQueryOptions = {
pollingInterval?: number
refetchOnReconnect?: boolean
refetchOnFocus?: boolean
skip?: boolean
refetchOnMountOrArgChange?: boolean | number
selectFromResult?: (result: UseQueryStateDefaultResult) => any
}
type UseQueryResult = {
// Base query state
originalArgs?: unknown // Arguments passed to the query
data?: T // The latest returned result regardless of hook arg, if present
currentData?: T // The latest returned result for the current hook arg, if present
error?: unknown // Error result if present
requestId?: string // A string generated by RTK Query
endpointName?: string // The name of the given endpoint for the query
startedTimeStamp?: number // Timestamp for when the query was initiated
fulfilledTimeStamp?: number // Timestamp for when the query was completed
// Derived request status booleans
isUninitialized: boolean // Query has not started yet.
isLoading: boolean // Query is currently loading for the first time. No data yet.
isFetching: boolean // Query is currently fetching, but might have data from an earlier request.
isSuccess: boolean // Query has data from a successful load.
isError: boolean // Query is currently in an "error" state.
refetch: () => void // A function to force refetch the query
}
Mutations
// Mutation endpoint definition
export type MutationDefinition<
QueryArg,
BaseQuery extends BaseQueryFn,
TagTypes extends string,
ResultType,
ReducerPath extends string = string,
Context = Record<string, any>
> = {
query(arg: QueryArg): BaseQueryArg
/* either `query` or `queryFn` can be present, but not both simultaneously */
queryFn(
arg: QueryArg,
api: BaseQueryApi,
extraOptions: BaseQueryExtraOptions,
baseQuery: (arg: Parameters[0]) => ReturnType
): MaybePromise<QueryReturnValue<ResultType, BaseQueryError>>
/* transformResponse only available with `query`, not `queryFn` */
transformResponse?(
baseQueryReturnValue: BaseQueryResult,
meta: BaseQueryMeta,
arg: QueryArg
): ResultType | Promise
/* transformErrorResponse only available with `query`, not `queryFn` */
transformErrorResponse?(
baseQueryReturnValue: BaseQueryError,
meta: BaseQueryMeta,
arg: QueryArg
): unknown
extraOptions?: BaseQueryExtraOptions
invalidatesTags?: ResultDescription<TagTypes, ResultType, QueryArg>
onQueryStarted?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
queryFulfilled,
getCacheEntry,
}: MutationLifecycleApi
): Promise
onCacheEntryAdded?(
arg: QueryArg,
{
dispatch,
getState,
extra,
requestId,
cacheEntryRemoved,
cacheDataLoaded,
getCacheEntry,
}: MutationCacheLifecycleApi
): Promise
}
// Use Mutation Hook
type UseMutation = (
options?: UseMutationStateOptions
) => [UseMutationTrigger, UseMutationResult | SelectedUseMutationResult]
type UseMutationStateOptions = {
// A method to determine the contents of `UseMutationResult`
selectFromResult?: (result: UseMutationStateDefaultResult) => any
// A string used to enable shared results across hook instances which have the same key
fixedCacheKey?: string
}
type UseMutationTrigger = (arg: any) => Promise<
{ data: T } | { error: BaseQueryError | SerializedError }
> & {
requestId: string // A string generated by RTK Query
abort: () => void // A method to cancel the mutation promise
unwrap: () => Promise // A method to unwrap the mutation call and provide the raw response/error
reset: () => void // A method to manually unsubscribe from the mutation call and reset the result to the uninitialized state
}
type UseMutationResult = {
// Base query state
originalArgs?: unknown // Arguments passed to the latest mutation call. Not available if using the `fixedCacheKey` option
data?: T // Returned result if present
error?: unknown // Error result if present
endpointName?: string // The name of the given endpoint for the mutation
fulfilledTimestamp?: number // Timestamp for when the mutation was completed
// Derived request status booleans
isUninitialized: boolean // Mutation has not been fired yet
isLoading: boolean // Mutation has been fired and is awaiting a response
isSuccess: boolean // Mutation has data from a successful call
isError: boolean // Mutation is currently in an "error" state
startedTimeStamp?: number // Timestamp for when the latest mutation was initiated
reset: () => void // A method to manually unsubscribe from the mutation call and reset the result to the uninitialized state
}
스토어 설정
‘API slice’에는 자동 생성된 Redux 슬라이스 리듀서와 구독 수명을 관리하는 사용자 정의 미들웨어도 포함되어 있습니다. 둘 다 Redux 스토어에 추가해야 합니다.
import { configureStore } from 'reduxjs/toolkit'
// Or from '@reduxjs/toolkit/query/react'
import { setupListeners } from '@reduxjs/toolkit/query'
import { pokemonApi } from './services/pokemon'
export const store = configureStore({
reducer: {
[userApi.reducerPath]: userApi.reducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(userApi.middleware),
})
setupListeners(store.dispatch)
훅 사용
컴포넌트 파일에 자동 생성된 useGetUserInfoQuery 훅을 가져와 필요한 매개변수를 전달하며 사용합니다. RTK Query는 마운트 시에 자동으로 데이터를 가져오고, 매개변수가 변경될 때 데이터를 다시 가져옵니다. 결괏값에서 { data, isFetching }을 제공하고 해당 값이 변경되면 컴포넌트를 다시 렌더링 합니다.
import * React from 'react'
import { useGetUserInfoQuery } from './service/user'
export default function App() {
const { data, error, isLoading } = useGetUserInfoQuery('123');
if (isLoading) return "Loading...";
if (error) return "error: " + error.status + JSON.stringify(error.data);
return (
<UserInfo data={data} />
)
}
상태에 따라 API 요청하지 않기
기본적으로 쿼리 훅은 컴포넌트가 마운트 될 때 자동적으로 데이터를 가져오기 시작하지만, 훅 옵션의 skip을 사용하여 쿼리가 자동 실행되는 것을 막을 수 있습니다.
const Profile = ({ name, skip }) => {
const { data, error, status } useGetUserInfoQuery(name, { skip });
return (
<div>
{ name } - { status }
</div>
)
}
example
API 데이터 다시 가져오기(refetch)
데이터를 다시 가져오기 위해 쿼리 훅의 결과 값들 중 refetch
함수를 이용할 수 있습니다.
refetch
함수를 호출하면 관련 쿼리를 강제로 다시 가져옵니다. 또는 동일한 효과를 위해 forceRefetch: true
옵션을 thunk action creator에 전달하여 엔드포인트의 시작 thunk action을 dispatch 할 수도 있습니다.
import { useDispatch } from 'react-redux'
import { useGetPostsQuery } from './api'
const Component = () => {
const dispatch = useDispatch()
const { data, refetch } = useGetPostsQuery({ count: 5 })
function handleRefetchOne() {
// force re-fetches the data
refetch()
}
function handleRefetchTwo() {
// has the same effect as `refetch` for the associated query
dispatch(
api.endpoints.getPosts.initiate(
{ count: 5 },
{ subscribe: false, forceRefetch: true }
)
)
}
return (
<div>
<button onClick={handleRefetchOne}>Force re-fetch 1</button>
<button onClick={handleRefetchTwo}>Force re-fetch 2</button>
</div>
)
}
API 요청 없이 데이터 업데이트 하기
그렇다면 refetch 없이 업데이트하고 싶을 때는 어떻게 할까요?
RTK Query의 중요 기능 중 하나가 캐시 된 데이터를 관리한다는 것입니다. 데이터를 서버에서 가져온 후, RTK Query는 Redux 스토어에 ‘cache’로 저장합니다. 동일한 데이터에 대해 추가 요청을 하는 경우, RTK Query는 서버에 추가 요청을 하는 것이 아니라 가지고 있는 cached data를 제공할 것입니다.
- 해당 엔드포인트에서 캐시 된 데이터를 구독하며 컴포넌트가
useGetPostsQuery()
훅을 사용하여 렌더링 됩니다. /posts
요청이 시작되고 서버에서 ID 값이 1, 2, 3인 게시글 데이터를 전송합니다.getPosts
엔드포인트에서 전송받은 데이터를 캐시에 저장하고, 아래 태그가 제공되었음을 내부적으로 등록합니다:editPost
요청이 특정 게시글을 변경하기 위해 시작됩니다.- 완료되면 RTK Query는 ‘Post’태그가 이제 무효화되었음을 내부적으로 등록하고 이전에 제공된 ‘Post’ 태그를 캐시에서 제거합니다.
getPosts
엔드포인트가 유효하지 않은 ‘Post’ 타입의 캐시 데이터를 제공했고 컴포넌트가 여전히 데이터를 구독 중이므로,/posts
요청을 자동으로 시작하여 새 데이터를 가져오고 업데이트된 캐시 데이터에 대한 새 태그를 등록합니다.
위는 mutation의 invalidatesTags에 따른 시나리오기도 하지만 query에서 사용하는 providesTags도 같은 방법으로 유효하지 않은 데이터를 관리합니다.
관련된 전체 내용은 아래 RTK Query 공식 문서의 글들을 읽어보세요.
example
Options
설명 | RTK Query (v1.9.5) |
refetch 주기 | pollingInterval |
refetch 하지 않도록 설정 | skip / refetfchOnMountOrArgChange = false |
화면이 focus 됐을 때 refetch | refetchOnFocus |
네트워크가 reconnect 됐을 때 refetch | refetchOnReconnect |
기타 기능
DevTools
Redux Toolkit 사용 시 Redux DevTools 익스텐션을 사용할 수 있습니다. @reduxjs/toolkit
패키지에서 configureStore
를 사용하여 스토어를 생성할 때 devTools
항목을 설정하여 DevTools를 사용할지 말지를 결정할 수 있으며 기본값이 true
이기 때문에 기본적으로 Redux DevTools 익스텐션을 사용할 수 있습니다.
Streaming Updates
추가적으로 RTK Query의 onCacheEntryAdded
항목을 이용하여 1️⃣GraphQL subscription(서버에서 real-time update), 2️⃣(WebSockets을 이용한) real-time 채팅 앱, 3️⃣멀티플레이어 게임, 4️⃣여러 사용자 간 동시 문서 수정 기능과 같은 실시간 업데이트를 수행할 수도 있습니다. 쿼리 데이터에 대한 기본적인 갱신이 아닌 작고 빈번한 변경이나 외부 이벤트에 따른 업데이트를 위해 onCacheEntryAdded
를 사용할 수 있습니다.
사용 예시
다음은 API를 사용을 위한 엔드포인트 예시 코드들입니다.
전체 예제 코드는 공식 문서에서 제공하는 RTK Query Examples에서 확인하세요.
데이터 가져오기
데이터를 가져오는 일을 아마도 가장 많이 하게되는 일이라 생각합니다. 위에서 코드를 한 번 살펴 봤지만, 아래 코드를 다시 살펴보겠습니다.
먼저 tagTypes
에 getPosts에서 사용할 ‘Post’태그를 지정하고 있으며 설정에서 query 타입의 엔드포인트인 것을 볼 수 있습니다. getPosts 엔드포인트는 (BASE_URL/)posts
로 요청을 하여 결괏값에 따라 각자의 id와 함께 providesTags를 지정하고 있습니다. getPost 엔드포인트에서는 (BASE_URL)/posts/${id}
로 상세 내용을 요청하고 있는 것을 확인할 수 있습니다.
이렇게 생성한 훅을 이용하여 PostList
컴포넌트에서 getPosts 엔트포인트를 통해 리스트 데이터를 받아오고 있고, PostJsonDetail
컴포넌트에서 getPost 엔드포인트를 통해 상세 내용을 가져오고 있습니다.
// app/services/posts.tsx
...
tagTypes: ['Post'],
endpoints: (builder) => ({
getPosts: build.query({
query: () => ({ url: 'posts' }),
providesTags: (result) =>
result
? [
...result.map(({ id }) => ({ type: 'Post', id })),
{ type: 'Post', id: 'LIST' },
]
: [{ type: 'Post', id: 'LIST' }],
}),
getPost: build.query({
query: (id) => `posts/${id}`,
providesTags: (result, error, id) => [{ type: 'Post', id }],
}),
...
})
// features/posts/PostsManager.tsx > PostList
...
const { data: posts, isLoading } = useGetPostsQuery()
if (isLoading) {
return <div>Loading</div>
}
if (!posts) {
return <div>No posts :(</div>
}
return (
<div>
{posts.map((post) => (
<PostListItem
key={post.id}
data={post}
onSelect={(id) => navigate(`/posts/${id}`)}
/>
))}
</div>
)
// features/posts/PostDetail.tsx > PostJsonDetail
const PostJsonDetail = ({ id }) => {
const { data: post } = useGetPostQuery(id)
return (
<Box mt={5} bg="#eee">
<pre>{JSON.stringify(post, null, 2)}</pre>
</Box>
)
}
데이터 저장
서버에 데이터를 저장할 때는 mutation type을 사용하여 훅 사용 시 받아오는 값을 body에 저장하고 ‘POST’ 메서드를 지정하여 엔드포인트를 정의합니다.
생성된 훅을 이용하여 AddPost
컴포넌트에서 post 값을 전달하는 형태로 사용합니다.
// app/services/posts.tsx
...
addPost: build.mutation({
query: (body) => ({
url: `posts`,
method: 'POST',
body,
}),
invalidatesTags: [{ type: 'Posts', id: 'LIST' }],
}),
// features/posts/PostsManager.tsx > AddPost
...
const [addPost, { isLoading }] = useAddPostMutation()
const handleSubmit = async (e) => {
e.preventDefault()
await addPost(post)
setPost(initialValue)
}
...
파일을 업로드하는 경우에도 마찬가지로 POST 메서드를 지정한 후 FormData에 저장한 파일을 전송합니다.
// 파일 업로드
...
uploadFile: builder.mutation({
query: (payload) => ({
url: 'files',
method: 'POST',
body: payload
}),
})
// UploaderEx.js
...
const handleUpload = () => {
let formData = new FormData();
formData.set("file", file);
uploadFile(formData)
.then(...)
.catch(...)
}
로그인
RTK Query를 이용하여 사용자 인증을 할 수 있는 방법이 몇 가지 있겠지만 아래 예시 코드에서는 Redux Toolkit으로 생성한 credential store에 토큰을 저장하여 fetch base query 설정 시 prepareHeaders
옵션에서 사용할 수 있도록 하여 사용자 인증을 하고 있습니다. getState
를 사용하여 redux 스토어에 접근하여 setCredentials로 dispatch된 토큰 값을 이용합니다.
// app/services/auth.ts
export const api = createApi({
baseQuery: fetchBaseQuery({
baseUrl: '/',
prepareHeaders: (headers, { getState }) => {
// By default, if we have a token in the store,
// let's use that for authenticated requests
const token = (getState()).auth.token
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
},
}),
endpoints: (builder) => ({
login: builder.mutation({
query: (credentials) => ({
url: 'login',
method: 'POST',
body: credentials,
}),
}),
...
}),
})
export const { useLoginMutation, useProtectedMutation } = api
// features/auth/Login.tsx > Login
...
const [login, { isLoading }] = useLoginMutation();
const handleSubmit = async () => {
try {
const user = await login(formState).unwrap()
dispatch(setCredentials(user))
navigate('/')
} catch (err) {
toast({
status: 'error',
title: 'Login failed',
description: 'Oh no, there was an error!',
isClosable: true,
})
}
}
return (
<LoginForm>
...
<Button onClick={handleSubmit}>Login</Button>
</LoginForm>
)
마무리
초기에 프로젝트를 구축하던 단계에서는 Redux만을 이용하여 상태 관리를 했지만 기존의 관련 컴포넌트들을 React Portal과 Context를 이용하며 스토어에서 사라졌기 때문에 그 자리에 서버 상태 관리를 위해 Redux Toolkit과 RTK Query를 사용하게 되었습니다. 프로젝트 내에서 모든 data fetching을 RTK Query에서 다루고 있지도 않고 글을 작성하며 Redux Toolkit과 RTK Query에 대해 공부해야 할 것이 많다고 느꼈습니다. 일반적인 웹 앱에서 가장 많은 리소스를 사용하는 부분 둥 하나가 data fetching 일 것이기 때문에 더 깊은 공부가 필요한 것 같기도 합니다.
Reference
- RTK Query Docs
- RTK Query Overview: https://redux-toolkit.js.org/rtk-query/overview
- Comparison with Other Tools: https://redux-toolkit.js.org/rtk-query/comparison
- Cache Behavior : https://redux-toolkit.js.org/rtk-query/usage/cache-behavior
- Automated Re-fetching: https://redux-toolkit.js.org/rtk-query/usage/automated-refetching
- Manual Cache Updates: https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates
- SWR 자동 갱신: https://swr.vercel.app/ko/docs/revalidation#disable-automatic-revalidations
- React에서 서버 데이터를 최신으로 관리하기(React Query, SWR), esme: https://fe-developers.kakaoent.com/2022/220224-data-fetching-libs/
- Redux를 넘어 SWR 로, min9nim: https://min9nim.vercel.app/2020-10-03-swr-intro1/
'Web Programming > React' 카테고리의 다른 글
React의 시작 (0) | 2022.09.30 |
---|---|
React 3. Redux, Redux Toolkit - Global UI Component 만들기 (0) | 2022.07.21 |
React 2. Emotion를 이용한 React component 스타일링 (with. CSS-in-JS) (0) | 2022.07.12 |
React 1. Component 렌더링 (0) | 2022.07.04 |
React 란? (0) | 2021.12.28 |