Web Programming/React

RTK Query를 이용하여 데이터 최신으로 관리하기(& Redux Toolkit)

시작하며

Redux Toolkit(RTK)과 RTK Query를 프로젝트에 적용하며 검색을 해보다 SWR과 React 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에는 querymutation 두 가지의 엔드포인트 타입이 있습니다. 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

 

 

CodeSandbox

CodeSandbox is an online editor tailored for web applications.

codesandbox.io

https://codesandbox.io/embed/github/reduxjs/redux-toolkit/tree/master/examples/query/react/conditional-fetching 미리보기 이미지

 

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를 제공할 것입니다.

 

Redux 스토어에 저장된 요청과 결과. Redux Toolkit과 RTK Query도 redux dev tool을 사용할 수 있습니다.

 

예상 시나리오는 다음과 같습니다:

  1. 해당 엔드포인트에서 캐시 된 데이터를 구독하며 컴포넌트가 useGetPostsQuery() 훅을 사용하여 렌더링 됩니다.
  2. /posts 요청이 시작되고 서버에서 ID 값이 1, 2, 3인 게시글 데이터를 전송합니다.
  3. getPosts 엔드포인트에서 전송받은 데이터를 캐시에 저장하고, 아래 태그가 제공되었음을 내부적으로 등록합니다:
  4. editPost 요청이 특정 게시글을 변경하기 위해 시작됩니다.
  5. 완료되면 RTK Query는 ‘Post’태그가 이제 무효화되었음을 내부적으로 등록하고 이전에 제공된 ‘Post’ 태그를 캐시에서 제거합니다.
  6. getPosts 엔드포인트가 유효하지 않은 ‘Post’ 타입의 캐시 데이터를 제공했고 컴포넌트가 여전히 데이터를 구독 중이므로, /posts 요청을 자동으로 시작하여 새 데이터를 가져오고 업데이트된 캐시 데이터에 대한 새 태그를 등록합니다.

예시 Post 데이터의 Tags

위는 mutation의 invalidatesTags에 따른 시나리오기도 하지만 query에서 사용하는 providesTags도 같은 방법으로 유효하지 않은 데이터를 관리합니다.

 

관련된 전체 내용은 아래 RTK Query 공식 문서의 글들을 읽어보세요.

 

example

 

RTK Query cache subscription lifetime example - CodeSandbox

RTK Query cache subscription lifetime example by Shrugsy using @emotion/css, @emotion/react, @emotion/styled, @reduxjs/toolkit, @types/react-redux, react, react-dom, react-redux, react-scripts

codesandbox.io

https://codesandbox.io/s/rtk-query-cache-subscription-lifetime-example-77tn4?from-embed 미리보기 이미지

 

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

 

728x90