이번에는 RTK(Redux Toolkit) Query에 대해서 이해해보겠습니다. RTK Query 공식문서를 번역하면서 정리한 내용입니다.
RTK Query는 Redux Toolkit을 설치하고 나면, 내장되어 있어 따로 설치할 필요가 없습니다. 선택적으로 사용 가능합니다.
우리가 서버와 데이터를 주고 받는 과정에서, 클라이언트 사이드(프론트엔드)에서는 웹 어플리케이션에 데이터를 로드하는 경우를 API에 맞게 CRUD 작업과 캐싱 처리를 하는 로직을 직접 설계하는 경우들이 다반사입니다.
하지만, RTK Query
를 사용하게 되면, 공식문서에서 언급하길, 강력한 데이터 가져오기, 캐싱 도구로 우리가 직접 설계할 필요 없이 쉽게 데이터 처리 및 캐싱을 할 수 있도록 도와준다고 합니다.
일반적인 데이터 처리 로직은 화면에 데이터를 표시하기 위해서 서버에서 데이터를 가져오고, 클라이언트 사이드에서 사용자의 조작에 의해 데이터가 업데이트 되고, 업데이트한 데이터를 서버로 보내고, 클라이언트 사이드에 캐시된 데이터를 서버의 데이터와 동기화 상태로 유지해야 합니다. 이런 과정이 현대의 웹 어플리케이션 구현에서는 더욱 복잡해집니다.
예를 들어,
- UI 스피너를 표시하기 위한 로드 상태 추적
- 동일한 데이터에 대한 중복 요청 방지
- 사용자가 UI와 상호작용 시 캐시 수명 관리
- UI가 빠르게 느껴지도록 낙관적 업데이트
제가 생각하는 RTK의 강력한 장점은 동일한 데이터에 대한 중복 요청을 제어하는 코드를 우리가 직접 구현할 필요가 없으며, 별도의 패키지 설치 없이 ReduxToolkit을 설치하면 Add-on
형태로 사용가능하다는 점인 것 같습니다.
또, 현재 TypeScript를 도입한 상태에서, RTK Query 자체가 TypeScript로 구현되었기에 TS 경험에 도움을 줄 것이라고 생각합니다.
RTK 쿼리의 핵심 기능입니다. 내부적으로 다음과 같은 내용들을 정의할 수 있습니다.
- 데이터를 가져오고 변환하는 방법에 대한 정의
- 서버와 직접적으로 통신하게 되는 지점(서버의 기본 URL)인 endPoints(끝점)에 대한 정의
- reduxToolkit의
createSlice()
와 같이 unique한 이름값인reducerPath
를 정의
코드를 살펴보면 다음과 같습니다.
// src/api/pokemonApi.ts
import {createApi, fetchBaseQuery} from '@reduxjs/toolkit/query/react'
export interface PokemonType {
id: number;
name: string;
order: number;
species: {
name: string;
url: string;
};
sprites: {
front_default: string;
front_shiny: string;
};
stats: {
base_stat: number;
effort: number;
stat: {
name: string;
url: string;
};
}[];
weight: number;
}
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({baseUrl : 'https://pokeapi.co/api/v2/'}),
endPoints: (builder) => ({
getPokemonByName : builder.query<PokemonType, string>({
query: (name) => `pokemon/${name}`,
}),
},
})
export const { useGetPokemonByNameQuery } = pokemonApi;
위의 코드에서
fetchBaseQuery()
는 데이터 요청 헤더와 응답을axios
와 같은 라이브러리와 유사한 방식으로 자동으로 파싱을 해주는 가벼운fetch
wrapper로 별도로axios
와 같은 라이브러리를 설치하고 사용할 필요가 없습니다.
다만, fetch를 사용하는 경우에는 폴리필이 필요할 수 있다고 공식문서에서 언급하고 있습니다.
reducerPath
는 redux Toolkit에서 고유한 slice
이름을 설정해야 하는 createSlice
와 같이 사용하는 고유한 이름과 같습니다.
endPoints
의 경우 서버와 직접적으로 맞닿는 지점(서버의 기본 URL - API)으로 서버에 대대응해서 행동하려는 action의 모음이라고 생각하면 좋습니다. 빌더 구문을 사용하여 객체로 정의합니다. 기본 유형으로는 builder.query
그리고 builder.mutation
이 있습니다.
createSlice
에서 원하는 해당 action
이 dispatch
될 때, 실행될 reducer 함수를 reducers
에 정의해주는 것처럼, 해당 createApi
의 특정 행동(action)의 이름을 정의해주는 곳이라고 생각하면 될 것 같습니다.코드 제일 하단의 useGetPokemonByNameQuery
가 getPokemonByName
을 실행하는 hook 함수라고 생각하면 쉽습니다.builder.query
가 실행되어 baseUrl 뒤에 name
이라는 string을 덧붙여 주어, 예를 들어, https://pokeapi.co/api/v2/pokemon/bulbasaur
가 되면 해당 API(url)로 데이터를 요청에서 가져오는, 즉 서버와 통신이 이루어지는 접점이라고 생각하면 됩니다.
코드를 살펴봅시다.
// src/store.ts
import {configureStore} from '@reduxjs/toolkit';
import {setUpListeners} from '@reduxjs/toolkit/query';
import {pokemonApi} from './api/pokemonApi';
export const store = configureStore({
reducer :{
[pokemonApi.reducerPath] : pokemonApi.reducer;
},
middleware: (getDefaultMiddleware) => {
getDefaultMiddleware().concat(pokemonApi.middleware)
}
})
setupListeners(store.dispatch);
위의 코드에서, createSlice
를 이용해서 만든 slice로부터 해당 reducer들을 저장하는 configureStore
를 같이 사용하고 있는 것을 볼 수 있습니다.
[pokemonApi.reducerPath]
는 마치, createSlice
에서 지정한 유니크한 name
프로퍼티를 지정할 때와 유사하고, 해당 프로퍼티의 값으로 reducer을 할당해준 것과 같은 모습입니다.
middleware
프로퍼티의 경우 선택적으로 캐싱, 무효화등을 도와주는 기능을 활용할 수 있도록 합니다.
setUpListners(store.dispatch)
는 선택적으로 같은 특정 이벤트에 대한 데이터를 다시 가져올 수 있도록 하는 옵션이라고 생각하면 됩니다.
전역 상태 관리를 위해 Provider
컴포넌트로 전역 상태를 사용할 모든 컴포넌트들을 감싸서 사용했었던 것처럼, RTK Query를 사용하기 위해서도 현재 store 파일을 만들고, 감싸준 상태라면 그대로 사용하면 됩니다.
// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { store } from './store/store';
import { Provider } from 'react-redux';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
);
만약, 전역 상태 관리 대상인
store
를 생성하지 않은 상태라면,APIProvider
를 사용하면 됩니다. 다만, 기존의Provider
와 같이 사용할 수 없다는 점은 유의해야 합니다.
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { ApiProvider } from '@reduxjs/toolkit/query/react';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ApiProvider api={api}>
<App />
</ApiProvider>
</React.StrictMode>,
);
// App.tsx
import React, { useState } from 'react';
import Pokemon from './components/Pokemon';
const pokemon = ['bulbasaur', 'pikachu', 'ditto', 'bulbasaur'];
function App() {
const [changeInterval, setChangeInterval] = useState<number>(0);
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setChangeInterval(Number(e.target.value));
};
return (
<div>
<select onChange={handleSelectChange}>
<option value={0}>Off</option>
<option value={1000}>1s</option>
<option value={5000}>5s</option>
</select>
<div>
{pokemon.map((poke, index) => (
<Pokemon key={`${poke}_${index}`} name={poke} pollingInterval={changeInterval} />
))}
</div>
</div>
);
}
export default App;
// src/components/Pokemon.tsx
import { useGetPokemonByNameQuery } from '../api/pokemonApi';
interface PokemonProps {
name: string;
pollingInterval: number
}
const Pokemon = ({ name, pollingInterval }: PokemonProps) => {
const { data, error, isLoading } = useGetPokemonByNameQuery(name, { pollingInterval });
if (error) {
return <>Oh, there was an error</>;
}
if (isLoading) {
return <>Loading ...</>;
}
return (
<div>
{data ? (
<>
<h3>
{data.id} : {data.species.name}
</h3>
<img src={data.sprites.front_shiny} alt={data.species.name} style={{ border: '1px solid #e5e5e5' }} />
</>
) : null}
<div>base-stat: {data?.stats[0].base_stat}</div>
<div>weight: {data?.weight}</div>
</div>
);
};
export default Pokemon;
위의 코드에서 중점적으로 볼 사항은 const { data, error, isLoading } = useGetPokemonByNameQuery(name, { pollingInterval });
입니다.
useGetPokemonByNameQuery
은 endpoints
에서 builder.query
로 객체로 정의했던 pokemonByNameQuery
를 담은 endpoint hook으로 2개의 파라미터(queryArg, queryOptions)를 필요로 합니다.
첫 번째 파라미터는 builder.query로 객체로 정의햇던 pokemonByNameQuery
에서 기본 baseUrl에 덧붙여질 string 값으로 사용되던 인자(위의 코드에서는 name
)를 전달하는 곳입니다.
두 번재 파라미터는 옵션값으로, 위의 코드에서는 pollingInterval
이라는 값으로 활용되고 있습니다. 쿼리가 밀리초 단위로 지정된 간격으로 자동으로 다시 가져오도록 하는 옵션으로 기본값은 0이지만, 위의 select를 통해 조절하고 해볼 수 있습니다.
해당 옵션은 다음과 같이 더 있습니다.
- 건너뛰기 - 쿼리가 해당 렌더링에 대해 실행 중인 '건너뛰기'를 허용합니다. 기본값은 false
- pollingInterval - 쿼리가 밀리초 단위로 지정된 제공된 간격으로 자동으로 다시 가져올 수 있습니다. 기본값은
0
- selectFromResult - 후크의 반환된 값을 변경하여 반환된 하위 집합에 대해 렌더링 최적화된 결과의 하위 집합을 얻을 수 있습니다.
- refetchOnMountOrArgChange - 쿼리가 마운트 시 항상 다시 가져오도록 허용합니다( true제공된 경우). 동일한 캐시에 대한 마지막 쿼리(제공된 경우) 이후 충분한 시간(초)이 경과한 경우 쿼리를 강제로 다시 가져올 수 있습니다. 기본값은
false
- refetchOnFocus - 브라우저 창이 다시 포커스를 받으면 쿼리를 강제로 다시 가져올 수 있습니다. 기본값은
false
- refetchOnReconnect - 네트워크 연결을 다시 얻을 때 강제로 쿼리를 다시 가져올 수 있습니다. 기본값은
false
위의 해당 useGetPokemonByNameQuery
hook이 반환하는 값은 다음과 같습니다.
- data
- currentData
- error
- isLoading
- isFetching
- isSuccess
- isError
- refetch
위의 코드에서 사용한 반환값은 data
, error
, isLoading
이 있습니다. 주로 우리가 axios를 활용할 때, 데이터 처리에 대한 결과를 다음과 같이 3가지 정도로 분류합니다. 데이터를 받아오는 동안 loading 상태일 때 보여줄 요소, 그리고 화면에 렌더링할 data, 실패했을 때 보여줄 에러 메세지등으로 정의합니다.
따라서, 이 3가지를 활용하면 좋을 것 같습니다:)
이렇게 RTK Query를 사용하면 좋은 점은 무엇인지, 어떻게 활용할 수 있는지 기본적인 개념들을 공식문서를 바탕으로 정리하고, 간단하게 화면에 어떻게 보여지는 지 테스트 해보았습니다.
생각보다 쉽지 않은 주제라 어렵게 느껴지지만, 로그인/로그아웃/회원가입과 같은 기능을 현재 진행하고 있는 프로젝트에서 구현하고 있는 상황에서 RTK Query를 도입하기로 했는데 매우 도움이 될 것 같습니다.