코드스테이츠 부트 캠프의 메인 프로젝트에서 데이터 패칭을 위해서 사용한 RTK Query를 사용한 이유와 사용하면서 느낀점을 간단하게 정리하겠습니다.
저희 프로젝트에서는 외부 상태관리 툴로 RTK(Redux-Tool-Kit)을 사용했으며 Redux를 사용해 비동기적인 작업을 처리하기 위해 Redux-saga 미들웨어를 도입했습니다.
Redux-saga를 사용하게 되면 기능을 추가할 때 마다 필요한 Action, Saga들을 새롭게 정의해줘야 했기 때문에 보일러 플레이트가(코드 볼륨)이 증가하는 단점이 있었습니다.
저희 팀에서는 RTK Query와 React Query 도입을 검토했습니다. 특정 조건에서 비동기적으로 서버 데이터를 자동으로 패칭해 주는 기능은 동일 했으나, React Query라는 라이브러리를 추가적으로 사용하는 것 보다 RTK에 내장되어 있는 RTK Query를 사용하는 것이 번들링 사이즈를 줄이고 전체적인 프로젝트 구조를 잡는데 적합하다고 생각했습니다.
첫 번째로, Query와 Mutation을 통해서 캐싱된 서버 데이터를 간편하게 화면에 보여줄 수 있다는 것이 가장 큰 장점이었습니다. tag로 연결된 컴포넌트에서는 서버 데이터가 변경될 때문에 새로운 데이터를 자동으로 패칭하기 때문에 보다 간결한 코드를 작성할 수 있었습니다.
// postDetail.tsx
import { postsApi } from '../api/postApi';
// useGetPostQuery Hook을 사용해 데이터를 간단하게 패칭
const postDetailQuery = postsApi.useGetPostQuery({ postId });
const { data, isSuccess, isLoading, refetch } = postDetailQuery;
// Mutation Hook을 사용해 서버에 요청을 보내고 변경된 캐싱 데이터를 최신화
const [deletePost] = postsApi.useDeletePostMutation();
const [addThumbUp] = postsApi.useAddPostThumbUpMutation();
const [deleteThumbUp] = postsApi.useDeletePostThumbUpMutation();
const [addThumbDown] = postsApi.useAddPostThumbDownMutation();
const [deleteThumbDown] = postsApi.useDeletePostThumbDownMutation();
const [addBookmark] = postsApi.useAddBookmarkMutation();
const [removeBookmark] = postsApi.useRemoveBookmarkMutation();
const [deleteComment] = commentsApi.useDeleteCommentMutation();
const [deleteReply] = repliesApi.useDeleteReplyMutation();
const [sendReport] = reportApi.usePostReportMutation();
두 번째로, 하나의 fetchBaseQuery를 정의하고 추가적으로 tag와 Endpoint를 주입해 반복적인 코드를 줄일 수 있었습니다.
아래의 코드를 보면 apiSlice라는 파일에서 fetchBaseQuery에서 Access Token을 요청 헤더에 설정하고baseQueryWithReauth 라는 비동기 함수 내부에서 Access Token과 Refresh Token을 핸들링하는 하고 있는 것을 알 수 있습니다.
이렇게 특정 로직을 작성하고 createApi 메소드를 사용해 생성한 apiSlice에 Tag와 Endpoint를 주입해서 재사용이 가능합니다.
// apiSlice.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import Cookies from 'js-cookie';
// Default baseQuery: 모든 요청에 access token을 포함시켜 요청
const baseQuery = fetchBaseQuery({
baseUrl: process.env.REACT_APP_SERVER_ADDRESS,
credentials: 'include',
prepareHeaders: (headers) => {
const accessToken = Cookies.get('Authorization');
if (accessToken) {
headers.set('Authorization', accessToken);
}
return headers;
},
});
// 권한 관련 오류와 access token 이 만료되었을때 실행하는 refresh token flow
export const baseQueryWithReauth = async (
args: any,
api: any,
extraOptions: any,
) => {
let result = await baseQuery(args, api, extraOptions);
type ErrorResHeader = {
status: number;
message: string;
};
// 접근 권한이 없는 경우 (일반 유저가 관리자 페이지로 들어갔을때)
if (
result?.error?.status === 403 &&
(result?.error?.data as ErrorResHeader)?.message === 'User unauthorized'
) {
alert('접근할 수 없는 페이지입니다.');
}
// 기타 인증과정 오류 발생 (내가 쓴 글이 아닌데 삭제나 수정할때)
if (
result?.error?.status === 403 &&
(result?.error?.data as ErrorResHeader)?.message === 'Authorized Fail'
) {
alert('요청을 수행할 수 없습니다.');
}
// access token이 만료되었다는 status와 메세지를 받으면, 새로운 access token 발급을 위해 refresh token 보내기
if (
result?.error?.status === 401 &&
(result?.error?.data as ErrorResHeader)?.message === 'Access token expired'
) {
const name = localStorage.getItem('name');
const refreshResult = await baseQuery(
{
url: `/auth/refresh/${name}`,
method: 'POST',
headers: {
Refresh: Cookies.get('Refresh'),
},
},
api,
extraOptions,
);
// response headers로 온 새로운 access token에 접근하기
const headers = refreshResult?.meta?.response?.headers;
const accessToken = headers?.get('Authorization');
// accessToken이 있다면 쿠키에 저장하고 다시 api 요청하기
if (accessToken) {
Cookies.set('Authorization', accessToken!);
result = await baseQuery(args, api, extraOptions);
} else {
//만약 refresh token도 만료되어 acces token이 없다면 로그아웃 시키기.
Cookies.remove('Authorization');
Cookies.remove('Refresh');
localStorage.clear();
alert('재로그인 해주세요.');
window.location.href = '/';
}
}
return result;
};
// Wrap baseQuery with the baseQueryWithReauth function
export const apiSlice = createApi({
baseQuery: baseQueryWithReauth,
endpoints: (builder) => ({}),
});
//postApi.ts
import { apiSlice } from './apiSlice';
// apiSlice에 태그와 엔드포인트 주입
// RecomendedPosts 정보를 가져오는 query에 Token 핸들링 로직을 포함 시킨다.
export const recommendedPostsApi = apiSlice
.enhanceEndpoints({ addTagTypes: ['RecomendedPosts'] })
.injectEndpoints({
endpoints: (builder) => ({
getRomendedPosts: builder.query({
query: ({ recommend }) => `posts/${recommend}`,
providesTags: ['RecomendedPosts'],
}),
}),
});
React Query에 비해서 레퍼런스가 부족했고 공식문서에 의존해서 로직을 작성하는데 생각보다 더 많은 시간이 소요되는 점이 불편했습니다.
프로젝트를 진행하는 과정에서 기본으로 제공되는 fetchBaseQuery만을 사용했는데 커스터 마이징을 통해서 반복된 코드를 줄이고 기능을 확장 시킬 수 있을 것이라고 생각합니다.