🌟 도입 배경
FanUP
프로젝트에서는 상태관리를 위해 Redux Tool Kit
을 선택하고 사용했습니다.
이번 글에서는 Redux Tool Kit
을 사용한 후기와 사용법에 대해서 얘기해보겠습니다.
Redux Tool Kit
을 도입한 배경은 다음과 같습니다.
- 서비스 특성상
클라이언트 상태 관리
와서버 상태 관리
를 모두 해야 하는 경우가 많았습니다. - 서버 상태 관리 라이브러리 + 클라이언트 상태 관리 라이브러리를 따로 적용해도 되지만,
Redux Tool Kit
라이브러리 하나만으로도, 클라이언트 상태관리와RTK Query
를 통한 서버 상태관리가 모두 가능하기 때문에, 기술의 통일성을 위하여Redux Tool Kit
을 선택했습니다. - 프로젝트의 클라이언트 개발자들이 모두
Redux
사용 경험이 있기 때문에, 큰 러닝 커브 없이 개발을 진행 할 수 있을 것이라 판단했습니다.
🌟 스토어 연결
RTK Query
를 사용하기 위해서는, RTK
에서 제공하는 createApi
를 이용해, 하나의 도메인에 대한 service
를 만들고, 이를 combineReducer
를 통해 만든 rootReducer
애 추가하고, Redux
의 configureStore를 통해 만든 Root Store
와 연결 해야 합니다. (RTK Query Overview)
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["User", "SubScribedArtist", "MyTicket"],
endpoints: (build) => ({
getUser: build.query<IUser, void>({
query: () => "/auth/me",
providesTags: ["User"],
}),
}),
});
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["User", "SubScribedArtist", "MyTicket"],
endpoints: (build) => ({
getUser: build.query<IUser, void>({
query: () => "/auth/me",
providesTags: ["User"],
}),
}),
});
const reducer = combineReducers({
artistSlice,
userSlice,
[artistApi.reducerPath]: artistApi.reducer,
[userApi.reducerPath]: userApi.reducer,
});
const reducer = combineReducers({
artistSlice,
userSlice,
[artistApi.reducerPath]: artistApi.reducer,
[userApi.reducerPath]: userApi.reducer,
});
const store = configureStore({
reducer,
middleware: (getDefaultMiddleWare) =>
getDefaultMiddleWare({ serializableCheck: false })
.concat(artistApi.middleware)
.concat(userApi.middleware),
});
const store = configureStore({
reducer,
middleware: (getDefaultMiddleWare) =>
getDefaultMiddleWare({ serializableCheck: false })
.concat(artistApi.middleware)
.concat(userApi.middleware),
});
🌟 Query 사용하기
Query
의 경우, 서버의 상태를 가져오는데 사용됩니다.
Store
에 연결을 끝낸 후, createApi
메서드의 endPoint
Key
에 작성한 Query
와 Mutation
을 사용 할 수 있습니다.
export const ticketApi = createApi({
reducerPath: "ticketApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["Ticket", "MyTicket", "TodayTicket", "TicketDetail"],
endpoints: (build) => ({
getTodayTickets: build.query<TicketSales[], void>({
//TODO : invalidate when 00:00
query: () => "/ticket/today",
providesTags: ["TodayTicket"],
}),
getAllTickets: build.query<TicketSales[], void>({
query: () => "/ticket",
providesTags: ["Ticket"],
}),
getDetailTicket: build.query<TicketDetail, string>({
query: (ticketid: string) => ({ url: `/ticket/${ticketid}` }),
providesTags: (result, error, id) => [{ type: "TicketDetail", id }],
}),
}),
});
export const {
useGetTodayTicketsQuery,
useGetAllTicketsQuery,
useGetDetailTicketQuery,
} = userApi;
export const ticketApi = createApi({
reducerPath: "ticketApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["Ticket", "MyTicket", "TodayTicket", "TicketDetail"],
endpoints: (build) => ({
getTodayTickets: build.query<TicketSales[], void>({
//TODO : invalidate when 00:00
query: () => "/ticket/today",
providesTags: ["TodayTicket"],
}),
getAllTickets: build.query<TicketSales[], void>({
query: () => "/ticket",
providesTags: ["Ticket"],
}),
getDetailTicket: build.query<TicketDetail, string>({
query: (ticketid: string) => ({ url: `/ticket/${ticketid}` }),
providesTags: (result, error, id) => [{ type: "TicketDetail", id }],
}),
}),
});
export const {
useGetTodayTicketsQuery,
useGetAllTicketsQuery,
useGetDetailTicketQuery,
} = userApi;
컴포넌트
에서 사용하기(Query
)
const Tickets = () => {
const { data: allTickets, isLoading } = useGetAllTicketsQuery(undefined, {
pollingInterval: 3000,
refetchOnMountOrArgChange: true,
});
return <></>;
};
const Tickets = () => {
const { data: allTickets, isLoading } = useGetAllTicketsQuery(undefined, {
pollingInterval: 3000,
refetchOnMountOrArgChange: true,
});
return <></>;
};
기본적인 useQuery
Hook
이외에도, 여러 상황에서 사용이 가능한 Hook
들이 존재합니다 => Query와 관련된 Hook 살펴보기
여러 가지 Hook
들을 사용해, Prefetch
, Refetch
, Lazy Fetch
등, Data Fetching
시점을 선택하고, Data Fetching
시점을 Caching
해 놓을 수 있습니다.
기본적인 useQuery
의 경우, React Query
에서 제공하는 useQuery
와 거의 같은 반환값을 제공하며, useQuery의 두번째 인자에 pollingInterval
, refechOnFocus
와 같은 옵션을 통해, 특정 시점에 해당 Query
를 다시 Refetch
하도록 만들 수 있습니다. -> useQuery의 반환값과 옵션 살펴보기
🌟 Mutation 사용하기
Query
가 서버의 상태를 가져오는데 사용 되었다면, Mutation
의 경우, 서버의 상태를 변화 시킬 때 사용합니다.
export const ticketApi = createApi({
reducerPath: "ticketApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["MyTicket"],
endpoints: (build) => ({
getMyTicket: build.query<MyTicket, string>({
query: () => "/ticket/my",
providesTags: ["MyTicket"],
}),
ticketing: build.mutation({
query: (ticketId: string) => {
return {
url: "/ticket/user",
method: "POST",
body: { ticketId },
};
},
invalidatesTags: (result, error, ticketId) => {
if (result?.status >= 400 || error) return [];
else return ["MyTicket"];
},
}),
}),
});
export const ticketApi = createApi({
reducerPath: "ticketApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["MyTicket"],
endpoints: (build) => ({
getMyTicket: build.query<MyTicket, string>({
query: () => "/ticket/my",
providesTags: ["MyTicket"],
}),
ticketing: build.mutation({
query: (ticketId: string) => {
return {
url: "/ticket/user",
method: "POST",
body: { ticketId },
};
},
invalidatesTags: (result, error, ticketId) => {
if (result?.status >= 400 || error) return [];
else return ["MyTicket"];
},
}),
}),
});
위 ticket API
에서 ticketing mutation
은 티켓팅을 하는 경우 사용될 mutation
입니다.
getMyTicket Query
는 내가 티켓팅 한 티켓의 목록을 가져오는 Query
입니다.
사용자가 티켓팅을 성공한다면, 내가 티켓팅한 티켓의 목록을 업데이트 시켜줘야 합니다.
invalidateTags
에 들어가는 메서드는 result
, error
, params
라는 3개의 매게변수를 받습니다.
result
는 실행 결과에 대한 서버의 응답을 받고, error
는 실행 중 에러가 발생했을 때 에러를 반환하며, params
에는 mutation
의 매게변수로 전달한 데이터가 반환됩니다.
따라서, 티켓팅이 성공 했을 때만, 내가 티켓팅 한 티켓의 목록을 업데이트(invalidate
) 시켜주도록 만들어, 효율적으로 API 요청을 관리 할 수 있었습니다.
🌟 Conditional Fetching
상황에 따라서, 원하는 경우에만 Query
를 통한 Data Fetching
을 하고 싶을 수도 있습니다.
FanUP
프로젝트에서는, 내가 티켓팅한 티켓의 목록을 가져오는 경우가 이에 해당했습니다,.
로그인을 한 사용자에 대해서만, 내가 티켓팅한 티켓의 목록을 가져와야 했습니다.
이때는 useQuery
의 옵션 중 skip
을 이용해 해결 할 수 있었습니다.
const { data: userData } = useGetUserQuery();
const { data: myTickets } = useGetMyTicketsQuery(undefined, {
skip: userData ? false : true,
});
const { data: userData } = useGetUserQuery();
const { data: myTickets } = useGetMyTicketsQuery(undefined, {
skip: userData ? false : true,
});
skip
의 값에 따라, Query
의 실행 여부를 결정 할 수 있었습니다.
사용자의 데이터가 있다면(로그인을 한 경우), 해당 Query
를 수행하고(skip : false
), 데이터가 없다면 해당 Query
를 수행하지 않도록 만들 수 있었습니다.
🌟Cache Invalidate
앞서, mutation
에 대해서 설명을 할 때, mutation
내에서, invalidateTags
를 통해서, Query
를 Refetch
하도록 만들었습니다.
하지만, mutation
을 통해, 서버의 상태를 변화시키는 경우가 아닐 때에도, Cache
를 invalidate
시켜야 하는 경우도 있었습니다.
FanUP
서비스에서는, 로그아웃
을 하는 경우가 그 경우에 해당했습니다.
토큰을 사용해, 로그인을 진행하기 때문에, 로그아웃을 하는 경우, 서버에 특정 요청을 보내지 않고, 클라이언트 자체적으로 token
을 날려주기로 했습니다.
추가로, Token
을 날려주는 것 외에도, 사용자와 관련된 모든 API
의 Cache
를 날려주어야 했습니다
이때는, createApi
의 util
함수에서 제공하는 resetApiState
메서드를 사용했습니다.
resetApiState
를 사용할 경우, 해당 도메인에 묶여 있는 모든 Query
가 Reset
되게 됩니다.
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["User", "SubScribedArtist", "MyTicket"],
endpoints: (build) => ({
getUser: build.query<IUser, void>({
query: () => "/auth/me",
providesTags: ["User"],
}),
getMyTickets: build.query<MyTicket[], void>({
query: () => "/ticket/my",
providesTags: ["MyTicket"],
}),
}),
});
export const { useGetUserQuery, useGetMyTicketsQuery } = userApi;
export const { resetApiState: resetUserService } = userApi.util;
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["User", "SubScribedArtist", "MyTicket"],
endpoints: (build) => ({
getUser: build.query<IUser, void>({
query: () => "/auth/me",
providesTags: ["User"],
}),
getMyTickets: build.query<MyTicket[], void>({
query: () => "/ticket/my",
providesTags: ["MyTicket"],
}),
}),
});
export const { useGetUserQuery, useGetMyTicketsQuery } = userApi;
export const { resetApiState: resetUserService } = userApi.util;
const logout = useCallback(() => {
if (!window.confirm("로그아웃 하시겠어요?")) return;
localStorage.removeItem("token");
dispatch(setToken(null));
dispatch(resetUserService());
}, []);
const logout = useCallback(() => {
if (!window.confirm("로그아웃 하시겠어요?")) return;
localStorage.removeItem("token");
dispatch(setToken(null));
dispatch(resetUserService());
}, []);
resetApi
를 이용해, 사용자와 관련된 모든 API
의 Cache
를 invalidate
시킬 수 있었습니다.
🌟Query결과 가공
Query를 통해 서버에서 가져온 데이터를 가공해야 하는 경우에는 transformResponse
를 사용했습니다.
아래 예제는 아티스트의 스케쥴을 가져온 뒤, 해당 스케쥴을 달력에 들어갈 수 있는 형태의 데이터로 가공하는 과정입니다.
getSchedules: build.query<any[], CalendarData>({
query: ({ calendarMonth, calendarYear }) =>
`/ticket/artist/calendar?year=${calendarYear}&month=${calendarMonth}`,
providesTags: ['Schedules'],
transformResponse: (response: any[]) => {
const temp: any[] = Array.from({ length: 31 }, () => []);
response?.forEach((data) => {
const startDate = new Date(data.startTime);
const date = startDate.getDate();
const [diff] = dateDiff(startDate, new Date());
temp[date].push({ data, isPast: diff < 0 ? true : false });
});
return temp;
},
}),
getSchedules: build.query<any[], CalendarData>({
query: ({ calendarMonth, calendarYear }) =>
`/ticket/artist/calendar?year=${calendarYear}&month=${calendarMonth}`,
providesTags: ['Schedules'],
transformResponse: (response: any[]) => {
const temp: any[] = Array.from({ length: 31 }, () => []);
response?.forEach((data) => {
const startDate = new Date(data.startTime);
const date = startDate.getDate();
const [diff] = dateDiff(startDate, new Date());
temp[date].push({ data, isPast: diff < 0 ? true : false });
});
return temp;
},
}),
Component
에서는 해당 Query
의 결과로 가공된 데이터를 받아, 바로 사용 할 수 있습니다.
const {
isLoading,
data: schedules,
isFetching,
} = useGetSchedulesQuery({
calendarYear,
calendarMonth,
});
const {
isLoading,
data: schedules,
isFetching,
} = useGetSchedulesQuery({
calendarYear,
calendarMonth,
});
🌟 FetchBaseQuery Header
RTK Query
에서는 axios
, fetch
를 사용해 서버와 통신을 하지 않고, RTK
에 내장 되어 있는 fetchBaseQuery
를 이용해, 서버와 통신을 하게 됩니다.
FanUP
에서는 로그인 후 서버에게 받은 token
을 헤더에 넣어, 통신을 진행해야 했습니다.
이를 위해, customFetchBaseQuery
를 만들어, 사용했습니다.
export const customFetchBaseQuery = fetchBaseQuery({
baseUrl: process.env.REACT_APP_SERVER_URL,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as any).userSlice.token;
if (token) headers.set("Authorization", `Bearer ${token}`);
return headers;
},
});
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["User", "SubScribedArtist", "MyTicket"],
endpoints: (build) => ({
getUser: build.query<IUser, void>({
query: () => "/auth/me",
providesTags: ["User"],
}),
}),
});
export const customFetchBaseQuery = fetchBaseQuery({
baseUrl: process.env.REACT_APP_SERVER_URL,
prepareHeaders: (headers, { getState }) => {
const token = (getState() as any).userSlice.token;
if (token) headers.set("Authorization", `Bearer ${token}`);
return headers;
},
});
export const userApi = createApi({
reducerPath: "userApi",
baseQuery: customFetchBaseQuery,
tagTypes: ["User", "SubScribedArtist", "MyTicket"],
endpoints: (build) => ({
getUser: build.query<IUser, void>({
query: () => "/auth/me",
providesTags: ["User"],
}),
}),
});
prepareHeaders
의 두번 째 매게변수를 통해, 클라이언트 전역 스토어에 접근 할 수 있었고, 이를 통해 header
에 token
을 넣은 baseQuery
를 만들 수 있었습니다.
🌟 Loading state 없이 사용하기
특정 Query
에 대해서는 loading
상태 없이 바로 데이터를 사용해야 하는 경우가 존재했습니다. (ex, 사용자 정보)
이럴 경우, HOC
패턴을 이용해서, Query
정보를 가져온 후, 컴포넌트를 렌더링 시켜 컴포넌트 내부에서 loading state
가 발생하지 않도록 만들 수 있었습니다.
function withGetUser<P extends object>(Component: ComponentType<P>) {
return function WihLoadingComponent({ ...props }) {
const { isLoading } = useGetUserQuery();
if (isLoading) return <></>;
return <Component {...(props as P)} />;
};
}
export default withGetUser;
function withGetUser<P extends object>(Component: ComponentType<P>) {
return function WihLoadingComponent({ ...props }) {
const { isLoading } = useGetUserQuery();
if (isLoading) return <></>;
return <Component {...(props as P)} />;
};
}
export default withGetUser;
const Home = () => {
const { data: userData } = useGetUserQuery();
//isLoading 없이 바로 사용 가능
return (
<>
<Header />
<BannerWrapper>
<SubLogo />
<h1>No Fan, No Artist</h1>
</BannerWrapper>
<UserContentsWrapper>
{userData ? <FanFeatureBox /> : <div></div>}
<ArtistsBox />
</UserContentsWrapper>
</>
);
};
export default withGetUser(Home);
const Home = () => {
const { data: userData } = useGetUserQuery();
//isLoading 없이 바로 사용 가능
return (
<>
<Header />
<BannerWrapper>
<SubLogo />
<h1>No Fan, No Artist</h1>
</BannerWrapper>
<UserContentsWrapper>
{userData ? <FanFeatureBox /> : <div></div>}
<ArtistsBox />
</UserContentsWrapper>
</>
);
};
export default withGetUser(Home);
🌟 사용 후기
기존에 React Query
를 즐겨 사용 했었고, 큰 불편함을 느끼지 못했기 때문에, 이번 프로젝트에 RTK Query
를 적용하기 이전에는, RTK Query
에 대한 거부감이 있었습니다.
하지만, 실제로 사용해보니, React Query
의 기능을 거의 대부분 제공 했습니다. (물론 제공하지 않는 기능도 있었습니다)
오히려, RTK Query
를 사용하면서, 좋았던 점도 있었습니다.
먼저, 서버와 통신하는 API
들을 특정 도메인 단위로 관리 할 수 있다는 점이, 좋았습니다.
그 다음, 클라이언트 스토어와 연동해서 사용할 수 있다는 점도 상당히 마음에 들었습니다.
앞으로도, 도메인 별로 API
를 관리하는 상황이 많으며, 클라이언트 상태 관리를 해야 하는 경우도 많다면, RTK Query
를 사용 할 것 같습니다.